mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
升级
This commit is contained in:
parent
40aabba498
commit
53643e81f0
@ -7,7 +7,7 @@
|
||||
const http = require('http')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const net = require('net')
|
||||
// const net = require('net')
|
||||
const crypto = require('crypto')
|
||||
|
||||
// 输出环境信息
|
||||
@ -114,23 +114,24 @@ const clients = {
|
||||
}
|
||||
|
||||
// 处理WebSocket连接
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
server.on('upgrade', (request, socket) => {
|
||||
try {
|
||||
console.log('[WebSocket] Connection upgrade request received')
|
||||
|
||||
// 解析WebSocket密钥
|
||||
const key = request.headers['sec-websocket-key']
|
||||
const acceptKey = crypto.createHash('sha1')
|
||||
const acceptKey = crypto
|
||||
.createHash('sha1')
|
||||
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary')
|
||||
.digest('base64')
|
||||
|
||||
// 发送WebSocket握手响应
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
'\r\n'
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
'\r\n'
|
||||
)
|
||||
|
||||
console.log('[WebSocket] Handshake successful')
|
||||
@ -157,10 +158,8 @@ function handleWebSocketConnection(socket) {
|
||||
// 检查是否有完整的帧
|
||||
const firstByte = buffer[0]
|
||||
const secondByte = buffer[1]
|
||||
const isFinalFrame = Boolean((firstByte >>> 7) & 0x1)
|
||||
const [opCode, maskFlag, payloadLength] = [
|
||||
firstByte & 0xF, (secondByte >>> 7) & 0x1, secondByte & 0x7F
|
||||
]
|
||||
// const isFinalFrame = Boolean((firstByte >>> 7) & 0x1)
|
||||
const [opCode, maskFlag, payloadLength] = [firstByte & 0xf, (secondByte >>> 7) & 0x1, secondByte & 0x7f]
|
||||
|
||||
// 处理不同的负载长度
|
||||
let payloadStartIndex = 2
|
||||
@ -265,7 +264,7 @@ function sendWebSocketFrame(socket, data, opCode = 0x1) {
|
||||
|
||||
// 发送Pong响应
|
||||
function sendPong(socket) {
|
||||
const pongFrame = Buffer.from([0x8A, 0x00])
|
||||
const pongFrame = Buffer.from([0x8a, 0x00])
|
||||
socket.write(pongFrame)
|
||||
}
|
||||
|
||||
@ -351,11 +350,11 @@ async function findAvailablePort(startPort) {
|
||||
port++
|
||||
}
|
||||
|
||||
throw new Error(`Could not find an available port between ${startPort} and ${maxPort-1}`)
|
||||
throw new Error(`Could not find an available port between ${startPort} and ${maxPort - 1}`)
|
||||
}
|
||||
|
||||
// 尝试启动服务器
|
||||
(async () => {
|
||||
;(async () => {
|
||||
try {
|
||||
// 默认端口
|
||||
const defaultPort = 34515
|
||||
|
||||
@ -3,67 +3,73 @@
|
||||
* 用于测试ASR服务器是否正常工作
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws')
|
||||
const http = require('http')
|
||||
|
||||
// 测试HTTP服务器
|
||||
console.log('测试HTTP服务器...');
|
||||
http.get('http://localhost:34515', (res) => {
|
||||
console.log(`HTTP状态码: ${res.statusCode}`);
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
console.log('HTTP响应接收完成');
|
||||
console.log(`响应长度: ${data.length} 字节`);
|
||||
console.log('HTTP测试完成');
|
||||
|
||||
// 测试WebSocket
|
||||
testWebSocket();
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
console.error('HTTP测试失败:', err.message);
|
||||
});
|
||||
console.log('测试HTTP服务器...')
|
||||
http
|
||||
.get('http://localhost:34515', (res) => {
|
||||
console.log(`HTTP状态码: ${res.statusCode}`)
|
||||
|
||||
let data = ''
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
console.log('HTTP响应接收完成')
|
||||
console.log(`响应长度: ${data.length} 字节`)
|
||||
console.log('HTTP测试完成')
|
||||
|
||||
// 测试WebSocket
|
||||
testWebSocket()
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
console.error('HTTP测试失败:', err.message)
|
||||
})
|
||||
|
||||
// 测试WebSocket
|
||||
function testWebSocket() {
|
||||
console.log('\n测试WebSocket...');
|
||||
const ws = new WebSocket('ws://localhost:34515');
|
||||
|
||||
console.log('\n测试WebSocket...')
|
||||
const ws = new WebSocket('ws://localhost:34515')
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('WebSocket连接已打开');
|
||||
|
||||
console.log('WebSocket连接已打开')
|
||||
|
||||
// 发送身份识别消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'identify',
|
||||
role: 'electron'
|
||||
}));
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'identify',
|
||||
role: 'electron'
|
||||
})
|
||||
)
|
||||
|
||||
// 发送测试消息
|
||||
setTimeout(() => {
|
||||
console.log('发送测试消息...');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'test',
|
||||
message: '这是一条测试消息'
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
console.log('发送测试消息...')
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'test',
|
||||
message: '这是一条测试消息'
|
||||
})
|
||||
)
|
||||
}, 1000)
|
||||
|
||||
// 关闭连接
|
||||
setTimeout(() => {
|
||||
console.log('关闭WebSocket连接...');
|
||||
ws.close();
|
||||
console.log('测试完成');
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
console.log('关闭WebSocket连接...')
|
||||
ws.close()
|
||||
console.log('测试完成')
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
console.log(`收到WebSocket消息: ${data}`);
|
||||
});
|
||||
|
||||
console.log(`收到WebSocket消息: ${data}`)
|
||||
})
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket测试失败:', error.message);
|
||||
});
|
||||
console.error('WebSocket测试失败:', error.message)
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,77 +1,77 @@
|
||||
// 检查重复消息的脚本
|
||||
const { app } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { app } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// 获取数据库文件路径
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbFilePath = path.join(userDataPath, 'CherryStudio.db');
|
||||
const userDataPath = app.getPath('userData')
|
||||
const dbFilePath = path.join(userDataPath, 'CherryStudio.db')
|
||||
|
||||
console.log('数据库文件路径:', dbFilePath);
|
||||
console.log('数据库文件路径:', dbFilePath)
|
||||
|
||||
// 检查文件是否存在
|
||||
if (fs.existsSync(dbFilePath)) {
|
||||
console.log('数据库文件存在');
|
||||
|
||||
console.log('数据库文件存在')
|
||||
|
||||
// 读取数据库内容
|
||||
const dbContent = fs.readFileSync(dbFilePath, 'utf8');
|
||||
|
||||
const dbContent = fs.readFileSync(dbFilePath, 'utf8')
|
||||
|
||||
// 解析数据库内容
|
||||
try {
|
||||
const data = JSON.parse(dbContent);
|
||||
|
||||
const data = JSON.parse(dbContent)
|
||||
|
||||
// 检查topics表中的消息
|
||||
if (data.topics) {
|
||||
console.log('找到topics表,共有', data.topics.length, '个主题');
|
||||
|
||||
console.log('找到topics表,共有', data.topics.length, '个主题')
|
||||
|
||||
// 遍历每个主题
|
||||
data.topics.forEach(topic => {
|
||||
console.log(`检查主题: ${topic.id}`);
|
||||
|
||||
data.topics.forEach((topic) => {
|
||||
console.log(`检查主题: ${topic.id}`)
|
||||
|
||||
if (topic.messages && Array.isArray(topic.messages)) {
|
||||
console.log(` 主题消息数量: ${topic.messages.length}`);
|
||||
|
||||
console.log(` 主题消息数量: ${topic.messages.length}`)
|
||||
|
||||
// 检查重复消息
|
||||
const messageIds = new Set();
|
||||
const duplicates = [];
|
||||
|
||||
topic.messages.forEach(message => {
|
||||
const messageIds = new Set()
|
||||
const duplicates = []
|
||||
|
||||
topic.messages.forEach((message) => {
|
||||
if (messageIds.has(message.id)) {
|
||||
duplicates.push(message.id);
|
||||
duplicates.push(message.id)
|
||||
} else {
|
||||
messageIds.add(message.id);
|
||||
messageIds.add(message.id)
|
||||
}
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
console.log(` 发现${duplicates.length}条重复消息ID:`, duplicates);
|
||||
console.log(` 发现${duplicates.length}条重复消息ID:`, duplicates)
|
||||
} else {
|
||||
console.log(' 未发现重复消息ID');
|
||||
console.log(' 未发现重复消息ID')
|
||||
}
|
||||
|
||||
|
||||
// 检查重复的askId (对于助手消息)
|
||||
const askIds = {};
|
||||
topic.messages.forEach(message => {
|
||||
const askIds = {}
|
||||
topic.messages.forEach((message) => {
|
||||
if (message.role === 'assistant' && message.askId) {
|
||||
if (!askIds[message.askId]) {
|
||||
askIds[message.askId] = [];
|
||||
askIds[message.askId] = []
|
||||
}
|
||||
askIds[message.askId].push(message.id);
|
||||
askIds[message.askId].push(message.id)
|
||||
}
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
// 输出每个askId对应的助手消息数量
|
||||
Object.entries(askIds).forEach(([askId, messageIds]) => {
|
||||
if (messageIds.length > 1) {
|
||||
console.log(` askId ${askId} 有 ${messageIds.length} 条助手消息`);
|
||||
console.log(` askId ${askId} 有 ${messageIds.length} 条助手消息`)
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析数据库内容失败:', error);
|
||||
console.error('解析数据库内容失败:', error)
|
||||
}
|
||||
} else {
|
||||
console.log('数据库文件不存在');
|
||||
console.log('数据库文件不存在')
|
||||
}
|
||||
|
||||
@ -1,64 +1,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 读取agents.json文件
|
||||
const filePath = path.join('resources', 'data', 'agents.json');
|
||||
const filePath = path.join('resources', 'data', 'agents.json')
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
console.error('读取文件失败:', err);
|
||||
return;
|
||||
console.error('读取文件失败:', err)
|
||||
return
|
||||
}
|
||||
|
||||
// 输出文件的前20个字节的十六进制表示
|
||||
console.log('文件前20个字节:');
|
||||
console.log('文件前20个字节:')
|
||||
for (let i = 0; i < Math.min(20, data.length); i++) {
|
||||
console.log(`字节 ${i}: 0x${data[i].toString(16)} (${String.fromCharCode(data[i])})`);
|
||||
console.log(`字节 ${i}: 0x${data[i].toString(16)} (${String.fromCharCode(data[i])})`)
|
||||
}
|
||||
|
||||
// 尝试不同的方式解析JSON
|
||||
console.log('\n尝试不同的方式解析JSON:');
|
||||
|
||||
console.log('\n尝试不同的方式解析JSON:')
|
||||
|
||||
// 1. 直接解析
|
||||
try {
|
||||
const json1 = JSON.parse(data);
|
||||
console.log('方法1成功: 直接解析');
|
||||
JSON.parse(data)
|
||||
console.log('方法1成功: 直接解析')
|
||||
} catch (e) {
|
||||
console.error('方法1失败:', e.message);
|
||||
console.error('方法1失败:', e.message)
|
||||
}
|
||||
|
||||
|
||||
// 2. 转换为字符串后解析
|
||||
try {
|
||||
const json2 = JSON.parse(data.toString());
|
||||
console.log('方法2成功: 转换为字符串后解析');
|
||||
JSON.parse(data.toString())
|
||||
console.log('方法2成功: 转换为字符串后解析')
|
||||
} catch (e) {
|
||||
console.error('方法2失败:', e.message);
|
||||
console.error('方法2失败:', e.message)
|
||||
}
|
||||
|
||||
|
||||
// 3. 移除BOM后解析
|
||||
try {
|
||||
const str = data.toString();
|
||||
const noBomStr = str.charCodeAt(0) === 0xFEFF ? str.slice(1) : str;
|
||||
const json3 = JSON.parse(noBomStr);
|
||||
console.log('方法3成功: 移除BOM后解析');
|
||||
const str = data.toString()
|
||||
const noBomStr = str.charCodeAt(0) === 0xfeff ? str.slice(1) : str
|
||||
JSON.parse(noBomStr)
|
||||
console.log('方法3成功: 移除BOM后解析')
|
||||
} catch (e) {
|
||||
console.error('方法3失败:', e.message);
|
||||
console.error('方法3失败:', e.message)
|
||||
}
|
||||
|
||||
|
||||
// 4. 移除前3个字符后解析
|
||||
try {
|
||||
const str = data.toString().slice(3);
|
||||
const json4 = JSON.parse(str);
|
||||
console.log('方法4成功: 移除前3个字符后解析');
|
||||
const str = data.toString().slice(3)
|
||||
JSON.parse(str)
|
||||
console.log('方法4成功: 移除前3个字符后解析')
|
||||
} catch (e) {
|
||||
console.error('方法4失败:', e.message);
|
||||
console.error('方法4失败:', e.message)
|
||||
}
|
||||
|
||||
|
||||
// 5. 移除所有非ASCII字符后解析
|
||||
try {
|
||||
const str = data.toString().replace(/[^\x20-\x7E]/g, '');
|
||||
const json5 = JSON.parse(str);
|
||||
console.log('方法5成功: 移除所有非ASCII字符后解析');
|
||||
const str = data.toString().replace(/[^\x20-\x7E]/g, '')
|
||||
JSON.parse(str)
|
||||
console.log('方法5成功: 移除所有非ASCII字符后解析')
|
||||
} catch (e) {
|
||||
console.error('方法5失败:', e.message);
|
||||
console.error('方法5失败:', e.message)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 创建一个空的agents.json文件
|
||||
const emptyAgents = [];
|
||||
const filePath = path.join('resources', 'data', 'agents.json');
|
||||
const emptyAgents = []
|
||||
const filePath = path.join('resources', 'data', 'agents.json')
|
||||
|
||||
// 备份原始文件
|
||||
fs.copyFileSync(filePath, filePath + '.bak');
|
||||
console.log('已备份原始文件到 ' + filePath + '.bak');
|
||||
fs.copyFileSync(filePath, filePath + '.bak')
|
||||
console.log('已备份原始文件到 ' + filePath + '.bak')
|
||||
|
||||
// 写入新文件
|
||||
fs.writeFileSync(filePath, JSON.stringify(emptyAgents, null, 2), 'utf8');
|
||||
console.log('已创建新的agents.json文件,内容为空数组');
|
||||
fs.writeFileSync(filePath, JSON.stringify(emptyAgents, null, 2), 'utf8')
|
||||
console.log('已创建新的agents.json文件,内容为空数组')
|
||||
|
||||
@ -65,6 +65,7 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
oneClick: false
|
||||
include: build/nsis-installer.nsh
|
||||
buildUniversalInstaller: false
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-${arch}-portable.${ext}
|
||||
mac:
|
||||
@ -101,6 +102,7 @@ electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
全新图标风格
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5-bate",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -23,7 +23,7 @@
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win && node scripts/after-build.js",
|
||||
"build:win": "dotenv npm run build && electron-builder --win",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac",
|
||||
@ -119,7 +119,6 @@
|
||||
"fs-extra": "^11.2.0",
|
||||
"got-scraping": "^4.1.1",
|
||||
"js-tiktoken": "^1.0.19",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
@ -154,7 +153,7 @@
|
||||
"@google/genai": "^0.8.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
|
||||
@ -163,7 +162,6 @@
|
||||
"@types/d3": "^7",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/js-yaml": "^4",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
@ -223,6 +221,7 @@
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^7.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
|
||||
@ -139,6 +139,7 @@ export enum IpcChannel {
|
||||
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
System_GetHostname = 'system:getHostname',
|
||||
|
||||
// events
|
||||
SelectionAction = 'selection-action',
|
||||
|
||||
48
scripts/artifact-build-completed.js
Normal file
48
scripts/artifact-build-completed.js
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* This script is executed after each artifact is built.
|
||||
* It removes spaces from filenames to ensure compatibility with various systems.
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* Removes spaces from a filename and replaces them with hyphens
|
||||
* @param {string} artifactPath - Path to the artifact file
|
||||
*/
|
||||
function removeSpacesFromFilename(artifactPath) {
|
||||
const dir = path.dirname(artifactPath)
|
||||
const filename = path.basename(artifactPath)
|
||||
// Replace spaces with hyphens in the filename
|
||||
const newFilename = filename.replace(/\s+/g, '-')
|
||||
// If the filename has changed, rename the file
|
||||
if (newFilename !== filename) {
|
||||
const newPath = path.join(dir, newFilename)
|
||||
console.log(`Renaming: ${filename} -> ${newFilename}`)
|
||||
fs.renameSync(artifactPath, newPath)
|
||||
return newPath
|
||||
}
|
||||
return artifactPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function that runs when an artifact is built
|
||||
* @param {object} params - Parameters from electron-builder
|
||||
*/
|
||||
module.exports = async function (params) {
|
||||
const { artifactPath } = params
|
||||
if (!artifactPath) {
|
||||
console.log('No artifact path provided')
|
||||
return
|
||||
}
|
||||
console.log(`Processing artifact: ${artifactPath}`)
|
||||
try {
|
||||
const newPath = removeSpacesFromFilename(artifactPath)
|
||||
// Return the new path so electron-builder knows where the artifact is
|
||||
return { artifactPath: newPath }
|
||||
} catch (error) {
|
||||
console.error('Error processing artifact:', error)
|
||||
// Return the original path if there was an error
|
||||
return { artifactPath }
|
||||
}
|
||||
}
|
||||
@ -63,6 +63,10 @@ if (!app.requestSingleInstanceLock()) {
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
|
||||
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => {
|
||||
return require('os').hostname()
|
||||
})
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import AxiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@ -20,7 +20,7 @@ export default class JinaReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = data.results
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import axiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@ -22,7 +22,7 @@ export default class SiliconFlowReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
const { data } = await axiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = data.results
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import axiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@ -22,7 +22,7 @@ export default class VoyageReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, {
|
||||
const { data } = await axiosProxy.axios.post(url, requestBody, {
|
||||
headers: {
|
||||
...this.defaultHeaders()
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import log from 'electron-log'
|
||||
|
||||
import { getResourcePath } from '../utils'
|
||||
|
||||
/**
|
||||
@ -14,19 +15,19 @@ import { getResourcePath } from '../utils'
|
||||
*/
|
||||
export class ASRServerService {
|
||||
// HTML内容
|
||||
private INDEX_HTML_CONTENT: string = '';
|
||||
private INDEX_HTML_CONTENT: string = ''
|
||||
|
||||
// 服务器相关属性
|
||||
private httpServer: http.Server | null = null;
|
||||
private wsClients: { browser: any | null; electron: any | null } = { browser: null, electron: null };
|
||||
private serverPort: number = 34515; // 默认端口
|
||||
private isServerRunning: boolean = false;
|
||||
private httpServer: http.Server | null = null
|
||||
private wsClients: { browser: any | null; electron: any | null } = { browser: null, electron: null }
|
||||
private serverPort: number = 34515 // 默认端口
|
||||
private isServerRunning: boolean = false
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
constructor() {
|
||||
this.loadIndexHtml();
|
||||
this.loadIndexHtml()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -35,24 +36,24 @@ export class ASRServerService {
|
||||
private loadIndexHtml(): void {
|
||||
try {
|
||||
// 在开发环境和生产环境中使用不同的路径
|
||||
let htmlPath = '';
|
||||
let htmlPath = ''
|
||||
|
||||
if (app.isPackaged) {
|
||||
// 生产环境
|
||||
const resourcePath = getResourcePath();
|
||||
htmlPath = path.join(resourcePath, 'app', 'asr-server', 'index.html');
|
||||
const resourcePath = getResourcePath()
|
||||
htmlPath = path.join(resourcePath, 'app', 'asr-server', 'index.html')
|
||||
} else {
|
||||
// 开发环境
|
||||
htmlPath = path.join(app.getAppPath(), 'asr-server', 'index.html');
|
||||
htmlPath = path.join(app.getAppPath(), 'asr-server', 'index.html')
|
||||
}
|
||||
|
||||
log.info(`加载index.html文件: ${htmlPath}`);
|
||||
log.info(`加载index.html文件: ${htmlPath}`)
|
||||
|
||||
if (fs.existsSync(htmlPath)) {
|
||||
this.INDEX_HTML_CONTENT = fs.readFileSync(htmlPath, 'utf8');
|
||||
log.info(`成功加载index.html文件`);
|
||||
this.INDEX_HTML_CONTENT = fs.readFileSync(htmlPath, 'utf8')
|
||||
log.info(`成功加载index.html文件`)
|
||||
} else {
|
||||
log.error(`index.html文件不存在: ${htmlPath}`);
|
||||
log.error(`index.html文件不存在: ${htmlPath}`)
|
||||
// 使用默认的HTML内容
|
||||
this.INDEX_HTML_CONTENT = `<!DOCTYPE html>
|
||||
<html>
|
||||
@ -63,10 +64,10 @@ export class ASRServerService {
|
||||
<h1>Error: index.html file not found</h1>
|
||||
<p>Please make sure the ASR server files are properly installed.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
</html>`
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`加载index.html文件时出错:`, error);
|
||||
log.error(`加载index.html文件时出错:`, error)
|
||||
// 使用默认的HTML内容
|
||||
this.INDEX_HTML_CONTENT = `<!DOCTYPE html>
|
||||
<html>
|
||||
@ -77,7 +78,7 @@ export class ASRServerService {
|
||||
<h1>Error loading index.html</h1>
|
||||
<p>An error occurred while loading the ASR server files.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
</html>`
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,10 +87,10 @@ export class ASRServerService {
|
||||
*/
|
||||
public registerIpcHandlers(): void {
|
||||
// 启动ASR服务器
|
||||
ipcMain.handle(IpcChannel.Asr_StartServer, this.startServer.bind(this));
|
||||
ipcMain.handle(IpcChannel.Asr_StartServer, this.startServer.bind(this))
|
||||
|
||||
// 停止ASR服务器
|
||||
ipcMain.handle(IpcChannel.Asr_StopServer, this.stopServer.bind(this));
|
||||
ipcMain.handle(IpcChannel.Asr_StopServer, this.stopServer.bind(this))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -99,22 +100,22 @@ export class ASRServerService {
|
||||
*/
|
||||
private isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const testServer = net.createServer();
|
||||
const testServer = net.createServer()
|
||||
testServer.once('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
log.info(`端口 ${port} 已被占用,尝试其他端口...`);
|
||||
resolve(false);
|
||||
log.info(`端口 ${port} 已被占用,尝试其他端口...`)
|
||||
resolve(false)
|
||||
} else {
|
||||
log.error(`检查端口 ${port} 时出错:`, err);
|
||||
resolve(false);
|
||||
log.error(`检查端口 ${port} 时出错:`, err)
|
||||
resolve(false)
|
||||
}
|
||||
});
|
||||
})
|
||||
testServer.once('listening', () => {
|
||||
testServer.close();
|
||||
resolve(true);
|
||||
});
|
||||
testServer.listen(port);
|
||||
});
|
||||
testServer.close()
|
||||
resolve(true)
|
||||
})
|
||||
testServer.listen(port)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -123,17 +124,17 @@ export class ASRServerService {
|
||||
* @returns 可用的端口
|
||||
*/
|
||||
private async findAvailablePort(startPort: number): Promise<number> {
|
||||
let port = startPort;
|
||||
const maxPort = startPort + 10; // 尝试最多10个端口
|
||||
let port = startPort
|
||||
const maxPort = startPort + 10 // 尝试最多10个端口
|
||||
|
||||
while (port < maxPort) {
|
||||
if (await this.isPortAvailable(port)) {
|
||||
return port;
|
||||
return port
|
||||
}
|
||||
port++;
|
||||
port++
|
||||
}
|
||||
|
||||
throw new Error(`在 ${startPort} 和 ${maxPort-1} 之间找不到可用的端口`);
|
||||
throw new Error(`在 ${startPort} 和 ${maxPort - 1} 之间找不到可用的端口`)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -144,18 +145,17 @@ export class ASRServerService {
|
||||
private handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
// 只处理根路径请求,返回index.html
|
||||
if (req.url === '/' || req.url === '/index.html') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(this.INDEX_HTML_CONTENT);
|
||||
log.info(`返回index.html到客户端`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(this.INDEX_HTML_CONTENT)
|
||||
log.info(`返回index.html到客户端`)
|
||||
} else {
|
||||
// 其他路径返回404
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
log.info(`请求的路径不存在: ${req.url}`);
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('Not Found')
|
||||
log.info(`请求的路径不存在: ${req.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 启动ASR服务器
|
||||
* @returns Promise<{success: boolean, pid?: number, port?: number, error?: string}>
|
||||
@ -164,63 +164,61 @@ export class ASRServerService {
|
||||
try {
|
||||
// 如果服务器已经运行,直接返回成功
|
||||
if (this.isServerRunning && this.httpServer) {
|
||||
return { success: true, port: this.serverPort };
|
||||
return { success: true, port: this.serverPort }
|
||||
}
|
||||
|
||||
// 尝试找到可用的端口
|
||||
try {
|
||||
this.serverPort = await this.findAvailablePort(this.serverPort);
|
||||
this.serverPort = await this.findAvailablePort(this.serverPort)
|
||||
} catch (error) {
|
||||
log.error('找不到可用的端口:', error);
|
||||
return { success: false, error: '找不到可用的端口' };
|
||||
log.error('找不到可用的端口:', error)
|
||||
return { success: false, error: '找不到可用的端口' }
|
||||
}
|
||||
|
||||
log.info(`使用端口: ${this.serverPort}`);
|
||||
log.info(`使用端口: ${this.serverPort}`)
|
||||
|
||||
// 创建HTTP服务器
|
||||
this.httpServer = http.createServer(this.handleHttpRequest.bind(this));
|
||||
this.httpServer = http.createServer(this.handleHttpRequest.bind(this))
|
||||
|
||||
// 启动HTTP服务器
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.httpServer) {
|
||||
reject(new Error('HTTP服务器创建失败'));
|
||||
return;
|
||||
reject(new Error('HTTP服务器创建失败'))
|
||||
return
|
||||
}
|
||||
|
||||
this.httpServer.on('error', (err) => {
|
||||
log.error(`HTTP服务器错误:`, err);
|
||||
reject(err);
|
||||
});
|
||||
log.error(`HTTP服务器错误:`, err)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
this.httpServer.listen(this.serverPort, () => {
|
||||
log.info(`HTTP服务器已启动,监听端口: ${this.serverPort}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
log.info(`HTTP服务器已启动,监听端口: ${this.serverPort}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
// 设置WebSocket处理
|
||||
this.setupWebSocketServer();
|
||||
this.setupWebSocketServer()
|
||||
|
||||
// 标记服务器已启动
|
||||
this.isServerRunning = true;
|
||||
this.isServerRunning = true
|
||||
|
||||
log.info(`ASR服务器启动成功,端口: ${this.serverPort}`);
|
||||
return { success: true, port: this.serverPort };
|
||||
log.info(`ASR服务器启动成功,端口: ${this.serverPort}`)
|
||||
return { success: true, port: this.serverPort }
|
||||
} catch (error) {
|
||||
log.error('启动HTTP服务器失败:', error);
|
||||
log.error('启动HTTP服务器失败:', error)
|
||||
// 关闭HTTP服务器
|
||||
if (this.httpServer) {
|
||||
this.httpServer.close();
|
||||
this.httpServer = null;
|
||||
this.httpServer.close()
|
||||
this.httpServer = null
|
||||
}
|
||||
return { success: false, error: `启动HTTP服务器失败: ${(error as Error).message}` };
|
||||
return { success: false, error: `启动HTTP服务器失败: ${(error as Error).message}` }
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
log.error('启动ASR服务器失败:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
log.error('启动ASR服务器失败:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,40 +227,40 @@ export class ASRServerService {
|
||||
*/
|
||||
private setupWebSocketServer(): void {
|
||||
if (!this.httpServer) {
|
||||
log.error('HTTP服务器不存在,无法设置WebSocket');
|
||||
return;
|
||||
log.error('HTTP服务器不存在,无法设置WebSocket')
|
||||
return
|
||||
}
|
||||
|
||||
// 处理WebSocket连接升级
|
||||
this.httpServer.on('upgrade', (request, socket, _head) => {
|
||||
this.httpServer.on('upgrade', (request, socket) => {
|
||||
try {
|
||||
log.info('[WebSocket] 收到连接升级请求');
|
||||
log.info('[WebSocket] 收到连接升级请求')
|
||||
|
||||
// 解析WebSocket密钥
|
||||
const key = request.headers['sec-websocket-key'] as string;
|
||||
const key = request.headers['sec-websocket-key'] as string
|
||||
const acceptKey = crypto
|
||||
.createHash('sha1')
|
||||
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary')
|
||||
.digest('base64');
|
||||
.digest('base64')
|
||||
|
||||
// 发送WebSocket握手响应
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
'\r\n'
|
||||
);
|
||||
'Upgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
|
||||
'\r\n'
|
||||
)
|
||||
|
||||
log.info('[WebSocket] 握手成功');
|
||||
log.info('[WebSocket] 握手成功')
|
||||
|
||||
// 处理WebSocket数据
|
||||
this.handleWebSocketConnection(socket);
|
||||
this.handleWebSocketConnection(socket)
|
||||
} catch (error) {
|
||||
log.error('[WebSocket] 处理升级错误:', error);
|
||||
socket.destroy();
|
||||
log.error('[WebSocket] 处理升级错误:', error)
|
||||
socket.destroy()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -270,106 +268,107 @@ export class ASRServerService {
|
||||
* @param socket 套接字
|
||||
*/
|
||||
private handleWebSocketConnection(socket: any): void {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let role: 'browser' | 'electron' | null = null;
|
||||
let buffer = Buffer.alloc(0)
|
||||
const role: 'browser' | 'electron' | null = null
|
||||
|
||||
socket.on('data', (data: Buffer) => {
|
||||
try {
|
||||
buffer = Buffer.concat([buffer, data]);
|
||||
buffer = Buffer.concat([buffer, data])
|
||||
|
||||
// 处理数据帧
|
||||
while (buffer.length > 2) {
|
||||
// 检查是否有完整的帧
|
||||
const firstByte = buffer[0];
|
||||
const secondByte = buffer[1];
|
||||
const firstByte = buffer[0]
|
||||
const secondByte = buffer[1]
|
||||
// const isFinalFrame = Boolean((firstByte >>> 7) & 0x1); // 暂时不使用
|
||||
const [opCode, maskFlag, payloadLength] = [
|
||||
firstByte & 0xF, (secondByte >>> 7) & 0x1, secondByte & 0x7F
|
||||
];
|
||||
const [opCode, maskFlag, payloadLength] = [firstByte & 0xf, (secondByte >>> 7) & 0x1, secondByte & 0x7f]
|
||||
|
||||
// 处理不同的负载长度
|
||||
let payloadStartIndex = 2;
|
||||
let payloadLen = payloadLength;
|
||||
let payloadStartIndex = 2
|
||||
let payloadLen = payloadLength
|
||||
|
||||
if (payloadLength === 126) {
|
||||
payloadLen = buffer.readUInt16BE(2);
|
||||
payloadStartIndex = 4;
|
||||
payloadLen = buffer.readUInt16BE(2)
|
||||
payloadStartIndex = 4
|
||||
} else if (payloadLength === 127) {
|
||||
// 处理大于16位的长度
|
||||
payloadLen = Number(buffer.readBigUInt64BE(2));
|
||||
payloadStartIndex = 10;
|
||||
payloadLen = Number(buffer.readBigUInt64BE(2))
|
||||
payloadStartIndex = 10
|
||||
}
|
||||
|
||||
// 处理掩码
|
||||
let maskingKey: Buffer | undefined;
|
||||
let maskingKey: Buffer | undefined
|
||||
if (maskFlag) {
|
||||
maskingKey = buffer.slice(payloadStartIndex, payloadStartIndex + 4);
|
||||
payloadStartIndex += 4;
|
||||
maskingKey = buffer.slice(payloadStartIndex, payloadStartIndex + 4)
|
||||
payloadStartIndex += 4
|
||||
}
|
||||
|
||||
// 检查是否有足够的数据
|
||||
const frameEnd = payloadStartIndex + payloadLen;
|
||||
const frameEnd = payloadStartIndex + payloadLen
|
||||
if (buffer.length < frameEnd) {
|
||||
// 需要更多数据
|
||||
break;
|
||||
break
|
||||
}
|
||||
|
||||
// 提取负载
|
||||
let payload = buffer.slice(payloadStartIndex, frameEnd);
|
||||
const payload = buffer.slice(payloadStartIndex, frameEnd)
|
||||
|
||||
// 如果有掩码,解码负载
|
||||
if (maskFlag && maskingKey) {
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] = payload[i] ^ maskingKey[i % 4];
|
||||
payload[i] = payload[i] ^ maskingKey[i % 4]
|
||||
}
|
||||
}
|
||||
|
||||
// 处理不同的操作码
|
||||
if (opCode === 0x8) {
|
||||
// 关闭帧
|
||||
log.info('[WebSocket] 收到关闭帧');
|
||||
socket.end();
|
||||
return;
|
||||
log.info('[WebSocket] 收到关闭帧')
|
||||
socket.end()
|
||||
return
|
||||
} else if (opCode === 0x9) {
|
||||
// Ping
|
||||
this.sendPong(socket);
|
||||
this.sendPong(socket)
|
||||
} else if (opCode === 0x1 || opCode === 0x2) {
|
||||
// 文本或二进制数据
|
||||
const message = opCode === 0x1 ? payload.toString('utf8') : payload;
|
||||
this.handleMessage(socket, message, role);
|
||||
const message = opCode === 0x1 ? payload.toString('utf8') : payload
|
||||
this.handleMessage(socket, message, role)
|
||||
}
|
||||
|
||||
// 移除已处理的帧
|
||||
buffer = buffer.slice(frameEnd);
|
||||
buffer = buffer.slice(frameEnd)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[WebSocket] 处理数据错误:', error);
|
||||
log.error('[WebSocket] 处理数据错误:', error)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
socket.on('close', () => {
|
||||
const socketRole = (socket as any)._role || role;
|
||||
log.info(`[WebSocket] 连接关闭${socketRole ? ` (${socketRole})` : ''}`);
|
||||
const socketRole = (socket as any)._role || role
|
||||
log.info(`[WebSocket] 连接关闭${socketRole ? ` (${socketRole})` : ''}`)
|
||||
|
||||
if (socketRole === 'browser') {
|
||||
this.wsClients.browser = null;
|
||||
this.wsClients.browser = null
|
||||
|
||||
// 如果浏览器断开连接,通知Electron客户端
|
||||
if (this.wsClients.electron) {
|
||||
this.sendWebSocketFrame(this.wsClients.electron, JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'Browser disconnected'
|
||||
}));
|
||||
log.info('[WebSocket] 已向Electron发送Browser disconnected状态');
|
||||
this.sendWebSocketFrame(
|
||||
this.wsClients.electron,
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'Browser disconnected'
|
||||
})
|
||||
)
|
||||
log.info('[WebSocket] 已向Electron发送Browser disconnected状态')
|
||||
}
|
||||
} else if (socketRole === 'electron') {
|
||||
this.wsClients.electron = null;
|
||||
this.wsClients.electron = null
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
log.error(`[WebSocket] 套接字错误${role ? ` (${role})` : ''}:`, error);
|
||||
});
|
||||
log.error(`[WebSocket] 套接字错误${role ? ` (${role})` : ''}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -380,27 +379,27 @@ export class ASRServerService {
|
||||
*/
|
||||
private sendWebSocketFrame(socket: any, data: string | object, opCode = 0x1): void {
|
||||
try {
|
||||
const payload = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
const payloadLength = payload.length;
|
||||
const payload = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data))
|
||||
const payloadLength = payload.length
|
||||
|
||||
let header: Buffer;
|
||||
let header: Buffer
|
||||
if (payloadLength < 126) {
|
||||
header = Buffer.from([0x80 | opCode, payloadLength]);
|
||||
header = Buffer.from([0x80 | opCode, payloadLength])
|
||||
} else if (payloadLength < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x80 | opCode;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(payloadLength, 2);
|
||||
header = Buffer.alloc(4)
|
||||
header[0] = 0x80 | opCode
|
||||
header[1] = 126
|
||||
header.writeUInt16BE(payloadLength, 2)
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x80 | opCode;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(payloadLength), 2);
|
||||
header = Buffer.alloc(10)
|
||||
header[0] = 0x80 | opCode
|
||||
header[1] = 127
|
||||
header.writeBigUInt64BE(BigInt(payloadLength), 2)
|
||||
}
|
||||
|
||||
socket.write(Buffer.concat([header, payload]));
|
||||
socket.write(Buffer.concat([header, payload]))
|
||||
} catch (error) {
|
||||
log.error('[WebSocket] 发送数据错误:', error);
|
||||
log.error('[WebSocket] 发送数据错误:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -409,8 +408,8 @@ export class ASRServerService {
|
||||
* @param socket 套接字
|
||||
*/
|
||||
private sendPong(socket: any): void {
|
||||
const pongFrame = Buffer.from([0x8A, 0x00]);
|
||||
socket.write(pongFrame);
|
||||
const pongFrame = Buffer.from([0x8a, 0x00])
|
||||
socket.write(pongFrame)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -422,65 +421,71 @@ export class ASRServerService {
|
||||
private handleMessage(socket: any, message: string | Buffer, currentRole: string | null): void {
|
||||
try {
|
||||
if (typeof message === 'string') {
|
||||
const data = JSON.parse(message);
|
||||
const data = JSON.parse(message)
|
||||
|
||||
// 处理身份识别
|
||||
if (data.type === 'identify') {
|
||||
const role = data.role;
|
||||
const role = data.role
|
||||
if (role === 'browser' || role === 'electron') {
|
||||
log.info(`[WebSocket] 客户端识别为: ${role}`);
|
||||
log.info(`[WebSocket] 客户端识别为: ${role}`)
|
||||
|
||||
// 存储客户端连接
|
||||
this.wsClients[role] = socket;
|
||||
this.wsClients[role] = socket
|
||||
// 设置当前连接的角色
|
||||
(socket as any)._role = role;
|
||||
;(socket as any)._role = role
|
||||
|
||||
// 如果是浏览器连接,通知Electron客户端
|
||||
if (role === 'browser' && this.wsClients.electron) {
|
||||
// 发送browser_ready消息
|
||||
this.sendWebSocketFrame(this.wsClients.electron, JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'browser_ready'
|
||||
}));
|
||||
log.info('[WebSocket] 已向Electron发送browser_ready状态');
|
||||
this.sendWebSocketFrame(
|
||||
this.wsClients.electron,
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'browser_ready'
|
||||
})
|
||||
)
|
||||
log.info('[WebSocket] 已向Electron发送browser_ready状态')
|
||||
|
||||
// 发送Browser connected消息
|
||||
this.sendWebSocketFrame(this.wsClients.electron, JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'Browser connected'
|
||||
}));
|
||||
log.info('[WebSocket] 已向Electron发送Browser connected状态');
|
||||
this.sendWebSocketFrame(
|
||||
this.wsClients.electron,
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'Browser connected'
|
||||
})
|
||||
)
|
||||
log.info('[WebSocket] 已向Electron发送Browser connected状态')
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前连接的角色
|
||||
const role = currentRole || (socket as any)._role;
|
||||
const role = currentRole || (socket as any)._role
|
||||
|
||||
// 转发消息
|
||||
if (role === 'browser') {
|
||||
// 浏览器发送的消息转发给Electron
|
||||
if (this.wsClients.electron) {
|
||||
log.info(`[WebSocket] Browser -> Electron: ${JSON.stringify(data)}`);
|
||||
this.sendWebSocketFrame(this.wsClients.electron, message);
|
||||
log.info(`[WebSocket] Browser -> Electron: ${JSON.stringify(data)}`)
|
||||
this.sendWebSocketFrame(this.wsClients.electron, message)
|
||||
} else {
|
||||
log.info('[WebSocket] 无法转发消息: Electron客户端未连接');
|
||||
log.info('[WebSocket] 无法转发消息: Electron客户端未连接')
|
||||
}
|
||||
} else if (role === 'electron') {
|
||||
// Electron发送的消息转发给浏览器
|
||||
if (this.wsClients.browser) {
|
||||
log.info(`[WebSocket] Electron -> Browser: ${JSON.stringify(data)}`);
|
||||
this.sendWebSocketFrame(this.wsClients.browser, message);
|
||||
log.info(`[WebSocket] Electron -> Browser: ${JSON.stringify(data)}`)
|
||||
this.sendWebSocketFrame(this.wsClients.browser, message)
|
||||
} else {
|
||||
log.info('[WebSocket] 无法转发消息: 浏览器客户端未连接');
|
||||
log.info('[WebSocket] 无法转发消息: 浏览器客户端未连接')
|
||||
}
|
||||
} else {
|
||||
log.info(`[WebSocket] 收到来自未知角色的消息: ${message}`);
|
||||
log.info(`[WebSocket] 收到来自未知角色的消息: ${message}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[WebSocket] 处理消息错误:', error, message);
|
||||
log.error('[WebSocket] 处理消息错误:', error, message)
|
||||
}
|
||||
}
|
||||
|
||||
@ -490,31 +495,28 @@ export class ASRServerService {
|
||||
* @param pid 进程ID
|
||||
* @returns Promise<{success: boolean, error?: string}>
|
||||
*/
|
||||
private async stopServer(
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
_pid?: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
private async stopServer(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 关闭HTTP服务器
|
||||
if (this.httpServer) {
|
||||
this.httpServer.close();
|
||||
this.httpServer = null;
|
||||
this.httpServer.close()
|
||||
this.httpServer = null
|
||||
}
|
||||
|
||||
// 重置客户端连接
|
||||
this.wsClients = { browser: null, electron: null };
|
||||
this.wsClients = { browser: null, electron: null }
|
||||
|
||||
// 重置服务器状态
|
||||
this.isServerRunning = false;
|
||||
this.isServerRunning = false
|
||||
|
||||
log.info('ASR服务器已停止');
|
||||
return { success: true };
|
||||
log.info('ASR服务器已停止')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
log.error('停止ASR服务器失败:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
log.error('停止ASR服务器失败:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并导出单例
|
||||
export const asrServerService = new ASRServerService();
|
||||
export const asrServerService = new ASRServerService()
|
||||
|
||||
27
src/main/services/AxiosProxy.ts
Normal file
27
src/main/services/AxiosProxy.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { AxiosInstance, default as axios_ } from 'axios'
|
||||
|
||||
import { proxyManager } from './ProxyManager'
|
||||
|
||||
class AxiosProxy {
|
||||
private cacheAxios: AxiosInstance | undefined
|
||||
private proxyURL: string | undefined
|
||||
|
||||
get axios(): AxiosInstance {
|
||||
const currentProxyURL = proxyManager.getProxyUrl()
|
||||
if (this.proxyURL !== currentProxyURL) {
|
||||
this.proxyURL = currentProxyURL
|
||||
const agent = proxyManager.getProxyAgent()
|
||||
this.cacheAxios = axios_.create({
|
||||
proxy: false,
|
||||
...(agent && { httpAgent: agent, httpsAgent: agent })
|
||||
})
|
||||
}
|
||||
|
||||
if (this.cacheAxios === undefined) {
|
||||
this.cacheAxios = axios_.create({ proxy: false })
|
||||
}
|
||||
return this.cacheAxios
|
||||
}
|
||||
}
|
||||
|
||||
export default new AxiosProxy()
|
||||
@ -1,13 +1,12 @@
|
||||
import { spawn } from 'child_process'
|
||||
// 如果将来需要使用这些工具函数,可以取消注释
|
||||
// import { getBinaryPath, isBinaryExists } from '@main/utils/process'
|
||||
import log from 'electron-log'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// 如果将来需要使用这些工具函数,可以取消注释
|
||||
// import { getBinaryPath, isBinaryExists } from '@main/utils/process'
|
||||
import log from 'electron-log'
|
||||
|
||||
// 支持的语言类型
|
||||
export enum CodeLanguage {
|
||||
JavaScript = 'javascript',
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import aoxisProxy from './AxiosProxy'
|
||||
|
||||
// 配置常量,集中管理
|
||||
const CONFIG = {
|
||||
GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98',
|
||||
@ -93,7 +95,7 @@ class CopilotService {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
const response = await aoxisProxy.axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
return {
|
||||
login: response.data.login,
|
||||
avatar: response.data.avatar_url
|
||||
@ -114,7 +116,7 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const response = await axios.post<AuthResponse>(
|
||||
const response = await aoxisProxy.axios.post<AuthResponse>(
|
||||
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
|
||||
{
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
@ -146,7 +148,7 @@ class CopilotService {
|
||||
await this.delay(currentDelay)
|
||||
|
||||
try {
|
||||
const response = await axios.post<TokenResponse>(
|
||||
const response = await aoxisProxy.axios.post<TokenResponse>(
|
||||
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
|
||||
{
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
@ -208,7 +210,7 @@ class CopilotService {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
const response = await aoxisProxy.axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@ -22,9 +23,12 @@ import {
|
||||
} from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
||||
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
|
||||
|
||||
// Generic type for caching wrapped functions
|
||||
@ -117,103 +121,172 @@ class McpService {
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
|
||||
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
const authProvider = new McpOAuthClientProvider({
|
||||
serverUrlHash: crypto
|
||||
.createHash('md5')
|
||||
.update(server.baseUrl || '')
|
||||
.digest('hex')
|
||||
})
|
||||
|
||||
try {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
// set the client transport to the client
|
||||
transport = clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
const options: StreamableHTTPClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
const initTransport = async (): Promise<
|
||||
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
> => {
|
||||
// Create appropriate transport based on configuration
|
||||
if (server.type === 'inMemory') {
|
||||
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
// start the in-memory server with the given name and environment variables
|
||||
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
|
||||
try {
|
||||
await inMemoryServer.connect(serverTransport)
|
||||
Logger.info(`[MCP] In-memory server started: ${server.name}`)
|
||||
} catch (error: Error | any) {
|
||||
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
|
||||
throw new Error(`Failed to start in-memory server: ${error.message}`)
|
||||
}
|
||||
// return the client transport
|
||||
return clientTransport
|
||||
} else if (server.baseUrl) {
|
||||
if (server.type === 'streamableHttp') {
|
||||
const options: StreamableHTTPClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||
} else if (server.type === 'sse') {
|
||||
const options: SSEClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
},
|
||||
authProvider
|
||||
}
|
||||
return new SSEClientTransport(new URL(server.baseUrl!), options)
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
if (!args.includes('-y')) {
|
||||
!args.includes('-y') && args.unshift('-y')
|
||||
}
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
}
|
||||
}
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
NPM_CONFIG_REGISTRY: server.registryUrl
|
||||
}
|
||||
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name.includes('mcp-auto-install')) {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
cmd = await getBinaryPath(server.command)
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
UV_DEFAULT_INDEX: server.registryUrl,
|
||||
PIP_INDEX_URL: server.registryUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
transport = new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
|
||||
} else if (server.type === 'sse') {
|
||||
const options: SSEClientTransportOptions = {
|
||||
requestInit: {
|
||||
headers: server.headers || {}
|
||||
}
|
||||
}
|
||||
transport = new SSEClientTransport(new URL(server.baseUrl!), options)
|
||||
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: await this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
stdioTransport.stderr?.on('data', (data: Buffer) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
return stdioTransport
|
||||
} else {
|
||||
throw new Error('Invalid server type')
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
}
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
if (args && args.length > 0) {
|
||||
if (!args.includes('-y')) {
|
||||
!args.includes('-y') && args.unshift('-y')
|
||||
}
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
}
|
||||
}
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
NPM_CONFIG_REGISTRY: server.registryUrl
|
||||
}
|
||||
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name.includes('mcp-auto-install')) {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
cmd = await getBinaryPath(server.command)
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
UV_DEFAULT_INDEX: server.registryUrl,
|
||||
PIP_INDEX_URL: server.registryUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: await this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
transport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
} else {
|
||||
throw new Error('Either baseUrl or command must be provided')
|
||||
// This line is unreachable
|
||||
}
|
||||
|
||||
await client.connect(transport)
|
||||
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
|
||||
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
|
||||
// Create an event emitter for the OAuth callback
|
||||
const events = new EventEmitter()
|
||||
|
||||
// Create a callback server
|
||||
const callbackServer = new CallBackServer({
|
||||
port: authProvider.config.callbackPort,
|
||||
path: authProvider.config.callbackPath || '/oauth/callback',
|
||||
events
|
||||
})
|
||||
|
||||
// Set a timeout to close the callback server
|
||||
const timeoutId = setTimeout(() => {
|
||||
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
|
||||
callbackServer.close()
|
||||
}, 300000) // 5 minutes timeout
|
||||
|
||||
try {
|
||||
// Wait for the authorization code
|
||||
const authCode = await callbackServer.waitForAuthCode()
|
||||
Logger.info(`[MCP] Received auth code: ${authCode}`)
|
||||
|
||||
// Complete the OAuth flow
|
||||
await transport.finishAuth(authCode)
|
||||
|
||||
await client.connect(transport)
|
||||
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
|
||||
|
||||
const newTransport = await initTransport()
|
||||
// Try to connect again
|
||||
await client.connect(newTransport)
|
||||
|
||||
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
|
||||
} catch (oauthError) {
|
||||
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
|
||||
throw new Error(
|
||||
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
|
||||
)
|
||||
} finally {
|
||||
// Clear the timeout and close the callback server
|
||||
clearTimeout(timeoutId)
|
||||
callbackServer.close()
|
||||
}
|
||||
}
|
||||
|
||||
const transport = await initTransport()
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error: Error | any) {
|
||||
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
|
||||
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
|
||||
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Store the new client in the cache
|
||||
this.clients.set(serverKey, client)
|
||||
@ -514,15 +587,12 @@ class McpService {
|
||||
|
||||
// 根据不同的 shell 构建不同的命令
|
||||
if (userShell.includes('zsh')) {
|
||||
shell = '/bin/zsh'
|
||||
command =
|
||||
'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
|
||||
} else if (userShell.includes('bash')) {
|
||||
shell = '/bin/bash'
|
||||
command =
|
||||
'source /etc/profile 2>/dev/null || true; source ~/.bash_profile 2>/dev/null || true; source ~/.bash_login 2>/dev/null || true; source ~/.profile 2>/dev/null || true; source ~/.bashrc 2>/dev/null || true; echo $PATH'
|
||||
} else if (userShell.includes('fish')) {
|
||||
shell = '/bin/fish'
|
||||
command =
|
||||
'source /etc/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.local.fish 2>/dev/null || true; echo $PATH'
|
||||
} else {
|
||||
@ -540,15 +610,15 @@ class McpService {
|
||||
})
|
||||
|
||||
let path = ''
|
||||
child.stdout.on('data', (data) => {
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
path += data.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
console.error('Error getting PATH:', data.toString())
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
child.on('close', (code: number) => {
|
||||
if (code === 0) {
|
||||
const trimmedPath = path.trim()
|
||||
resolve(trimmedPath)
|
||||
|
||||
@ -405,6 +405,7 @@ export class WindowService {
|
||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
//make miniWindow always on top of fullscreen apps with level set
|
||||
//[mac] level higher than 'floating' will cover the pinyin input method
|
||||
//[win] level 'floating' will cover the pinyin input method
|
||||
this.miniWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
this.miniWindow.on('ready-to-show', () => {
|
||||
|
||||
76
src/main/services/mcp/oauth/callback.ts
Normal file
76
src/main/services/mcp/oauth/callback.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import Logger from 'electron-log'
|
||||
import EventEmitter from 'events'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
|
||||
import { OAuthCallbackServerOptions } from './types'
|
||||
|
||||
export class CallBackServer {
|
||||
private server: Promise<http.Server>
|
||||
private events: EventEmitter
|
||||
|
||||
constructor(options: OAuthCallbackServerOptions) {
|
||||
const { port, path, events } = options
|
||||
this.events = events
|
||||
this.server = this.initialize(port, path)
|
||||
}
|
||||
|
||||
initialize(port: number, path: string): Promise<http.Server> {
|
||||
const server = http.createServer((req, res) => {
|
||||
// Only handle requests to the callback path
|
||||
if (req.url?.startsWith(path)) {
|
||||
try {
|
||||
// Parse the URL to extract the authorization code
|
||||
const url = new URL(req.url, `http://localhost:${port}`)
|
||||
const code = url.searchParams.get('code')
|
||||
if (code) {
|
||||
// Emit the code event
|
||||
this.events.emit('auth-code-received', code)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Error processing OAuth callback:', error)
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
||||
res.end('Internal Server Error')
|
||||
}
|
||||
} else {
|
||||
// Not a callback request
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('Not Found')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle server errors
|
||||
server.on('error', (error) => {
|
||||
Logger.error('OAuth callback server error:', error)
|
||||
})
|
||||
|
||||
const runningServer = new Promise<http.Server>((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
Logger.info(`OAuth callback server listening on port ${port}`)
|
||||
resolve(server)
|
||||
})
|
||||
|
||||
server.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
return runningServer
|
||||
}
|
||||
|
||||
get getServer(): Promise<http.Server> {
|
||||
return this.server
|
||||
}
|
||||
|
||||
async close() {
|
||||
const server = await this.server
|
||||
server.close()
|
||||
}
|
||||
|
||||
async waitForAuthCode(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
this.events.once('auth-code-received', (code) => {
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
78
src/main/services/mcp/oauth/provider.ts
Normal file
78
src/main/services/mcp/oauth/provider.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { getConfigDir } from '@main/utils/file'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth'
|
||||
import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth'
|
||||
import Logger from 'electron-log'
|
||||
import open from 'open'
|
||||
|
||||
import { JsonFileStorage } from './storage'
|
||||
import { OAuthProviderOptions } from './types'
|
||||
|
||||
export class McpOAuthClientProvider implements OAuthClientProvider {
|
||||
private storage: JsonFileStorage
|
||||
public readonly config: Required<OAuthProviderOptions>
|
||||
|
||||
constructor(options: OAuthProviderOptions) {
|
||||
const configDir = path.join(getConfigDir(), 'mcp', 'oauth')
|
||||
this.config = {
|
||||
serverUrlHash: options.serverUrlHash,
|
||||
callbackPort: options.callbackPort || 12346,
|
||||
callbackPath: options.callbackPath || '/oauth/callback',
|
||||
configDir: options.configDir || configDir,
|
||||
clientName: options.clientName || 'Cherry Studio',
|
||||
clientUri: options.clientUri || 'https://github.com/CherryHQ/cherry-studio'
|
||||
}
|
||||
this.storage = new JsonFileStorage(this.config.serverUrlHash, this.config.configDir)
|
||||
}
|
||||
|
||||
get redirectUrl(): string {
|
||||
return `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: 'none',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
client_name: this.config.clientName,
|
||||
client_uri: this.config.clientUri
|
||||
}
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
return this.storage.getClientInformation()
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
await this.storage.saveClientInformation(info)
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return this.storage.getTokens()
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await this.storage.saveTokens(tokens)
|
||||
}
|
||||
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
try {
|
||||
// Open the browser to the authorization URL
|
||||
await open(authorizationUrl.toString())
|
||||
Logger.info('Browser opened automatically.')
|
||||
} catch (error) {
|
||||
Logger.error('Could not open browser automatically.')
|
||||
throw error // Let caller handle the error
|
||||
}
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
await this.storage.saveCodeVerifier(codeVerifier)
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
return this.storage.getCodeVerifier()
|
||||
}
|
||||
}
|
||||
120
src/main/services/mcp/oauth/storage.ts
Normal file
120
src/main/services/mcp/oauth/storage.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import Logger from 'electron-log'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import { IOAuthStorage, OAuthStorageData, OAuthStorageSchema } from './types'
|
||||
|
||||
export class JsonFileStorage implements IOAuthStorage {
|
||||
private readonly filePath: string
|
||||
private cache: OAuthStorageData | null = null
|
||||
|
||||
constructor(
|
||||
readonly serverUrlHash: string,
|
||||
configDir: string
|
||||
) {
|
||||
this.filePath = path.join(configDir, `${serverUrlHash}_oauth.json`)
|
||||
}
|
||||
|
||||
private async readStorage(): Promise<OAuthStorageData> {
|
||||
if (this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(this.filePath, 'utf-8')
|
||||
const parsed = JSON.parse(data)
|
||||
const validated = OAuthStorageSchema.parse(parsed)
|
||||
this.cache = validated
|
||||
return validated
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
// File doesn't exist, return initial state
|
||||
const initial: OAuthStorageData = { lastUpdated: Date.now() }
|
||||
await this.writeStorage(initial)
|
||||
return initial
|
||||
}
|
||||
Logger.error('Error reading OAuth storage:', error)
|
||||
throw new Error(`Failed to read OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStorage(data: OAuthStorageData): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
|
||||
|
||||
// Update timestamp
|
||||
data.lastUpdated = Date.now()
|
||||
|
||||
// Write file atomically
|
||||
const tempPath = `${this.filePath}.tmp`
|
||||
await fs.writeFile(tempPath, JSON.stringify(data, null, 2))
|
||||
await fs.rename(tempPath, this.filePath)
|
||||
|
||||
// Update cache
|
||||
this.cache = data
|
||||
} catch (error) {
|
||||
Logger.error('Error writing OAuth storage:', error)
|
||||
throw new Error(`Failed to write OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getClientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
const data = await this.readStorage()
|
||||
return data.clientInfo
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
const data = await this.readStorage()
|
||||
await this.writeStorage({
|
||||
...data,
|
||||
clientInfo: info
|
||||
})
|
||||
}
|
||||
|
||||
async getTokens(): Promise<OAuthTokens | undefined> {
|
||||
const data = await this.readStorage()
|
||||
return data.tokens
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const data = await this.readStorage()
|
||||
await this.writeStorage({
|
||||
...data,
|
||||
tokens
|
||||
})
|
||||
}
|
||||
|
||||
async getCodeVerifier(): Promise<string> {
|
||||
const data = await this.readStorage()
|
||||
if (!data.codeVerifier) {
|
||||
throw new Error('No code verifier saved for session')
|
||||
}
|
||||
return data.codeVerifier
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
const data = await this.readStorage()
|
||||
await this.writeStorage({
|
||||
...data,
|
||||
codeVerifier
|
||||
})
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.filePath)
|
||||
this.cache = null
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||
Logger.error('Error clearing OAuth storage:', error)
|
||||
throw new Error(`Failed to clear OAuth storage: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/main/services/mcp/oauth/types.ts
Normal file
61
src/main/services/mcp/oauth/types.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import EventEmitter from 'events'
|
||||
import { z } from 'zod'
|
||||
|
||||
export interface OAuthStorageData {
|
||||
clientInfo?: OAuthClientInformation
|
||||
tokens?: OAuthTokens
|
||||
codeVerifier?: string
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
export const OAuthStorageSchema = z.object({
|
||||
clientInfo: z.any().optional(),
|
||||
tokens: z.any().optional(),
|
||||
codeVerifier: z.string().optional(),
|
||||
lastUpdated: z.number()
|
||||
})
|
||||
|
||||
export interface IOAuthStorage {
|
||||
getClientInformation(): Promise<OAuthClientInformation | undefined>
|
||||
saveClientInformation(info: OAuthClientInformationFull): Promise<void>
|
||||
getTokens(): Promise<OAuthTokens | undefined>
|
||||
saveTokens(tokens: OAuthTokens): Promise<void>
|
||||
getCodeVerifier(): Promise<string>
|
||||
saveCodeVerifier(codeVerifier: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth callback server setup options
|
||||
*/
|
||||
export interface OAuthCallbackServerOptions {
|
||||
/** Port for the callback server */
|
||||
port: number
|
||||
/** Path for the callback endpoint */
|
||||
path: string
|
||||
/** Event emitter to signal when auth code is received */
|
||||
events: EventEmitter
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating an OAuth client provider
|
||||
*/
|
||||
export interface OAuthProviderOptions {
|
||||
/** Server URL to connect to */
|
||||
serverUrlHash: string
|
||||
/** Port for the OAuth callback server */
|
||||
callbackPort?: number
|
||||
/** Path for the OAuth callback endpoint */
|
||||
callbackPath?: string
|
||||
/** Directory to store OAuth credentials */
|
||||
configDir?: string
|
||||
/** Client name to use for OAuth registration */
|
||||
clientName?: string
|
||||
/** Client URI to use for OAuth registration */
|
||||
clientUri?: string
|
||||
}
|
||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@ -33,6 +33,7 @@ declare global {
|
||||
clearCache: () => Promise<{ success: boolean; error?: string }>
|
||||
system: {
|
||||
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
|
||||
getHostname: () => Promise<string>
|
||||
}
|
||||
zip: {
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
@ -207,11 +208,11 @@ declare global {
|
||||
deleteShortMemoryById: (id: string) => Promise<boolean>
|
||||
loadLongTermData: () => Promise<any>
|
||||
saveLongTermData: (data: any, forceOverwrite?: boolean) => Promise<boolean>
|
||||
},
|
||||
}
|
||||
asrServer: {
|
||||
startServer: () => Promise<{ success: boolean; pid?: number; port?: number; error?: string }>
|
||||
stopServer: (pid: number) => Promise<{ success: boolean; error?: string }>
|
||||
},
|
||||
}
|
||||
pdf: {
|
||||
splitPDF: (file: FileType, pageRange: string) => Promise<FileType>
|
||||
}
|
||||
|
||||
@ -22,7 +22,8 @@ const api = {
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType)
|
||||
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
|
||||
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
|
||||
},
|
||||
zip: {
|
||||
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),
|
||||
|
||||
1
src/renderer/src/assets/images/apps/zai.png
Normal file
1
src/renderer/src/assets/images/apps/zai.png
Normal file
@ -0,0 +1 @@
|
||||
iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF8WlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDI1LTA0LTE5VDIyOjE1OjI3KzA4OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyNS0wNC0xOVQyMjoxNjoxMCswODowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyNS0wNC0xOVQyMjoxNjoxMCswODowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJzUkdCIElFQzYxOTY2LTIuMSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo2YzRlZjRmZC1hMzA0LTRkNDQtOWM0Yy1mZjk3MzA5ZDRkMzAiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo3ZDlmNzZiYS1jMzA1LTExZWQtOGM1Ny1mMTQzNzA3ZWM4ZjAiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo2YzRlZjRmZC1hMzA0LTRkNDQtOWM0Yy1mZjk3MzA5ZDRkMzAiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjZjNGVmNGZkLWEzMDQtNGQ0NC05YzRjLWZmOTczMDlkNGQzMCIgc3RFdnQ6d2hlbj0iMjAyNS0wNC0xOVQyMjoxNToyNyswODowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PgEk4MkAAAiASURBVHic7Z1bbBRVGMd/Z7rtLqUUWmgLpRQQEMFbQUVRo4KXRB+M8YFo9MFEI2h8MvHyYIwxPnl5wMQYb9EYb4kmxqgxxvsF8IIo3kBuQqGl0Jbdnd3j4+zU2e3Mzs7Mzpk53f1/SdPOzM6e8/2/851zvnNmzoBlDh7Zt2jw1hNXDw4NnDo4NHAqjnOWYvlMYFngZeAJYMD9+3bgIuBT4FXgUOgRWgYHh+4bvPWEO4eGTjt1cGjgVOAU4GTgZGAJsBRYBiwHVgKrgVXAGmAtsA5YD2wANgKbgM3AFuB4YCtwArANOBHYDpwE7ABOBXYCG4BdwGnA6cAZwJnAWcDZwDnAucB5wPnABcCFwEXAxcAlwKXAZcDlwBXAlcBVwNXANcC1wHXA9cANwI3ATcDNwC3ArcBtwO3AHcCdwF3A3cA9wL3AfcD9wAPAg8BDwMPAI8CjwGPADuBx4AngSeAp4GngGeA54HngBeAl4GXgFeBV4DXgdeAN4E3gLeAd4F3gPeB94APgQ+Aj4GPgE+BT4DPgc+AL4EvgK+Br4BvgW+A74Hvgh8Gfj/wI/AT8DPwC/Ar8BvwO/AH8CfwF/A38A/wL/Af8D/wHHAYOA0eAo8AxYAQYBcaAcWACmASmgGlgBpgF5oB5oAJUgSqggCZQA+pAA2gCLaANdIAu0AP6QAkYAMrAIDAEjABjwDgwAUwCU8A0MAPMAnPAPFABqkAVaAJ1oAE0gRbQBjpAF+gBfaAEDIAyMAiMAKPAGDAOTACTwBQwDcwAs8AcMA9UgCpQBZpAHWgATaAFtIEO0AV6QB8oAQNgGBgERoBRYAwYByaASWAKmAZmgFlgDpgHKkAVqAJNoA40gCbQAtpAB+gCPaAPlIABMAwMAiPAKDAGjAMTwCQwBUwDM8AsMAfMAxWgClSBJlAHGkATaAFtoAN0gR7QB0rAABgGBoERYBQYA8aBCWASmAKmgRlgFpgD5oEKUAWqQBOoAw2gCbSANtABukAP6AMlYAAMA4PACDAKjAHjwAQwCUwB08AMMAvMAfNABagCVaAJ1IEG0ARaQBvoAF2gB/SBEjAAhoFBYAQYBcaAcWACmASmgGlgBpgF5oB5oAJUgSrQBOpAA2gCLaANdIAu0AP6QAkYAMPAIDAC/A9QXvgfAE4AAAABJRU5ErkJggg==
|
||||
@ -260,6 +260,7 @@ body,
|
||||
.markdown,
|
||||
.anticon,
|
||||
.iconfont,
|
||||
.lucide,
|
||||
.message-tokens {
|
||||
color: var(--chat-text-user) !important;
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { addAssistantMemoryItem } from '@renderer/services/MemoryService'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { deleteAssistantMemory } from '@renderer/store/memory'
|
||||
import { Button, Empty, Input, List, Modal, Tooltip, Typography } from 'antd'
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Provider } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface ExecutionResultProps {
|
||||
success: boolean
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { openSearchPanel, search } from '@codemirror/search'
|
||||
import { Extension } from '@codemirror/state'
|
||||
import { search, openSearchPanel } from '@codemirror/search'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
// 创建中文搜索面板
|
||||
|
||||
@ -1,34 +1,33 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap, undo, redo, indentWithTab } from '@codemirror/commands'
|
||||
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cpp } from '@codemirror/lang-cpp'
|
||||
import { java } from '@codemirror/lang-java'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { rust } from '@codemirror/lang-rust'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { xml } from '@codemirror/lang-xml'
|
||||
import { vue } from '@codemirror/lang-vue'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import { searchKeymap } from '@codemirror/search'
|
||||
import { createChineseSearchPanel, openChineseSearchPanel } from './ChineseSearchPanel'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { useEffect, useRef, useMemo, forwardRef, useImperativeHandle } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import './styles.css'
|
||||
import './ChineseSearchPanel.css'
|
||||
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab, redo, undo } from '@codemirror/commands'
|
||||
import { cpp } from '@codemirror/lang-cpp'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { java } from '@codemirror/lang-java'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { rust } from '@codemirror/lang-rust'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { vue } from '@codemirror/lang-vue'
|
||||
import { xml } from '@codemirror/lang-xml'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { searchKeymap } from '@codemirror/search'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { createChineseSearchPanel, openChineseSearchPanel } from './ChineseSearchPanel'
|
||||
|
||||
// 自定义语法高亮样式
|
||||
const lightThemeHighlightStyle = HighlightStyle.define([
|
||||
@ -54,7 +53,7 @@ const lightThemeHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading, color: '#800000', fontWeight: 'bold' },
|
||||
{ tag: tags.link, color: '#0000ff', textDecoration: 'underline' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' }
|
||||
])
|
||||
|
||||
// 暗色主题语法高亮样式
|
||||
@ -81,7 +80,7 @@ const darkThemeHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading, color: '#569cd6', fontWeight: 'bold' },
|
||||
{ tag: tags.link, color: '#569cd6', textDecoration: 'underline' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' }
|
||||
])
|
||||
|
||||
export interface CodeMirrorEditorRef {
|
||||
@ -149,18 +148,16 @@ const getLanguageExtension = (language: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>((
|
||||
{
|
||||
code,
|
||||
language,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
showLineNumbers = true,
|
||||
fontSize = 14,
|
||||
height = 'auto'
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const CodeMirrorEditor = ({
|
||||
ref,
|
||||
code,
|
||||
language,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
showLineNumbers = true,
|
||||
fontSize = 14,
|
||||
height = 'auto'
|
||||
}: CodeMirrorEditorProps & { ref?: React.RefObject<CodeMirrorEditorRef | null> }) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const { theme } = useTheme()
|
||||
@ -223,13 +220,11 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
|
||||
const languageExtension = getLanguageExtension(language)
|
||||
|
||||
// 监听编辑器所有更新
|
||||
const updateListener = EditorView.updateListener.of(update => {
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
// 当文档变化时更新内部状态
|
||||
if (update.docChanged) {
|
||||
// 检查是否是撤销/重做操作
|
||||
const isUndoRedo = update.transactions.some(tr =>
|
||||
tr.isUserEvent('undo') || tr.isUserEvent('redo')
|
||||
)
|
||||
const isUndoRedo = update.transactions.some((tr) => tr.isUserEvent('undo') || tr.isUserEvent('redo'))
|
||||
|
||||
// 记录所有文档变化,但只在撤销/重做时触发 onChange
|
||||
if (isUndoRedo && onChange) {
|
||||
@ -247,9 +242,9 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
indentWithTab,
|
||||
{ key: "Mod-z", run: undo },
|
||||
{ key: "Mod-y", run: redo },
|
||||
{ key: "Mod-Shift-z", run: redo }
|
||||
{ key: 'Mod-z', run: undo },
|
||||
{ key: 'Mod-y', run: redo },
|
||||
{ key: 'Mod-Shift-z', run: redo }
|
||||
]),
|
||||
syntaxHighlighting(highlightStyle),
|
||||
languageExtension,
|
||||
@ -298,7 +293,7 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
|
||||
}, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height])
|
||||
|
||||
return <EditorContainer ref={editorRef} />
|
||||
});
|
||||
}
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
width: 100%;
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addProvider, removeProvider } from '@renderer/store/llm'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { createAllDeepClaudeProviders, checkModelCombinationsInLocalStorage } from '@renderer/utils/createDeepClaudeProvider'
|
||||
import {
|
||||
checkModelCombinationsInLocalStorage,
|
||||
createAllDeepClaudeProviders
|
||||
} from '@renderer/utils/createDeepClaudeProvider'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* DeepClaudeProvider组件
|
||||
@ -10,7 +13,7 @@ import { createAllDeepClaudeProviders, checkModelCombinationsInLocalStorage } fr
|
||||
*/
|
||||
const DeepClaudeProvider = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const providers = useAppSelector(state => state.llm.providers)
|
||||
const providers = useAppSelector((state) => state.llm.providers)
|
||||
|
||||
// 监听localStorage中的modelCombinations变化
|
||||
useEffect(() => {
|
||||
@ -41,9 +44,9 @@ const DeepClaudeProvider = () => {
|
||||
checkModelCombinationsInLocalStorage()
|
||||
|
||||
// 移除所有现有的DeepClaude提供商
|
||||
const existingDeepClaudeProviders = providers.filter(p => p.type === 'deepclaude')
|
||||
const existingDeepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
|
||||
console.log('[DeepClaudeProvider] 移除现有DeepClaude提供商数量:', existingDeepClaudeProviders.length)
|
||||
existingDeepClaudeProviders.forEach(provider => {
|
||||
existingDeepClaudeProviders.forEach((provider) => {
|
||||
dispatch(removeProvider(provider))
|
||||
})
|
||||
|
||||
@ -52,21 +55,30 @@ const DeepClaudeProvider = () => {
|
||||
console.log('[DeepClaudeProvider] 创建的DeepClaude提供商数量:', deepClaudeProviders.length)
|
||||
|
||||
// 列出所有提供商,便于调试
|
||||
console.log('[DeepClaudeProvider] 当前所有提供商:',
|
||||
providers.map(p => ({ id: p.id, name: p.name, type: p.type })))
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 当前所有提供商:',
|
||||
providers.map((p) => ({ id: p.id, name: p.name, type: p.type }))
|
||||
)
|
||||
|
||||
// 添加DeepClaude提供商
|
||||
deepClaudeProviders.forEach(provider => {
|
||||
console.log('[DeepClaudeProvider] 添加DeepClaude提供商:', provider.id, provider.name, provider.type,
|
||||
provider.models.length > 0 ? `包含${provider.models.length}个模型` : '无模型')
|
||||
deepClaudeProviders.forEach((provider) => {
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 添加DeepClaude提供商:',
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.type,
|
||||
provider.models.length > 0 ? `包含${provider.models.length}个模型` : '无模型'
|
||||
)
|
||||
dispatch(addProvider(provider))
|
||||
})
|
||||
|
||||
// 再次列出所有提供商,确认添加成功
|
||||
setTimeout(() => {
|
||||
const currentProviders = store.getState().llm.providers
|
||||
console.log('[DeepClaudeProvider] 添加后的所有提供商:',
|
||||
currentProviders.map((p: Provider) => ({ id: p.id, name: p.name, type: p.type })))
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 添加后的所有提供商:',
|
||||
currentProviders.map((p: Provider) => ({ id: p.id, name: p.name, type: p.type }))
|
||||
)
|
||||
console.log('[DeepClaudeProvider] DeepClaude提供商加载完成')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import store from '@renderer/store'
|
||||
import { setPdfSettings } from '@renderer/store/settings'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useEffect } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
/**
|
||||
* 用于在应用启动时初始化PDF设置
|
||||
@ -41,10 +41,12 @@ const PDFSettingsInitializer = () => {
|
||||
// 如果设置仍然不正确,再次强制设置
|
||||
if (!state.settings.pdfSettings?.enablePdfSplitting) {
|
||||
console.log('[PDFSettingsInitializer] Settings still incorrect, forcing again')
|
||||
dispatch(setPdfSettings({
|
||||
...state.settings.pdfSettings,
|
||||
enablePdfSplitting: true
|
||||
}))
|
||||
dispatch(
|
||||
setPdfSettings({
|
||||
...state.settings.pdfSettings,
|
||||
enablePdfSplitting: true
|
||||
})
|
||||
)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
|
||||
@ -58,7 +58,9 @@ const PopupContainer: React.FC<Props> = ({ assistantId, resolve }) => {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const assistantMemories = useAppSelector((state) => selectAssistantMemoriesByAssistantId(state, assistantId)) as AssistantMemory[]
|
||||
const assistantMemories = useAppSelector((state) =>
|
||||
selectAssistantMemoriesByAssistantId(state, assistantId)
|
||||
) as AssistantMemory[]
|
||||
|
||||
// 获取分析统计数据
|
||||
const totalAnalyses = useAppSelector((state) => state.memory?.analysisStats?.totalAnalyses || 0)
|
||||
@ -217,7 +219,13 @@ const PopupContainer: React.FC<Props> = ({ assistantId, resolve }) => {
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description={!assistantId ? t('settings.memory.noCurrentAssistant') || '无当前助手' : t('settings.memory.noAssistantMemories') || '无助手记忆'} />
|
||||
<Empty
|
||||
description={
|
||||
!assistantId
|
||||
? t('settings.memory.noCurrentAssistant') || '无当前助手'
|
||||
: t('settings.memory.noAssistantMemories') || '无助手记忆'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MemoriesList>
|
||||
</Modal>
|
||||
|
||||
@ -89,8 +89,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
|
||||
|
||||
// 缓存所有模型列表,只在providers变化时重新计算
|
||||
const allModels = useMemo(() => {
|
||||
return providers.flatMap((p) => p.models || [])
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
return providers.flatMap((p) => p.models || []).filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
}, [providers])
|
||||
|
||||
// --- Filter Models for Right Column ---
|
||||
@ -131,10 +130,13 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
|
||||
setSelectedProviderId(providerId)
|
||||
}, [])
|
||||
|
||||
const handleModelSelect = useCallback((model: Model) => {
|
||||
resolve(model)
|
||||
setOpen(false)
|
||||
}, [resolve, setOpen])
|
||||
const handleModelSelect = useCallback(
|
||||
(model: Model) => {
|
||||
resolve(model)
|
||||
setOpen(false)
|
||||
},
|
||||
[resolve, setOpen]
|
||||
)
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setOpen(false)
|
||||
@ -202,14 +204,17 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
|
||||
ref={inputRef}
|
||||
placeholder={t('models.search')}
|
||||
value={searchText}
|
||||
onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setSearchText(value)
|
||||
// 当搜索时,自动选择"all"供应商,以显示所有匹配的模型
|
||||
if (value.trim() && selectedProviderId !== 'all') {
|
||||
setSelectedProviderId('all')
|
||||
}
|
||||
}, [selectedProviderId, t])}
|
||||
onChange={useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setSearchText(value)
|
||||
// 当搜索时,自动选择"all"供应商,以显示所有匹配的模型
|
||||
if (value.trim() && selectedProviderId !== 'all') {
|
||||
setSelectedProviderId('all')
|
||||
}
|
||||
},
|
||||
[selectedProviderId, t]
|
||||
)}
|
||||
// 移除焦点事件处理
|
||||
allowClear
|
||||
autoFocus
|
||||
@ -266,7 +271,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
|
||||
</Tooltip>
|
||||
{/* Show provider only if not in pinned view or if search is active */}
|
||||
{(selectedProviderId !== PINNED_PROVIDER_ID || searchText) && (
|
||||
<Tooltip title={providers.find((p) => p.id === m.provider)?.name ?? m.provider} mouseEnterDelay={0.5}>
|
||||
<Tooltip
|
||||
title={providers.find((p) => p.id === m.provider)?.name ?? m.provider}
|
||||
mouseEnterDelay={0.5}>
|
||||
<span className="provider-name">
|
||||
| {providers.find((p) => p.id === m.provider)?.name ?? m.provider}
|
||||
</span>
|
||||
@ -382,12 +389,14 @@ const ModelListItem = styled.div<{ $selected: boolean }>`
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
.pin-button, .settings-button {
|
||||
.pin-button,
|
||||
.settings-button {
|
||||
opacity: 0.5; // Show buttons on hover
|
||||
}
|
||||
}
|
||||
|
||||
.pin-button, .settings-button {
|
||||
.pin-button,
|
||||
.settings-button {
|
||||
opacity: ${(props) => (props.$selected ? 0.5 : 0)}; // Show if selected or hovered
|
||||
transition: opacity 0.2s;
|
||||
&:hover {
|
||||
|
||||
@ -82,11 +82,18 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const pattern = lowerSearchText.split('').join('.*')
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true)
|
||||
if (pinyinText.toLowerCase().includes(lowerSearchText)) {
|
||||
try {
|
||||
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const regex = new RegExp(pattern, 'ig')
|
||||
return regex.test(filterText.toLowerCase())
|
||||
}
|
||||
|
||||
return false
|
||||
@ -206,6 +213,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
if (isComposing.current) return
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const cursorPosition = target.selectionStart
|
||||
const textBeforeCursor = target.value.slice(0, cursorPosition)
|
||||
@ -225,8 +233,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
isComposing.current = true
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
const handleCompositionEnd = (e: CompositionEvent) => {
|
||||
isComposing.current = false
|
||||
handleInput(e)
|
||||
}
|
||||
|
||||
textArea.addEventListener('input', handleInput)
|
||||
|
||||
@ -42,8 +42,9 @@ export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof b
|
||||
const showBackupModal = useCallback(async () => {
|
||||
// 获取默认文件名
|
||||
const deviceType = await window.api.system.getDeviceType()
|
||||
const hostname = await window.api.system.getHostname()
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
setCustomFileName(defaultFileName)
|
||||
setIsModalVisible(true)
|
||||
}, [])
|
||||
|
||||
@ -58,7 +58,8 @@ const NavbarCenterContainer = styled.div`
|
||||
color: var(--color-text-1);
|
||||
|
||||
/* 确保标题区域的按钮可点击 */
|
||||
& button, & a {
|
||||
& button,
|
||||
& a {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`
|
||||
@ -70,7 +71,6 @@ const NavbarRightContainer = styled.div`
|
||||
padding: 0 12px;
|
||||
padding-right: ${isWindows ? '140px' : 12};
|
||||
justify-content: flex-end;
|
||||
-webkit-app-region: no-drag; /* 确保按钮可点击 */
|
||||
|
||||
/* 确保所有子元素都可点击 */
|
||||
& > * {
|
||||
|
||||
@ -158,10 +158,13 @@ const visionAllowedModels = [
|
||||
'grok-vision-beta',
|
||||
'pixtral',
|
||||
'gpt-4(?:-[\\w-]+)',
|
||||
'gpt-4.1(?:-[\\w-]+)?',
|
||||
'gpt-4o(?:-[\\w-]+)?',
|
||||
'gpt-4.5(?:-[\\w-]+)',
|
||||
'chatgpt-4o(?:-[\\w-]+)?',
|
||||
'o1(?:-[\\w-]+)?',
|
||||
'o3(?:-[\\w-]+)?',
|
||||
'o4(?:-[\\w-]+)?',
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest',
|
||||
'gemma-3(?:-[\\w-]+)'
|
||||
@ -173,6 +176,7 @@ const visionExcludedModels = [
|
||||
'gpt-4-32k',
|
||||
'gpt-4-\\d+',
|
||||
'o1-mini',
|
||||
'o3-mini',
|
||||
'o1-preview',
|
||||
'AIDC-AI/Marco-o1'
|
||||
]
|
||||
@ -260,6 +264,7 @@ export function getModelLogo(modelId: string) {
|
||||
minimax: isLight ? MinimaxModelLogo : MinimaxModelLogoDark,
|
||||
o3: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
o1: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
o4: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
'gpt-3': isLight ? ChatGPT35ModelLogo : ChatGPT35ModelLogoDark,
|
||||
'gpt-4': isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||
@ -1084,16 +1089,22 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
],
|
||||
zhipu: [
|
||||
{
|
||||
id: 'glm-zero-preview',
|
||||
id: 'glm-z1-air',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-Zero-Preview',
|
||||
group: 'GLM-Zero'
|
||||
name: 'GLM-Z1-AIR',
|
||||
group: 'GLM-Z1'
|
||||
},
|
||||
{
|
||||
id: 'glm-4-0520',
|
||||
id: 'glm-z1-airx',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-4-0520',
|
||||
group: 'GLM-4'
|
||||
name: 'GLM-Z1-AIRX',
|
||||
group: 'GLM-Z1'
|
||||
},
|
||||
{
|
||||
id: 'glm-z1-flash',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-Z1-FLASH',
|
||||
group: 'GLM-Z1'
|
||||
},
|
||||
{
|
||||
id: 'glm-4-long',
|
||||
@ -1108,9 +1119,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'GLM-4'
|
||||
},
|
||||
{
|
||||
id: 'glm-4-air',
|
||||
id: 'glm-4-air-250414',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-4-Air',
|
||||
name: 'GLM-4-Air-250414',
|
||||
group: 'GLM-4'
|
||||
},
|
||||
{
|
||||
@ -1120,9 +1131,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'GLM-4'
|
||||
},
|
||||
{
|
||||
id: 'glm-4-flash',
|
||||
id: 'glm-4-flash-250414',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-4-Flash',
|
||||
name: 'GLM-4-Flash-250414',
|
||||
group: 'GLM-4'
|
||||
},
|
||||
{
|
||||
@ -1144,9 +1155,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'GLM-4v'
|
||||
},
|
||||
{
|
||||
id: 'glm-4v-plus',
|
||||
id: 'glm-4v-plus-0111',
|
||||
provider: 'zhipu',
|
||||
name: 'GLM-4V-Plus',
|
||||
name: 'GLM-4V-Plus-0111',
|
||||
group: 'GLM-4v'
|
||||
},
|
||||
{
|
||||
@ -2211,7 +2222,7 @@ export function isVisionModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
export function isOpenAIoSeries(model: Model): boolean {
|
||||
return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3')
|
||||
return model.id.includes('o1') || model.id.includes('o3') || model.id.includes('o4')
|
||||
}
|
||||
export function isOpenAIWebSearch(model: Model): boolean {
|
||||
return model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview')
|
||||
@ -2234,6 +2245,13 @@ export function isSupportedReasoningEffortModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isGrokModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
return model.id.includes('grok')
|
||||
}
|
||||
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
@ -2263,6 +2281,10 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
if (model.id.includes('glm-z1')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,8 @@ import {
|
||||
setTrayOnClose,
|
||||
setWindowStyle
|
||||
} from '@renderer/store/settings'
|
||||
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { SidebarIcon } from '@renderer/store/settings'
|
||||
import { ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
|
||||
export function useSettings() {
|
||||
const settings = useAppSelector((state) => state.settings)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SidebarIcon } from '@renderer/types'
|
||||
import { SidebarIcon } from '@renderer/store/settings'
|
||||
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
|
||||
@ -97,7 +97,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
|
||||
const data = { ...topic, name: topicName }
|
||||
// Check if _setActiveTopic exists and is a function before calling
|
||||
if (typeof _setActiveTopic === 'function') {
|
||||
_setActiveTopic(data)
|
||||
_setActiveTopic(data)
|
||||
}
|
||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
}
|
||||
@ -114,9 +114,9 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
|
||||
// Ensure topic is defined before using it
|
||||
if (summaryText && topic) {
|
||||
const data = { ...topic, name: summaryText }
|
||||
// Check if _setActiveTopic exists and is a function before calling
|
||||
// Check if _setActiveTopic exists and is a function before calling
|
||||
if (typeof _setActiveTopic === 'function') {
|
||||
_setActiveTopic(data)
|
||||
_setActiveTopic(data)
|
||||
}
|
||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
}
|
||||
|
||||
@ -374,7 +374,7 @@
|
||||
"no_api_key": "API key is not configured",
|
||||
"provider_disabled": "Model provider is not enabled",
|
||||
"render": {
|
||||
"description": "Failed to render formula. Please check if the formula format is correct",
|
||||
"description": "Failed to render message content. Please check if the message content format is correct",
|
||||
"title": "Render Error"
|
||||
},
|
||||
"user_message_not_found": "Cannot find original user message to resend",
|
||||
@ -1582,7 +1582,7 @@
|
||||
"subscribe_name": "Alternative name",
|
||||
"subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.",
|
||||
"subscribe_add_success": "Subscription feed added successfully!",
|
||||
"subscribe_delete": "Delete subscription source",
|
||||
"subscribe_delete": "Delete",
|
||||
"overwrite": "Override search service",
|
||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||
"apikey": "API key",
|
||||
|
||||
@ -335,7 +335,7 @@
|
||||
"no_api_key": "APIキーが設定されていません",
|
||||
"provider_disabled": "モデルプロバイダーが有効になっていません",
|
||||
"render": {
|
||||
"description": "数式のレンダリングに失敗しました。数式の形式が正しいか確認してください",
|
||||
"description": "メッセージの内容のレンダリングに失敗しました。メッセージの内容の形式が正しいか確認してください",
|
||||
"title": "レンダリングエラー"
|
||||
},
|
||||
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした",
|
||||
@ -1363,7 +1363,7 @@
|
||||
"subscribe_name": "代替名",
|
||||
"subscribe_name.placeholder": "ダウンロードしたフィードに名前がない場合に使用される代替名",
|
||||
"subscribe_add_success": "フィードの追加が成功しました!",
|
||||
"subscribe_delete": "フィードの削除",
|
||||
"subscribe_delete": "削除",
|
||||
"overwrite": "サービス検索を上書き",
|
||||
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
|
||||
"apikey": "API キー",
|
||||
|
||||
@ -338,7 +338,7 @@
|
||||
"no_api_key": "Ключ API не настроен",
|
||||
"provider_disabled": "Провайдер моделей не включен",
|
||||
"render": {
|
||||
"description": "Не удалось рендерить формулу. Пожалуйста, проверьте, правильно ли формат формулы",
|
||||
"description": "Не удалось рендерить содержимое сообщения. Пожалуйста, проверьте, правильно ли формат содержимого сообщения",
|
||||
"title": "Ошибка рендеринга"
|
||||
},
|
||||
"user_message_not_found": "Не удалось найти исходное сообщение пользователя",
|
||||
@ -1366,7 +1366,7 @@
|
||||
"subscribe_name": "альтернативное имя",
|
||||
"subscribe_name.placeholder": "替代名称, используемый, когда загружаемый подписочный источник не имеет названия",
|
||||
"subscribe_add_success": "Подписка добавлена успешно!",
|
||||
"subscribe_delete": "Удалить источник подписки",
|
||||
"subscribe_delete": "Удалить",
|
||||
"overwrite": "Переопределить поставщика поиска",
|
||||
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
|
||||
"apikey": "Ключ API",
|
||||
|
||||
@ -375,7 +375,7 @@
|
||||
"no_api_key": "API 密钥未配置",
|
||||
"provider_disabled": "模型提供商未启用",
|
||||
"render": {
|
||||
"description": "渲染公式失败,请检查公式格式是否正确",
|
||||
"description": "渲染消息内容失败,请检查消息内容格式是否正确",
|
||||
"title": "渲染错误"
|
||||
},
|
||||
"user_message_not_found": "无法找到原始用户消息",
|
||||
@ -1372,6 +1372,20 @@
|
||||
"description": "描述",
|
||||
"duplicateName": "已存在同名服务器",
|
||||
"editJson": "编辑JSON",
|
||||
"importServer": "导入服务器",
|
||||
"importServerDesc": "从JSON文件或文本导入单个MCP服务器配置",
|
||||
"dropJsonFile": "拖拽JSON文件到此处",
|
||||
"clickOrDrop": "点击或拖拽文件上传",
|
||||
"orPasteJson": "或粘贴JSON配置",
|
||||
"jsonRequired": "请输入JSON配置",
|
||||
"noServerFound": "未找到服务器配置",
|
||||
"importSuccess": "服务器导入成功",
|
||||
"invalidServerFormat": "无效的服务器格式",
|
||||
"jsonImportError": "导入JSON配置失败",
|
||||
"fileReadError": "读取文件失败",
|
||||
"importedServer": "导入的服务器",
|
||||
"import": "导入",
|
||||
"importModeHint": "支持两种格式:单个服务器配置或完整的mcpServers配置",
|
||||
"editServer": "编辑服务器",
|
||||
"env": "环境变量",
|
||||
"envTooltip": "格式:KEY=value,每行一个",
|
||||
|
||||
@ -335,7 +335,7 @@
|
||||
"no_api_key": "API 金鑰未設定",
|
||||
"provider_disabled": "模型供應商未啟用",
|
||||
"render": {
|
||||
"description": "渲染公式失敗,請檢查公式格式是否正確",
|
||||
"description": "渲染訊息內容失敗,請檢查訊息內容格式是否正確",
|
||||
"title": "渲染錯誤"
|
||||
},
|
||||
"user_message_not_found": "無法找到原始用戶訊息",
|
||||
@ -1362,7 +1362,7 @@
|
||||
"subscribe_name": "替代名稱",
|
||||
"subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱",
|
||||
"subscribe_add_success": "訂閱源添加成功!",
|
||||
"subscribe_delete": "刪除訂閱源",
|
||||
"subscribe_delete": "刪除",
|
||||
"title": "網路搜尋",
|
||||
"overwrite": "覆蓋搜尋服務商",
|
||||
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",
|
||||
|
||||
@ -118,7 +118,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
prompt: AGENT_PROMPT,
|
||||
content: promptText
|
||||
})
|
||||
formRef.current?.setFieldValue('prompt', generatedText)
|
||||
form.setFieldsValue({ prompt: generatedText })
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
}
|
||||
@ -170,11 +170,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
label={t('agents.add.prompt')}
|
||||
rules={[{ required: true }]}
|
||||
style={{ position: 'relative' }}>
|
||||
<TextAreaContainer>
|
||||
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
|
||||
<TokenCount>Tokens: {tokenCount}</TokenCount>
|
||||
</TextAreaContainer>
|
||||
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
|
||||
</Form.Item>
|
||||
<TokenCount>Tokens: {tokenCount}</TokenCount>
|
||||
<Button
|
||||
icon={loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
||||
onClick={handleButtonClick}
|
||||
@ -203,11 +201,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const TextAreaContainer = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const TokenCount = styled.div`
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
|
||||
@ -38,7 +38,10 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
// 处理Uint8Array类型(二进制数据)
|
||||
if (fileContent instanceof Uint8Array || Object.prototype.toString.call(fileContent) === '[object Uint8Array]') {
|
||||
if (
|
||||
fileContent instanceof Uint8Array ||
|
||||
Object.prototype.toString.call(fileContent) === '[object Uint8Array]'
|
||||
) {
|
||||
console.log('文件内容是Uint8Array类型,转换为字符串')
|
||||
// 将Uint8Array转换为字符串
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import PDFSplitter from '@renderer/components/PDFSplitter'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { setPdfSettings } from '@renderer/store/settings'
|
||||
@ -8,7 +9,6 @@ import { Paperclip } from 'lucide-react'
|
||||
import { FC, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import PDFSplitter from '@renderer/components/PDFSplitter'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
@ -54,41 +54,47 @@ const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButto
|
||||
return pdfSettings
|
||||
}, [dispatch, pdfSettings])
|
||||
|
||||
const handlePdfFile = useCallback((file: FileType) => {
|
||||
console.log('[AttachmentButton] handlePdfFile called with file:', file)
|
||||
const handlePdfFile = useCallback(
|
||||
(file: FileType) => {
|
||||
console.log('[AttachmentButton] handlePdfFile called with file:', file)
|
||||
|
||||
// 强制初始化PDF设置
|
||||
const settings = forcePdfSettingsInitialization()
|
||||
console.log('[AttachmentButton] PDF settings after initialization:', settings)
|
||||
// 强制初始化PDF设置
|
||||
const settings = forcePdfSettingsInitialization()
|
||||
console.log('[AttachmentButton] PDF settings after initialization:', settings)
|
||||
|
||||
if (settings.enablePdfSplitting && file.ext.toLowerCase() === '.pdf') {
|
||||
console.log('[AttachmentButton] PDF splitting enabled, showing splitter dialog')
|
||||
setSelectedPdfFile(file)
|
||||
setPdfSplitterVisible(true)
|
||||
return true // 返回true表示我们已经处理了这个文件
|
||||
}
|
||||
console.log('[AttachmentButton] PDF splitting disabled or not a PDF file, returning false')
|
||||
return false // 返回false表示这个文件需要正常处理
|
||||
}, [forcePdfSettingsInitialization])
|
||||
if (settings.enablePdfSplitting && file.ext.toLowerCase() === '.pdf') {
|
||||
console.log('[AttachmentButton] PDF splitting enabled, showing splitter dialog')
|
||||
setSelectedPdfFile(file)
|
||||
setPdfSplitterVisible(true)
|
||||
return true // 返回true表示我们已经处理了这个文件
|
||||
}
|
||||
console.log('[AttachmentButton] PDF splitting disabled or not a PDF file, returning false')
|
||||
return false // 返回false表示这个文件需要正常处理
|
||||
},
|
||||
[forcePdfSettingsInitialization]
|
||||
)
|
||||
|
||||
const handlePdfSplitterConfirm = useCallback(async (file: FileType, pageRange: string) => {
|
||||
console.log('[AttachmentButton] handlePdfSplitterConfirm called with file:', file, 'pageRange:', pageRange)
|
||||
try {
|
||||
// 调用主进程的PDF分割功能
|
||||
console.log('[AttachmentButton] Calling window.api.pdf.splitPDF')
|
||||
const newFile = await window.api.pdf.splitPDF(file, pageRange)
|
||||
console.log('[AttachmentButton] PDF split successful, new file:', newFile)
|
||||
setFiles([...files, newFile])
|
||||
setPdfSplitterVisible(false)
|
||||
setSelectedPdfFile(null)
|
||||
} catch (error) {
|
||||
console.error('[AttachmentButton] Error splitting PDF:', error)
|
||||
window.message.error({
|
||||
content: t('pdf.error_splitting'),
|
||||
key: 'pdf-error-splitting'
|
||||
})
|
||||
}
|
||||
}, [files, setFiles, t])
|
||||
const handlePdfSplitterConfirm = useCallback(
|
||||
async (file: FileType, pageRange: string) => {
|
||||
console.log('[AttachmentButton] handlePdfSplitterConfirm called with file:', file, 'pageRange:', pageRange)
|
||||
try {
|
||||
// 调用主进程的PDF分割功能
|
||||
console.log('[AttachmentButton] Calling window.api.pdf.splitPDF')
|
||||
const newFile = await window.api.pdf.splitPDF(file, pageRange)
|
||||
console.log('[AttachmentButton] PDF split successful, new file:', newFile)
|
||||
setFiles([...files, newFile])
|
||||
setPdfSplitterVisible(false)
|
||||
setSelectedPdfFile(null)
|
||||
} catch (error) {
|
||||
console.error('[AttachmentButton] Error splitting PDF:', error)
|
||||
window.message.error({
|
||||
content: t('pdf.error_splitting'),
|
||||
key: 'pdf-error-splitting'
|
||||
})
|
||||
}
|
||||
},
|
||||
[files, setFiles, t]
|
||||
)
|
||||
|
||||
const onSelectFile = useCallback(async () => {
|
||||
// 强制初始化PDF设置
|
||||
@ -107,8 +113,8 @@ const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButto
|
||||
|
||||
if (_files) {
|
||||
// 检查是否有PDF文件需要特殊处理
|
||||
const pdfFiles = _files.filter(file => file.ext.toLowerCase() === '.pdf')
|
||||
const nonPdfFiles = _files.filter(file => file.ext.toLowerCase() !== '.pdf')
|
||||
const pdfFiles = _files.filter((file) => file.ext.toLowerCase() === '.pdf')
|
||||
const nonPdfFiles = _files.filter((file) => file.ext.toLowerCase() !== '.pdf')
|
||||
|
||||
// 添加非PDF文件
|
||||
if (nonPdfFiles.length > 0) {
|
||||
|
||||
@ -62,6 +62,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
@ -89,6 +90,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
|
||||
@ -7,7 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import type { Message } from '@renderer/types'
|
||||
import { parseJSON } from '@renderer/utils'
|
||||
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
|
||||
import { findCitationInChildren } from '@renderer/utils/markdown'
|
||||
import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -16,6 +16,8 @@ import rehypeKatex from 'rehype-katex'
|
||||
// @ts-ignore next-line
|
||||
import rehypeMathjax from 'rehype-mathjax'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
// @ts-ignore next-line
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
@ -24,21 +26,16 @@ import EditableCodeBlock from './EditableCodeBlock'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import Link from './Link'
|
||||
|
||||
const ALLOWED_ELEMENTS =
|
||||
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup|think)/i
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
|
||||
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
|
||||
const disallowedElements = ['iframe']
|
||||
|
||||
const Markdown: FC<Props> = ({ message }) => {
|
||||
const { t } = useTranslation()
|
||||
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
|
||||
|
||||
const rehypeMath = useMemo(() => (mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax), [mathEngine])
|
||||
|
||||
const messageContent = useMemo(() => {
|
||||
const empty = isEmpty(message.content)
|
||||
const paused = message.status === 'paused'
|
||||
@ -47,9 +44,8 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
}, [message, t])
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const hasElements = ALLOWED_ELEMENTS.test(messageContent)
|
||||
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath]
|
||||
}, [messageContent, rehypeMath])
|
||||
return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
|
||||
}, [mathEngine])
|
||||
|
||||
const components = useMemo(() => {
|
||||
const baseComponents = {
|
||||
@ -95,7 +91,6 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
remarkPlugins={remarkPlugins}
|
||||
className="markdown"
|
||||
components={components}
|
||||
disallowedElements={disallowedElements}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
|
||||
@ -1,52 +1,53 @@
|
||||
import TTSProgressBar from '@renderer/components/TTSProgressBar';
|
||||
import { FONT_FAMILY } from '@renderer/config/constant';
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant';
|
||||
import { useModel } from '@renderer/hooks/useModel';
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime';
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings';
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService';
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService';
|
||||
import { getModelUniqId } from '@renderer/services/ModelService';
|
||||
import TTSService from '@renderer/services/TTSService';
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store';
|
||||
import { setLastPlayedMessageId, setSkipNextAutoTTS } from '@renderer/store/settings';
|
||||
import { Assistant, Message, Topic } from '@renderer/types';
|
||||
import { classNames } from '@renderer/utils';
|
||||
import { Divider, Dropdown } from 'antd';
|
||||
import { ItemType } from 'antd/es/menu/interface';
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TTSProgressBar from '@renderer/components/TTSProgressBar'
|
||||
import { FONT_FAMILY } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setLastPlayedMessageId, setSkipNextAutoTTS } from '@renderer/store/settings'
|
||||
import { Assistant, Message, Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Divider, Dropdown } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
// import { useSelector } from 'react-redux'; // Removed unused import
|
||||
import styled from 'styled-components'; // Ensure styled-components is imported
|
||||
import styled from 'styled-components' // Ensure styled-components is imported
|
||||
|
||||
import MessageContent from './MessageContent';
|
||||
import MessageErrorBoundary from './MessageErrorBoundary';
|
||||
import MessageHeader from './MessageHeader';
|
||||
import MessageMenubar from './MessageMenubar';
|
||||
import MessageTokens from './MessageTokens';
|
||||
import MessageContent from './MessageContent'
|
||||
import MessageErrorBoundary from './MessageErrorBoundary'
|
||||
import MessageHeader from './MessageHeader'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
import MessageTokens from './MessageTokens'
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
topic: Topic;
|
||||
assistant?: Assistant;
|
||||
index?: number;
|
||||
total?: number;
|
||||
hidePresetMessages?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
isGrouped?: boolean;
|
||||
isStreaming?: boolean;
|
||||
onSetMessages?: Dispatch<SetStateAction<Message[]>>;
|
||||
message: Message
|
||||
topic: Topic
|
||||
assistant?: Assistant
|
||||
index?: number
|
||||
total?: number
|
||||
hidePresetMessages?: boolean
|
||||
style?: React.CSSProperties
|
||||
isGrouped?: boolean
|
||||
isStreaming?: boolean
|
||||
onSetMessages?: Dispatch<SetStateAction<Message[]>>
|
||||
}
|
||||
|
||||
// Function definition moved before its first use, fixing potential TS issue & improving readability
|
||||
// FIX 1: Added explicit else to satisfy TS7030
|
||||
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean): string | undefined => {
|
||||
if (!isBubbleStyle) {
|
||||
return undefined;
|
||||
} else { // Explicit else block
|
||||
return isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)';
|
||||
return undefined
|
||||
} else {
|
||||
// Explicit else block
|
||||
return isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// FIX 2: Define styled component for the context menu trigger div
|
||||
const ContextMenuTriggerDiv = styled.div<{ x: number; y: number }>`
|
||||
@ -58,8 +59,7 @@ const ContextMenuTriggerDiv = styled.div<{ x: number; y: number }>`
|
||||
/* Optional: Ensure it doesn't interfere with other elements */
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
`
|
||||
|
||||
const MessageItem: FC<Props> = ({
|
||||
message,
|
||||
@ -71,74 +71,74 @@ const MessageItem: FC<Props> = ({
|
||||
isStreaming = false,
|
||||
style
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { assistant, setModel } = useAssistant(message.assistantId);
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model;
|
||||
const { isBubbleStyle } = useMessageStyle();
|
||||
const { showMessageDivider, messageFont, fontSize } = useSettings();
|
||||
const { generating } = useRuntime();
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('');
|
||||
const [selectedText, setSelectedText] = useState<string>('');
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation()
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize } = useSettings()
|
||||
const { generating } = useRuntime()
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
const dispatch = useAppDispatch()
|
||||
const playTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// --- Consolidated State Selection ---
|
||||
const ttsEnabled = useAppSelector((state) => state.settings.ttsEnabled);
|
||||
const voiceCallEnabled = useAppSelector((state) => state.settings.voiceCallEnabled);
|
||||
const autoPlayTTSOutsideVoiceCall = useAppSelector((state) => state.settings.autoPlayTTSOutsideVoiceCall);
|
||||
const isVoiceCallActive = useAppSelector((state) => state.settings.isVoiceCallActive);
|
||||
const lastPlayedMessageId = useAppSelector((state) => state.settings.lastPlayedMessageId);
|
||||
const skipNextAutoTTS = useAppSelector((state) => state.settings.skipNextAutoTTS);
|
||||
const ttsEnabled = useAppSelector((state) => state.settings.ttsEnabled)
|
||||
const voiceCallEnabled = useAppSelector((state) => state.settings.voiceCallEnabled)
|
||||
const autoPlayTTSOutsideVoiceCall = useAppSelector((state) => state.settings.autoPlayTTSOutsideVoiceCall)
|
||||
const isVoiceCallActive = useAppSelector((state) => state.settings.isVoiceCallActive)
|
||||
const lastPlayedMessageId = useAppSelector((state) => state.settings.lastPlayedMessageId)
|
||||
const skipNextAutoTTS = useAppSelector((state) => state.settings.skipNextAutoTTS)
|
||||
// ---------------------------------
|
||||
|
||||
const isLastMessage = index === 0;
|
||||
const isAssistantMessage = message.role === 'assistant';
|
||||
const showMenubar = !isStreaming && !message.status.includes('ing');
|
||||
const isLastMessage = index === 0
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const showMenubar = !isStreaming && !message.status.includes('ing')
|
||||
|
||||
const fontFamily = useMemo(() => {
|
||||
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY;
|
||||
}, [messageFont]);
|
||||
|
||||
const messageBorder = showMessageDivider ? '1px dotted var(--color-border)' : 'none'; // Applied directly in MessageFooter style
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage); // Call the fixed function
|
||||
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
|
||||
}, [messageFont])
|
||||
|
||||
const messageBorder = showMessageDivider ? '1px dotted var(--color-border)' : 'none' // Applied directly in MessageFooter style
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) // Call the fixed function
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const _selectedText = window.getSelection()?.toString() || '';
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString() || ''
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------';
|
||||
setSelectedQuoteText(quotedText);
|
||||
setSelectedText(_selectedText);
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setSelectedText(_selectedText)
|
||||
} else {
|
||||
setSelectedQuoteText('');
|
||||
setSelectedText('');
|
||||
setSelectedQuoteText('')
|
||||
setSelectedText('')
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
// Close context menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null);
|
||||
};
|
||||
document.addEventListener('click', handleClick);
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, []);
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- Reset skipNextAutoTTS on New Message Completion ---
|
||||
const prevGeneratingRef = useRef(generating);
|
||||
const prevGeneratingRef = useRef(generating)
|
||||
useEffect(() => {
|
||||
prevGeneratingRef.current = generating;
|
||||
}, [generating]);
|
||||
prevGeneratingRef.current = generating
|
||||
}, [generating])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -149,87 +149,110 @@ const MessageItem: FC<Props> = ({
|
||||
message.status === 'success'
|
||||
) {
|
||||
// 简化日志输出
|
||||
console.log('消息生成完成,重置skipNextAutoTTS为false, 消息ID:', message.id);
|
||||
dispatch(setSkipNextAutoTTS(false));
|
||||
console.log('消息生成完成,重置skipNextAutoTTS为false, 消息ID:', message.id)
|
||||
dispatch(setSkipNextAutoTTS(false))
|
||||
}
|
||||
}, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch]);
|
||||
|
||||
}, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch])
|
||||
|
||||
// --- Auto-play TTS Logic ---
|
||||
useEffect(() => {
|
||||
// 基本条件检查
|
||||
if (!isLastMessage || !isAssistantMessage || message.status !== 'success' || generating) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (!ttsEnabled) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 语音通话相关条件检查
|
||||
if (voiceCallEnabled === false && autoPlayTTSOutsideVoiceCall === false) {
|
||||
// 简化日志输出
|
||||
console.log('不自动播放TTS: 语音通话功能未启用 + 不允许在语音通话模式外自动播放');
|
||||
return;
|
||||
console.log('不自动播放TTS: 语音通话功能未启用 + 不允许在语音通话模式外自动播放')
|
||||
return
|
||||
}
|
||||
if (voiceCallEnabled === true && isVoiceCallActive === false && autoPlayTTSOutsideVoiceCall === false) {
|
||||
// 简化日志输出
|
||||
console.log('不自动播放TTS: 语音通话窗口未打开 + 不允许在语音通话模式外自动播放');
|
||||
return;
|
||||
console.log('不自动播放TTS: 语音通话窗口未打开 + 不允许在语音通话模式外自动播放')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否需要跳过自动TTS
|
||||
if (skipNextAutoTTS === true) {
|
||||
console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id);
|
||||
return;
|
||||
console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id)
|
||||
return
|
||||
}
|
||||
// 检查消息是否有内容,且消息是新的(不是上次播放过的消息)
|
||||
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) {
|
||||
// 简化日志输出
|
||||
console.log('准备自动播放TTS, 消息ID:', message.id);
|
||||
dispatch(setLastPlayedMessageId(message.id));
|
||||
const playTimeout = setTimeout(() => {
|
||||
console.log('自动播放TTS: 消息ID:', message.id);
|
||||
TTSService.speakFromMessage(message);
|
||||
}, 500);
|
||||
return () => clearTimeout(playTimeout);
|
||||
console.log('准备自动播放TTS, 消息ID:', message.id)
|
||||
|
||||
// 先设置状态,防止重复播放
|
||||
const currentMessageId = message.id
|
||||
dispatch(setLastPlayedMessageId(currentMessageId))
|
||||
|
||||
// 只有当没有设置过定时器时才设置
|
||||
if (!playTimeoutRef.current) {
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
console.log('自动播放TTS: 消息ID:', currentMessageId)
|
||||
TTSService.speakFromMessage(message)
|
||||
// 清除定时器引用
|
||||
playTimeoutRef.current = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current)
|
||||
playTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
} else if (message.id === lastPlayedMessageId) {
|
||||
// 简化日志输出
|
||||
console.log('不自动播放TTS: 消息已播放过 (lastPlayedMessageId), ID:', message.id);
|
||||
return; // 添加返回语句,解决TypeScript错误
|
||||
console.log('不自动播放TTS: 消息已播放过 (lastPlayedMessageId), ID:', message.id)
|
||||
return // 添加返回语句,解决TypeScript错误
|
||||
}
|
||||
|
||||
// 添加默认返回值,确保所有代码路径都有返回值
|
||||
return;
|
||||
return
|
||||
}, [
|
||||
isLastMessage, isAssistantMessage, message, generating, ttsEnabled,
|
||||
voiceCallEnabled, autoPlayTTSOutsideVoiceCall, isVoiceCallActive,
|
||||
skipNextAutoTTS, lastPlayedMessageId, dispatch
|
||||
]);
|
||||
isLastMessage,
|
||||
isAssistantMessage,
|
||||
message,
|
||||
generating,
|
||||
ttsEnabled,
|
||||
voiceCallEnabled,
|
||||
autoPlayTTSOutsideVoiceCall,
|
||||
isVoiceCallActive,
|
||||
skipNextAutoTTS,
|
||||
lastPlayedMessageId,
|
||||
dispatch
|
||||
])
|
||||
|
||||
// --- Highlight message on event ---
|
||||
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
if (highlight) {
|
||||
const element = messageContainerRef.current;
|
||||
element.classList.add('message-highlight');
|
||||
const element = messageContainerRef.current
|
||||
element.classList.add('message-highlight')
|
||||
setTimeout(() => {
|
||||
element?.classList.remove('message-highlight');
|
||||
}, 2500);
|
||||
element?.classList.remove('message-highlight')
|
||||
}, 2500)
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}`;
|
||||
const unsubscribe = EventEmitter.on(eventName, messageHighlightHandler);
|
||||
return () => unsubscribe();
|
||||
}, [message.id, messageHighlightHandler]);
|
||||
const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}`
|
||||
const unsubscribe = EventEmitter.on(eventName, messageHighlightHandler)
|
||||
return () => unsubscribe()
|
||||
}, [message.id, messageHighlightHandler])
|
||||
|
||||
// --- Component Rendering ---
|
||||
|
||||
if (hidePresetMessages && message.isPreset) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
if (message.type === 'clear') {
|
||||
@ -239,7 +262,7 @@ const MessageItem: FC<Props> = ({
|
||||
{t('chat.message.new.context')}
|
||||
</Divider>
|
||||
</NewContextMessage>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -258,8 +281,7 @@ const MessageItem: FC<Props> = ({
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
trigger={['contextMenu']}>
|
||||
{/* FIX 2: Use the styled component instead of inline style */}
|
||||
<ContextMenuTriggerDiv x={contextMenuPosition.x} y={contextMenuPosition.y} />
|
||||
</Dropdown>
|
||||
@ -299,75 +321,79 @@ const MessageItem: FC<Props> = ({
|
||||
)}
|
||||
</MessageContentContainer>
|
||||
</MessageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
// Updated context menu items function
|
||||
const getContextMenuItems = (
|
||||
t: (key: string) => string,
|
||||
selectedQuoteText: string,
|
||||
selectedText: string,
|
||||
message: Message,
|
||||
message: Message
|
||||
): ItemType[] => {
|
||||
const items: ItemType[] = [];
|
||||
const items: ItemType[] = []
|
||||
|
||||
if (selectedText) {
|
||||
items.push({
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(selectedText)
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => window.message.success({ content: t('message.copied'), key: 'copy-message' }))
|
||||
.catch(err => console.error('Failed to copy text: ', err));
|
||||
.catch((err) => console.error('Failed to copy text: ', err))
|
||||
}
|
||||
});
|
||||
})
|
||||
items.push({
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText);
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
});
|
||||
})
|
||||
items.push({
|
||||
key: 'speak_selected',
|
||||
label: t('chat.message.speak_selection') || '朗读选中部分',
|
||||
onClick: () => {
|
||||
// 首先手动关闭菜单
|
||||
document.dispatchEvent(new MouseEvent('click'));
|
||||
document.dispatchEvent(new MouseEvent('click'))
|
||||
|
||||
// 使用setTimeout确保菜单关闭后再执行TTS功能
|
||||
setTimeout(() => {
|
||||
import('@renderer/services/TTSService').then(({ default: TTSServiceInstance }) => {
|
||||
let textToSpeak = selectedText;
|
||||
import('@renderer/services/TTSService')
|
||||
.then(({ default: TTSServiceInstance }) => {
|
||||
let textToSpeak = selectedText
|
||||
if (message.content) {
|
||||
const startIndex = message.content.indexOf(selectedText);
|
||||
if (startIndex !== -1) {
|
||||
textToSpeak = selectedText; // Just speak selection
|
||||
}
|
||||
const startIndex = message.content.indexOf(selectedText)
|
||||
if (startIndex !== -1) {
|
||||
textToSpeak = selectedText // Just speak selection
|
||||
}
|
||||
}
|
||||
// 传递消息ID,确保进度条和停止按钮正常工作
|
||||
TTSServiceInstance.speak(textToSpeak, false, message.id); // 使用普通播放模式而非分段播放
|
||||
}).catch(err => console.error('Failed to load or use TTSService:', err));
|
||||
}, 100);
|
||||
TTSServiceInstance.speak(textToSpeak, false, message.id) // 使用普通播放模式而非分段播放
|
||||
})
|
||||
.catch((err) => console.error('Failed to load or use TTSService:', err))
|
||||
}, 100)
|
||||
}
|
||||
});
|
||||
items.push({ type: 'divider' });
|
||||
})
|
||||
items.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'copy_id',
|
||||
label: t('message.copy_id') || '复制消息ID',
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(message.id)
|
||||
.then(() => window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' }))
|
||||
.catch(err => console.error('Failed to copy message ID: ', err));
|
||||
navigator.clipboard
|
||||
.writeText(message.id)
|
||||
.then(() =>
|
||||
window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' })
|
||||
)
|
||||
.catch((err) => console.error('Failed to copy message ID: ', err))
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// Styled components definitions
|
||||
const MessageContainer = styled.div`
|
||||
@ -395,7 +421,7 @@ const MessageContainer = styled.div`
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
const MessageContentContainer = styled.div`
|
||||
max-width: 100%;
|
||||
@ -404,7 +430,7 @@ const MessageContentContainer = styled.div`
|
||||
flex-direction: column;
|
||||
margin-left: 46px;
|
||||
margin-top: 5px;
|
||||
`;
|
||||
`
|
||||
|
||||
const MessageFooter = styled.div`
|
||||
display: flex;
|
||||
@ -415,16 +441,16 @@ const MessageFooter = styled.div`
|
||||
margin-top: 8px;
|
||||
/* borderTop applied via style prop based on showMessageDivider */
|
||||
gap: 16px;
|
||||
`;
|
||||
`
|
||||
|
||||
const NewContextMessage = styled.div`
|
||||
cursor: pointer;
|
||||
`;
|
||||
`
|
||||
|
||||
const ProgressBarWrapper = styled.div`
|
||||
width: calc(100% - 20px);
|
||||
padding: 5px 10px;
|
||||
margin-left: -10px;
|
||||
`;
|
||||
`
|
||||
|
||||
export default memo(MessageItem);
|
||||
export default memo(MessageItem)
|
||||
|
||||
@ -1,53 +1,53 @@
|
||||
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import TTSHighlightedText from '@renderer/components/TTSHighlightedText';
|
||||
import { isOpenAIWebSearch } from '@renderer/config/models';
|
||||
import { getModelUniqId } from '@renderer/services/ModelService';
|
||||
import { Message, Model } from '@renderer/types';
|
||||
import { getBriefInfo } from '@renderer/utils';
|
||||
import { withMessageThought } from '@renderer/utils/formats';
|
||||
import { Collapse, Divider, Flex } from 'antd';
|
||||
import { clone } from 'lodash';
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BarLoader from 'react-spinners/BarLoader';
|
||||
import BeatLoader from 'react-spinners/BeatLoader';
|
||||
import styled from 'styled-components';
|
||||
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import TTSHighlightedText from '@renderer/components/TTSHighlightedText'
|
||||
import { isOpenAIWebSearch } from '@renderer/config/models'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { getBriefInfo } from '@renderer/utils'
|
||||
import { withMessageThought } from '@renderer/utils/formats'
|
||||
import { Collapse, Divider, Flex } from 'antd'
|
||||
import { clone } from 'lodash'
|
||||
import { Search } from 'lucide-react'
|
||||
import React, { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Markdown from '../Markdown/Markdown';
|
||||
import CitationsList from './CitationsList';
|
||||
import MessageAttachments from './MessageAttachments';
|
||||
import MessageError from './MessageError';
|
||||
import MessageImage from './MessageImage';
|
||||
import MessageThought from './MessageThought';
|
||||
import MessageTools from './MessageTools';
|
||||
import Markdown from '../Markdown/Markdown'
|
||||
import CitationsList from './CitationsList'
|
||||
import MessageAttachments from './MessageAttachments'
|
||||
import MessageError from './MessageError'
|
||||
import MessageImage from './MessageImage'
|
||||
import MessageThought from './MessageThought'
|
||||
import MessageTools from './MessageTools'
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
model?: Model;
|
||||
message: Message
|
||||
model?: Model
|
||||
}
|
||||
|
||||
const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
const { t } = useTranslation();
|
||||
const message = withMessageThought(clone(_message));
|
||||
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter');
|
||||
const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false);
|
||||
const { t } = useTranslation()
|
||||
const message = withMessageThought(clone(_message))
|
||||
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
|
||||
const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false)
|
||||
|
||||
// 监听分段播放状态变化
|
||||
useEffect(() => {
|
||||
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
|
||||
const { isSegmentedPlayback } = event.detail;
|
||||
setIsSegmentedPlayback(isSegmentedPlayback);
|
||||
};
|
||||
const { isSegmentedPlayback } = event.detail
|
||||
setIsSegmentedPlayback(isSegmentedPlayback)
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener);
|
||||
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener);
|
||||
};
|
||||
}, []);
|
||||
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// HTML实体编码辅助函数
|
||||
const encodeHTML = (str: string) => {
|
||||
@ -58,47 +58,47 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return entities[match];
|
||||
});
|
||||
};
|
||||
}
|
||||
return entities[match]
|
||||
})
|
||||
}
|
||||
|
||||
// Format citations for display
|
||||
const formattedCitations = useMemo(() => {
|
||||
if (!message.metadata?.citations?.length && !message.metadata?.annotations?.length) return null;
|
||||
if (!message.metadata?.citations?.length && !message.metadata?.annotations?.length) return null
|
||||
|
||||
let citations: any[] = [];
|
||||
let citations: any[] = []
|
||||
|
||||
if (model && isOpenAIWebSearch(model)) {
|
||||
citations =
|
||||
message.metadata.annotations?.map((url, index) => {
|
||||
return { number: index + 1, url: url.url_citation?.url, hostname: url.url_citation.title };
|
||||
}) || [];
|
||||
return { number: index + 1, url: url.url_citation?.url, hostname: url.url_citation.title }
|
||||
}) || []
|
||||
} else {
|
||||
citations =
|
||||
message.metadata?.citations?.map((url, index) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return { number: index + 1, url, hostname };
|
||||
const hostname = new URL(url).hostname
|
||||
return { number: index + 1, url, hostname }
|
||||
} catch {
|
||||
return { number: index + 1, url, hostname: url };
|
||||
return { number: index + 1, url, hostname: url }
|
||||
}
|
||||
}) || [];
|
||||
}) || []
|
||||
}
|
||||
|
||||
// Deduplicate by URL
|
||||
const urlSet = new Set();
|
||||
const urlSet = new Set()
|
||||
return citations
|
||||
.filter((citation) => {
|
||||
if (!citation.url || urlSet.has(citation.url)) return false;
|
||||
urlSet.add(citation.url);
|
||||
return true;
|
||||
if (!citation.url || urlSet.has(citation.url)) return false
|
||||
urlSet.add(citation.url)
|
||||
return true
|
||||
})
|
||||
.map((citation, index) => ({
|
||||
...citation,
|
||||
number: index + 1 // Renumber citations sequentially after deduplication
|
||||
}));
|
||||
}, [message.metadata?.citations, message.metadata?.annotations, model]);
|
||||
}))
|
||||
}, [message.metadata?.citations, message.metadata?.annotations, model])
|
||||
|
||||
// 获取引用数据
|
||||
const citationsData = useMemo(() => {
|
||||
@ -107,11 +107,11 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
message?.metadata?.webSearchInfo ||
|
||||
message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) ||
|
||||
message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ||
|
||||
[];
|
||||
const citationsUrls = formattedCitations || [];
|
||||
[]
|
||||
const citationsUrls = formattedCitations || []
|
||||
|
||||
// 合并引用数据
|
||||
const data = new Map();
|
||||
const data = new Map()
|
||||
|
||||
// 添加webSearch结果
|
||||
searchResults.forEach((result) => {
|
||||
@ -119,8 +119,8 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
url: result.url || result.uri || result.link,
|
||||
title: result.title || result.hostname,
|
||||
content: result.content
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// 添加citations
|
||||
citationsUrls.forEach((result) => {
|
||||
@ -129,18 +129,19 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
url: result.url,
|
||||
title: result.title || result.hostname || undefined,
|
||||
content: result.content || undefined
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
return data;
|
||||
return data
|
||||
}, [
|
||||
formattedCitations,
|
||||
message?.metadata?.annotations,
|
||||
message?.metadata?.groundingMetadata?.groundingChunks,
|
||||
message?.metadata?.webSearch?.results,
|
||||
message?.metadata?.webSearchInfo
|
||||
]);
|
||||
// knowledge 依赖已移除,因为它在 useMemo 中没有被使用
|
||||
])
|
||||
|
||||
// Process content to make citation numbers clickable
|
||||
const processedContent = useMemo(() => {
|
||||
@ -149,55 +150,63 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
message.metadata?.citations ||
|
||||
message.metadata?.webSearch ||
|
||||
message.metadata?.webSearchInfo ||
|
||||
message.metadata?.annotations
|
||||
message.metadata?.annotations ||
|
||||
message.metadata?.knowledge
|
||||
)
|
||||
) {
|
||||
return message.content;
|
||||
return message.content
|
||||
}
|
||||
|
||||
let content = message.content;
|
||||
let content = message.content
|
||||
|
||||
const searchResultsCitations = message?.metadata?.webSearch?.results?.map((result) => result.url) || [];
|
||||
const websearchResultsCitations = message?.metadata?.webSearch?.results?.map((result) => result.url) || []
|
||||
const knowledgeResultsCitations = message?.metadata?.knowledge?.map((result) => result.sourceUrl) || []
|
||||
|
||||
const citations = message?.metadata?.citations || searchResultsCitations;
|
||||
const searchResultsCitations = [...websearchResultsCitations, ...knowledgeResultsCitations]
|
||||
|
||||
const citations = message?.metadata?.citations || searchResultsCitations
|
||||
|
||||
// Convert [n] format to superscript numbers and make them clickable
|
||||
// Use <sup> tag for superscript and make it a link with citation data
|
||||
if (message.metadata?.webSearch) {
|
||||
if (message.metadata?.webSearch || message.metadata?.knowledge) {
|
||||
content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => {
|
||||
const num = num1 || num2;
|
||||
const index = parseInt(num) - 1;
|
||||
const num = num1 || num2
|
||||
const index = parseInt(num) - 1
|
||||
if (index >= 0 && index < citations.length) {
|
||||
const link = citations[index];
|
||||
const citationData = link ? encodeHTML(JSON.stringify(citationsData.get(link) || { url: link })) : null;
|
||||
return link ? `[<sup data-citation='${citationData}'>${num}</sup>](${link})` : `<sup>${num}</sup>`;
|
||||
const link = citations[index]
|
||||
const isWebLink = link && (link.startsWith('http://') || link.startsWith('https://'))
|
||||
const citationData = link ? encodeHTML(JSON.stringify(citationsData.get(link) || { url: link })) : null
|
||||
return link && isWebLink
|
||||
? `[<sup data-citation='${citationData}'>${num}</sup>](${link})`
|
||||
: `<sup>${num}</sup>`
|
||||
}
|
||||
return match;
|
||||
});
|
||||
return match
|
||||
})
|
||||
} else {
|
||||
// Handle other citation formats if necessary, potentially adjusting this logic
|
||||
// The original else block seemed specific, ensure it covers necessary cases or adjust
|
||||
content = content.replace(/\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => {
|
||||
const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null;
|
||||
return `[<sup data-citation='${citationData}'>${num}</sup>](${url})`
|
||||
});
|
||||
content = content.replace(/\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => {
|
||||
const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null
|
||||
return `[<sup data-citation='${citationData}'>${num}</sup>](${url})`
|
||||
})
|
||||
}
|
||||
return content;
|
||||
return content
|
||||
}, [
|
||||
message.metadata?.citations,
|
||||
message.metadata?.webSearch,
|
||||
message.metadata?.knowledge,
|
||||
message.metadata?.webSearchInfo,
|
||||
message.metadata?.annotations,
|
||||
message.content,
|
||||
citationsData
|
||||
]);
|
||||
])
|
||||
|
||||
if (message.status === 'sending') {
|
||||
return (
|
||||
<MessageContentLoading>
|
||||
<SyncOutlined spin size={24} />
|
||||
</MessageContentLoading>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (message.status === 'searching') {
|
||||
@ -207,22 +216,22 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
<SearchingText>{t('message.searching')}</SearchingText>
|
||||
<BarLoader color="#1677ff" />
|
||||
</SearchingContainer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (message.status === 'error') {
|
||||
return <MessageError message={message} />;
|
||||
return <MessageError message={message} />
|
||||
}
|
||||
|
||||
if (message.type === '@' && model) {
|
||||
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`;
|
||||
return <Markdown message={{ ...message, content }} />;
|
||||
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
|
||||
return <Markdown message={{ ...message, content }} />
|
||||
}
|
||||
|
||||
// --- MODIFIED LINE BELOW ---
|
||||
// This regex now matches <tool_use ...> OR <XML ...> tags (case-insensitive)
|
||||
// and allows for attributes and whitespace, then removes the entire tag pair and content.
|
||||
const tagsToRemoveRegex = /<(?:tool_use|XML)(?:[^>]*)?>(?:.*?)<\/\s*(?:tool_use|XML)\s*>/gsi;
|
||||
const tagsToRemoveRegex = /<(?:tool_use|XML)(?:[^>]*)?>(?:.*?)<\/\s*(?:tool_use|XML)\s*>/gis
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@ -255,12 +264,12 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
<span
|
||||
className="reference-id"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(refMsg.id);
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(refMsg.id)
|
||||
window.message.success({
|
||||
content: t('message.id_copied') || '消息ID已复制',
|
||||
key: 'copy-reference-id'
|
||||
});
|
||||
})
|
||||
}}>
|
||||
ID: {refMsg.id}
|
||||
</span>
|
||||
@ -299,12 +308,12 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
<span
|
||||
className="reference-id"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText((message as any).referencedMessage.id);
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText((message as any).referencedMessage.id)
|
||||
window.message.success({
|
||||
content: t('message.id_copied') || '消息ID已复制',
|
||||
key: 'copy-reference-id'
|
||||
});
|
||||
})
|
||||
}}>
|
||||
ID: {(message as any).referencedMessage.id}
|
||||
</span>
|
||||
@ -325,10 +334,10 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
<MessageTools message={message} />
|
||||
</div>
|
||||
{isSegmentedPlayback ? (
|
||||
// Apply regex replacement here for TTS
|
||||
// Apply regex replacement here for TTS
|
||||
<TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} />
|
||||
) : (
|
||||
// Apply regex replacement here for Markdown display
|
||||
// Apply regex replacement here for Markdown display
|
||||
<Markdown message={{ ...message, content: processedContent.replace(tagsToRemoveRegex, '') }} />
|
||||
)}
|
||||
{message.metadata?.generateImage && <MessageImage message={message} />}
|
||||
@ -340,7 +349,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
{message.translatedContent === t('translate.processing') ? (
|
||||
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 15 }} />
|
||||
) : (
|
||||
// Render translated content (assuming it doesn't need tag removal, adjust if needed)
|
||||
// Render translated content (assuming it doesn't need tag removal, adjust if needed)
|
||||
<Markdown message={{ ...message, content: message.translatedContent }} />
|
||||
)}
|
||||
</Fragment>
|
||||
@ -400,8 +409,8 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
)}
|
||||
<MessageAttachments message={message} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
// Styled components and global styles remain the same...
|
||||
|
||||
@ -412,7 +421,7 @@ const MessageContentLoading = styled.div`
|
||||
height: 32px;
|
||||
margin-top: -5px;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
`
|
||||
|
||||
const SearchingContainer = styled.div`
|
||||
display: flex;
|
||||
@ -423,22 +432,22 @@ const SearchingContainer = styled.div`
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
`;
|
||||
`
|
||||
|
||||
const MentionTag = styled.span`
|
||||
color: var(--color-link);
|
||||
`;
|
||||
`
|
||||
|
||||
const SearchingText = styled.div`
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-1);
|
||||
`;
|
||||
`
|
||||
|
||||
const SearchEntryPoint = styled.div`
|
||||
margin: 10px 2px;
|
||||
`;
|
||||
`
|
||||
|
||||
// 引用消息样式 - 使用全局样式
|
||||
const referenceStyles = `
|
||||
@ -549,29 +558,29 @@ const referenceStyles = `
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
// 将样式添加到文档中
|
||||
try {
|
||||
if (typeof document !== 'undefined') {
|
||||
// Check if style already exists to prevent duplicates during HMR
|
||||
let styleElement = document.getElementById('message-content-reference-styles');
|
||||
let styleElement = document.getElementById('message-content-reference-styles')
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = 'message-content-reference-styles';
|
||||
styleElement.textContent =
|
||||
referenceStyles +
|
||||
`
|
||||
styleElement = document.createElement('style')
|
||||
styleElement.id = 'message-content-reference-styles'
|
||||
styleElement.textContent =
|
||||
referenceStyles +
|
||||
`
|
||||
.message-content-tools {
|
||||
margin-top: 20px; /* Adjust as needed */
|
||||
margin-bottom: 10px; /* Add space before main content */
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleElement);
|
||||
`
|
||||
document.head.appendChild(styleElement)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add reference styles:', error);
|
||||
console.error('Failed to add reference styles:', error)
|
||||
}
|
||||
|
||||
export default React.memo(MessageContent);
|
||||
export default React.memo(MessageContent)
|
||||
|
||||
@ -9,16 +9,19 @@ interface Props {
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
errorMessage?: string
|
||||
error?: Error
|
||||
}
|
||||
|
||||
const ErrorFallback = ({ fallback }: { fallback?: React.ReactNode }) => {
|
||||
const ErrorFallback = ({ fallback, error }: { fallback?: React.ReactNode; error?: Error }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
fallback || (
|
||||
<Alert message={t('error.render.title')} description={t('error.render.description')} type="error" showIcon />
|
||||
)
|
||||
)
|
||||
|
||||
// 如果有详细错误信息,添加到描述中
|
||||
const errorDescription =
|
||||
process.env.NODE_ENV !== 'production' && error
|
||||
? `${t('error.render.description')}: ${error.message}`
|
||||
: t('error.render.description')
|
||||
|
||||
return fallback || <Alert message={t('error.render.title')} description={errorDescription} type="error" showIcon />
|
||||
}
|
||||
|
||||
class MessageErrorBoundary extends React.Component<Props, State> {
|
||||
@ -28,21 +31,7 @@ class MessageErrorBoundary extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
// 检查是否是特定错误
|
||||
let errorMessage: string | undefined = undefined
|
||||
|
||||
if (error.message === 'rememberInstructions is not defined') {
|
||||
errorMessage = '消息加载时发生错误'
|
||||
} else if (error.message === 'network error') {
|
||||
errorMessage = '网络连接错误,请检查您的网络连接并重试'
|
||||
} else if (
|
||||
typeof error.message === 'string' &&
|
||||
(error.message.includes('network') || error.message.includes('timeout') || error.message.includes('connection'))
|
||||
) {
|
||||
errorMessage = '网络连接问题'
|
||||
}
|
||||
|
||||
return { hasError: true, errorMessage }
|
||||
return { hasError: true, error }
|
||||
}
|
||||
// 正确缩进 componentDidCatch
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
@ -65,11 +54,7 @@ class MessageErrorBoundary extends React.Component<Props, State> {
|
||||
// 正确缩进 render
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// 如果有特定错误消息,显示自定义错误
|
||||
if (this.state.errorMessage) {
|
||||
return <Alert message="渲染错误" description={this.state.errorMessage} type="error" showIcon />
|
||||
}
|
||||
return <ErrorFallback fallback={this.props.fallback} />
|
||||
return <ErrorFallback fallback={this.props.fallback} error={this.state.error} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined, BookOutlined } from '@ant-design/icons'
|
||||
import { BookOutlined, CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import AssistantMemoryPopup from '@renderer/components/AssistantMemoryPopup'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
|
||||
@ -50,9 +50,9 @@ const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
|
||||
// 使用useMemo缓存计算结果
|
||||
const { isStreaming, message } = useMemo(() => {
|
||||
const isStreaming = !!(streamMessage && streamMessage.id === _message.id);
|
||||
const message = isStreaming ? streamMessage : regularMessage;
|
||||
return { isStreaming, message };
|
||||
const isStreaming = !!(streamMessage && streamMessage.id === _message.id)
|
||||
const message = isStreaming ? streamMessage : regularMessage
|
||||
return { isStreaming, message }
|
||||
}, [streamMessage, regularMessage, _message.id])
|
||||
return (
|
||||
<MessageStreamContainer>
|
||||
@ -78,5 +78,5 @@ export default memo(MessageStream, (prevProps, nextProps) => {
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.topic.id === nextProps.topic.id
|
||||
);
|
||||
)
|
||||
})
|
||||
|
||||
@ -11,7 +11,6 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setNarrowMode } from '@renderer/store/settings'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
@ -64,8 +63,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, activeTopic }) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Navbar className="home-navbar">
|
||||
{showAssistants && (
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import { ApiOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { HStack, VStack } from '@renderer/components/Layout'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { Button, Divider, Form, Input, Switch, Tooltip, message } from 'antd'
|
||||
import { InfoCircleOutlined, PlusOutlined, ApiOutlined } from '@ant-design/icons'
|
||||
import { HStack, VStack } from '@renderer/components/Layout'
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { Model } from '@renderer/types'
|
||||
// 不再需要 useAppDispatch
|
||||
import { createAllDeepClaudeProviders } from '@renderer/utils/createDeepClaudeProvider'
|
||||
import { Button, Divider, Form, Input, message, Switch, Tooltip } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
interface ModelCombination {
|
||||
id: string
|
||||
@ -29,9 +30,9 @@ const DeepClaudeSettings: FC = () => {
|
||||
// 本地状态
|
||||
const [combinations, setCombinations] = useState<ModelCombination[]>([])
|
||||
const [newCombination, setNewCombination] = useState<{
|
||||
name: string;
|
||||
reasonerModel: string;
|
||||
targetModel: string;
|
||||
name: string
|
||||
reasonerModel: string
|
||||
targetModel: string
|
||||
}>({
|
||||
name: '',
|
||||
reasonerModel: '',
|
||||
@ -41,9 +42,9 @@ const DeepClaudeSettings: FC = () => {
|
||||
// 编辑状态
|
||||
const [editingCombination, setEditingCombination] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState<{
|
||||
name: string;
|
||||
reasonerModel: string;
|
||||
targetModel: string;
|
||||
name: string
|
||||
reasonerModel: string
|
||||
targetModel: string
|
||||
}>({
|
||||
name: '',
|
||||
reasonerModel: '',
|
||||
@ -51,8 +52,8 @@ const DeepClaudeSettings: FC = () => {
|
||||
})
|
||||
|
||||
// 获取所有可用的模型
|
||||
const allModels = providers.flatMap(provider =>
|
||||
provider.models.map(model => ({
|
||||
const allModels = providers.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
...model,
|
||||
providerName: provider.name,
|
||||
providerId: provider.id
|
||||
@ -60,21 +61,25 @@ const DeepClaudeSettings: FC = () => {
|
||||
)
|
||||
|
||||
// 推荐的推理模型
|
||||
const recommendedReasonerModels = allModels.filter(model => {
|
||||
const recommendedReasonerModels = allModels.filter((model) => {
|
||||
// 推荐 DeepSeek 模型作为推理模型
|
||||
return model.name.toLowerCase().includes('deepseek') ||
|
||||
model.name.toLowerCase().includes('deep-seek') ||
|
||||
model.name.toLowerCase().includes('yi') ||
|
||||
model.name.toLowerCase().includes('qwen') ||
|
||||
model.name.toLowerCase().includes('glm')
|
||||
return (
|
||||
model.name.toLowerCase().includes('deepseek') ||
|
||||
model.name.toLowerCase().includes('deep-seek') ||
|
||||
model.name.toLowerCase().includes('yi') ||
|
||||
model.name.toLowerCase().includes('qwen') ||
|
||||
model.name.toLowerCase().includes('glm')
|
||||
)
|
||||
})
|
||||
|
||||
// 推荐的目标模型
|
||||
const recommendedTargetModels = allModels.filter(model => {
|
||||
const recommendedTargetModels = allModels.filter((model) => {
|
||||
// 推荐 Claude 和 Gemini 模型作为目标模型
|
||||
return model.name.toLowerCase().includes('claude') ||
|
||||
model.name.toLowerCase().includes('gemini') ||
|
||||
model.name.toLowerCase().includes('gpt')
|
||||
return (
|
||||
model.name.toLowerCase().includes('claude') ||
|
||||
model.name.toLowerCase().includes('gemini') ||
|
||||
model.name.toLowerCase().includes('gpt')
|
||||
)
|
||||
})
|
||||
|
||||
// 创建提供商
|
||||
@ -132,7 +137,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
|
||||
// 开始编辑组合
|
||||
const startEditCombination = (id: string) => {
|
||||
const combination = combinations.find(c => c.id === id)
|
||||
const combination = combinations.find((c) => c.id === id)
|
||||
if (!combination) return
|
||||
|
||||
setEditingCombination(id)
|
||||
@ -149,7 +154,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const newCombinations = combinations.map(c =>
|
||||
const newCombinations = combinations.map((c) =>
|
||||
c.id === editingCombination
|
||||
? {
|
||||
...c,
|
||||
@ -178,28 +183,26 @@ const DeepClaudeSettings: FC = () => {
|
||||
|
||||
// 删除组合
|
||||
const deleteCombination = (id: string) => {
|
||||
const newCombinations = combinations.filter(c => c.id !== id)
|
||||
const newCombinations = combinations.filter((c) => c.id !== id)
|
||||
setCombinations(newCombinations)
|
||||
}
|
||||
|
||||
// 更新组合状态
|
||||
const updateCombinationStatus = (id: string, enabled: boolean) => {
|
||||
const newCombinations = combinations.map(c =>
|
||||
c.id === id ? { ...c, enabled } : c
|
||||
)
|
||||
const newCombinations = combinations.map((c) => (c.id === id ? { ...c, enabled } : c))
|
||||
setCombinations(newCombinations)
|
||||
}
|
||||
|
||||
// 获取模型名称
|
||||
const getModelFullName = (modelId: string) => {
|
||||
const model = allModels.find(m => m.id === modelId)
|
||||
const model = allModels.find((m) => m.id === modelId)
|
||||
if (!model) return modelId
|
||||
return `${model.name} (${model.providerName})`
|
||||
}
|
||||
|
||||
// 获取模型对象
|
||||
const getModelById = (modelId: string): Model | undefined => {
|
||||
return allModels.find(m => m.id === modelId)
|
||||
return allModels.find((m) => m.id === modelId)
|
||||
}
|
||||
|
||||
// 选择推理模型
|
||||
@ -306,7 +309,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
<SettingRowTitle>{t('settings.deepclaude.combinations')}</SettingRowTitle>
|
||||
</SettingRow>
|
||||
|
||||
{combinations.map(combination => (
|
||||
{combinations.map((combination) => (
|
||||
<CombinationItem key={combination.id}>
|
||||
<VStack gap={10}>
|
||||
<HStack justifyContent="space-between" alignItems="center">
|
||||
@ -327,26 +330,13 @@ const DeepClaudeSettings: FC = () => {
|
||||
</ModelInfo>
|
||||
</HStack>
|
||||
<HStack justifyContent="flex-end" gap={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ApiOutlined />}
|
||||
onClick={() => createProvider()}
|
||||
>
|
||||
<Button type="primary" size="small" icon={<ApiOutlined />} onClick={() => createProvider()}>
|
||||
{t('settings.deepclaude.create_provider')}
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={() => startEditCombination(combination.id)}
|
||||
>
|
||||
<Button type="default" size="small" onClick={() => startEditCombination(combination.id)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => deleteCombination(combination.id)}
|
||||
>
|
||||
<Button danger size="small" onClick={() => deleteCombination(combination.id)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@ -360,11 +350,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
|
||||
{combinations.length > 0 && (
|
||||
<HStack justifyContent="flex-end" style={{ marginBottom: '20px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ApiOutlined />}
|
||||
onClick={createAllProviders}
|
||||
>
|
||||
<Button type="primary" icon={<ApiOutlined />} onClick={createAllProviders}>
|
||||
{t('settings.deepclaude.create_all_providers')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@ -381,7 +367,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
<Form.Item label={t('settings.deepclaude.combination_name')}>
|
||||
<Input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({...editForm, name: e.target.value})}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder={t('settings.deepclaude.combination_name_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
@ -391,7 +377,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
model={getModelById(editForm.reasonerModel)}
|
||||
onClick={selectEditReasonerModel}
|
||||
placeholder={t('settings.deepclaude.select_reasoner_placeholder')}
|
||||
recommended={recommendedReasonerModels.some(m => m.id === editForm.reasonerModel) ? '★' : ''}
|
||||
recommended={recommendedReasonerModels.some((m) => m.id === editForm.reasonerModel) ? '★' : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ModelTip>{t('settings.deepclaude.reasoner_tip')}</ModelTip>
|
||||
@ -401,7 +387,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
model={getModelById(editForm.targetModel)}
|
||||
onClick={selectEditTargetModel}
|
||||
placeholder={t('settings.deepclaude.select_target_placeholder')}
|
||||
recommended={recommendedTargetModels.some(m => m.id === editForm.targetModel) ? '★' : ''}
|
||||
recommended={recommendedTargetModels.some((m) => m.id === editForm.targetModel) ? '★' : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ModelTip>{t('settings.deepclaude.target_tip')}</ModelTip>
|
||||
@ -411,13 +397,10 @@ const DeepClaudeSettings: FC = () => {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={saveEditCombination}
|
||||
disabled={!editForm.name || !editForm.reasonerModel || !editForm.targetModel}
|
||||
>
|
||||
disabled={!editForm.name || !editForm.reasonerModel || !editForm.targetModel}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button onClick={cancelEdit}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={cancelEdit}>{t('common.cancel')}</Button>
|
||||
</HStack>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@ -437,7 +420,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
<Form.Item label={t('settings.deepclaude.combination_name')}>
|
||||
<Input
|
||||
value={newCombination.name}
|
||||
onChange={(e) => setNewCombination({...newCombination, name: e.target.value})}
|
||||
onChange={(e) => setNewCombination({ ...newCombination, name: e.target.value })}
|
||||
placeholder={t('settings.deepclaude.combination_name_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
@ -447,7 +430,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
model={getModelById(newCombination.reasonerModel)}
|
||||
onClick={selectReasonerModel}
|
||||
placeholder={t('settings.deepclaude.select_reasoner_placeholder')}
|
||||
recommended={recommendedReasonerModels.some(m => m.id === newCombination.reasonerModel) ? '★' : ''}
|
||||
recommended={recommendedReasonerModels.some((m) => m.id === newCombination.reasonerModel) ? '★' : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ModelTip>{t('settings.deepclaude.reasoner_tip')}</ModelTip>
|
||||
@ -457,7 +440,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
model={getModelById(newCombination.targetModel)}
|
||||
onClick={selectTargetModel}
|
||||
placeholder={t('settings.deepclaude.select_target_placeholder')}
|
||||
recommended={recommendedTargetModels.some(m => m.id === newCombination.targetModel) ? '★' : ''}
|
||||
recommended={recommendedTargetModels.some((m) => m.id === newCombination.targetModel) ? '★' : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ModelTip>{t('settings.deepclaude.target_tip')}</ModelTip>
|
||||
@ -467,8 +450,7 @@ const DeepClaudeSettings: FC = () => {
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addCombination}
|
||||
disabled={!newCombination.name || !newCombination.reasonerModel || !newCombination.targetModel}
|
||||
>
|
||||
disabled={!newCombination.name || !newCombination.reasonerModel || !newCombination.targetModel}>
|
||||
{t('settings.deepclaude.add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
@ -510,10 +492,10 @@ const ModelTip = styled.div`
|
||||
`
|
||||
|
||||
interface ModelSelectButtonProps {
|
||||
model?: Model;
|
||||
onClick: () => void;
|
||||
placeholder: string;
|
||||
recommended?: string;
|
||||
model?: Model
|
||||
onClick: () => void
|
||||
placeholder: string
|
||||
recommended?: string
|
||||
}
|
||||
|
||||
const ModelSelectButton: FC<ModelSelectButtonProps> = ({ model, onClick, placeholder, recommended }) => {
|
||||
|
||||
@ -82,6 +82,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
window.message.success(t('settings.mcp.jsonSaveSuccess'))
|
||||
setJsonError('')
|
||||
setOpen(false)
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save JSON config:', error)
|
||||
setJsonError(error.message || t('settings.mcp.jsonSaveError'))
|
||||
@ -93,6 +95,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
import { InboxOutlined } from '@ant-design/icons'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Input, Modal, Space, Typography, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Dragger } = Upload
|
||||
const { TextArea } = Input
|
||||
const { Text } = Typography
|
||||
|
||||
interface Props {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [jsonConfig, setJsonConfig] = useState('')
|
||||
const [jsonSaving, setJsonSaving] = useState(false)
|
||||
const [jsonError, setJsonError] = useState('')
|
||||
const { addMCPServer } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = async () => {
|
||||
setJsonSaving(true)
|
||||
|
||||
try {
|
||||
if (!jsonConfig.trim()) {
|
||||
setJsonError(t('settings.mcp.jsonRequired'))
|
||||
setJsonSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const parsedConfig = JSON.parse(jsonConfig)
|
||||
|
||||
// 处理两种可能的格式:
|
||||
// 1. 单个服务器配置: { "command": "npx", ... }
|
||||
// 2. mcpServers 格式: { "mcpServers": { "serverId": { ... } } }
|
||||
|
||||
if (parsedConfig.mcpServers && typeof parsedConfig.mcpServers === 'object') {
|
||||
// 处理 mcpServers 格式
|
||||
const serverEntries = Object.entries(parsedConfig.mcpServers)
|
||||
if (serverEntries.length === 0) {
|
||||
throw new Error(t('settings.mcp.noServerFound'))
|
||||
}
|
||||
|
||||
// 只导入第一个服务器
|
||||
const [id, serverConfig] = serverEntries[0]
|
||||
|
||||
const server: MCPServer = {
|
||||
id: nanoid(), // 生成新ID,避免与现有服务器冲突
|
||||
isActive: false,
|
||||
...(serverConfig as any)
|
||||
}
|
||||
|
||||
if (!server.name) {
|
||||
server.name = id
|
||||
}
|
||||
|
||||
addMCPServer(server)
|
||||
window.message.success(t('settings.mcp.importSuccess'))
|
||||
setOpen(false)
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
} else if (typeof parsedConfig === 'object') {
|
||||
// 处理单个服务器配置
|
||||
const server: MCPServer = {
|
||||
id: nanoid(),
|
||||
name: parsedConfig.name || t('settings.mcp.importedServer'),
|
||||
isActive: false,
|
||||
...parsedConfig
|
||||
}
|
||||
|
||||
addMCPServer(server)
|
||||
window.message.success(t('settings.mcp.importSuccess'))
|
||||
setOpen(false)
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
} else {
|
||||
throw new Error(t('settings.mcp.invalidServerFormat'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to import MCP server config:', error)
|
||||
setJsonError(error.message || t('settings.mcp.jsonImportError'))
|
||||
window.message.error(t('settings.mcp.jsonImportError'))
|
||||
} finally {
|
||||
setJsonSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const handleFileUpload = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string
|
||||
setJsonConfig(content)
|
||||
setJsonError('')
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error)
|
||||
setJsonError(t('settings.mcp.fileReadError'))
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
return false // 阻止默认上传行为
|
||||
}
|
||||
|
||||
ImportMcpServerPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.mcp.importServer')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
okText={t('settings.mcp.import')}
|
||||
confirmLoading={jsonSaving}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
width={600}
|
||||
centered>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>{t('settings.mcp.importServerDesc')}</Text>
|
||||
|
||||
<Dragger accept=".json" showUploadList={false} beforeUpload={handleFileUpload} style={{ marginBottom: 16 }}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">{t('settings.mcp.dropJsonFile')}</p>
|
||||
<p className="ant-upload-hint">{t('settings.mcp.clickOrDrop')}</p>
|
||||
</Dragger>
|
||||
|
||||
<Text>{t('settings.mcp.orPasteJson')}</Text>
|
||||
|
||||
<TextArea
|
||||
value={jsonConfig}
|
||||
onChange={(e) => {
|
||||
setJsonConfig(e.target.value)
|
||||
setJsonError('')
|
||||
}}
|
||||
placeholder={`{
|
||||
"name": "my-mcp-server",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@example/mcp-server"],
|
||||
"type": "stdio"
|
||||
}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontFamily: 'monospace',
|
||||
minHeight: '200px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{jsonError && <ErrorText>{jsonError}</ErrorText>}
|
||||
|
||||
<Text type="secondary">{t('settings.mcp.importModeHint')}</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorText = styled(Text)`
|
||||
color: #ff4d4f;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const TopViewKey = 'ImportMcpServerPopup'
|
||||
|
||||
export default class ImportMcpServerPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show() {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { EditOutlined, ExportOutlined } from '@ant-design/icons'
|
||||
import { EditOutlined, ExportOutlined, ImportOutlined } from '@ant-design/icons'
|
||||
import { NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isWindows } from '@renderer/config/constant'
|
||||
@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import EditMcpJsonPopup from './EditMcpJsonPopup'
|
||||
import ImportMcpServerPopup from './ImportMcpServerPopup'
|
||||
import InstallNpxUv from './InstallNpxUv'
|
||||
|
||||
export const McpSettingsNavbar = () => {
|
||||
@ -27,6 +28,15 @@ export const McpSettingsNavbar = () => {
|
||||
style={{ fontSize: 13, height: 28, borderRadius: 20 }}>
|
||||
{t('settings.mcp.searchNpx')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => ImportMcpServerPopup.show()}
|
||||
icon={<ImportOutlined />}
|
||||
className="nodrag"
|
||||
style={{ fontSize: 13, height: 28, borderRadius: 20 }}>
|
||||
{t('settings.mcp.importServer')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
|
||||
@ -5,6 +5,7 @@ import { Center, HStack } from '@renderer/components/Layout'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { builtinMCPServers } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { getMcpConfigSampleFromReadme } from '@renderer/utils'
|
||||
import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd'
|
||||
import { npxFinder } from 'npx-scope-finder'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
@ -19,6 +20,7 @@ interface SearchResult {
|
||||
npmLink: string
|
||||
fullName: string
|
||||
type: MCPServer['type']
|
||||
configSample?: MCPServer['configSample']
|
||||
}
|
||||
|
||||
const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket']
|
||||
@ -76,6 +78,11 @@ const NpxSearch: FC<{
|
||||
|
||||
// Map the packages to our desired format
|
||||
const formattedResults: SearchResult[] = packages.map((pkg) => {
|
||||
let configSample
|
||||
if (pkg.original?.readme) {
|
||||
configSample = getMcpConfigSampleFromReadme(pkg.original.readme)
|
||||
}
|
||||
|
||||
return {
|
||||
key: pkg.name,
|
||||
name: pkg.name?.split('/')[1] || '',
|
||||
@ -84,7 +91,8 @@ const NpxSearch: FC<{
|
||||
usage: `npx ${pkg.name}`,
|
||||
npmLink: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
|
||||
fullName: pkg.name || '',
|
||||
type: 'stdio'
|
||||
type: 'stdio',
|
||||
configSample
|
||||
}
|
||||
})
|
||||
|
||||
@ -199,9 +207,11 @@ const NpxSearch: FC<{
|
||||
name: record.name,
|
||||
description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
|
||||
command: 'npx',
|
||||
args: ['-y', record.fullName],
|
||||
args: record.configSample?.args ?? ['-y', record.fullName],
|
||||
env: record.configSample?.env,
|
||||
isActive: false,
|
||||
type: record.type
|
||||
type: record.type,
|
||||
configSample: record.configSample
|
||||
}
|
||||
|
||||
addMCPServer(newServer)
|
||||
|
||||
@ -59,6 +59,8 @@ const MCPSettings: FC = () => {
|
||||
}
|
||||
}, [mcpServers, selectedMcpServer])
|
||||
|
||||
// 这些函数已移至顶部工具栏,不再需要
|
||||
|
||||
const McpServersList = useCallback(
|
||||
() => (
|
||||
<GridContainer>
|
||||
@ -266,4 +268,6 @@ const BackButton = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
// 这些样式组件已不再需要,因为按钮已移至顶部工具栏
|
||||
|
||||
export default MCPSettings
|
||||
|
||||
@ -35,7 +35,9 @@ const AssistantMemoryManager = () => {
|
||||
const assistantMemories = useAppSelector((state) => {
|
||||
const allAssistantMemories = state.memory?.assistantMemories || []
|
||||
// 只显示选中助手的记忆
|
||||
return selectedAssistantId ? allAssistantMemories.filter((memory) => memory.assistantId === selectedAssistantId) : []
|
||||
return selectedAssistantId
|
||||
? allAssistantMemories.filter((memory) => memory.assistantId === selectedAssistantId)
|
||||
: []
|
||||
})
|
||||
|
||||
// 添加助手记忆的状态
|
||||
@ -112,8 +114,7 @@ const AssistantMemoryManager = () => {
|
||||
onChange={setSelectedAssistantId}
|
||||
placeholder={t('settings.memory.selectAssistant') || '选择助手'}
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
disabled={!assistantMemoryActive}
|
||||
>
|
||||
disabled={!assistantMemoryActive}>
|
||||
{assistants.map((assistant) => (
|
||||
<Select.Option key={assistant.id} value={assistant.id}>
|
||||
{assistant.name}
|
||||
@ -165,7 +166,11 @@ const AssistantMemoryManager = () => {
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={!selectedAssistantId ? t('settings.memory.selectAssistantFirst') || '请先选择助手' : t('settings.memory.noAssistantMemories') || '无助手记忆'}
|
||||
description={
|
||||
!selectedAssistantId
|
||||
? t('settings.memory.selectAssistantFirst') || '请先选择助手'
|
||||
: t('settings.memory.noAssistantMemories') || '无助手记忆'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -691,7 +691,10 @@ const MemorySettings: FC = () => {
|
||||
<SettingDivider />
|
||||
|
||||
<SettingTitle>{t('settings.memory.assistantMemorySettings') || '助手记忆设置'}</SettingTitle>
|
||||
<SettingHelpText>{t('settings.memory.assistantMemoryDescription') || '助手记忆是与特定助手关联的记忆,可以帮助助手记住重要信息。'}</SettingHelpText>
|
||||
<SettingHelpText>
|
||||
{t('settings.memory.assistantMemoryDescription') ||
|
||||
'助手记忆是与特定助手关联的记忆,可以帮助助手记住重要信息。'}
|
||||
</SettingHelpText>
|
||||
<SettingDivider />
|
||||
|
||||
{/* 助手记忆设置 */}
|
||||
@ -1540,4 +1543,4 @@ const TabsContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default MemorySettings
|
||||
export default MemorySettings
|
||||
|
||||
@ -1,17 +1,29 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { Button, Form, Input, Modal, Select, Switch, Tabs, message } from 'antd'
|
||||
import { addProvider, removeProvider } from '@renderer/store/llm'
|
||||
import { Model } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import {
|
||||
checkModelCombinationsInLocalStorage,
|
||||
createDeepClaudeProvider,
|
||||
ThinkingLibrary
|
||||
} from '@renderer/utils/createDeepClaudeProvider'
|
||||
import {
|
||||
addThinkingLibrary,
|
||||
debugThinkingLibraries,
|
||||
DEFAULT_THINKING_LIBRARIES,
|
||||
getThinkingLibraries,
|
||||
saveThinkingLibraries,
|
||||
updateThinkingLibrary
|
||||
} from '@renderer/utils/thinkingLibrary'
|
||||
import { Button, Form, Input, message, Modal, Select, Switch, Tabs } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { Model } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { ThinkingLibrary, createDeepClaudeProvider, checkModelCombinationsInLocalStorage } from '@renderer/utils/createDeepClaudeProvider'
|
||||
import { addProvider, removeProvider } from '@renderer/store/llm'
|
||||
import { getThinkingLibraries, addThinkingLibrary, updateThinkingLibrary, debugThinkingLibraries, saveThinkingLibraries, DEFAULT_THINKING_LIBRARIES } from '@renderer/utils/thinkingLibrary'
|
||||
|
||||
// 模型组合类型
|
||||
interface ModelCombination {
|
||||
@ -41,8 +53,8 @@ const ModelCombinationSettings: FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('combinations')
|
||||
|
||||
// 获取所有可用的模型
|
||||
const allModels = providers.flatMap(provider =>
|
||||
provider.models.map(model => ({
|
||||
const allModels = providers.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
...model,
|
||||
providerName: provider.name
|
||||
}))
|
||||
@ -51,7 +63,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
// 根据ID查找模型
|
||||
const findModelById = (id: string): Model | null => {
|
||||
for (const provider of providers) {
|
||||
const model = provider.models.find(m => m.id === id)
|
||||
const model = provider.models.find((m) => m.id === id)
|
||||
if (model) return model
|
||||
}
|
||||
return null
|
||||
@ -110,41 +122,47 @@ const ModelCombinationSettings: FC = () => {
|
||||
|
||||
// 保存模型组合到localStorage
|
||||
const saveCombinations = (newCombinations: ModelCombination[]) => {
|
||||
console.log('[ModelCombinationSettings] 保存模型组合:',
|
||||
newCombinations.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: {
|
||||
id: c.reasonerModel?.id,
|
||||
name: c.reasonerModel?.name,
|
||||
provider: c.reasonerModel?.provider
|
||||
},
|
||||
targetModel: {
|
||||
id: c.targetModel?.id,
|
||||
name: c.targetModel?.name,
|
||||
provider: c.targetModel?.provider
|
||||
},
|
||||
isActive: c.isActive
|
||||
})))
|
||||
console.log(
|
||||
'[ModelCombinationSettings] 保存模型组合:',
|
||||
newCombinations.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: {
|
||||
id: c.reasonerModel?.id,
|
||||
name: c.reasonerModel?.name,
|
||||
provider: c.reasonerModel?.provider
|
||||
},
|
||||
targetModel: {
|
||||
id: c.targetModel?.id,
|
||||
name: c.targetModel?.name,
|
||||
provider: c.targetModel?.provider
|
||||
},
|
||||
isActive: c.isActive
|
||||
}))
|
||||
)
|
||||
|
||||
// 确保模型组合中的模型对象是完整的
|
||||
const combinationsToSave = newCombinations.map(c => ({
|
||||
const combinationsToSave = newCombinations.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: c.reasonerModel ? {
|
||||
id: c.reasonerModel.id,
|
||||
name: c.reasonerModel.name,
|
||||
provider: c.reasonerModel.provider,
|
||||
group: c.reasonerModel.group,
|
||||
type: c.reasonerModel.type
|
||||
} : null,
|
||||
targetModel: c.targetModel ? {
|
||||
id: c.targetModel.id,
|
||||
name: c.targetModel.name,
|
||||
provider: c.targetModel.provider,
|
||||
group: c.targetModel.group,
|
||||
type: c.targetModel.type
|
||||
} : null,
|
||||
reasonerModel: c.reasonerModel
|
||||
? {
|
||||
id: c.reasonerModel.id,
|
||||
name: c.reasonerModel.name,
|
||||
provider: c.reasonerModel.provider,
|
||||
group: c.reasonerModel.group,
|
||||
type: c.reasonerModel.type
|
||||
}
|
||||
: null,
|
||||
targetModel: c.targetModel
|
||||
? {
|
||||
id: c.targetModel.id,
|
||||
name: c.targetModel.name,
|
||||
provider: c.targetModel.provider,
|
||||
group: c.targetModel.group,
|
||||
type: c.targetModel.type
|
||||
}
|
||||
: null,
|
||||
isActive: c.isActive
|
||||
}))
|
||||
|
||||
@ -162,44 +180,48 @@ const ModelCombinationSettings: FC = () => {
|
||||
// 使用setTimeout来避免在渲染周期内进行多次状态更新
|
||||
setTimeout(() => {
|
||||
// 移除所有现有的DeepClaude提供商
|
||||
const existingDeepClaudeProviders = providers.filter(p => p.type === 'deepclaude')
|
||||
const existingDeepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
|
||||
console.log('[ModelCombinationSettings] 移除现有DeepClaude提供商数量:', existingDeepClaudeProviders.length)
|
||||
existingDeepClaudeProviders.forEach(provider => {
|
||||
existingDeepClaudeProviders.forEach((provider) => {
|
||||
dispatch(removeProvider(provider))
|
||||
})
|
||||
|
||||
// 创建并添加新的DeepClaude提供商
|
||||
const activeCombinations = combinations.filter(c => c.isActive && c.reasonerModel && c.targetModel)
|
||||
const activeCombinations = combinations.filter((c) => c.isActive && c.reasonerModel && c.targetModel)
|
||||
console.log('[ModelCombinationSettings] 激活的模型组合数量:', activeCombinations.length)
|
||||
console.log('[ModelCombinationSettings] 激活的模型组合详情:',
|
||||
activeCombinations.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: {
|
||||
id: c.reasonerModel?.id,
|
||||
name: c.reasonerModel?.name,
|
||||
provider: c.reasonerModel?.provider
|
||||
},
|
||||
targetModel: {
|
||||
id: c.targetModel?.id,
|
||||
name: c.targetModel?.name,
|
||||
provider: c.targetModel?.provider
|
||||
}
|
||||
})))
|
||||
console.log(
|
||||
'[ModelCombinationSettings] 激活的模型组合详情:',
|
||||
activeCombinations.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: {
|
||||
id: c.reasonerModel?.id,
|
||||
name: c.reasonerModel?.name,
|
||||
provider: c.reasonerModel?.provider
|
||||
},
|
||||
targetModel: {
|
||||
id: c.targetModel?.id,
|
||||
name: c.targetModel?.name,
|
||||
provider: c.targetModel?.provider
|
||||
}
|
||||
}))
|
||||
)
|
||||
|
||||
if (activeCombinations.length > 0) {
|
||||
// 创建一个单一的DeepClaude提供商,包含所有激活的模型组合
|
||||
const provider = createDeepClaudeProvider(activeCombinations)
|
||||
console.log('[ModelCombinationSettings] 创建的DeepClaude提供商:',
|
||||
provider.id, provider.name, provider.type,
|
||||
provider.models.map(m => ({ id: m.id, name: m.name, provider: m.provider })))
|
||||
console.log(
|
||||
'[ModelCombinationSettings] 创建的DeepClaude提供商:',
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.type,
|
||||
provider.models.map((m) => ({ id: m.id, name: m.name, provider: m.provider }))
|
||||
)
|
||||
dispatch(addProvider(provider))
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 添加或编辑模型组合
|
||||
const handleAddOrEditCombination = (values: any) => {
|
||||
const { name, reasonerModelId, targetModelId, isActive, thinkingLibraryId } = values
|
||||
@ -214,7 +236,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
|
||||
if (editingCombination) {
|
||||
// 编辑现有组合
|
||||
const updatedCombinations = combinations.map(comb =>
|
||||
const updatedCombinations = combinations.map((comb) =>
|
||||
comb.id === editingCombination.id
|
||||
? { ...comb, name, reasonerModel, targetModel, isActive: isActive !== false, thinkingLibraryId }
|
||||
: comb
|
||||
@ -246,7 +268,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
title: t('settings.modelCombination.confirmDelete'),
|
||||
content: t('settings.modelCombination.confirmDeleteContent'),
|
||||
onOk: () => {
|
||||
const updatedCombinations = combinations.filter(comb => comb.id !== id)
|
||||
const updatedCombinations = combinations.filter((comb) => comb.id !== id)
|
||||
saveCombinations(updatedCombinations)
|
||||
message.success(t('settings.modelCombination.deleteSuccess'))
|
||||
}
|
||||
@ -268,9 +290,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
|
||||
// 切换模型组合的激活状态
|
||||
const toggleCombinationActive = (id: string, isActive: boolean) => {
|
||||
const updatedCombinations = combinations.map(comb =>
|
||||
comb.id === id ? { ...comb, isActive } : comb
|
||||
)
|
||||
const updatedCombinations = combinations.map((comb) => (comb.id === id ? { ...comb, isActive } : comb))
|
||||
saveCombinations(updatedCombinations)
|
||||
}
|
||||
|
||||
@ -351,7 +371,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
console.log('[ModelCombinationSettings] 当前思考库数量:', currentLibraries.length)
|
||||
|
||||
// 直接在内存中过滤要删除的思考库
|
||||
const filteredLibraries = currentLibraries.filter(lib => lib.id !== id)
|
||||
const filteredLibraries = currentLibraries.filter((lib) => lib.id !== id)
|
||||
console.log('[ModelCombinationSettings] 过滤后思考库数量:', filteredLibraries.length)
|
||||
|
||||
// 保存到localStorage
|
||||
@ -395,58 +415,59 @@ const ModelCombinationSettings: FC = () => {
|
||||
label: t('settings.modelCombination.title'),
|
||||
children: (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('settings.modelCombination.title')}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingCombination(null)
|
||||
form.resetFields()
|
||||
setIsModalVisible(true)
|
||||
}}
|
||||
>
|
||||
{t('settings.modelCombination.add')}
|
||||
</Button>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingTitle>
|
||||
{t('settings.modelCombination.title')}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingCombination(null)
|
||||
form.resetFields()
|
||||
setIsModalVisible(true)
|
||||
}}>
|
||||
{t('settings.modelCombination.add')}
|
||||
</Button>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
|
||||
{combinations.length === 0 ? (
|
||||
<EmptyState>{t('settings.modelCombination.empty')}</EmptyState>
|
||||
) : (
|
||||
<CombinationList>
|
||||
{combinations.map(combination => (
|
||||
<CombinationItem key={combination.id}>
|
||||
<CombinationInfo>
|
||||
<CombinationName>{combination.name}</CombinationName>
|
||||
<CombinationDetail>
|
||||
{t('settings.modelCombination.reasoner')}: {combination.reasonerModel?.name || t('settings.modelCombination.notSelected')}
|
||||
</CombinationDetail>
|
||||
<CombinationDetail>
|
||||
{t('settings.modelCombination.target')}: {combination.targetModel?.name || t('settings.modelCombination.notSelected')}
|
||||
</CombinationDetail>
|
||||
</CombinationInfo>
|
||||
<CombinationActions>
|
||||
<Switch
|
||||
checked={combination.isActive}
|
||||
onChange={(checked) => toggleCombinationActive(combination.id, checked)}
|
||||
/>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type="text"
|
||||
onClick={() => handleEditCombination(combination)}
|
||||
/>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
type="text"
|
||||
danger
|
||||
onClick={() => handleDeleteCombination(combination.id)}
|
||||
/>
|
||||
</CombinationActions>
|
||||
</CombinationItem>
|
||||
))}
|
||||
</CombinationList>
|
||||
)}
|
||||
{combinations.length === 0 ? (
|
||||
<EmptyState>{t('settings.modelCombination.empty')}</EmptyState>
|
||||
) : (
|
||||
<CombinationList>
|
||||
{combinations.map((combination) => (
|
||||
<CombinationItem key={combination.id}>
|
||||
<CombinationInfo>
|
||||
<CombinationName>{combination.name}</CombinationName>
|
||||
<CombinationDetail>
|
||||
{t('settings.modelCombination.reasoner')}:{' '}
|
||||
{combination.reasonerModel?.name || t('settings.modelCombination.notSelected')}
|
||||
</CombinationDetail>
|
||||
<CombinationDetail>
|
||||
{t('settings.modelCombination.target')}:{' '}
|
||||
{combination.targetModel?.name || t('settings.modelCombination.notSelected')}
|
||||
</CombinationDetail>
|
||||
</CombinationInfo>
|
||||
<CombinationActions>
|
||||
<Switch
|
||||
checked={combination.isActive}
|
||||
onChange={(checked) => toggleCombinationActive(combination.id, checked)}
|
||||
/>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type="text"
|
||||
onClick={() => handleEditCombination(combination)}
|
||||
/>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
type="text"
|
||||
danger
|
||||
onClick={() => handleDeleteCombination(combination.id)}
|
||||
/>
|
||||
</CombinationActions>
|
||||
</CombinationItem>
|
||||
))}
|
||||
</CombinationList>
|
||||
)}
|
||||
</SettingGroup>
|
||||
)
|
||||
},
|
||||
@ -465,8 +486,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
setEditingLibrary(null)
|
||||
libraryForm.resetFields()
|
||||
setIsLibraryModalVisible(true)
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{t('settings.thinkingLibrary.add')}
|
||||
</Button>
|
||||
<Button
|
||||
@ -493,8 +513,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
@ -502,8 +521,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
// 调用调试函数,在控制台显示思考库数据
|
||||
debugThinkingLibraries()
|
||||
message.info('思考库调试信息已输出到控制台,请按F12查看')
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
调试
|
||||
</Button>
|
||||
<Button
|
||||
@ -520,8 +538,10 @@ const ModelCombinationSettings: FC = () => {
|
||||
console.log('[ModelCombinationSettings] 当前思考库数量:', currentLibraries.length)
|
||||
|
||||
// 获取默认思考库中缺失的思考库
|
||||
const existingIds = new Set(currentLibraries.map(lib => lib.id))
|
||||
const missingLibraries = DEFAULT_THINKING_LIBRARIES.filter((lib: ThinkingLibrary) => !existingIds.has(lib.id))
|
||||
const existingIds = new Set(currentLibraries.map((lib) => lib.id))
|
||||
const missingLibraries = DEFAULT_THINKING_LIBRARIES.filter(
|
||||
(lib: ThinkingLibrary) => !existingIds.has(lib.id)
|
||||
)
|
||||
console.log('[ModelCombinationSettings] 缺失的默认思考库数量:', missingLibraries.length)
|
||||
|
||||
if (missingLibraries.length > 0) {
|
||||
@ -548,8 +568,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
更新
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
@ -560,7 +579,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
<EmptyState>{t('settings.thinkingLibrary.empty')}</EmptyState>
|
||||
) : (
|
||||
<CombinationList>
|
||||
{thinkingLibraries.map(library => (
|
||||
{thinkingLibraries.map((library) => (
|
||||
<CombinationItem key={library.id}>
|
||||
<CombinationInfo>
|
||||
<CombinationName>{library.name}</CombinationName>
|
||||
@ -572,11 +591,7 @@ const ModelCombinationSettings: FC = () => {
|
||||
</CombinationDetail>
|
||||
</CombinationInfo>
|
||||
<CombinationActions>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type="text"
|
||||
onClick={() => handleEditLibrary(library)}
|
||||
/>
|
||||
<Button icon={<EditOutlined />} type="text" onClick={() => handleEditLibrary(library)} />
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
type="text"
|
||||
@ -596,47 +611,29 @@ const ModelCombinationSettings: FC = () => {
|
||||
|
||||
{/* 添加/编辑模型组合的模态框 */}
|
||||
<Modal
|
||||
title={editingCombination
|
||||
? t('settings.modelCombination.editTitle')
|
||||
: t('settings.modelCombination.addTitle')
|
||||
}
|
||||
title={editingCombination ? t('settings.modelCombination.editTitle') : t('settings.modelCombination.addTitle')}
|
||||
open={isModalVisible}
|
||||
onCancel={() => {
|
||||
setIsModalVisible(false)
|
||||
setEditingCombination(null)
|
||||
form.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleAddOrEditCombination}
|
||||
>
|
||||
footer={null}>
|
||||
<Form form={form} layout="vertical" onFinish={handleAddOrEditCombination}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.modelCombination.name')}
|
||||
rules={[{ required: true, message: t('settings.modelCombination.nameRequired') }]}
|
||||
>
|
||||
rules={[{ required: true, message: t('settings.modelCombination.nameRequired') }]}>
|
||||
<Input placeholder={t('settings.modelCombination.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="reasonerModelId"
|
||||
label={t('settings.modelCombination.reasonerModel')}
|
||||
rules={[{ required: true, message: t('settings.modelCombination.reasonerModelRequired') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('settings.modelCombination.selectModel')}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{allModels.map(model => (
|
||||
<Select.Option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
label={`${model.name} (${model.providerName})`}
|
||||
>
|
||||
rules={[{ required: true, message: t('settings.modelCombination.reasonerModelRequired') }]}>
|
||||
<Select placeholder={t('settings.modelCombination.selectModel')} showSearch optionFilterProp="label">
|
||||
{allModels.map((model) => (
|
||||
<Select.Option key={model.id} value={model.id} label={`${model.name} (${model.providerName})`}>
|
||||
{model.name} ({model.providerName})
|
||||
</Select.Option>
|
||||
))}
|
||||
@ -646,50 +643,27 @@ const ModelCombinationSettings: FC = () => {
|
||||
<Form.Item
|
||||
name="targetModelId"
|
||||
label={t('settings.modelCombination.targetModel')}
|
||||
rules={[{ required: true, message: t('settings.modelCombination.targetModelRequired') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('settings.modelCombination.selectModel')}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{allModels.map(model => (
|
||||
<Select.Option
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
label={`${model.name} (${model.providerName})`}
|
||||
>
|
||||
rules={[{ required: true, message: t('settings.modelCombination.targetModelRequired') }]}>
|
||||
<Select placeholder={t('settings.modelCombination.selectModel')} showSearch optionFilterProp="label">
|
||||
{allModels.map((model) => (
|
||||
<Select.Option key={model.id} value={model.id} label={`${model.name} (${model.providerName})`}>
|
||||
{model.name} ({model.providerName})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="thinkingLibraryId"
|
||||
label="思考库"
|
||||
>
|
||||
<Select
|
||||
placeholder="选择思考库(可选)"
|
||||
allowClear
|
||||
>
|
||||
{thinkingLibraries.map(library => (
|
||||
<Select.Option
|
||||
key={library.id}
|
||||
value={library.id}
|
||||
label={`${library.name} (${library.category})`}
|
||||
>
|
||||
<Form.Item name="thinkingLibraryId" label="思考库">
|
||||
<Select placeholder="选择思考库(可选)" allowClear>
|
||||
{thinkingLibraries.map((library) => (
|
||||
<Select.Option key={library.id} value={library.id} label={`${library.name} (${library.category})`}>
|
||||
{library.name} ({library.category})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="isActive"
|
||||
valuePropName="checked"
|
||||
initialValue={true}
|
||||
>
|
||||
<Form.Item name="isActive" valuePropName="checked" initialValue={true}>
|
||||
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
|
||||
</Form.Item>
|
||||
|
||||
@ -703,59 +677,41 @@ const ModelCombinationSettings: FC = () => {
|
||||
|
||||
{/* 添加/编辑思考库的模态框 */}
|
||||
<Modal
|
||||
title={editingLibrary
|
||||
? t('settings.thinkingLibrary.editTitle')
|
||||
: t('settings.thinkingLibrary.addTitle')
|
||||
}
|
||||
title={editingLibrary ? t('settings.thinkingLibrary.editTitle') : t('settings.thinkingLibrary.addTitle')}
|
||||
open={isLibraryModalVisible}
|
||||
onCancel={() => {
|
||||
setIsLibraryModalVisible(false)
|
||||
setEditingLibrary(null)
|
||||
libraryForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={libraryForm}
|
||||
layout="vertical"
|
||||
onFinish={handleAddOrEditLibrary}
|
||||
>
|
||||
footer={null}>
|
||||
<Form form={libraryForm} layout="vertical" onFinish={handleAddOrEditLibrary}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.thinkingLibrary.name')}
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.nameRequired') }]}
|
||||
>
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.nameRequired') }]}>
|
||||
<Input placeholder={t('settings.thinkingLibrary.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('settings.thinkingLibrary.description')}
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.descriptionRequired') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('settings.thinkingLibrary.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
/>
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.descriptionRequired') }]}>
|
||||
<Input.TextArea placeholder={t('settings.thinkingLibrary.descriptionPlaceholder')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="category"
|
||||
label={t('settings.thinkingLibrary.category')}
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.categoryRequired') }]}
|
||||
>
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.categoryRequired') }]}>
|
||||
<Input placeholder={t('settings.thinkingLibrary.categoryPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="prompt"
|
||||
label={t('settings.thinkingLibrary.prompt')}
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.promptRequired') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('settings.thinkingLibrary.promptPlaceholder')}
|
||||
rows={10}
|
||||
/>
|
||||
rules={[{ required: true, message: t('settings.thinkingLibrary.promptRequired') }]}>
|
||||
<Input.TextArea placeholder={t('settings.thinkingLibrary.promptPlaceholder')} rows={10} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { CopyOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { maskApiKey } from '@renderer/utils/api'
|
||||
import { Button, Input, Modal, Space, Typography, Upload } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -22,7 +23,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
const [importText, setImportText] = useState('')
|
||||
|
||||
// 当前密钥列表
|
||||
const currentKeys = currentApiKey.split(',').filter(key => key.trim() !== '')
|
||||
const currentKeys = currentApiKey.split(',').filter((key) => key.trim() !== '')
|
||||
|
||||
// 添加新密钥
|
||||
const handleAddKey = () => {
|
||||
@ -42,8 +43,8 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
|
||||
const importedKeys = importText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line !== '')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '')
|
||||
|
||||
const allKeys = [...currentKeys, ...importedKeys]
|
||||
const uniqueKeys = [...new Set(allKeys)]
|
||||
@ -67,42 +68,57 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
// 复制密钥到剪贴板
|
||||
const copyKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key)
|
||||
window.message.success({
|
||||
content: t('common.copied'),
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyManagerContainer>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsAddKeyModalVisible(true)}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsAddKeyModalVisible(true)}>
|
||||
{t('settings.provider.gemini.add_key')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setIsImportModalVisible(true)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} onClick={() => setIsImportModalVisible(true)}>
|
||||
{t('settings.provider.gemini.import_keys')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<KeyCountInfo>
|
||||
{currentKeys.length > 0 && (
|
||||
<Text type="secondary">
|
||||
{t('settings.provider.gemini.key_count', { count: currentKeys.length })}
|
||||
</Text>
|
||||
<Text type="secondary">{t('settings.provider.gemini.key_count', { count: currentKeys.length })}</Text>
|
||||
)}
|
||||
</KeyCountInfo>
|
||||
</KeyManagerContainer>
|
||||
|
||||
{/* 显示密钥列表 */}
|
||||
{currentKeys.length > 0 && (
|
||||
<KeysListContainer>
|
||||
{currentKeys.map((key, index) => (
|
||||
<KeyItem key={index}>
|
||||
<Text>{maskApiKey(key)}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyKey(key)}
|
||||
/>
|
||||
</KeyItem>
|
||||
))}
|
||||
</KeysListContainer>
|
||||
)}
|
||||
|
||||
{/* 添加新密钥的模态框 */}
|
||||
<Modal
|
||||
title={t('settings.provider.gemini.add_key_title')}
|
||||
open={isAddKeyModalVisible}
|
||||
onOk={handleAddKey}
|
||||
onCancel={() => setIsAddKeyModalVisible(false)}
|
||||
okButtonProps={{ disabled: !newKey.trim() }}
|
||||
>
|
||||
okButtonProps={{ disabled: !newKey.trim() }}>
|
||||
<Input.Password
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(formatApiKeys(e.target.value))}
|
||||
@ -118,8 +134,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
onOk={handleImportKeys}
|
||||
onCancel={() => setIsImportModalVisible(false)}
|
||||
okButtonProps={{ disabled: !importText.trim() }}
|
||||
width={600}
|
||||
>
|
||||
width={600}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>{t('settings.provider.gemini.import_keys_desc')}</Text>
|
||||
|
||||
@ -128,8 +143,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
beforeUpload={() => false}
|
||||
onChange={handleFileImport}
|
||||
showUploadList={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
style={{ marginBottom: 16 }}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
@ -161,4 +175,26 @@ const KeyCountInfo = styled.div`
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const KeysListContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const KeyItem = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default GeminiKeyManager
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { CheckOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { CheckOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
|
||||
@ -12,9 +12,9 @@ import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { formatApiHost, maskApiKey } from '@renderer/utils/api'
|
||||
import { providerCharge } from '@renderer/utils/oauth'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip, Typography } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { Settings, SquareArrowOutUpRight } from 'lucide-react'
|
||||
@ -344,6 +344,31 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{/* 显示API密钥列表 */}
|
||||
{provider.id !== 'gemini' && provider.id !== 'ollama' && provider.id !== 'lmstudio' && provider.id !== 'copilot' && apiKey.includes(',') && (
|
||||
<KeysListContainer>
|
||||
{apiKey
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key !== '')
|
||||
.map((key, index) => (
|
||||
<KeyItem key={index}>
|
||||
<Typography.Text>{maskApiKey(key)}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(key)
|
||||
window.message.success({
|
||||
content: t('common.copied'),
|
||||
duration: 2
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</KeyItem>
|
||||
))}
|
||||
</KeysListContainer>
|
||||
)}
|
||||
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
@ -384,11 +409,17 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
{provider.id === 'lmstudio' && <LMStudioSettings />}
|
||||
{provider.id === 'gpustack' && <GPUStackSettings />}
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
|
||||
{provider.id === 'gemini' && <GeminiKeyManager provider={provider} currentApiKey={apiKey} onApiKeyChange={(newApiKey) => {
|
||||
setApiKey(newApiKey)
|
||||
setInputValue(newApiKey)
|
||||
updateProvider({ ...provider, apiKey: newApiKey })
|
||||
}} />}
|
||||
{provider.id === 'gemini' && (
|
||||
<GeminiKeyManager
|
||||
provider={provider}
|
||||
currentApiKey={apiKey}
|
||||
onApiKeyChange={(newApiKey) => {
|
||||
setApiKey(newApiKey)
|
||||
setInputValue(newApiKey)
|
||||
updateProvider({ ...provider, apiKey: newApiKey })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingSubtitle style={{ marginBottom: 5 }}>
|
||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
@ -418,4 +449,25 @@ const ProviderName = styled.span`
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const KeysListContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const KeyItem = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default ProviderSetting
|
||||
|
||||
@ -171,9 +171,7 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
|
||||
// 系统内置的供应商不允许删除,但以 provider 开头的自动生成供应商或DeepClaude供应商允许删除
|
||||
if (provider.isSystem &&
|
||||
!provider.id.includes('provider') &&
|
||||
provider.type !== 'deepclaude') {
|
||||
if (provider.isSystem && !provider.id.includes('provider') && provider.type !== 'deepclaude') {
|
||||
return []
|
||||
}
|
||||
|
||||
@ -255,7 +253,10 @@ const ProvidersList: FC = () => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
|
||||
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']} destroyPopupOnHide>
|
||||
<Dropdown
|
||||
menu={{ items: getDropdownMenus(provider) }}
|
||||
trigger={['contextMenu']}
|
||||
destroyPopupOnHide>
|
||||
<ProviderListItem
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
|
||||
@ -65,7 +65,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
centered>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{ flex: '110px' }}
|
||||
labelCol={{ flex: '80px' }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
style={{ marginTop: 25 }}
|
||||
@ -90,7 +90,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
</Form.Item>
|
||||
<Form.Item label=" ">
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('settings.websearch.subscribe_add')}
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { CheckOutlined, CopyOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
|
||||
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { formatApiKeys } from '@renderer/services/ApiService'
|
||||
import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { WebSearchProvider } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import { Avatar, Button, Divider, Flex, Input } from 'antd'
|
||||
import { maskApiKey } from '@renderer/utils/api'
|
||||
import { Avatar, Button, Divider, Flex, Input, Typography } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { Info } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
@ -154,6 +155,31 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</SettingHelpLink>
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
{/* 显示API密钥列表 */}
|
||||
{apiKey.includes(',') && (
|
||||
<KeysListContainer>
|
||||
{apiKey
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key !== '')
|
||||
.map((key, index) => (
|
||||
<KeyItem key={index}>
|
||||
<Typography.Text>{maskApiKey(key)}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(key)
|
||||
window.message.success({
|
||||
content: t('common.copied'),
|
||||
duration: 2
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</KeyItem>
|
||||
))}
|
||||
</KeysListContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasObjectKey(provider, 'apiHost') && (
|
||||
@ -190,4 +216,25 @@ const ProviderLogo = styled(Avatar)`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const KeysListContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const KeyItem = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default WebSearchProviderSetting
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import styled from 'styled-components'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const SettingContainer = styled.div<{ theme: ThemeMode }>`
|
||||
padding: 20px;
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { isEmpty, takeRight } from 'lodash'
|
||||
import store from '@renderer/store'
|
||||
import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { getThinkingLibraryById } from '@renderer/utils/thinkingLibrary'
|
||||
import { isEmpty, takeRight } from 'lodash'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
import BaseProvider from './BaseProvider'
|
||||
@ -26,10 +26,17 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
constructor(provider: Provider, modelCombination: ModelCombination) {
|
||||
super(provider)
|
||||
|
||||
console.log('[DeepClaudeProvider] 构造函数被调用,接收到的模型组合:',
|
||||
modelCombination.id, modelCombination.name,
|
||||
'推理模型:', modelCombination.reasonerModel?.id, modelCombination.reasonerModel?.name,
|
||||
'目标模型:', modelCombination.targetModel?.id, modelCombination.targetModel?.name)
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 构造函数被调用,接收到的模型组合:',
|
||||
modelCombination.id,
|
||||
modelCombination.name,
|
||||
'推理模型:',
|
||||
modelCombination.reasonerModel?.id,
|
||||
modelCombination.reasonerModel?.name,
|
||||
'目标模型:',
|
||||
modelCombination.targetModel?.id,
|
||||
modelCombination.targetModel?.name
|
||||
)
|
||||
|
||||
// 查找推理模型和目标模型的提供商
|
||||
const providers = store.getState().llm.providers
|
||||
@ -58,21 +65,36 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
console.log('[DeepClaudeProvider] 提供商实例创建完成')
|
||||
this.modelCombination = modelCombination
|
||||
|
||||
console.log('[DeepClaudeProvider] 初始化完成,推理模型:', this.modelCombination.reasonerModel.name,
|
||||
'推理模型提供商:', reasonerModelProvider.name,
|
||||
'目标模型:', this.modelCombination.targetModel.name,
|
||||
'目标模型提供商:', targetModelProvider.name)
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 初始化完成,推理模型:',
|
||||
this.modelCombination.reasonerModel.name,
|
||||
'推理模型提供商:',
|
||||
reasonerModelProvider.name,
|
||||
'目标模型:',
|
||||
this.modelCombination.targetModel.name,
|
||||
'目标模型提供商:',
|
||||
targetModelProvider.name
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完成
|
||||
*/
|
||||
public async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
|
||||
public async completions({
|
||||
messages,
|
||||
assistant,
|
||||
mcpTools,
|
||||
onChunk,
|
||||
onFilterMessages
|
||||
}: CompletionsParams): Promise<void> {
|
||||
// 获取设置
|
||||
const contextCount = assistant.settings?.contextCount || 10
|
||||
|
||||
// 过滤消息
|
||||
const filteredMessages = takeRight(messages.filter(m => !isEmpty(m.content)), contextCount + 2)
|
||||
const filteredMessages = takeRight(
|
||||
messages.filter((m) => !isEmpty(m.content)),
|
||||
contextCount + 2
|
||||
)
|
||||
|
||||
if (onFilterMessages) {
|
||||
onFilterMessages(filteredMessages)
|
||||
@ -95,12 +117,12 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
try {
|
||||
// 创建状态对象来跟踪推理过程
|
||||
const state = {
|
||||
isReasoningStarted: false, // 是否已经开始显示思考过程
|
||||
isReasoningFinished: false, // 推理模型是否已完成
|
||||
isTargetStarted: false, // 目标模型是否已开始
|
||||
accumulatedThinking: '', // 累积的思考过程
|
||||
extractedThinking: '', // 提取的思考过程
|
||||
isFirstTargetChunk: true // 是否是目标模型的第一个chunk
|
||||
isReasoningStarted: false, // 是否已经开始显示思考过程
|
||||
isReasoningFinished: false, // 推理模型是否已完成
|
||||
isTargetStarted: false, // 目标模型是否已开始
|
||||
accumulatedThinking: '', // 累积的思考过程
|
||||
extractedThinking: '', // 提取的思考过程
|
||||
isFirstTargetChunk: true // 是否是目标模型的第一个chunk
|
||||
}
|
||||
|
||||
// 同时启动两个模型的调用
|
||||
@ -108,48 +130,53 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
// 推理模型任务
|
||||
(async () => {
|
||||
try {
|
||||
console.log('[DeepClaudeProvider] 启动推理模型任务,使用模型:',
|
||||
this.modelCombination.reasonerModel.name,
|
||||
'模型ID:', this.modelCombination.reasonerModel.id,
|
||||
'提供商:', this.modelCombination.reasonerModel.provider)
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 启动推理模型任务,使用模型:',
|
||||
this.modelCombination.reasonerModel.name,
|
||||
'模型ID:',
|
||||
this.modelCombination.reasonerModel.id,
|
||||
'提供商:',
|
||||
this.modelCombination.reasonerModel.provider
|
||||
)
|
||||
|
||||
// 检查推理模型是否是专门的推理模型
|
||||
const isSpecialReasonerModel = this.modelCombination.reasonerModel.group === 'DeepSeek' ||
|
||||
this.modelCombination.reasonerModel.name.toLowerCase().includes('reason');
|
||||
const isSpecialReasonerModel =
|
||||
this.modelCombination.reasonerModel.group === 'DeepSeek' ||
|
||||
this.modelCombination.reasonerModel.name.toLowerCase().includes('reason')
|
||||
|
||||
// 根据模型类型和思考库选择不同的提示词
|
||||
let reasoningPrompt = '';
|
||||
let reasoningPrompt = ''
|
||||
if (isSpecialReasonerModel) {
|
||||
// 专门的推理模型使用简单提示词
|
||||
reasoningPrompt = `你是一个思考助手。请对以下问题进行深入思考,分析问题的各个方面,并提供详细的推理过程。
|
||||
请以<thinking>开始,以</thinking>结束你的思考过程。
|
||||
不要在思考过程中包含“思考过程”或类似的标题,直接开始思考即可。
|
||||
|
||||
问题: ${lastUserMessage.content}`;
|
||||
问题: ${lastUserMessage.content}`
|
||||
} else {
|
||||
// 普通模型使用思考库提示词或默认提示词
|
||||
const thinkingLibrary = getThinkingLibraryById(this.modelCombination.thinkingLibraryId);
|
||||
const thinkingLibrary = getThinkingLibraryById(this.modelCombination.thinkingLibraryId)
|
||||
|
||||
if (thinkingLibrary) {
|
||||
// 使用选定的思考库提示词
|
||||
console.log('[DeepClaudeProvider] 使用思考库:', thinkingLibrary.name);
|
||||
reasoningPrompt = thinkingLibrary.prompt.replace('{question}', lastUserMessage.content);
|
||||
console.log('[DeepClaudeProvider] 使用思考库:', thinkingLibrary.name)
|
||||
reasoningPrompt = thinkingLibrary.prompt.replace('{question}', lastUserMessage.content)
|
||||
} else {
|
||||
// 使用默认提示词
|
||||
console.log('[DeepClaudeProvider] 使用默认思考提示词');
|
||||
console.log('[DeepClaudeProvider] 使用默认思考提示词')
|
||||
reasoningPrompt = `你是一个思考助手。请对以下问题进行深入思考,分析问题的各个方面,并提供详细的推理过程。
|
||||
请非常详细地思考这个问题的各个方面,考虑不同的角度和可能性。
|
||||
你的回答将作为另一个AI助手的思考基础,所以请尽可能详细和全面。
|
||||
请以<think>开始,以</think>结束你的思考过程。
|
||||
不要在思考过程中包含“思考过程”或类似的标题,直接开始思考即可。
|
||||
|
||||
问题: ${lastUserMessage.content}`;
|
||||
问题: ${lastUserMessage.content}`
|
||||
}
|
||||
}
|
||||
|
||||
// 创建推理模型的消息列表
|
||||
// 保留历史消息,但修改最后一条用户消息
|
||||
console.log('[DeepClaudeProvider] 推理模型使用原始对话历史消息数量:', filteredMessages.length);
|
||||
console.log('[DeepClaudeProvider] 推理模型使用原始对话历史消息数量:', filteredMessages.length)
|
||||
|
||||
// 复制历史消息,但修改最后一条用户消息
|
||||
const reasoningMessages = filteredMessages.map((msg, index) => {
|
||||
@ -161,7 +188,7 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
}
|
||||
}
|
||||
return msg
|
||||
});
|
||||
})
|
||||
|
||||
// 使用completions方法调用推理模型
|
||||
await this.reasonerProvider.completions({
|
||||
@ -169,72 +196,79 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
assistant: {
|
||||
...assistant,
|
||||
model: this.modelCombination.reasonerModel,
|
||||
prompt: '' // 不使用assistant的prompt,而是使用我们自定义的reasoningPrompt
|
||||
prompt: '' // 不使用assistant的prompt,而是使用我们自定义的reasoningPrompt
|
||||
},
|
||||
mcpTools: [], // 不使用工具,避免干扰推理过程
|
||||
onChunk: (chunk) => {
|
||||
// 累积推理过程
|
||||
if (chunk.text) {
|
||||
state.accumulatedThinking += chunk.text;
|
||||
state.accumulatedThinking += chunk.text
|
||||
|
||||
// 实时将思考过程传递给前端
|
||||
if (!state.isTargetStarted) {
|
||||
// 只有在目标模型尚未开始时才发送思考过程
|
||||
if (!state.isReasoningStarted) {
|
||||
state.isReasoningStarted = true;
|
||||
state.isReasoningStarted = true
|
||||
// 第一次发送思考过程,使用reasoning_content字段
|
||||
onChunk({
|
||||
reasoning_content: chunk.text,
|
||||
text: '' // 不显示文本,只显示思考过程
|
||||
});
|
||||
text: '' // 不显示文本,只显示思考过程
|
||||
})
|
||||
} else {
|
||||
// 后续发送思考过程,继续使用reasoning_content字段
|
||||
onChunk({
|
||||
reasoning_content: chunk.text,
|
||||
text: '' // 不显示文本,只显示思考过程
|
||||
});
|
||||
text: '' // 不显示文本,只显示思考过程
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 输出日志,让用户看到推理过程
|
||||
console.log('[DeepClaudeProvider] 推理模型输出:', chunk.text.length, '字符');
|
||||
console.log('[DeepClaudeProvider] 推理模型输出:', chunk.text.length, '字符')
|
||||
}
|
||||
},
|
||||
onFilterMessages: () => {}
|
||||
});
|
||||
})
|
||||
|
||||
// 如果不是专门的推理模型,将其输出包装在<think></think>标签中
|
||||
if (!isSpecialReasonerModel &&
|
||||
!state.accumulatedThinking.includes('<think>') &&
|
||||
!state.accumulatedThinking.includes('<thinking>')) {
|
||||
state.accumulatedThinking = `<think>${state.accumulatedThinking}</think>`;
|
||||
if (
|
||||
!isSpecialReasonerModel &&
|
||||
!state.accumulatedThinking.includes('<think>') &&
|
||||
!state.accumulatedThinking.includes('<thinking>')
|
||||
) {
|
||||
state.accumulatedThinking = `<think>${state.accumulatedThinking}</think>`
|
||||
}
|
||||
|
||||
// 提取思考过程
|
||||
let extractedThinking = '';
|
||||
let extractedThinking = ''
|
||||
|
||||
// 检查是否是Gemini模型的JSON格式输出
|
||||
if (state.accumulatedThinking.includes('data: {"candidates"') || state.accumulatedThinking.includes('data: {\"candidates\"')) {
|
||||
console.log('[DeepClaudeProvider] 检测到Gemini模型的JSON格式输出');
|
||||
if (
|
||||
state.accumulatedThinking.includes('data: {"candidates"') ||
|
||||
state.accumulatedThinking.includes('data: {"candidates"')
|
||||
) {
|
||||
console.log('[DeepClaudeProvider] 检测到Gemini模型的JSON格式输出')
|
||||
|
||||
try {
|
||||
// 尝试提取JSON中的文本内容
|
||||
const lines = state.accumulatedThinking.split('\n');
|
||||
let combinedText = '';
|
||||
const lines = state.accumulatedThinking.split('\n')
|
||||
let combinedText = ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.substring(6);
|
||||
const jsonData = JSON.parse(jsonStr);
|
||||
const jsonStr = line.substring(6)
|
||||
const jsonData = JSON.parse(jsonStr)
|
||||
|
||||
if (jsonData.candidates &&
|
||||
jsonData.candidates[0] &&
|
||||
jsonData.candidates[0].content &&
|
||||
jsonData.candidates[0].content.parts &&
|
||||
jsonData.candidates[0].content.parts[0] &&
|
||||
jsonData.candidates[0].content.parts[0].text) {
|
||||
combinedText += jsonData.candidates[0].content.parts[0].text;
|
||||
if (
|
||||
jsonData.candidates &&
|
||||
jsonData.candidates[0] &&
|
||||
jsonData.candidates[0].content &&
|
||||
jsonData.candidates[0].content.parts &&
|
||||
jsonData.candidates[0].content.parts[0] &&
|
||||
jsonData.candidates[0].content.parts[0].text
|
||||
) {
|
||||
combinedText += jsonData.candidates[0].content.parts[0].text
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略JSON解析错误
|
||||
@ -244,60 +278,64 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
|
||||
if (combinedText) {
|
||||
// 尝试从组合的文本中提取<think>标签
|
||||
const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>');
|
||||
const thinkMatch = combinedText.match(thinkRegex);
|
||||
const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>')
|
||||
const thinkMatch = combinedText.match(thinkRegex)
|
||||
|
||||
if (thinkMatch && thinkMatch[1]) {
|
||||
extractedThinking = thinkMatch[1].trim();
|
||||
console.log('[DeepClaudeProvider] 成功从 Gemini JSON 输出中提取<think>标签的思考过程');
|
||||
extractedThinking = thinkMatch[1].trim()
|
||||
console.log('[DeepClaudeProvider] 成功从 Gemini JSON 输出中提取<think>标签的思考过程')
|
||||
} else {
|
||||
// 如果没有标签,使用整个文本作为思考过程
|
||||
extractedThinking = combinedText.trim();
|
||||
console.log('[DeepClaudeProvider] 从 Gemini JSON 输出中提取了思考过程,但没有<think>标签');
|
||||
extractedThinking = combinedText.trim()
|
||||
console.log('[DeepClaudeProvider] 从 Gemini JSON 输出中提取了思考过程,但没有<think>标签')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DeepClaudeProvider] 解析 Gemini JSON 输出时出错:', error);
|
||||
extractedThinking = state.accumulatedThinking;
|
||||
console.error('[DeepClaudeProvider] 解析 Gemini JSON 输出时出错:', error)
|
||||
extractedThinking = state.accumulatedThinking
|
||||
}
|
||||
} else {
|
||||
// 常规模型输出处理
|
||||
// 先尝试匹配<think>标签
|
||||
const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>');
|
||||
const thinkMatch = state.accumulatedThinking.match(thinkRegex);
|
||||
const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>')
|
||||
const thinkMatch = state.accumulatedThinking.match(thinkRegex)
|
||||
|
||||
// 如果没有匹配到<think>标签,尝试匹配<thinking>标签
|
||||
if (thinkMatch && thinkMatch[1]) {
|
||||
extractedThinking = thinkMatch[1].trim();
|
||||
console.log('[DeepClaudeProvider] 成功提取<think>标签中的思考过程');
|
||||
extractedThinking = thinkMatch[1].trim()
|
||||
console.log('[DeepClaudeProvider] 成功提取<think>标签中的思考过程')
|
||||
} else {
|
||||
const thinkingRegex = new RegExp('<thinking>([\\s\\S]*?)</thinking>');
|
||||
const thinkingMatch = state.accumulatedThinking.match(thinkingRegex);
|
||||
const thinkingRegex = new RegExp('<thinking>([\\s\\S]*?)</thinking>')
|
||||
const thinkingMatch = state.accumulatedThinking.match(thinkingRegex)
|
||||
|
||||
if (thinkingMatch && thinkingMatch[1]) {
|
||||
extractedThinking = thinkingMatch[1].trim();
|
||||
console.log('[DeepClaudeProvider] 成功提取<thinking>标签中的思考过程');
|
||||
extractedThinking = thinkingMatch[1].trim()
|
||||
console.log('[DeepClaudeProvider] 成功提取<thinking>标签中的思考过程')
|
||||
} else {
|
||||
extractedThinking = state.accumulatedThinking;
|
||||
console.log('[DeepClaudeProvider] 未能提取思考过程,使用原始输出');
|
||||
extractedThinking = state.accumulatedThinking
|
||||
console.log('[DeepClaudeProvider] 未能提取思考过程,使用原始输出')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新思考过程
|
||||
state.extractedThinking = extractedThinking;
|
||||
state.extractedThinking = extractedThinking
|
||||
|
||||
console.log('[DeepClaudeProvider] 推理模型完成,思考过程长度:', state.extractedThinking.length);
|
||||
console.log('[DeepClaudeProvider] 推理模型输出示例:', state.extractedThinking.substring(0, 100) + '...');
|
||||
console.log('[DeepClaudeProvider] 推理模型信息:', this.modelCombination.reasonerModel.name, this.modelCombination.reasonerModel.id);
|
||||
console.log('[DeepClaudeProvider] 推理模型完成,思考过程长度:', state.extractedThinking.length)
|
||||
console.log('[DeepClaudeProvider] 推理模型输出示例:', state.extractedThinking.substring(0, 100) + '...')
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 推理模型信息:',
|
||||
this.modelCombination.reasonerModel.name,
|
||||
this.modelCombination.reasonerModel.id
|
||||
)
|
||||
|
||||
// 标记推理模型已完成
|
||||
state.isReasoningFinished = true;
|
||||
state.isReasoningFinished = true
|
||||
} catch (error) {
|
||||
console.error('[DeepClaudeProvider] 推理模型错误:', error);
|
||||
console.error('[DeepClaudeProvider] 推理模型错误:', error)
|
||||
// 即使出错,也要标记推理模型已完成,以便目标模型可以继续
|
||||
state.isReasoningFinished = true;
|
||||
state.extractedThinking = '推理模型出错,无法获取思考过程。';
|
||||
state.isReasoningFinished = true
|
||||
state.extractedThinking = '推理模型出错,无法获取思考过程。'
|
||||
}
|
||||
})(),
|
||||
|
||||
@ -308,21 +346,25 @@ export default class DeepClaudeProvider extends BaseProvider {
|
||||
|
||||
// 等待推理模型开始生成思考过程
|
||||
while (!state.isReasoningStarted && !state.isReasoningFinished) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// 等待推理模型完成
|
||||
while (!state.isReasoningFinished) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
console.log('[DeepClaudeProvider] 推理模型已完成,立即启动目标模型任务')
|
||||
|
||||
// 标记目标模型已开始
|
||||
state.isTargetStarted = true;
|
||||
state.isTargetStarted = true
|
||||
|
||||
console.log('[DeepClaudeProvider] 启动目标模型任务')
|
||||
console.log('[DeepClaudeProvider] 目标模型信息:', this.modelCombination.targetModel.name, this.modelCombination.targetModel.id)
|
||||
console.log(
|
||||
'[DeepClaudeProvider] 目标模型信息:',
|
||||
this.modelCombination.targetModel.name,
|
||||
this.modelCombination.targetModel.id
|
||||
)
|
||||
|
||||
// 构建目标模型的提示词
|
||||
const targetPrompt = `以下是对这个问题的思考过程,请基于这个思考过程回答我的问题,但不要重复思考过程,不要在回答中包含“思考过程”或类似的标题,直接给出清晰、准确的回答:
|
||||
@ -385,10 +427,14 @@ ${state.extractedThinking}`
|
||||
*/
|
||||
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
|
||||
// 使用目标模型进行翻译
|
||||
return this.targetProvider.translate(message, {
|
||||
...assistant,
|
||||
model: this.modelCombination.targetModel
|
||||
}, onResponse)
|
||||
return this.targetProvider.translate(
|
||||
message,
|
||||
{
|
||||
...assistant,
|
||||
model: this.modelCombination.targetModel
|
||||
},
|
||||
onResponse
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -458,7 +504,7 @@ ${state.extractedThinking}`
|
||||
/**
|
||||
* 检查模型
|
||||
*/
|
||||
public async check(_model: Model): Promise<{ valid: boolean; error: Error | null }> {
|
||||
public async check(): Promise<{ valid: boolean; error: Error | null }> {
|
||||
// 检查推理模型和目标模型
|
||||
const reasonerCheck = await this.reasonerProvider.check(this.modelCombination.reasonerModel)
|
||||
if (!reasonerCheck.valid) {
|
||||
|
||||
@ -19,7 +19,13 @@ import {
|
||||
TextPart,
|
||||
Tool
|
||||
} from '@google/generative-ai'
|
||||
import { isGemmaModel, isSupportedThinkingBudgetModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import {
|
||||
isGemmaModel,
|
||||
isGenerateImageModel,
|
||||
isSupportedThinkingBudgetModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
@ -823,7 +829,14 @@ export default class GeminiProvider extends BaseProvider {
|
||||
const thinkingBudget = assistant?.settings?.thinkingBudget
|
||||
|
||||
if (!streamOutput) {
|
||||
const response = await this.callGeminiGenerateContent(model.id, contents, maxTokens, imageSdk, thinkingBudget)
|
||||
const response = await this.callGeminiGenerateContent(
|
||||
model.id,
|
||||
contents,
|
||||
maxTokens,
|
||||
imageSdk,
|
||||
thinkingBudget,
|
||||
model
|
||||
)
|
||||
|
||||
const { isValid, message } = this.isValidGeminiResponse(response)
|
||||
if (!isValid) {
|
||||
@ -833,7 +846,14 @@ export default class GeminiProvider extends BaseProvider {
|
||||
this.processGeminiImageResponse(response, onChunk)
|
||||
return
|
||||
}
|
||||
const response = await this.callGeminiGenerateContentStream(model.id, contents, maxTokens, imageSdk, thinkingBudget)
|
||||
const response = await this.callGeminiGenerateContentStream(
|
||||
model.id,
|
||||
contents,
|
||||
maxTokens,
|
||||
imageSdk,
|
||||
thinkingBudget,
|
||||
model
|
||||
)
|
||||
|
||||
for await (const chunk of response) {
|
||||
this.processGeminiImageResponse(chunk, onChunk)
|
||||
@ -870,7 +890,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
contents: ContentListUnion,
|
||||
maxTokens?: number,
|
||||
sdk?: GoogleGenAI,
|
||||
thinkingBudget?: number
|
||||
thinkingBudget?: number,
|
||||
model?: Model
|
||||
): Promise<GenerateContentResponse> {
|
||||
try {
|
||||
// 获取新的API密钥,实现轮流使用多个密钥
|
||||
@ -889,8 +910,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
|
||||
// 构建请求配置
|
||||
const config = {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
responseModalities: model && isGenerateImageModel(model) ? ['Text', 'Image'] : undefined,
|
||||
responseMimeType: model && isGenerateImageModel(model) ? 'text/plain' : undefined,
|
||||
maxOutputTokens: maxTokens,
|
||||
...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {})
|
||||
}
|
||||
@ -913,7 +934,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
contents: ContentListUnion,
|
||||
maxTokens?: number,
|
||||
sdk?: GoogleGenAI,
|
||||
thinkingBudget?: number
|
||||
thinkingBudget?: number,
|
||||
model?: Model
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
try {
|
||||
// 获取新的API密钥,实现轮流使用多个密钥
|
||||
@ -932,8 +954,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
|
||||
// 构建请求配置
|
||||
const config = {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
responseModalities: model && isGenerateImageModel(model) ? ['Text', 'Image'] : undefined,
|
||||
responseMimeType: model && isGenerateImageModel(model) ? 'text/plain' : undefined,
|
||||
maxOutputTokens: maxTokens,
|
||||
...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {})
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
filterEmptyMessages,
|
||||
filterUserRoleStartMessages
|
||||
} from '@renderer/services/MessagesService'
|
||||
import { processReqMessages } from '@renderer/services/ModelMessageService'
|
||||
import store from '@renderer/store'
|
||||
import { getActiveServers } from '@renderer/store/mcp'
|
||||
import {
|
||||
@ -294,7 +295,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
* @returns True if the model is an OpenAI reasoning model, false otherwise
|
||||
*/
|
||||
private isOpenAIReasoning(model: Model) {
|
||||
return model.id.startsWith('o1') || model.id.startsWith('o3')
|
||||
return model.id.startsWith('o1') || model.id.startsWith('o3') || model.id.startsWith('o4')
|
||||
}
|
||||
|
||||
/**
|
||||
@ -403,9 +404,16 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
const { signal } = abortController
|
||||
await this.checkIsCopilot()
|
||||
|
||||
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
|
||||
Boolean
|
||||
) as ChatCompletionMessageParam[]
|
||||
//当 systemMessage 内容为空时不发送 systemMessage
|
||||
let reqMessages: ChatCompletionMessageParam[]
|
||||
if (!systemMessage.content) {
|
||||
reqMessages = [...userMessages]
|
||||
} else {
|
||||
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[]
|
||||
}
|
||||
|
||||
// 处理连续的相同角色消息,例如 deepseek-reasoner 模型不支持连续的用户或助手消息
|
||||
reqMessages = processReqMessages(model, reqMessages)
|
||||
|
||||
const toolResponses: MCPToolResponse[] = []
|
||||
let firstChunk = true
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import store from '@renderer/store'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
|
||||
import AnthropicProvider from './AnthropicProvider'
|
||||
import BaseProvider from './BaseProvider'
|
||||
@ -34,23 +34,28 @@ export default class ProviderFactory {
|
||||
let combination: ModelCombination | undefined = undefined
|
||||
if (selectedModelId) {
|
||||
// 在provider的models中查找匹配的模型
|
||||
const selectedModel = provider.models.find(m => m.id === selectedModelId)
|
||||
const selectedModel = provider.models.find((m) => m.id === selectedModelId)
|
||||
if (selectedModel) {
|
||||
// 直接使用模型ID查找对应的组合
|
||||
// 在DeepClaude中,模型ID就是组合ID
|
||||
combination = combinations.find(c => c.id === selectedModelId && c.isActive)
|
||||
combination = combinations.find((c) => c.id === selectedModelId && c.isActive)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到匹配的组合,使用第一个激活的组合
|
||||
if (!combination) {
|
||||
combination = combinations.find(c => c.isActive) || undefined
|
||||
combination = combinations.find((c) => c.isActive) || undefined
|
||||
}
|
||||
|
||||
if (combination) {
|
||||
console.log('[ProviderFactory] 创建DeepClaudeProvider,使用模型组合:', combination.name,
|
||||
'推理模型:', combination.reasonerModel?.name,
|
||||
'目标模型:', combination.targetModel?.name)
|
||||
console.log(
|
||||
'[ProviderFactory] 创建DeepClaudeProvider,使用模型组合:',
|
||||
combination.name,
|
||||
'推理模型:',
|
||||
combination.reasonerModel?.name,
|
||||
'目标模型:',
|
||||
combination.targetModel?.name
|
||||
)
|
||||
|
||||
// 确保reasonerModel和targetModel是完整的模型对象
|
||||
const allProviders = store.getState().llm.providers
|
||||
@ -94,10 +99,17 @@ export default class ProviderFactory {
|
||||
targetModel: fullTargetModel
|
||||
}
|
||||
|
||||
console.log('[ProviderFactory] 创建完整的模型组合:',
|
||||
fullCombination.id, fullCombination.name,
|
||||
'推理模型:', fullCombination.reasonerModel.id, fullCombination.reasonerModel.name,
|
||||
'目标模型:', fullCombination.targetModel.id, fullCombination.targetModel.name)
|
||||
console.log(
|
||||
'[ProviderFactory] 创建完整的模型组合:',
|
||||
fullCombination.id,
|
||||
fullCombination.name,
|
||||
'推理模型:',
|
||||
fullCombination.reasonerModel.id,
|
||||
fullCombination.reasonerModel.name,
|
||||
'目标模型:',
|
||||
fullCombination.targetModel.id,
|
||||
fullCombination.targetModel.name
|
||||
)
|
||||
|
||||
return new DeepClaudeProvider(provider, fullCombination)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
|
||||
import ASRServerService from './ASRServerService'
|
||||
|
||||
/**
|
||||
|
||||
@ -330,7 +330,7 @@ export async function fetchChatCompletion({
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.log('message', message)
|
||||
// console.log('message', message) // 注释掉以避免日志过多
|
||||
} catch (error: any) {
|
||||
if (isAbortError(error)) {
|
||||
message.status = 'paused'
|
||||
|
||||
@ -26,19 +26,19 @@ export const analyzeAndAddAssistantMemories = async (assistantId: string, messag
|
||||
|
||||
// 获取当前助手的记忆
|
||||
const assistantMemories = state.memory?.assistantMemories || []
|
||||
const currentAssistantMemories = assistantMemories.filter(memory => memory.assistantId === assistantId)
|
||||
const currentAssistantMemories = assistantMemories.filter((memory) => memory.assistantId === assistantId)
|
||||
|
||||
// 获取已分析过的消息ID
|
||||
const analyzedMessageIds = new Set<string>()
|
||||
currentAssistantMemories.forEach(memory => {
|
||||
currentAssistantMemories.forEach((memory) => {
|
||||
if (memory.analyzedMessageIds) {
|
||||
memory.analyzedMessageIds.forEach(id => analyzedMessageIds.add(id))
|
||||
memory.analyzedMessageIds.forEach((id) => analyzedMessageIds.add(id))
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤出未分析的消息
|
||||
const newMessages = messages.filter(msg =>
|
||||
msg.id && !analyzedMessageIds.has(msg.id) && msg.content && msg.content.trim() !== ''
|
||||
const newMessages = messages.filter(
|
||||
(msg) => msg.id && !analyzedMessageIds.has(msg.id) && msg.content && msg.content.trim() !== ''
|
||||
)
|
||||
|
||||
if (newMessages.length === 0) {
|
||||
@ -60,7 +60,7 @@ export const analyzeAndAddAssistantMemories = async (assistantId: string, messag
|
||||
console.log('[Assistant Memory Analysis] New conversation length:', newConversation.length)
|
||||
|
||||
// 构建助手记忆分析提示词
|
||||
let prompt = `
|
||||
const prompt = `
|
||||
请分析以下对话内容,提取对助手需要长期记住的重要信息。这些信息将作为助手的记忆,帮助助手在未来的对话中更好地理解用户和提供个性化服务。
|
||||
|
||||
请注意以下几点:
|
||||
@ -107,9 +107,9 @@ ${newConversation}
|
||||
// 先尝试根据供应商和模型ID查找
|
||||
let model: any = null
|
||||
if (providerId) {
|
||||
const provider = state.llm.providers.find(p => p.id === providerId)
|
||||
const provider = state.llm.providers.find((p) => p.id === providerId)
|
||||
if (provider) {
|
||||
const foundModel = provider.models.find(m => m.id === modelId)
|
||||
const foundModel = provider.models.find((m) => m.id === modelId)
|
||||
if (foundModel) {
|
||||
model = foundModel
|
||||
}
|
||||
@ -118,9 +118,7 @@ ${newConversation}
|
||||
|
||||
// 如果没找到,尝试在所有模型中查找
|
||||
if (!model) {
|
||||
const foundModel = state.llm.providers
|
||||
.flatMap((provider) => provider.models)
|
||||
.find((m) => m.id === modelId)
|
||||
const foundModel = state.llm.providers.flatMap((provider) => provider.models).find((m) => m.id === modelId)
|
||||
if (foundModel) {
|
||||
model = foundModel
|
||||
}
|
||||
@ -156,37 +154,37 @@ ${newConversation}
|
||||
// 如果没有找到JSON数组,尝试按行分割并处理
|
||||
memories = result
|
||||
.split('\n')
|
||||
.filter(line => line.trim().startsWith('"') || line.trim().startsWith('-'))
|
||||
.map(line => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, ''))
|
||||
.filter((line) => line.trim().startsWith('"') || line.trim().startsWith('-'))
|
||||
.map((line) => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, ''))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Assistant Memory Analysis] Failed to parse memories:', error)
|
||||
// 尝试使用正则表达式提取引号中的内容
|
||||
const quotedStrings = result.match(/"([^"]*)"/g)
|
||||
if (quotedStrings) {
|
||||
memories = quotedStrings.map(str => str.slice(1, -1))
|
||||
memories = quotedStrings.map((str) => str.slice(1, -1))
|
||||
} else {
|
||||
// 最后尝试按行分割
|
||||
memories = result
|
||||
.split('\n')
|
||||
.filter(line => line.trim() && !line.includes('```'))
|
||||
.map(line => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, ''))
|
||||
.filter((line) => line.trim() && !line.includes('```'))
|
||||
.map((line) => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, ''))
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤空字符串和已存在的记忆
|
||||
memories = memories.filter(
|
||||
memory =>
|
||||
(memory) =>
|
||||
memory &&
|
||||
memory.trim() !== '' &&
|
||||
!currentAssistantMemories.some(m => m.content.toLowerCase() === memory.toLowerCase())
|
||||
!currentAssistantMemories.some((m) => m.content.toLowerCase() === memory.toLowerCase())
|
||||
)
|
||||
|
||||
console.log(`[Assistant Memory Analysis] Extracted ${memories.length} new memories`)
|
||||
|
||||
// 添加新记忆
|
||||
const addedMemories: string[] = []
|
||||
const newMessageIds = newMessages.map(msg => msg.id).filter(Boolean) as string[]
|
||||
const newMessageIds = newMessages.map((msg) => msg.id).filter(Boolean) as string[]
|
||||
const lastMessageId = newMessages.length > 0 ? newMessages[newMessages.length - 1].id : undefined
|
||||
|
||||
for (const memoryContent of memories) {
|
||||
@ -240,7 +238,7 @@ export const resetAssistantMemoryAnalyzedMessageIds = async (assistantId: string
|
||||
const assistantMemories = state.memory?.assistantMemories || []
|
||||
|
||||
// 获取当前助手的记忆
|
||||
const currentAssistantMemories = assistantMemories.filter(memory => memory.assistantId === assistantId)
|
||||
const currentAssistantMemories = assistantMemories.filter((memory) => memory.assistantId === assistantId)
|
||||
|
||||
if (currentAssistantMemories.length === 0) {
|
||||
console.log(`[Assistant Memory] No memories found for assistant ${assistantId}`)
|
||||
@ -248,7 +246,7 @@ export const resetAssistantMemoryAnalyzedMessageIds = async (assistantId: string
|
||||
}
|
||||
|
||||
// 创建新的助手记忆数组,清除分析标记
|
||||
const updatedMemories = assistantMemories.map(memory => {
|
||||
const updatedMemories = assistantMemories.map((memory) => {
|
||||
if (memory.assistantId === assistantId) {
|
||||
return {
|
||||
...memory,
|
||||
@ -260,14 +258,16 @@ export const resetAssistantMemoryAnalyzedMessageIds = async (assistantId: string
|
||||
})
|
||||
|
||||
// 保存更新后的记忆
|
||||
await store.dispatch(
|
||||
saveMemoryData({
|
||||
assistantMemories: updatedMemories,
|
||||
assistantMemoryActive: state.memory?.assistantMemoryActive,
|
||||
assistantMemoryAnalyzeModel: state.memory?.assistantMemoryAnalyzeModel,
|
||||
forceOverwrite: true
|
||||
})
|
||||
).unwrap()
|
||||
await store
|
||||
.dispatch(
|
||||
saveMemoryData({
|
||||
assistantMemories: updatedMemories,
|
||||
assistantMemoryActive: state.memory?.assistantMemoryActive,
|
||||
assistantMemoryAnalyzeModel: state.memory?.assistantMemoryAnalyzeModel,
|
||||
forceOverwrite: true
|
||||
})
|
||||
)
|
||||
.unwrap()
|
||||
|
||||
console.log(`[Assistant Memory] Reset analysis markers for assistant ${assistantId}`)
|
||||
return true
|
||||
|
||||
@ -76,23 +76,29 @@ export function getAssistantProvider(assistant: Assistant): Provider {
|
||||
console.log('[getAssistantProvider] 检测到DeepClaude模型:', assistant.model.id, assistant.model.name)
|
||||
|
||||
// 列出所有提供商,便于调试
|
||||
console.log('[getAssistantProvider] 当前所有提供商:',
|
||||
providers.map(p => ({ id: p.id, name: p.name, type: p.type })))
|
||||
console.log(
|
||||
'[getAssistantProvider] 当前所有提供商:',
|
||||
providers.map((p) => ({ id: p.id, name: p.name, type: p.type }))
|
||||
)
|
||||
|
||||
// 查找所有DeepClaude类型的提供商
|
||||
const deepClaudeProviders = providers.filter(p => p.type === 'deepclaude')
|
||||
const deepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
|
||||
console.log('[getAssistantProvider] 找到DeepClaude类型的提供商数量:', deepClaudeProviders.length)
|
||||
|
||||
if (deepClaudeProviders.length > 0) {
|
||||
// 先尝试查找与model.id匹配的提供商
|
||||
const matchingProvider = deepClaudeProviders.find(p => p.id === assistant.model?.id)
|
||||
const matchingProvider = deepClaudeProviders.find((p) => p.id === assistant.model?.id)
|
||||
if (matchingProvider) {
|
||||
console.log('[getAssistantProvider] 找到匹配的DeepClaude提供商:', matchingProvider.id, matchingProvider.name)
|
||||
return matchingProvider
|
||||
}
|
||||
|
||||
// 如果没有找到匹配的,使用第一个DeepClaude提供商
|
||||
console.log('[getAssistantProvider] 使用第一个DeepClaude提供商:', deepClaudeProviders[0].id, deepClaudeProviders[0].name)
|
||||
console.log(
|
||||
'[getAssistantProvider] 使用第一个DeepClaude提供商:',
|
||||
deepClaudeProviders[0].id,
|
||||
deepClaudeProviders[0].name
|
||||
)
|
||||
return deepClaudeProviders[0]
|
||||
}
|
||||
|
||||
|
||||
@ -84,13 +84,15 @@ export async function backupToWebdav({
|
||||
|
||||
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
|
||||
let deviceType = 'unknown'
|
||||
let hostname = 'unknown'
|
||||
try {
|
||||
deviceType = (await window.api.system.getDeviceType()) || 'unknown'
|
||||
hostname = (await window.api.system.getHostname()) || 'unknown'
|
||||
} catch (error) {
|
||||
Logger.error('[Backup] Failed to get device type:', error)
|
||||
Logger.error('[Backup] Failed to get device type or hostname:', error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
const backupData = await getBackupData()
|
||||
|
||||
|
||||
@ -160,10 +160,12 @@ ${memoriesToCheck}
|
||||
/-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*"([^"]+)"\s*-\s*分类:\s*"([^"]+)"\s*(?:-\s*重要性:\s*"([^"]+)")?\s*(?:-\s*关键词:\s*"([^"]+)")?/g
|
||||
|
||||
// 新增正则表达式,匹配AI返回的不同格式
|
||||
const alternativeGroupRegex = /-\s*组(\d+)?:\s*(?:\*\*)?["\[]?([\d,\s]+)["\]]?(?:\*\*)?\s*-\s*合并建议:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?\s*-\s*分类:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?/g
|
||||
const alternativeGroupRegex =
|
||||
/-\s*组(\d+)?:\s*(?:\*\*)?["[]?([\d,\s]+)["]?(?:\*\*)?\s*-\s*合并建议:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?\s*-\s*分类:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?/g
|
||||
|
||||
// 简化的正则表达式,直接匹配组号和方括号内的数字
|
||||
const simpleGroupRegex = /-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*(.+?)\s*-\s*分类:\s*(.+?)(?=\s*$|\s*-\s*组|\s*\n)/gm
|
||||
const simpleGroupRegex =
|
||||
/-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*(.+?)\s*-\s*分类:\s*(.+?)(?=\s*$|\s*-\s*组|\s*\n)/gm
|
||||
|
||||
// 尝试所有正则表达式
|
||||
const regexesToTry = [simpleGroupRegex, alternativeGroupRegex, originalGroupRegex]
|
||||
@ -180,7 +182,7 @@ ${memoriesToCheck}
|
||||
found = true
|
||||
const groupId = match[1] || String(similarGroups.length + 1)
|
||||
// 清理引号和方括号
|
||||
const memoryIndicesStr = match[2].replace(/["'\[\]]/g, '')
|
||||
const memoryIndicesStr = match[2].replace(/["'[\]]/g, '')
|
||||
const memoryIndices = memoryIndicesStr.split(',').map((s: string) => s.trim())
|
||||
const mergedContent = match[3].trim().replace(/^["']|["']$/g, '') // 移除首尾的引号
|
||||
const category = match[4]?.trim().replace(/^["']|["']$/g, '') // 移除首尾的引号
|
||||
@ -201,7 +203,7 @@ ${memoriesToCheck}
|
||||
}
|
||||
|
||||
// 如果找到了匹配项,就不再尝试其他正则表达式
|
||||
if (found) break;
|
||||
if (found) break
|
||||
}
|
||||
|
||||
// 旧的解析代码已被上面的新代码替代
|
||||
@ -223,10 +225,13 @@ ${memoriesToCheck}
|
||||
const independentMatch = result.match(regex)
|
||||
if (independentMatch && independentMatch[1]) {
|
||||
// 处理可能包含引号的情况
|
||||
const cleanedIndependentStr = independentMatch[1].replace(/["'\[\]]/g, '')
|
||||
const cleanedIndependentStr = independentMatch[1].replace(/["'[\]]/g, '')
|
||||
const items = cleanedIndependentStr.split(',').map((s: string) => s.trim())
|
||||
|
||||
console.log(`[Memory Deduplication] Found independent memories with regex ${regex.toString().substring(0, 30)}...`, items)
|
||||
console.log(
|
||||
`[Memory Deduplication] Found independent memories with regex ${regex.toString().substring(0, 30)}...`,
|
||||
items
|
||||
)
|
||||
|
||||
independentMemories.push(...items)
|
||||
independentFound = true
|
||||
@ -241,11 +246,11 @@ ${memoriesToCheck}
|
||||
if (numberMatches) {
|
||||
// 过滤出不在相似组中的数字
|
||||
const usedIndices = new Set()
|
||||
similarGroups.forEach(group => {
|
||||
group.memoryIds.forEach(id => usedIndices.add(id))
|
||||
similarGroups.forEach((group) => {
|
||||
group.memoryIds.forEach((id) => usedIndices.add(id))
|
||||
})
|
||||
|
||||
const unusedIndices = numberMatches.filter(num => !usedIndices.has(num))
|
||||
const unusedIndices = numberMatches.filter((num) => !usedIndices.has(num))
|
||||
if (unusedIndices.length > 0) {
|
||||
console.log('[Memory Deduplication] Extracted independent memories from numbers in result:', unusedIndices)
|
||||
independentMemories.push(...unusedIndices)
|
||||
@ -256,13 +261,14 @@ ${memoriesToCheck}
|
||||
// 如果没有解析到相似组和独立记忆项,但结果中包含“组”字样,尝试使用更宽松的正则表达式
|
||||
if (similarGroups.length === 0 && independentMemories.length === 0 && result.includes('组')) {
|
||||
// 尝试使用更宽松的正则表达式提取组信息
|
||||
const looseGroupRegex = /-\s*组\s*(\d+)?\s*:\s*["\[]?\s*([\d,\s"]+)\s*["\]]?\s*-\s*合并建议\s*:\s*["']?([^"'\n-]+)["']?/g
|
||||
const looseGroupRegex =
|
||||
/-\s*组\s*(\d+)?\s*:\s*["[]?\s*([\d,\s"]+)\s*["]?\s*-\s*合并建议\s*:\s*["']?([^"'\n-]+)["']?/g
|
||||
|
||||
let looseMatch: RegExpExecArray | null
|
||||
while ((looseMatch = looseGroupRegex.exec(result)) !== null) {
|
||||
const groupId = looseMatch[1] || String(similarGroups.length + 1)
|
||||
// 清理引号和方括号
|
||||
const memoryIndicesStr = looseMatch[2].replace(/["'\[\]]/g, '')
|
||||
const memoryIndicesStr = looseMatch[2].replace(/["'[\]]/g, '')
|
||||
const memoryIndices = memoryIndicesStr.split(',').map((s: string) => s.trim())
|
||||
const mergedContent = looseMatch[3].trim()
|
||||
|
||||
|
||||
@ -9,9 +9,9 @@ import store from '@renderer/store' // Import store
|
||||
import {
|
||||
accessMemory,
|
||||
addAnalysisLatency,
|
||||
addAssistantMemory,
|
||||
addMemory,
|
||||
addShortMemory,
|
||||
addAssistantMemory,
|
||||
clearCurrentRecommendations,
|
||||
Memory,
|
||||
MemoryRecommendation,
|
||||
@ -28,8 +28,8 @@ import {
|
||||
import { Message } from '@renderer/types' // Import Message type
|
||||
import { useCallback, useEffect, useRef } from 'react' // Add useRef back
|
||||
|
||||
import { contextualMemoryService } from './ContextualMemoryService' // Import contextual memory service
|
||||
import { analyzeAndAddAssistantMemories, resetAssistantMemoryAnalyzedMessageIds } from './AssistantMemoryService' // Import assistant memory service
|
||||
import { contextualMemoryService } from './ContextualMemoryService' // Import contextual memory service
|
||||
|
||||
// calculateConversationComplexity is unused, removing its definition
|
||||
/*
|
||||
@ -794,9 +794,12 @@ ${existingMemoriesContent}
|
||||
}, [analyzeAndAddMemories])
|
||||
|
||||
// 记录记忆访问
|
||||
const recordMemoryAccess = useCallback((memoryId: string, isShortMemory: boolean = false, isAssistantMemory: boolean = false) => {
|
||||
store.dispatch(accessMemory({ id: memoryId, isShortMemory, isAssistantMemory }))
|
||||
}, [])
|
||||
const recordMemoryAccess = useCallback(
|
||||
(memoryId: string, isShortMemory: boolean = false, isAssistantMemory: boolean = false) => {
|
||||
store.dispatch(accessMemory({ id: memoryId, isShortMemory, isAssistantMemory }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Effect 来设置/清除定时器,只依赖于启动条件
|
||||
useEffect(() => {
|
||||
@ -1317,9 +1320,9 @@ ${newConversation}
|
||||
// 先尝试根据供应商和模型ID查找
|
||||
let model: any = null
|
||||
if (providerId) {
|
||||
const provider = store.getState().llm.providers.find(p => p.id === providerId)
|
||||
const provider = store.getState().llm.providers.find((p) => p.id === providerId)
|
||||
if (provider) {
|
||||
const foundModel = provider.models.find(m => m.id === modelId)
|
||||
const foundModel = provider.models.find((m) => m.id === modelId)
|
||||
if (foundModel) {
|
||||
model = foundModel
|
||||
}
|
||||
@ -1604,7 +1607,7 @@ export const applyMemoriesToPrompt = async (systemPrompt: string, topicId?: stri
|
||||
// 从当前状态中获取话题的助手ID
|
||||
const assistants = state.assistants.assistants
|
||||
for (const assistant of assistants) {
|
||||
const topic = assistant.topics.find(t => t.id === topicId)
|
||||
const topic = assistant.topics.find((t) => t.id === topicId)
|
||||
if (topic) {
|
||||
topicAssistantId = assistant.id
|
||||
console.log('[Memory] Using topic assistant ID:', topicAssistantId)
|
||||
|
||||
49
src/renderer/src/services/ModelMessageService.ts
Normal file
49
src/renderer/src/services/ModelMessageService.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ChatCompletionMessageParam } from 'openai/resources'
|
||||
|
||||
export function processReqMessages(
|
||||
model: Model,
|
||||
reqMessages: ChatCompletionMessageParam[]
|
||||
): ChatCompletionMessageParam[] {
|
||||
if (!needStrictlyInterleaveUserAndAssistantMessages(model)) {
|
||||
return reqMessages
|
||||
}
|
||||
|
||||
return mergeSameRoleMessages(reqMessages)
|
||||
}
|
||||
|
||||
function needStrictlyInterleaveUserAndAssistantMessages(model: Model) {
|
||||
return model.id === 'deepseek-reasoner'
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge successive messages with the same role
|
||||
*/
|
||||
function mergeSameRoleMessages(messages: ChatCompletionMessageParam[]): ChatCompletionMessageParam[] {
|
||||
const split = '\n'
|
||||
const processedMessages: ChatCompletionMessageParam[] = []
|
||||
let currentGroup: ChatCompletionMessageParam[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (currentGroup.length === 0 || currentGroup[0].role === message.role) {
|
||||
currentGroup.push(message)
|
||||
} else {
|
||||
// merge the current group and add to processed messages
|
||||
processedMessages.push({
|
||||
...currentGroup[0],
|
||||
content: currentGroup.map((m) => m.content).join(split)
|
||||
})
|
||||
currentGroup = [message]
|
||||
}
|
||||
}
|
||||
|
||||
// process the last group
|
||||
if (currentGroup.length > 0) {
|
||||
processedMessages.push({
|
||||
...currentGroup[0],
|
||||
content: currentGroup.map((m) => m.content).join(split)
|
||||
})
|
||||
}
|
||||
|
||||
return processedMessages
|
||||
}
|
||||
124
src/renderer/src/services/__tests__/ModelMessageService.test.ts
Normal file
124
src/renderer/src/services/__tests__/ModelMessageService.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { ChatCompletionMessageParam } from 'openai/resources'
|
||||
|
||||
const { processReqMessages } = require('../ModelMessageService')
|
||||
|
||||
test('ModelMessageService', async (t) => {
|
||||
const mockMessages: ChatCompletionMessageParam[] = [
|
||||
{ role: 'user', content: 'First question' },
|
||||
{ role: 'user', content: 'Additional context' },
|
||||
{ role: 'assistant', content: 'First answer' },
|
||||
{ role: 'assistant', content: 'Additional information' },
|
||||
{ role: 'user', content: 'Second question' },
|
||||
{ role: 'assistant', content: 'Second answer' }
|
||||
]
|
||||
|
||||
await t.test('should merge successive messages with same role for deepseek-reasoner model', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const result = processReqMessages(model, mockMessages)
|
||||
|
||||
assert.strictEqual(result.length, 4)
|
||||
assert.deepStrictEqual(result[0], {
|
||||
role: 'user',
|
||||
content: 'First question\nAdditional context'
|
||||
})
|
||||
assert.deepStrictEqual(result[1], {
|
||||
role: 'assistant',
|
||||
content: 'First answer\nAdditional information'
|
||||
})
|
||||
assert.deepStrictEqual(result[2], {
|
||||
role: 'user',
|
||||
content: 'Second question'
|
||||
})
|
||||
assert.deepStrictEqual(result[3], {
|
||||
role: 'assistant',
|
||||
content: 'Second answer'
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('should not merge messages for other models', () => {
|
||||
const model = { id: 'gpt-4' }
|
||||
const result = processReqMessages(model, mockMessages)
|
||||
|
||||
assert.strictEqual(result.length, mockMessages.length)
|
||||
assert.deepStrictEqual(result, mockMessages)
|
||||
})
|
||||
|
||||
await t.test('should handle empty messages array', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const result = processReqMessages(model, [])
|
||||
|
||||
assert.strictEqual(result.length, 0)
|
||||
assert.deepStrictEqual(result, [])
|
||||
})
|
||||
|
||||
await t.test('should handle single message', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const singleMessage = [{ role: 'user', content: 'Single message' }]
|
||||
const result = processReqMessages(model, singleMessage)
|
||||
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.deepStrictEqual(result, singleMessage)
|
||||
})
|
||||
|
||||
await t.test('should preserve other message properties when merging', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const messagesWithProps = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'First message',
|
||||
name: 'user1',
|
||||
function_call: { name: 'test', arguments: '{}' }
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Second message',
|
||||
name: 'user1'
|
||||
}
|
||||
] as ChatCompletionMessageParam[]
|
||||
|
||||
const result = processReqMessages(model, messagesWithProps)
|
||||
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.deepStrictEqual(result[0], {
|
||||
role: 'user',
|
||||
content: 'First message\nSecond message',
|
||||
name: 'user1',
|
||||
function_call: { name: 'test', arguments: '{}' }
|
||||
})
|
||||
})
|
||||
|
||||
await t.test('should handle alternating roles correctly', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const alternatingMessages = [
|
||||
{ role: 'user', content: 'Q1' },
|
||||
{ role: 'assistant', content: 'A1' },
|
||||
{ role: 'user', content: 'Q2' },
|
||||
{ role: 'assistant', content: 'A2' }
|
||||
] as ChatCompletionMessageParam[]
|
||||
|
||||
const result = processReqMessages(model, alternatingMessages)
|
||||
|
||||
assert.strictEqual(result.length, 4)
|
||||
assert.deepStrictEqual(result, alternatingMessages)
|
||||
})
|
||||
|
||||
await t.test('should handle messages with empty content', () => {
|
||||
const model = { id: 'deepseek-reasoner' }
|
||||
const messagesWithEmpty = [
|
||||
{ role: 'user', content: 'Q1' },
|
||||
{ role: 'user', content: '' },
|
||||
{ role: 'user', content: 'Q2' }
|
||||
] as ChatCompletionMessageParam[]
|
||||
|
||||
const result = processReqMessages(model, messagesWithEmpty)
|
||||
|
||||
assert.strictEqual(result.length, 1)
|
||||
assert.deepStrictEqual(result[0], {
|
||||
role: 'user',
|
||||
content: 'Q1\n\nQ2'
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -629,7 +629,9 @@ const memorySlice = createSlice({
|
||||
// 如果找到了要删除的记忆,并且它有分析过的消息ID
|
||||
if (memoryToDelete && memoryToDelete.analyzedMessageIds && memoryToDelete.analyzedMessageIds.length > 0) {
|
||||
// 记录日志,方便调试
|
||||
console.log(`[Memory] Deleting assistant memory with ${memoryToDelete.analyzedMessageIds.length} analyzed message IDs`)
|
||||
console.log(
|
||||
`[Memory] Deleting assistant memory with ${memoryToDelete.analyzedMessageIds.length} analyzed message IDs`
|
||||
)
|
||||
}
|
||||
|
||||
// 删除记忆
|
||||
@ -902,7 +904,10 @@ const memorySlice = createSlice({
|
||||
},
|
||||
|
||||
// 记录记忆访问
|
||||
accessMemory: (state, action: PayloadAction<{ id: string; isShortMemory?: boolean; isAssistantMemory?: boolean }>) => {
|
||||
accessMemory: (
|
||||
state,
|
||||
action: PayloadAction<{ id: string; isShortMemory?: boolean; isAssistantMemory?: boolean }>
|
||||
) => {
|
||||
const { id, isShortMemory, isAssistantMemory } = action.payload
|
||||
const now = new Date().toISOString()
|
||||
|
||||
@ -1000,7 +1005,10 @@ const memorySlice = createSlice({
|
||||
// 助手记忆分析模型
|
||||
if (action.payload.assistantMemoryAnalyzeModel) {
|
||||
state.assistantMemoryAnalyzeModel = action.payload.assistantMemoryAnalyzeModel
|
||||
console.log('[Memory Reducer] Loaded assistant memory analyze model:', action.payload.assistantMemoryAnalyzeModel)
|
||||
console.log(
|
||||
'[Memory Reducer] Loaded assistant memory analyze model:',
|
||||
action.payload.assistantMemoryAnalyzeModel
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Short-term memory data loaded into state')
|
||||
|
||||
@ -83,15 +83,13 @@ const messagesSlice = createSlice({
|
||||
// 为了兼容多模型新发消息,一次性添加多个助手消息
|
||||
// 不是什么好主意,不符合语义
|
||||
// 检查每条消息是否已存在,避免重复添加
|
||||
const messagesToAdd = messages.filter(msg =>
|
||||
!currentMessages.some(existing => existing.id === msg.id)
|
||||
)
|
||||
const messagesToAdd = messages.filter((msg) => !currentMessages.some((existing) => existing.id === msg.id))
|
||||
if (messagesToAdd.length > 0) {
|
||||
state.messagesByTopic[topicId].push(...messagesToAdd)
|
||||
}
|
||||
} else {
|
||||
// 添加单条消息,先检查是否已存在
|
||||
if (!currentMessages.some(existing => existing.id === messages.id)) {
|
||||
if (!currentMessages.some((existing) => existing.id === messages.id)) {
|
||||
state.messagesByTopic[topicId].push(messages)
|
||||
}
|
||||
}
|
||||
@ -111,8 +109,8 @@ const messagesSlice = createSlice({
|
||||
|
||||
// 要插入的消息,先过滤掉已存在的消息
|
||||
const messagesToInsert = Array.isArray(messages) ? messages : [messages]
|
||||
const uniqueMessagesToInsert = messagesToInsert.filter(msg =>
|
||||
!messagesList.some(existing => existing.id === msg.id)
|
||||
const uniqueMessagesToInsert = messagesToInsert.filter(
|
||||
(msg) => !messagesList.some((existing) => existing.id === msg.id)
|
||||
)
|
||||
|
||||
// 如果没有新消息需要插入,直接返回
|
||||
@ -192,9 +190,7 @@ const messagesSlice = createSlice({
|
||||
} else {
|
||||
// 检查是否有重复的消息(相同的askId和内容)
|
||||
const duplicateMessage = state.messagesByTopic[topicId].find(
|
||||
(m) => m.role === 'assistant' &&
|
||||
m.askId === streamMessage.askId &&
|
||||
m.content === streamMessage.content
|
||||
(m) => m.role === 'assistant' && m.askId === streamMessage.askId && m.content === streamMessage.content
|
||||
)
|
||||
|
||||
// 只有在没有重复消息的情况下才添加新消息
|
||||
@ -548,12 +544,15 @@ export const loadTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDisp
|
||||
const topicWithDB = await TopicManager.getTopic(topic.id)
|
||||
if (topicWithDB && topicWithDB.messages) {
|
||||
// 只加载最近的N条消息,而不是全部加载
|
||||
const initialLoadCount = state.messages.displayCount * 2; // 初始加载显示数量的2倍
|
||||
const recentMessages = topicWithDB.messages.length > initialLoadCount
|
||||
? topicWithDB.messages.slice(-initialLoadCount)
|
||||
: topicWithDB.messages;
|
||||
const initialLoadCount = state.messages.displayCount * 2 // 初始加载显示数量的2倍
|
||||
const recentMessages =
|
||||
topicWithDB.messages.length > initialLoadCount
|
||||
? topicWithDB.messages.slice(-initialLoadCount)
|
||||
: topicWithDB.messages
|
||||
|
||||
console.log(`[Messages] Loaded ${recentMessages.length}/${topicWithDB.messages.length} messages for topic ${topic.id}`);
|
||||
console.log(
|
||||
`[Messages] Loaded ${recentMessages.length}/${topicWithDB.messages.length} messages for topic ${topic.id}`
|
||||
)
|
||||
|
||||
dispatch(loadTopicMessages({ topicId: topic.id, messages: recentMessages }))
|
||||
} else {
|
||||
@ -561,7 +560,7 @@ export const loadTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDisp
|
||||
}
|
||||
dispatch(setCurrentTopic(topic))
|
||||
} catch (error) {
|
||||
console.error('[Messages] Error loading topic messages:', error);
|
||||
console.error('[Messages] Error loading topic messages:', error)
|
||||
dispatch(setError(error instanceof Error ? error.message : 'Failed to load messages'))
|
||||
} finally {
|
||||
dispatch(setTopicLoading({ topicId: topic.id, loading: false }))
|
||||
|
||||
@ -7,7 +7,15 @@ import { WebDAVSyncState } from './backup'
|
||||
|
||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
|
||||
|
||||
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
|
||||
export type SidebarIcon =
|
||||
| 'assistants'
|
||||
| 'agents'
|
||||
| 'paintings'
|
||||
| 'translate'
|
||||
| 'minapp'
|
||||
| 'knowledge'
|
||||
| 'files'
|
||||
| 'projects'
|
||||
|
||||
export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
|
||||
'assistants',
|
||||
|
||||
@ -96,6 +96,8 @@ export type Message = {
|
||||
mcpTools?: MCPToolResponse[]
|
||||
// Generate Image
|
||||
generateImage?: GenerateImageResponse
|
||||
// Knowledge base results
|
||||
knowledge?: KnowledgeReference[]
|
||||
}
|
||||
// 多模型消息样式
|
||||
multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid'
|
||||
@ -341,7 +343,15 @@ export interface TranslateHistory {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
|
||||
export type SidebarIcon =
|
||||
| 'assistants'
|
||||
| 'agents'
|
||||
| 'paintings'
|
||||
| 'translate'
|
||||
| 'minapp'
|
||||
| 'knowledge'
|
||||
| 'files'
|
||||
| 'projects'
|
||||
|
||||
export type WebSearchProvider = {
|
||||
id: string
|
||||
@ -384,6 +394,12 @@ export interface MCPServerParameter {
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface MCPConfigSample {
|
||||
command: string
|
||||
args: string[]
|
||||
env?: Record<string, string> | undefined
|
||||
}
|
||||
|
||||
export interface MCPServer {
|
||||
id: string
|
||||
name: string
|
||||
@ -396,6 +412,7 @@ export interface MCPServer {
|
||||
env?: Record<string, string>
|
||||
isActive: boolean
|
||||
disabledTools?: string[] // List of tool names that are disabled for this server
|
||||
configSample?: MCPConfigSample
|
||||
headers?: Record<string, string> // Custom headers to be sent with requests to this server
|
||||
}
|
||||
|
||||
|
||||
222
src/renderer/src/types/model.ts
Normal file
222
src/renderer/src/types/model.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const InputType = z.enum(['text', 'image', 'audio', 'video', 'document'])
|
||||
export type InputType = z.infer<typeof InputType>
|
||||
|
||||
export const OutputType = z.enum(['text', 'image', 'audio', 'video', 'vector'])
|
||||
export type OutputType = z.infer<typeof OutputType>
|
||||
|
||||
export const OutputMode = z.enum(['sync', 'streaming'])
|
||||
export type OutputMode = z.infer<typeof OutputMode>
|
||||
|
||||
export const ModelCapability = z.enum([
|
||||
'audioGeneration',
|
||||
'cache',
|
||||
'codeExecution',
|
||||
'embedding',
|
||||
'fineTuning',
|
||||
'imageGeneration',
|
||||
'OCR',
|
||||
'realTime',
|
||||
'rerank',
|
||||
'reasoning',
|
||||
'streaming',
|
||||
'structuredOutput',
|
||||
'textGeneration',
|
||||
'translation',
|
||||
'transcription',
|
||||
'toolUse',
|
||||
'videoGeneration',
|
||||
'webSearch'
|
||||
])
|
||||
export type ModelCapability = z.infer<typeof ModelCapability>
|
||||
|
||||
export const ModelSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
modelId: z.string(),
|
||||
providerId: z.string(),
|
||||
name: z.string(),
|
||||
group: z.string(),
|
||||
description: z.string().optional(),
|
||||
owned_by: z.string().optional(),
|
||||
|
||||
supportedInputs: z.array(InputType),
|
||||
supportedOutputs: z.array(OutputType),
|
||||
supportedOutputModes: z.array(OutputMode),
|
||||
|
||||
limits: z
|
||||
.object({
|
||||
inputTokenLimit: z.number().optional(),
|
||||
outputTokenLimit: z.number().optional(),
|
||||
contextWindow: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
price: z
|
||||
.object({
|
||||
inputTokenPrice: z.number().optional(),
|
||||
outputTokenPrice: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
capabilities: z.array(ModelCapability)
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// 如果模型支持streaming,则必须支持streamingOutputMode
|
||||
if (data.capabilities.includes('streaming') && !data.supportedOutputModes.includes('streaming')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有OCR能力,则必须支持图像输入类型或者文件输入类型
|
||||
if (
|
||||
data.capabilities.includes('OCR') &&
|
||||
!data.supportedInputs.includes('image') &&
|
||||
!data.supportedInputs.includes('document')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有图像生成能力,则必须支持图像输出
|
||||
if (data.capabilities.includes('imageGeneration') && !data.supportedOutputs.includes('image')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有音频生成能力,则必须支持音频输出类型
|
||||
if (data.capabilities.includes('audioGeneration') && !data.supportedOutputs.includes('audio')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有音频识别能力,则必须支持音频输入类型
|
||||
if (
|
||||
(data.capabilities.includes('transcription') || data.capabilities.includes('translation')) &&
|
||||
!data.supportedInputs.includes('audio')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有视频生成能力,则必须支持视频输出类型
|
||||
if (data.capabilities.includes('videoGeneration') && !data.supportedOutputs.includes('video')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有embedding能力,则必须支持向量输出类型
|
||||
if (data.capabilities.includes('embedding') && !data.supportedOutputs.includes('vector')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有toolUse, Reasoning, streaming, cache, codeExecution, imageGeneration, audioGeneration, videoGeneration, webSearch能力,则必须支持文字的输入
|
||||
if (
|
||||
(data.capabilities.includes('toolUse') ||
|
||||
data.capabilities.includes('reasoning') ||
|
||||
data.capabilities.includes('streaming') ||
|
||||
data.capabilities.includes('cache') ||
|
||||
data.capabilities.includes('codeExecution') ||
|
||||
data.capabilities.includes('imageGeneration') ||
|
||||
data.capabilities.includes('audioGeneration') ||
|
||||
data.capabilities.includes('videoGeneration') ||
|
||||
data.capabilities.includes('webSearch')) &&
|
||||
!data.supportedInputs.includes('text')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有toolUse, Reasoning, streaming, cache, codeExecution, OCR, textGeneration, translation, transcription, webSearch, structuredOutput能力,则必须支持文字的输出
|
||||
if (
|
||||
(data.capabilities.includes('toolUse') ||
|
||||
data.capabilities.includes('reasoning') ||
|
||||
data.capabilities.includes('streaming') ||
|
||||
data.capabilities.includes('cache') ||
|
||||
data.capabilities.includes('codeExecution') ||
|
||||
data.capabilities.includes('OCR') ||
|
||||
data.capabilities.includes('textGeneration') ||
|
||||
data.capabilities.includes('translation') ||
|
||||
data.capabilities.includes('transcription') ||
|
||||
data.capabilities.includes('webSearch') ||
|
||||
data.capabilities.includes('structuredOutput')) &&
|
||||
!data.supportedOutputs.includes('text')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'ModelCard has inconsistent capabilities and supported input/output type'
|
||||
}
|
||||
)
|
||||
|
||||
export type ModelCard = z.infer<typeof ModelSchema>
|
||||
|
||||
export function createModelCard(model: ModelCard): ModelCard {
|
||||
return ModelSchema.parse(model)
|
||||
}
|
||||
|
||||
export function supportesInputType(model: ModelCard, inputType: InputType) {
|
||||
return model.supportedInputs.includes(inputType)
|
||||
}
|
||||
|
||||
export function supportesOutputType(model: ModelCard, outputType: OutputType) {
|
||||
return model.supportedOutputs.includes(outputType)
|
||||
}
|
||||
|
||||
export function supportesOutputMode(model: ModelCard, outputMode: OutputMode) {
|
||||
return model.supportedOutputModes.includes(outputMode)
|
||||
}
|
||||
|
||||
export function supportesCapability(model: ModelCard, capability: ModelCapability) {
|
||||
return model.capabilities.includes(capability)
|
||||
}
|
||||
|
||||
export function isVisionModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'image')
|
||||
}
|
||||
|
||||
export function isImageGenerationModel(model: ModelCard) {
|
||||
return isVisionModel(model) && supportesCapability(model, 'imageGeneration')
|
||||
}
|
||||
|
||||
export function isAudioModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'audio')
|
||||
}
|
||||
|
||||
export function isAudioGenerationModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'audioGeneration')
|
||||
}
|
||||
|
||||
export function isVideoModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'video')
|
||||
}
|
||||
|
||||
export function isEmbedModel(model: ModelCard) {
|
||||
return supportesOutputType(model, 'vector') && supportesCapability(model, 'embedding')
|
||||
}
|
||||
|
||||
export function isTextEmbeddingModel(model: ModelCard) {
|
||||
return isEmbedModel(model) && supportesInputType(model, 'text') && model.supportedInputs.length === 1
|
||||
}
|
||||
|
||||
export function isMultiModalEmbeddingModel(model: ModelCard) {
|
||||
return isEmbedModel(model) && model.supportedInputs.length > 1
|
||||
}
|
||||
|
||||
export function isRerankModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'rerank')
|
||||
}
|
||||
|
||||
export function isReasoningModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'reasoning')
|
||||
}
|
||||
|
||||
export function isToolUseModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'toolUse')
|
||||
}
|
||||
|
||||
export function isOnlyStreamingModel(model: ModelCard) {
|
||||
return (
|
||||
supportesCapability(model, 'streaming') &&
|
||||
supportesOutputMode(model, 'streaming') &&
|
||||
model.supportedOutputModes.length === 1
|
||||
)
|
||||
}
|
||||
@ -27,8 +27,10 @@ export function checkModelCombinationsInLocalStorage() {
|
||||
}
|
||||
|
||||
const combinations = JSON.parse(savedCombinations)
|
||||
console.log('[checkModelCombinationsInLocalStorage] localStorage中的模型组合数据:',
|
||||
JSON.stringify(combinations, null, 2))
|
||||
console.log(
|
||||
'[checkModelCombinationsInLocalStorage] localStorage中的模型组合数据:',
|
||||
JSON.stringify(combinations, null, 2)
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('[checkModelCombinationsInLocalStorage] 解析localStorage中的模型组合数据失败:', e)
|
||||
}
|
||||
@ -41,10 +43,18 @@ export function checkModelCombinationsInLocalStorage() {
|
||||
*/
|
||||
// 创建模型对象,用于添加到DeepClaude提供商中
|
||||
export function createDeepClaudeModel(combination: ModelCombination): Model {
|
||||
console.log('[createDeepClaudeModel] 创建DeepClaude模型,组合ID:', combination.id,
|
||||
'组合名称:', combination.name,
|
||||
'推理模型:', combination.reasonerModel?.id, combination.reasonerModel?.name,
|
||||
'目标模型:', combination.targetModel?.id, combination.targetModel?.name)
|
||||
console.log(
|
||||
'[createDeepClaudeModel] 创建DeepClaude模型,组合ID:',
|
||||
combination.id,
|
||||
'组合名称:',
|
||||
combination.name,
|
||||
'推理模型:',
|
||||
combination.reasonerModel?.id,
|
||||
combination.reasonerModel?.name,
|
||||
'目标模型:',
|
||||
combination.targetModel?.id,
|
||||
combination.targetModel?.name
|
||||
)
|
||||
|
||||
// 使用组合ID作为模型ID
|
||||
console.log('[createDeepClaudeModel] 使用组合ID作为模型ID:', combination.id)
|
||||
@ -80,9 +90,12 @@ export function createDeepClaudeProvider(combinations: ModelCombination[]): Prov
|
||||
isSystem: false
|
||||
}
|
||||
|
||||
console.log('[createDeepClaudeProvider] 创建的提供商详情:',
|
||||
{ id: provider.id, name: provider.name, type: provider.type,
|
||||
models: provider.models.map(m => ({ id: m.id, name: m.name, provider: m.provider })) })
|
||||
console.log('[createDeepClaudeProvider] 创建的提供商详情:', {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
models: provider.models.map((m) => ({ id: m.id, name: m.name, provider: m.provider }))
|
||||
})
|
||||
|
||||
return provider
|
||||
}
|
||||
@ -100,23 +113,25 @@ export function getActiveModelCombinations(): ModelCombination[] {
|
||||
}
|
||||
|
||||
const combinations = JSON.parse(savedCombinations) as ModelCombination[]
|
||||
const activeCombinations = combinations.filter(c => c.isActive)
|
||||
const activeCombinations = combinations.filter((c) => c.isActive)
|
||||
console.log('[getActiveModelCombinations] 找到激活的模型组合数量:', activeCombinations.length)
|
||||
console.log('[getActiveModelCombinations] 激活的模型组合详情:',
|
||||
activeCombinations.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: {
|
||||
id: c.reasonerModel?.id,
|
||||
name: c.reasonerModel?.name,
|
||||
provider: c.reasonerModel?.provider
|
||||
},
|
||||
targetModel: {
|
||||
id: c.targetModel?.id,
|
||||
name: c.targetModel?.name,
|
||||
provider: c.targetModel?.provider
|
||||
}
|
||||
})))
|
||||
console.log(
|
||||
'[getActiveModelCombinations] 激活的模型组合详情:',
|
||||
activeCombinations.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
reasonerModel: {
|
||||
id: c.reasonerModel?.id,
|
||||
name: c.reasonerModel?.name,
|
||||
provider: c.reasonerModel?.provider
|
||||
},
|
||||
targetModel: {
|
||||
id: c.targetModel?.id,
|
||||
name: c.targetModel?.name,
|
||||
provider: c.targetModel?.provider
|
||||
}
|
||||
}))
|
||||
)
|
||||
return activeCombinations
|
||||
} catch (e) {
|
||||
console.error('[getActiveModelCombinations] Failed to parse model combinations:', e)
|
||||
@ -138,6 +153,11 @@ export function createAllDeepClaudeProviders(): Provider[] {
|
||||
|
||||
// 创建一个单一的DeepClaude提供商
|
||||
const provider = createDeepClaudeProvider(activeCombinations)
|
||||
console.log('[createAllDeepClaudeProviders] 创建的DeepClaude提供商:', provider.id, provider.name, provider.models.length)
|
||||
console.log(
|
||||
'[createAllDeepClaudeProviders] 创建的DeepClaude提供商:',
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.models.length
|
||||
)
|
||||
return [provider]
|
||||
}
|
||||
|
||||
@ -522,4 +522,30 @@ export function hasObjectKey(obj: any, key: string) {
|
||||
return Object.keys(obj).includes(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从npm readme中提取 npx mcp config
|
||||
* @param readme readme字符串
|
||||
* @returns mcp config sample
|
||||
*/
|
||||
export function getMcpConfigSampleFromReadme(readme: string) {
|
||||
if (readme) {
|
||||
// 使用正则表达式匹配 mcpServers 对象内容
|
||||
const regex = /"mcpServers"\s*:\s*({(?:[^{}]*|{(?:[^{}]*|{[^{}]*})*})*})/
|
||||
const match = readme.match(regex)
|
||||
console.log('match', match)
|
||||
if (match && match[1]) {
|
||||
// 添加缺失的闭合括号检测
|
||||
try {
|
||||
let orgSample = JSON.parse(match[1])
|
||||
orgSample = orgSample[Object.keys(orgSample)[0] ?? '']
|
||||
if (orgSample.command === 'npx') {
|
||||
return orgSample
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { classNames }
|
||||
|
||||
@ -17,3 +17,68 @@ export const findCitationInChildren = (children) => {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const MARKDOWN_ALLOWED_TAGS = [
|
||||
'style',
|
||||
'p',
|
||||
'div',
|
||||
'span',
|
||||
'b',
|
||||
'i',
|
||||
'strong',
|
||||
'em',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'table',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'thead',
|
||||
'tbody',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'code',
|
||||
'br',
|
||||
'hr',
|
||||
'svg',
|
||||
'path',
|
||||
'circle',
|
||||
'rect',
|
||||
'line',
|
||||
'polyline',
|
||||
'polygon',
|
||||
'text',
|
||||
'g',
|
||||
'defs',
|
||||
'title',
|
||||
'desc',
|
||||
'tspan',
|
||||
'sub',
|
||||
'sup',
|
||||
'think'
|
||||
]
|
||||
|
||||
// rehype-sanitize配置
|
||||
export const sanitizeSchema = {
|
||||
tagNames: MARKDOWN_ALLOWED_TAGS,
|
||||
attributes: {
|
||||
'*': ['className', 'style', 'id', 'title'],
|
||||
svg: ['viewBox', 'width', 'height', 'xmlns', 'fill', 'stroke'],
|
||||
path: ['d', 'fill', 'stroke', 'strokeWidth', 'strokeLinecap', 'strokeLinejoin'],
|
||||
circle: ['cx', 'cy', 'r', 'fill', 'stroke'],
|
||||
rect: ['x', 'y', 'width', 'height', 'fill', 'stroke'],
|
||||
line: ['x1', 'y1', 'x2', 'y2', 'stroke'],
|
||||
polyline: ['points', 'fill', 'stroke'],
|
||||
polygon: ['points', 'fill', 'stroke'],
|
||||
text: ['x', 'y', 'fill', 'textAnchor', 'dominantBaseline'],
|
||||
g: ['transform', 'fill', 'stroke'],
|
||||
a: ['href', 'target', 'rel']
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,8 +44,8 @@ export const DEFAULT_THINKING_LIBRARIES: ThinkingLibrary[] = [
|
||||
问题: {question}`
|
||||
},
|
||||
{
|
||||
id: 'scientific_rigorous',
|
||||
name: '严谨科学分析',
|
||||
id: 'scientific_rigorous',
|
||||
name: '严谨科学分析',
|
||||
description: '运用系统化的科学方法论,对问题进行深入、严谨的分析、假设检验与评估。', // 更新描述
|
||||
category: '专业',
|
||||
prompt: `你是一位严谨的科学分析师,遵循科学方法论对问题进行系统性探究。请对以下问题,按照结构化的科学探究过程进行深入思考和分析:
|
||||
@ -497,20 +497,25 @@ export function getThinkingLibraries(): ThinkingLibrary[] {
|
||||
const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[]
|
||||
console.log('[ThinkingLibrary] 解析思考库数量:', parsed.length)
|
||||
|
||||
if (parsed.length < DEFAULT_THINKING_LIBRARIES.length || !parsed.every(lib => DEFAULT_THINKING_LIBRARIES.some(defLib => defLib.id === lib.id))) {
|
||||
if (
|
||||
parsed.length < DEFAULT_THINKING_LIBRARIES.length ||
|
||||
!parsed.every((lib) => DEFAULT_THINKING_LIBRARIES.some((defLib) => defLib.id === lib.id))
|
||||
) {
|
||||
console.log('[ThinkingLibrary] 存储的思考库需要更新,与默认库合并')
|
||||
|
||||
const librariesToMerge = DEFAULT_THINKING_LIBRARIES.map(defaultLib => {
|
||||
const existingLib = parsed.find(lib => lib.id === defaultLib.id);
|
||||
return existingLib || defaultLib;
|
||||
});
|
||||
const librariesToMerge = DEFAULT_THINKING_LIBRARIES.map((defaultLib) => {
|
||||
const existingLib = parsed.find((lib) => lib.id === defaultLib.id)
|
||||
return existingLib || defaultLib
|
||||
})
|
||||
|
||||
const customLibraries = parsed.filter(lib => !DEFAULT_THINKING_LIBRARIES.some(defLib => defLib.id === lib.id));
|
||||
const updatedLibraries = [...librariesToMerge, ...customLibraries];
|
||||
const customLibraries = parsed.filter(
|
||||
(lib) => !DEFAULT_THINKING_LIBRARIES.some((defLib) => defLib.id === lib.id)
|
||||
)
|
||||
const updatedLibraries = [...librariesToMerge, ...customLibraries]
|
||||
|
||||
console.log('[ThinkingLibrary] 更新后思考库数量:', updatedLibraries.length);
|
||||
saveThinkingLibraries(updatedLibraries);
|
||||
return updatedLibraries;
|
||||
console.log('[ThinkingLibrary] 更新后思考库数量:', updatedLibraries.length)
|
||||
saveThinkingLibraries(updatedLibraries)
|
||||
return updatedLibraries
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@ -533,9 +538,9 @@ export function saveThinkingLibraries(libraries: ThinkingLibrary[]): void {
|
||||
|
||||
const savedLibraries = localStorage.getItem('thinkingLibraries')
|
||||
if (savedLibraries) {
|
||||
console.log('[ThinkingLibrary] 验证保存结果 - 数据已写入localStorage');
|
||||
console.log('[ThinkingLibrary] 验证保存结果 - 数据已写入localStorage')
|
||||
} else {
|
||||
console.warn('[ThinkingLibrary] 验证保存结果 - 未在localStorage中找到数据');
|
||||
console.warn('[ThinkingLibrary] 验证保存结果 - 未在localStorage中找到数据')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ThinkingLibrary] 保存思考库失败:', e)
|
||||
@ -547,7 +552,7 @@ export function getThinkingLibraryById(id: string | undefined): ThinkingLibrary
|
||||
if (!id) return undefined
|
||||
|
||||
const libraries = getThinkingLibraries()
|
||||
return libraries.find(lib => lib.id === id)
|
||||
return libraries.find((lib) => lib.id === id)
|
||||
}
|
||||
|
||||
// 调试函数:显示思考库数据
|
||||
@ -560,7 +565,7 @@ export function debugThinkingLibraries(): void {
|
||||
try {
|
||||
const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[]
|
||||
console.log('[ThinkingLibrary] DEBUG - 解析后的思考库数量:', parsed.length)
|
||||
console.log('[ThinkingLibrary] DEBUG - 思考库列表详情:', JSON.stringify(parsed, null, 2));
|
||||
console.log('[ThinkingLibrary] DEBUG - 思考库列表详情:', JSON.stringify(parsed, null, 2))
|
||||
} catch (e) {
|
||||
console.error('[ThinkingLibrary] DEBUG - 解析思考库JSON失败:', e)
|
||||
}
|
||||
@ -582,44 +587,44 @@ export function addThinkingLibrary(library: Omit<ThinkingLibrary, 'id'>): Thinki
|
||||
}
|
||||
|
||||
console.log('[ThinkingLibrary] 添加前思考库数量:', libraries.length)
|
||||
const updatedLibraries = [...libraries, newLibrary];
|
||||
const updatedLibraries = [...libraries, newLibrary]
|
||||
console.log('[ThinkingLibrary] 添加后思考库数量:', updatedLibraries.length)
|
||||
saveThinkingLibraries(updatedLibraries)
|
||||
console.log('[ThinkingLibrary] 新增库ID:', newLibrary.id);
|
||||
console.log('[ThinkingLibrary] 新增库ID:', newLibrary.id)
|
||||
return newLibrary
|
||||
}
|
||||
|
||||
// 更新思考库
|
||||
export function updateThinkingLibrary(library: ThinkingLibrary): boolean {
|
||||
console.log('[ThinkingLibrary] 更新思考库 ID:', library.id, '名称:', library.name);
|
||||
console.log('[ThinkingLibrary] 更新思考库 ID:', library.id, '名称:', library.name)
|
||||
const libraries = getThinkingLibraries()
|
||||
const index = libraries.findIndex(lib => lib.id === library.id)
|
||||
const index = libraries.findIndex((lib) => lib.id === library.id)
|
||||
|
||||
if (index !== -1) {
|
||||
const updatedLibraries = [...libraries];
|
||||
updatedLibraries[index] = library;
|
||||
saveThinkingLibraries(updatedLibraries);
|
||||
console.log('[ThinkingLibrary] 思考库更新成功');
|
||||
return true;
|
||||
const updatedLibraries = [...libraries]
|
||||
updatedLibraries[index] = library
|
||||
saveThinkingLibraries(updatedLibraries)
|
||||
console.log('[ThinkingLibrary] 思考库更新成功')
|
||||
return true
|
||||
} else {
|
||||
console.warn('[ThinkingLibrary] 更新失败:未找到ID为', library.id, '的思考库');
|
||||
return false;
|
||||
console.warn('[ThinkingLibrary] 更新失败:未找到ID为', library.id, '的思考库')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除思考库
|
||||
export function deleteThinkingLibrary(id: string): boolean {
|
||||
console.log('[ThinkingLibrary] 删除思考库 ID:', id);
|
||||
console.log('[ThinkingLibrary] 删除思考库 ID:', id)
|
||||
const libraries = getThinkingLibraries()
|
||||
const initialLength = libraries.length;
|
||||
const filteredLibraries = libraries.filter(lib => lib.id !== id)
|
||||
const initialLength = libraries.length
|
||||
const filteredLibraries = libraries.filter((lib) => lib.id !== id)
|
||||
|
||||
if (filteredLibraries.length < initialLength) {
|
||||
saveThinkingLibraries(filteredLibraries);
|
||||
console.log('[ThinkingLibrary] 思考库删除成功,剩余数量:', filteredLibraries.length);
|
||||
return true;
|
||||
saveThinkingLibraries(filteredLibraries)
|
||||
console.log('[ThinkingLibrary] 思考库删除成功,剩余数量:', filteredLibraries.length)
|
||||
return true
|
||||
} else {
|
||||
console.warn('[ThinkingLibrary] 删除失败:未找到ID为', id, '的思考库');
|
||||
return false;
|
||||
console.warn('[ThinkingLibrary] 删除失败:未找到ID为', id, '的思考库')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +98,6 @@ const HomeWindow: FC = () => {
|
||||
setRoute('chat')
|
||||
onSendMessage().then()
|
||||
focusInput()
|
||||
setTimeout(() => setText(''), 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -161,6 +160,7 @@ const HomeWindow: FC = () => {
|
||||
}
|
||||
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
|
||||
setIsFirstMessage(false)
|
||||
setText('') // ✅ 清除输入框内容
|
||||
}, 0)
|
||||
},
|
||||
[content, defaultAssistant.id, defaultAssistant.topics]
|
||||
|
||||
41
yarn.lock
41
yarn.lock
@ -3144,9 +3144,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@modelcontextprotocol/sdk@npm:^1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "@modelcontextprotocol/sdk@npm:1.9.0"
|
||||
"@modelcontextprotocol/sdk@npm:^1.10.1":
|
||||
version: 1.10.1
|
||||
resolution: "@modelcontextprotocol/sdk@npm:1.10.1"
|
||||
dependencies:
|
||||
content-type: "npm:^1.0.5"
|
||||
cors: "npm:^2.8.5"
|
||||
@ -3158,7 +3158,7 @@ __metadata:
|
||||
raw-body: "npm:^3.0.0"
|
||||
zod: "npm:^3.23.8"
|
||||
zod-to-json-schema: "npm:^3.24.1"
|
||||
checksum: 10c0/d93653990c114690c20db606076afdc1836cdf41e1b0249fb6c3432877caad1577ef2ff9bf9476e259bfaaf422a281cda2b77e9b61eaa9b64b359f3b511b2074
|
||||
checksum: 10c0/375a7a7c2753f3bbf9b0cb57d5515d74be1845c5d42567cfc83ed48ca853cb37c195b830580e72df9969632b2cc53f70d37440ef5531c0bd2cb031bbb0aaf2e2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -4444,13 +4444,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/js-yaml@npm:^4":
|
||||
version: 4.0.9
|
||||
resolution: "@types/js-yaml@npm:4.0.9"
|
||||
checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/json-schema@npm:^7.0.15":
|
||||
version: 7.0.15
|
||||
resolution: "@types/json-schema@npm:7.0.15"
|
||||
@ -4972,7 +4965,7 @@ __metadata:
|
||||
"@langchain/community": "npm:^0.3.36"
|
||||
"@langchain/core": "npm:^0.3.44"
|
||||
"@lezer/highlight": "npm:^1.2.1"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.9.0"
|
||||
"@modelcontextprotocol/sdk": "npm:^1.10.1"
|
||||
"@monaco-editor/react": "npm:^4.7.0"
|
||||
"@mozilla/readability": "npm:^0.6.0"
|
||||
"@notionhq/client": "npm:^2.2.15"
|
||||
@ -4984,7 +4977,6 @@ __metadata:
|
||||
"@types/d3": "npm:^7"
|
||||
"@types/diff": "npm:^7"
|
||||
"@types/fs-extra": "npm:^11"
|
||||
"@types/js-yaml": "npm:^4"
|
||||
"@types/lodash": "npm:^4.17.16"
|
||||
"@types/markdown-it": "npm:^14"
|
||||
"@types/md5": "npm:^2.3.5"
|
||||
@ -5039,7 +5031,6 @@ __metadata:
|
||||
husky: "npm:^9.1.7"
|
||||
i18next: "npm:^23.11.5"
|
||||
js-tiktoken: "npm:^1.0.19"
|
||||
js-yaml: "npm:^4.1.0"
|
||||
jsdom: "npm:^26.0.0"
|
||||
lint-staged: "npm:^15.5.0"
|
||||
lodash: "npm:^4.17.21"
|
||||
@ -5074,6 +5065,7 @@ __metadata:
|
||||
rehype-katex: "npm:^7.0.1"
|
||||
rehype-mathjax: "npm:^7.0.0"
|
||||
rehype-raw: "npm:^7.0.0"
|
||||
rehype-sanitize: "npm:^6.0.0"
|
||||
remark-cjk-friendly: "npm:^1.1.0"
|
||||
remark-gfm: "npm:^4.0.0"
|
||||
remark-math: "npm:^6.0.0"
|
||||
@ -10233,6 +10225,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hast-util-sanitize@npm:^5.0.0":
|
||||
version: 5.0.2
|
||||
resolution: "hast-util-sanitize@npm:5.0.2"
|
||||
dependencies:
|
||||
"@types/hast": "npm:^3.0.0"
|
||||
"@ungap/structured-clone": "npm:^1.0.0"
|
||||
unist-util-position: "npm:^5.0.0"
|
||||
checksum: 10c0/20951652078a8c21341c1c9a84f90015b2ba01cc41fa16772f122c65cda26a7adb0501fdeba5c8e37e40e2632447e8fe455d0dd2dc27d39663baacca76f2ecb6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hast-util-to-html@npm:^9.0.5":
|
||||
version: 9.0.5
|
||||
resolution: "hast-util-to-html@npm:9.0.5"
|
||||
@ -15991,6 +15994,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rehype-sanitize@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "rehype-sanitize@npm:6.0.0"
|
||||
dependencies:
|
||||
"@types/hast": "npm:^3.0.0"
|
||||
hast-util-sanitize: "npm:^5.0.0"
|
||||
checksum: 10c0/43d6c056e63c994cf56e5ee0e157052d2030dc5ac160845ee494af9a26e5906bf5ec5af56c7d90c99f9c4dc0091e45a48a168618135fb6c64a76481ad3c449e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-cjk-friendly@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "remark-cjk-friendly@npm:1.1.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user