This commit is contained in:
1600822305 2025-04-20 01:46:29 +08:00
parent 40aabba498
commit 53643e81f0
97 changed files with 3113 additions and 1554 deletions

View File

@ -7,7 +7,7 @@
const http = require('http') const http = require('http')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
const net = require('net') // const net = require('net')
const crypto = require('crypto') const crypto = require('crypto')
// 输出环境信息 // 输出环境信息
@ -114,23 +114,24 @@ const clients = {
} }
// 处理WebSocket连接 // 处理WebSocket连接
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (request, socket) => {
try { try {
console.log('[WebSocket] Connection upgrade request received') console.log('[WebSocket] Connection upgrade request received')
// 解析WebSocket密钥 // 解析WebSocket密钥
const key = request.headers['sec-websocket-key'] 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') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary')
.digest('base64') .digest('base64')
// 发送WebSocket握手响应 // 发送WebSocket握手响应
socket.write( socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' + 'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' + 'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' + 'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` + `Sec-WebSocket-Accept: ${acceptKey}\r\n` +
'\r\n' '\r\n'
) )
console.log('[WebSocket] Handshake successful') console.log('[WebSocket] Handshake successful')
@ -157,10 +158,8 @@ function handleWebSocketConnection(socket) {
// 检查是否有完整的帧 // 检查是否有完整的帧
const firstByte = buffer[0] const firstByte = buffer[0]
const secondByte = buffer[1] const secondByte = buffer[1]
const isFinalFrame = Boolean((firstByte >>> 7) & 0x1) // const isFinalFrame = Boolean((firstByte >>> 7) & 0x1)
const [opCode, maskFlag, payloadLength] = [ const [opCode, maskFlag, payloadLength] = [firstByte & 0xf, (secondByte >>> 7) & 0x1, secondByte & 0x7f]
firstByte & 0xF, (secondByte >>> 7) & 0x1, secondByte & 0x7F
]
// 处理不同的负载长度 // 处理不同的负载长度
let payloadStartIndex = 2 let payloadStartIndex = 2
@ -265,7 +264,7 @@ function sendWebSocketFrame(socket, data, opCode = 0x1) {
// 发送Pong响应 // 发送Pong响应
function sendPong(socket) { function sendPong(socket) {
const pongFrame = Buffer.from([0x8A, 0x00]) const pongFrame = Buffer.from([0x8a, 0x00])
socket.write(pongFrame) socket.write(pongFrame)
} }
@ -351,11 +350,11 @@ async function findAvailablePort(startPort) {
port++ 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 { try {
// 默认端口 // 默认端口
const defaultPort = 34515 const defaultPort = 34515

View File

@ -3,67 +3,73 @@
* 用于测试ASR服务器是否正常工作 * 用于测试ASR服务器是否正常工作
*/ */
const WebSocket = require('ws'); const WebSocket = require('ws')
const http = require('http'); const http = require('http')
// 测试HTTP服务器 // 测试HTTP服务器
console.log('测试HTTP服务器...'); console.log('测试HTTP服务器...')
http.get('http://localhost:34515', (res) => { http
console.log(`HTTP状态码: ${res.statusCode}`); .get('http://localhost:34515', (res) => {
console.log(`HTTP状态码: ${res.statusCode}`)
let data = ''; let data = ''
res.on('data', (chunk) => { res.on('data', (chunk) => {
data += chunk; data += chunk
}); })
res.on('end', () => { res.on('end', () => {
console.log('HTTP响应接收完成'); console.log('HTTP响应接收完成')
console.log(`响应长度: ${data.length} 字节`); console.log(`响应长度: ${data.length} 字节`)
console.log('HTTP测试完成'); console.log('HTTP测试完成')
// 测试WebSocket // 测试WebSocket
testWebSocket(); testWebSocket()
}); })
}).on('error', (err) => { })
console.error('HTTP测试失败:', err.message); .on('error', (err) => {
}); console.error('HTTP测试失败:', err.message)
})
// 测试WebSocket // 测试WebSocket
function testWebSocket() { function testWebSocket() {
console.log('\n测试WebSocket...'); console.log('\n测试WebSocket...')
const ws = new WebSocket('ws://localhost:34515'); const ws = new WebSocket('ws://localhost:34515')
ws.on('open', () => { ws.on('open', () => {
console.log('WebSocket连接已打开'); console.log('WebSocket连接已打开')
// 发送身份识别消息 // 发送身份识别消息
ws.send(JSON.stringify({ ws.send(
type: 'identify', JSON.stringify({
role: 'electron' type: 'identify',
})); role: 'electron'
})
)
// 发送测试消息 // 发送测试消息
setTimeout(() => { setTimeout(() => {
console.log('发送测试消息...'); console.log('发送测试消息...')
ws.send(JSON.stringify({ ws.send(
type: 'test', JSON.stringify({
message: '这是一条测试消息' type: 'test',
})); message: '这是一条测试消息'
}, 1000); })
)
}, 1000)
// 关闭连接 // 关闭连接
setTimeout(() => { setTimeout(() => {
console.log('关闭WebSocket连接...'); console.log('关闭WebSocket连接...')
ws.close(); ws.close()
console.log('测试完成'); console.log('测试完成')
}, 2000); }, 2000)
}); })
ws.on('message', (data) => { ws.on('message', (data) => {
console.log(`收到WebSocket消息: ${data}`); console.log(`收到WebSocket消息: ${data}`)
}); })
ws.on('error', (error) => { ws.on('error', (error) => {
console.error('WebSocket测试失败:', error.message); console.error('WebSocket测试失败:', error.message)
}); })
} }

View File

@ -1,77 +1,77 @@
// 检查重复消息的脚本 // 检查重复消息的脚本
const { app } = require('electron'); const { app } = require('electron')
const path = require('path'); const path = require('path')
const fs = require('fs'); const fs = require('fs')
// 获取数据库文件路径 // 获取数据库文件路径
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData')
const dbFilePath = path.join(userDataPath, 'CherryStudio.db'); const dbFilePath = path.join(userDataPath, 'CherryStudio.db')
console.log('数据库文件路径:', dbFilePath); console.log('数据库文件路径:', dbFilePath)
// 检查文件是否存在 // 检查文件是否存在
if (fs.existsSync(dbFilePath)) { if (fs.existsSync(dbFilePath)) {
console.log('数据库文件存在'); console.log('数据库文件存在')
// 读取数据库内容 // 读取数据库内容
const dbContent = fs.readFileSync(dbFilePath, 'utf8'); const dbContent = fs.readFileSync(dbFilePath, 'utf8')
// 解析数据库内容 // 解析数据库内容
try { try {
const data = JSON.parse(dbContent); const data = JSON.parse(dbContent)
// 检查topics表中的消息 // 检查topics表中的消息
if (data.topics) { if (data.topics) {
console.log('找到topics表共有', data.topics.length, '个主题'); console.log('找到topics表共有', data.topics.length, '个主题')
// 遍历每个主题 // 遍历每个主题
data.topics.forEach(topic => { data.topics.forEach((topic) => {
console.log(`检查主题: ${topic.id}`); console.log(`检查主题: ${topic.id}`)
if (topic.messages && Array.isArray(topic.messages)) { if (topic.messages && Array.isArray(topic.messages)) {
console.log(` 主题消息数量: ${topic.messages.length}`); console.log(` 主题消息数量: ${topic.messages.length}`)
// 检查重复消息 // 检查重复消息
const messageIds = new Set(); const messageIds = new Set()
const duplicates = []; const duplicates = []
topic.messages.forEach(message => { topic.messages.forEach((message) => {
if (messageIds.has(message.id)) { if (messageIds.has(message.id)) {
duplicates.push(message.id); duplicates.push(message.id)
} else { } else {
messageIds.add(message.id); messageIds.add(message.id)
} }
}); })
if (duplicates.length > 0) { if (duplicates.length > 0) {
console.log(` 发现${duplicates.length}条重复消息ID:`, duplicates); console.log(` 发现${duplicates.length}条重复消息ID:`, duplicates)
} else { } else {
console.log(' 未发现重复消息ID'); console.log(' 未发现重复消息ID')
} }
// 检查重复的askId (对于助手消息) // 检查重复的askId (对于助手消息)
const askIds = {}; const askIds = {}
topic.messages.forEach(message => { topic.messages.forEach((message) => {
if (message.role === 'assistant' && message.askId) { if (message.role === 'assistant' && message.askId) {
if (!askIds[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对应的助手消息数量 // 输出每个askId对应的助手消息数量
Object.entries(askIds).forEach(([askId, messageIds]) => { Object.entries(askIds).forEach(([askId, messageIds]) => {
if (messageIds.length > 1) { if (messageIds.length > 1) {
console.log(` askId ${askId}${messageIds.length} 条助手消息`); console.log(` askId ${askId}${messageIds.length} 条助手消息`)
} }
}); })
} }
}); })
} }
} catch (error) { } catch (error) {
console.error('解析数据库内容失败:', error); console.error('解析数据库内容失败:', error)
} }
} else { } else {
console.log('数据库文件不存在'); console.log('数据库文件不存在')
} }

View File

@ -1,64 +1,64 @@
const fs = require('fs'); const fs = require('fs')
const path = require('path'); const path = require('path')
// 读取agents.json文件 // 读取agents.json文件
const filePath = path.join('resources', 'data', 'agents.json'); const filePath = path.join('resources', 'data', 'agents.json')
fs.readFile(filePath, (err, data) => { fs.readFile(filePath, (err, data) => {
if (err) { if (err) {
console.error('读取文件失败:', err); console.error('读取文件失败:', err)
return; return
} }
// 输出文件的前20个字节的十六进制表示 // 输出文件的前20个字节的十六进制表示
console.log('文件前20个字节:'); console.log('文件前20个字节:')
for (let i = 0; i < Math.min(20, data.length); i++) { 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 // 尝试不同的方式解析JSON
console.log('\n尝试不同的方式解析JSON:'); console.log('\n尝试不同的方式解析JSON:')
// 1. 直接解析 // 1. 直接解析
try { try {
const json1 = JSON.parse(data); JSON.parse(data)
console.log('方法1成功: 直接解析'); console.log('方法1成功: 直接解析')
} catch (e) { } catch (e) {
console.error('方法1失败:', e.message); console.error('方法1失败:', e.message)
} }
// 2. 转换为字符串后解析 // 2. 转换为字符串后解析
try { try {
const json2 = JSON.parse(data.toString()); JSON.parse(data.toString())
console.log('方法2成功: 转换为字符串后解析'); console.log('方法2成功: 转换为字符串后解析')
} catch (e) { } catch (e) {
console.error('方法2失败:', e.message); console.error('方法2失败:', e.message)
} }
// 3. 移除BOM后解析 // 3. 移除BOM后解析
try { try {
const str = data.toString(); const str = data.toString()
const noBomStr = str.charCodeAt(0) === 0xFEFF ? str.slice(1) : str; const noBomStr = str.charCodeAt(0) === 0xfeff ? str.slice(1) : str
const json3 = JSON.parse(noBomStr); JSON.parse(noBomStr)
console.log('方法3成功: 移除BOM后解析'); console.log('方法3成功: 移除BOM后解析')
} catch (e) { } catch (e) {
console.error('方法3失败:', e.message); console.error('方法3失败:', e.message)
} }
// 4. 移除前3个字符后解析 // 4. 移除前3个字符后解析
try { try {
const str = data.toString().slice(3); const str = data.toString().slice(3)
const json4 = JSON.parse(str); JSON.parse(str)
console.log('方法4成功: 移除前3个字符后解析'); console.log('方法4成功: 移除前3个字符后解析')
} catch (e) { } catch (e) {
console.error('方法4失败:', e.message); console.error('方法4失败:', e.message)
} }
// 5. 移除所有非ASCII字符后解析 // 5. 移除所有非ASCII字符后解析
try { try {
const str = data.toString().replace(/[^\x20-\x7E]/g, ''); const str = data.toString().replace(/[^\x20-\x7E]/g, '')
const json5 = JSON.parse(str); JSON.parse(str)
console.log('方法5成功: 移除所有非ASCII字符后解析'); console.log('方法5成功: 移除所有非ASCII字符后解析')
} catch (e) { } catch (e) {
console.error('方法5失败:', e.message); console.error('方法5失败:', e.message)
} }
}); })

View File

@ -1,14 +1,14 @@
const fs = require('fs'); const fs = require('fs')
const path = require('path'); const path = require('path')
// 创建一个空的agents.json文件 // 创建一个空的agents.json文件
const emptyAgents = []; const emptyAgents = []
const filePath = path.join('resources', 'data', 'agents.json'); const filePath = path.join('resources', 'data', 'agents.json')
// 备份原始文件 // 备份原始文件
fs.copyFileSync(filePath, filePath + '.bak'); fs.copyFileSync(filePath, filePath + '.bak')
console.log('已备份原始文件到 ' + filePath + '.bak'); console.log('已备份原始文件到 ' + filePath + '.bak')
// 写入新文件 // 写入新文件
fs.writeFileSync(filePath, JSON.stringify(emptyAgents, null, 2), 'utf8'); fs.writeFileSync(filePath, JSON.stringify(emptyAgents, null, 2), 'utf8')
console.log('已创建新的agents.json文件内容为空数组'); console.log('已创建新的agents.json文件内容为空数组')

View File

@ -65,6 +65,7 @@ nsis:
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
oneClick: false oneClick: false
include: build/nsis-installer.nsh include: build/nsis-installer.nsh
buildUniversalInstaller: false
portable: portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext} artifactName: ${productName}-${version}-${arch}-portable.${ext}
mac: mac:
@ -101,6 +102,7 @@ electronDownload:
mirror: https://npmmirror.com/mirrors/electron/ mirror: https://npmmirror.com/mirrors/electron/
afterPack: scripts/after-pack.js afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
全新图标风格 全新图标风格

View File

@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.2.4", "version": "1.2.5-bate",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@ -23,7 +23,7 @@
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"build:check": "yarn test && yarn typecheck && yarn check:i18n", "build:check": "yarn test && yarn typecheck && yarn check:i18n",
"build:unpack": "dotenv npm run build && electron-builder --dir", "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:x64": "dotenv npm run build && electron-builder --win --x64",
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64", "build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
"build:mac": "dotenv electron-vite build && electron-builder --mac", "build:mac": "dotenv electron-vite build && electron-builder --mac",
@ -119,7 +119,6 @@
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"got-scraping": "^4.1.1", "got-scraping": "^4.1.1",
"js-tiktoken": "^1.0.19", "js-tiktoken": "^1.0.19",
"js-yaml": "^4.1.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
@ -154,7 +153,7 @@
"@google/genai": "^0.8.0", "@google/genai": "^0.8.0",
"@hello-pangea/dnd": "^16.6.0", "@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0", "@kangfenmao/keyv-storage": "^0.1.0",
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.10.1",
"@notionhq/client": "^2.2.15", "@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", "@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/d3": "^7",
"@types/diff": "^7", "@types/diff": "^7",
"@types/fs-extra": "^11", "@types/fs-extra": "^11",
"@types/js-yaml": "^4",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/markdown-it": "^14", "@types/markdown-it": "^14",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.5",
@ -223,6 +221,7 @@
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.0.0", "rehype-mathjax": "^7.0.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-cjk-friendly": "^1.1.0", "remark-cjk-friendly": "^1.1.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",

View File

@ -139,6 +139,7 @@ export enum IpcChannel {
// system // system
System_GetDeviceType = 'system:getDeviceType', System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
// events // events
SelectionAction = 'selection-action', SelectionAction = 'selection-action',

View 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 }
}
}

View File

@ -63,6 +63,10 @@ if (!app.requestSingleInstanceLock()) {
ipcMain.handle(IpcChannel.System_GetDeviceType, () => { ipcMain.handle(IpcChannel.System_GetDeviceType, () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux' return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
}) })
ipcMain.handle(IpcChannel.System_GetHostname, () => {
return require('os').hostname()
})
}) })
registerProtocolClient(app) registerProtocolClient(app)

View File

@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import AxiosProxy from '@main/services/AxiosProxy'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker' import BaseReranker from './BaseReranker'
@ -20,7 +20,7 @@ export default class JinaReranker extends BaseReranker {
} }
try { 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 const rerankResults = data.results
return this.getRerankResult(searchResults, rerankResults) return this.getRerankResult(searchResults, rerankResults)

View File

@ -1,6 +1,6 @@
import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import axiosProxy from '@main/services/AxiosProxy'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker' import BaseReranker from './BaseReranker'
@ -22,7 +22,7 @@ export default class SiliconFlowReranker extends BaseReranker {
} }
try { 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 const rerankResults = data.results
return this.getRerankResult(searchResults, rerankResults) return this.getRerankResult(searchResults, rerankResults)

View File

@ -1,6 +1,6 @@
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import axiosProxy from '@main/services/AxiosProxy'
import { KnowledgeBaseParams } from '@types' import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker' import BaseReranker from './BaseReranker'
@ -22,7 +22,7 @@ export default class VoyageReranker extends BaseReranker {
} }
try { try {
const { data } = await axios.post(url, requestBody, { const { data } = await axiosProxy.axios.post(url, requestBody, {
headers: { headers: {
...this.defaultHeaders() ...this.defaultHeaders()
} }

View File

@ -1,12 +1,13 @@
import http from 'node:http'
import net from 'node:net'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import http from 'node:http'
import net from 'node:net'
import path from 'node:path' import path from 'node:path'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { app, ipcMain } from 'electron' import { app, ipcMain } from 'electron'
import log from 'electron-log' import log from 'electron-log'
import { getResourcePath } from '../utils' import { getResourcePath } from '../utils'
/** /**
@ -14,19 +15,19 @@ import { getResourcePath } from '../utils'
*/ */
export class ASRServerService { export class ASRServerService {
// HTML内容 // HTML内容
private INDEX_HTML_CONTENT: string = ''; private INDEX_HTML_CONTENT: string = ''
// 服务器相关属性 // 服务器相关属性
private httpServer: http.Server | null = null; private httpServer: http.Server | null = null
private wsClients: { browser: any | null; electron: any | null } = { browser: null, electron: null }; private wsClients: { browser: any | null; electron: any | null } = { browser: null, electron: null }
private serverPort: number = 34515; // 默认端口 private serverPort: number = 34515 // 默认端口
private isServerRunning: boolean = false; private isServerRunning: boolean = false
/** /**
* *
*/ */
constructor() { constructor() {
this.loadIndexHtml(); this.loadIndexHtml()
} }
/** /**
@ -35,24 +36,24 @@ export class ASRServerService {
private loadIndexHtml(): void { private loadIndexHtml(): void {
try { try {
// 在开发环境和生产环境中使用不同的路径 // 在开发环境和生产环境中使用不同的路径
let htmlPath = ''; let htmlPath = ''
if (app.isPackaged) { if (app.isPackaged) {
// 生产环境 // 生产环境
const resourcePath = getResourcePath(); const resourcePath = getResourcePath()
htmlPath = path.join(resourcePath, 'app', 'asr-server', 'index.html'); htmlPath = path.join(resourcePath, 'app', 'asr-server', 'index.html')
} else { } 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)) { if (fs.existsSync(htmlPath)) {
this.INDEX_HTML_CONTENT = fs.readFileSync(htmlPath, 'utf8'); this.INDEX_HTML_CONTENT = fs.readFileSync(htmlPath, 'utf8')
log.info(`成功加载index.html文件`); log.info(`成功加载index.html文件`)
} else { } else {
log.error(`index.html文件不存在: ${htmlPath}`); log.error(`index.html文件不存在: ${htmlPath}`)
// 使用默认的HTML内容 // 使用默认的HTML内容
this.INDEX_HTML_CONTENT = `<!DOCTYPE html> this.INDEX_HTML_CONTENT = `<!DOCTYPE html>
<html> <html>
@ -63,10 +64,10 @@ export class ASRServerService {
<h1>Error: index.html file not found</h1> <h1>Error: index.html file not found</h1>
<p>Please make sure the ASR server files are properly installed.</p> <p>Please make sure the ASR server files are properly installed.</p>
</body> </body>
</html>`; </html>`
} }
} catch (error) { } catch (error) {
log.error(`加载index.html文件时出错:`, error); log.error(`加载index.html文件时出错:`, error)
// 使用默认的HTML内容 // 使用默认的HTML内容
this.INDEX_HTML_CONTENT = `<!DOCTYPE html> this.INDEX_HTML_CONTENT = `<!DOCTYPE html>
<html> <html>
@ -77,7 +78,7 @@ export class ASRServerService {
<h1>Error loading index.html</h1> <h1>Error loading index.html</h1>
<p>An error occurred while loading the ASR server files.</p> <p>An error occurred while loading the ASR server files.</p>
</body> </body>
</html>`; </html>`
} }
} }
@ -86,10 +87,10 @@ export class ASRServerService {
*/ */
public registerIpcHandlers(): void { public registerIpcHandlers(): void {
// 启动ASR服务器 // 启动ASR服务器
ipcMain.handle(IpcChannel.Asr_StartServer, this.startServer.bind(this)); ipcMain.handle(IpcChannel.Asr_StartServer, this.startServer.bind(this))
// 停止ASR服务器 // 停止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> { private isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const testServer = net.createServer(); const testServer = net.createServer()
testServer.once('error', (err: any) => { testServer.once('error', (err: any) => {
if (err.code === 'EADDRINUSE') { if (err.code === 'EADDRINUSE') {
log.info(`端口 ${port} 已被占用,尝试其他端口...`); log.info(`端口 ${port} 已被占用,尝试其他端口...`)
resolve(false); resolve(false)
} else { } else {
log.error(`检查端口 ${port} 时出错:`, err); log.error(`检查端口 ${port} 时出错:`, err)
resolve(false); resolve(false)
} }
}); })
testServer.once('listening', () => { testServer.once('listening', () => {
testServer.close(); testServer.close()
resolve(true); resolve(true)
}); })
testServer.listen(port); testServer.listen(port)
}); })
} }
/** /**
@ -123,17 +124,17 @@ export class ASRServerService {
* @returns * @returns
*/ */
private async findAvailablePort(startPort: number): Promise<number> { private async findAvailablePort(startPort: number): Promise<number> {
let port = startPort; let port = startPort
const maxPort = startPort + 10; // 尝试最多10个端口 const maxPort = startPort + 10 // 尝试最多10个端口
while (port < maxPort) { while (port < maxPort) {
if (await this.isPortAvailable(port)) { 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 { private handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
// 只处理根路径请求返回index.html // 只处理根路径请求返回index.html
if (req.url === '/' || req.url === '/index.html') { if (req.url === '/' || req.url === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(this.INDEX_HTML_CONTENT); res.end(this.INDEX_HTML_CONTENT)
log.info(`返回index.html到客户端`); log.info(`返回index.html到客户端`)
} else { } else {
// 其他路径返回404 // 其他路径返回404
res.writeHead(404, { 'Content-Type': 'text/plain' }); res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found'); res.end('Not Found')
log.info(`请求的路径不存在: ${req.url}`); log.info(`请求的路径不存在: ${req.url}`)
} }
} }
/** /**
* ASR服务器 * ASR服务器
* @returns Promise<{success: boolean, pid?: number, port?: number, error?: string}> * @returns Promise<{success: boolean, pid?: number, port?: number, error?: string}>
@ -164,63 +164,61 @@ export class ASRServerService {
try { try {
// 如果服务器已经运行,直接返回成功 // 如果服务器已经运行,直接返回成功
if (this.isServerRunning && this.httpServer) { if (this.isServerRunning && this.httpServer) {
return { success: true, port: this.serverPort }; return { success: true, port: this.serverPort }
} }
// 尝试找到可用的端口 // 尝试找到可用的端口
try { try {
this.serverPort = await this.findAvailablePort(this.serverPort); this.serverPort = await this.findAvailablePort(this.serverPort)
} catch (error) { } catch (error) {
log.error('找不到可用的端口:', error); log.error('找不到可用的端口:', error)
return { success: false, error: '找不到可用的端口' }; return { success: false, error: '找不到可用的端口' }
} }
log.info(`使用端口: ${this.serverPort}`); log.info(`使用端口: ${this.serverPort}`)
// 创建HTTP服务器 // 创建HTTP服务器
this.httpServer = http.createServer(this.handleHttpRequest.bind(this)); this.httpServer = http.createServer(this.handleHttpRequest.bind(this))
// 启动HTTP服务器 // 启动HTTP服务器
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
if (!this.httpServer) { if (!this.httpServer) {
reject(new Error('HTTP服务器创建失败')); reject(new Error('HTTP服务器创建失败'))
return; return
} }
this.httpServer.on('error', (err) => { this.httpServer.on('error', (err) => {
log.error(`HTTP服务器错误:`, err); log.error(`HTTP服务器错误:`, err)
reject(err); reject(err)
}); })
this.httpServer.listen(this.serverPort, () => { this.httpServer.listen(this.serverPort, () => {
log.info(`HTTP服务器已启动监听端口: ${this.serverPort}`); log.info(`HTTP服务器已启动监听端口: ${this.serverPort}`)
resolve(); resolve()
}); })
}); })
// 设置WebSocket处理 // 设置WebSocket处理
this.setupWebSocketServer(); this.setupWebSocketServer()
// 标记服务器已启动 // 标记服务器已启动
this.isServerRunning = true; this.isServerRunning = true
log.info(`ASR服务器启动成功端口: ${this.serverPort}`); log.info(`ASR服务器启动成功端口: ${this.serverPort}`)
return { success: true, port: this.serverPort }; return { success: true, port: this.serverPort }
} catch (error) { } catch (error) {
log.error('启动HTTP服务器失败:', error); log.error('启动HTTP服务器失败:', error)
// 关闭HTTP服务器 // 关闭HTTP服务器
if (this.httpServer) { if (this.httpServer) {
this.httpServer.close(); this.httpServer.close()
this.httpServer = null; this.httpServer = null
} }
return { success: false, error: `启动HTTP服务器失败: ${(error as Error).message}` }; return { success: false, error: `启动HTTP服务器失败: ${(error as Error).message}` }
} }
} catch (error) { } catch (error) {
log.error('启动ASR服务器失败:', error); log.error('启动ASR服务器失败:', error)
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message }
} }
} }
@ -229,40 +227,40 @@ export class ASRServerService {
*/ */
private setupWebSocketServer(): void { private setupWebSocketServer(): void {
if (!this.httpServer) { if (!this.httpServer) {
log.error('HTTP服务器不存在无法设置WebSocket'); log.error('HTTP服务器不存在无法设置WebSocket')
return; return
} }
// 处理WebSocket连接升级 // 处理WebSocket连接升级
this.httpServer.on('upgrade', (request, socket, _head) => { this.httpServer.on('upgrade', (request, socket) => {
try { try {
log.info('[WebSocket] 收到连接升级请求'); log.info('[WebSocket] 收到连接升级请求')
// 解析WebSocket密钥 // 解析WebSocket密钥
const key = request.headers['sec-websocket-key'] as string; const key = request.headers['sec-websocket-key'] as string
const acceptKey = crypto const acceptKey = crypto
.createHash('sha1') .createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary')
.digest('base64'); .digest('base64')
// 发送WebSocket握手响应 // 发送WebSocket握手响应
socket.write( socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' + 'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' + 'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' + 'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` + `Sec-WebSocket-Accept: ${acceptKey}\r\n` +
'\r\n' '\r\n'
); )
log.info('[WebSocket] 握手成功'); log.info('[WebSocket] 握手成功')
// 处理WebSocket数据 // 处理WebSocket数据
this.handleWebSocketConnection(socket); this.handleWebSocketConnection(socket)
} catch (error) { } catch (error) {
log.error('[WebSocket] 处理升级错误:', error); log.error('[WebSocket] 处理升级错误:', error)
socket.destroy(); socket.destroy()
} }
}); })
} }
/** /**
@ -270,106 +268,107 @@ export class ASRServerService {
* @param socket * @param socket
*/ */
private handleWebSocketConnection(socket: any): void { private handleWebSocketConnection(socket: any): void {
let buffer = Buffer.alloc(0); let buffer = Buffer.alloc(0)
let role: 'browser' | 'electron' | null = null; const role: 'browser' | 'electron' | null = null
socket.on('data', (data: Buffer) => { socket.on('data', (data: Buffer) => {
try { try {
buffer = Buffer.concat([buffer, data]); buffer = Buffer.concat([buffer, data])
// 处理数据帧 // 处理数据帧
while (buffer.length > 2) { while (buffer.length > 2) {
// 检查是否有完整的帧 // 检查是否有完整的帧
const firstByte = buffer[0]; const firstByte = buffer[0]
const secondByte = buffer[1]; const secondByte = buffer[1]
// const isFinalFrame = Boolean((firstByte >>> 7) & 0x1); // 暂时不使用 // const isFinalFrame = Boolean((firstByte >>> 7) & 0x1); // 暂时不使用
const [opCode, maskFlag, payloadLength] = [ const [opCode, maskFlag, payloadLength] = [firstByte & 0xf, (secondByte >>> 7) & 0x1, secondByte & 0x7f]
firstByte & 0xF, (secondByte >>> 7) & 0x1, secondByte & 0x7F
];
// 处理不同的负载长度 // 处理不同的负载长度
let payloadStartIndex = 2; let payloadStartIndex = 2
let payloadLen = payloadLength; let payloadLen = payloadLength
if (payloadLength === 126) { if (payloadLength === 126) {
payloadLen = buffer.readUInt16BE(2); payloadLen = buffer.readUInt16BE(2)
payloadStartIndex = 4; payloadStartIndex = 4
} else if (payloadLength === 127) { } else if (payloadLength === 127) {
// 处理大于16位的长度 // 处理大于16位的长度
payloadLen = Number(buffer.readBigUInt64BE(2)); payloadLen = Number(buffer.readBigUInt64BE(2))
payloadStartIndex = 10; payloadStartIndex = 10
} }
// 处理掩码 // 处理掩码
let maskingKey: Buffer | undefined; let maskingKey: Buffer | undefined
if (maskFlag) { if (maskFlag) {
maskingKey = buffer.slice(payloadStartIndex, payloadStartIndex + 4); maskingKey = buffer.slice(payloadStartIndex, payloadStartIndex + 4)
payloadStartIndex += 4; payloadStartIndex += 4
} }
// 检查是否有足够的数据 // 检查是否有足够的数据
const frameEnd = payloadStartIndex + payloadLen; const frameEnd = payloadStartIndex + payloadLen
if (buffer.length < frameEnd) { if (buffer.length < frameEnd) {
// 需要更多数据 // 需要更多数据
break; break
} }
// 提取负载 // 提取负载
let payload = buffer.slice(payloadStartIndex, frameEnd); const payload = buffer.slice(payloadStartIndex, frameEnd)
// 如果有掩码,解码负载 // 如果有掩码,解码负载
if (maskFlag && maskingKey) { if (maskFlag && maskingKey) {
for (let i = 0; i < payload.length; i++) { 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) { if (opCode === 0x8) {
// 关闭帧 // 关闭帧
log.info('[WebSocket] 收到关闭帧'); log.info('[WebSocket] 收到关闭帧')
socket.end(); socket.end()
return; return
} else if (opCode === 0x9) { } else if (opCode === 0x9) {
// Ping // Ping
this.sendPong(socket); this.sendPong(socket)
} else if (opCode === 0x1 || opCode === 0x2) { } else if (opCode === 0x1 || opCode === 0x2) {
// 文本或二进制数据 // 文本或二进制数据
const message = opCode === 0x1 ? payload.toString('utf8') : payload; const message = opCode === 0x1 ? payload.toString('utf8') : payload
this.handleMessage(socket, message, role); this.handleMessage(socket, message, role)
} }
// 移除已处理的帧 // 移除已处理的帧
buffer = buffer.slice(frameEnd); buffer = buffer.slice(frameEnd)
} }
} catch (error) { } catch (error) {
log.error('[WebSocket] 处理数据错误:', error); log.error('[WebSocket] 处理数据错误:', error)
} }
}); })
socket.on('close', () => { socket.on('close', () => {
const socketRole = (socket as any)._role || role; const socketRole = (socket as any)._role || role
log.info(`[WebSocket] 连接关闭${socketRole ? ` (${socketRole})` : ''}`); log.info(`[WebSocket] 连接关闭${socketRole ? ` (${socketRole})` : ''}`)
if (socketRole === 'browser') { if (socketRole === 'browser') {
this.wsClients.browser = null; this.wsClients.browser = null
// 如果浏览器断开连接通知Electron客户端 // 如果浏览器断开连接通知Electron客户端
if (this.wsClients.electron) { if (this.wsClients.electron) {
this.sendWebSocketFrame(this.wsClients.electron, JSON.stringify({ this.sendWebSocketFrame(
type: 'status', this.wsClients.electron,
message: 'Browser disconnected' JSON.stringify({
})); type: 'status',
log.info('[WebSocket] 已向Electron发送Browser disconnected状态'); message: 'Browser disconnected'
})
)
log.info('[WebSocket] 已向Electron发送Browser disconnected状态')
} }
} else if (socketRole === 'electron') { } else if (socketRole === 'electron') {
this.wsClients.electron = null; this.wsClients.electron = null
} }
}); })
socket.on('error', (error: Error) => { 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 { private sendWebSocketFrame(socket: any, data: string | object, opCode = 0x1): void {
try { try {
const payload = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data)); const payload = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data))
const payloadLength = payload.length; const payloadLength = payload.length
let header: Buffer; let header: Buffer
if (payloadLength < 126) { if (payloadLength < 126) {
header = Buffer.from([0x80 | opCode, payloadLength]); header = Buffer.from([0x80 | opCode, payloadLength])
} else if (payloadLength < 65536) { } else if (payloadLength < 65536) {
header = Buffer.alloc(4); header = Buffer.alloc(4)
header[0] = 0x80 | opCode; header[0] = 0x80 | opCode
header[1] = 126; header[1] = 126
header.writeUInt16BE(payloadLength, 2); header.writeUInt16BE(payloadLength, 2)
} else { } else {
header = Buffer.alloc(10); header = Buffer.alloc(10)
header[0] = 0x80 | opCode; header[0] = 0x80 | opCode
header[1] = 127; header[1] = 127
header.writeBigUInt64BE(BigInt(payloadLength), 2); header.writeBigUInt64BE(BigInt(payloadLength), 2)
} }
socket.write(Buffer.concat([header, payload])); socket.write(Buffer.concat([header, payload]))
} catch (error) { } catch (error) {
log.error('[WebSocket] 发送数据错误:', error); log.error('[WebSocket] 发送数据错误:', error)
} }
} }
@ -409,8 +408,8 @@ export class ASRServerService {
* @param socket * @param socket
*/ */
private sendPong(socket: any): void { private sendPong(socket: any): void {
const pongFrame = Buffer.from([0x8A, 0x00]); const pongFrame = Buffer.from([0x8a, 0x00])
socket.write(pongFrame); socket.write(pongFrame)
} }
/** /**
@ -422,65 +421,71 @@ export class ASRServerService {
private handleMessage(socket: any, message: string | Buffer, currentRole: string | null): void { private handleMessage(socket: any, message: string | Buffer, currentRole: string | null): void {
try { try {
if (typeof message === 'string') { if (typeof message === 'string') {
const data = JSON.parse(message); const data = JSON.parse(message)
// 处理身份识别 // 处理身份识别
if (data.type === 'identify') { if (data.type === 'identify') {
const role = data.role; const role = data.role
if (role === 'browser' || role === 'electron') { 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客户端 // 如果是浏览器连接通知Electron客户端
if (role === 'browser' && this.wsClients.electron) { if (role === 'browser' && this.wsClients.electron) {
// 发送browser_ready消息 // 发送browser_ready消息
this.sendWebSocketFrame(this.wsClients.electron, JSON.stringify({ this.sendWebSocketFrame(
type: 'status', this.wsClients.electron,
message: 'browser_ready' JSON.stringify({
})); type: 'status',
log.info('[WebSocket] 已向Electron发送browser_ready状态'); message: 'browser_ready'
})
)
log.info('[WebSocket] 已向Electron发送browser_ready状态')
// 发送Browser connected消息 // 发送Browser connected消息
this.sendWebSocketFrame(this.wsClients.electron, JSON.stringify({ this.sendWebSocketFrame(
type: 'status', this.wsClients.electron,
message: 'Browser connected' JSON.stringify({
})); type: 'status',
log.info('[WebSocket] 已向Electron发送Browser connected状态'); 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') { if (role === 'browser') {
// 浏览器发送的消息转发给Electron // 浏览器发送的消息转发给Electron
if (this.wsClients.electron) { if (this.wsClients.electron) {
log.info(`[WebSocket] Browser -> Electron: ${JSON.stringify(data)}`); log.info(`[WebSocket] Browser -> Electron: ${JSON.stringify(data)}`)
this.sendWebSocketFrame(this.wsClients.electron, message); this.sendWebSocketFrame(this.wsClients.electron, message)
} else { } else {
log.info('[WebSocket] 无法转发消息: Electron客户端未连接'); log.info('[WebSocket] 无法转发消息: Electron客户端未连接')
} }
} else if (role === 'electron') { } else if (role === 'electron') {
// Electron发送的消息转发给浏览器 // Electron发送的消息转发给浏览器
if (this.wsClients.browser) { if (this.wsClients.browser) {
log.info(`[WebSocket] Electron -> Browser: ${JSON.stringify(data)}`); log.info(`[WebSocket] Electron -> Browser: ${JSON.stringify(data)}`)
this.sendWebSocketFrame(this.wsClients.browser, message); this.sendWebSocketFrame(this.wsClients.browser, message)
} else { } else {
log.info('[WebSocket] 无法转发消息: 浏览器客户端未连接'); log.info('[WebSocket] 无法转发消息: 浏览器客户端未连接')
} }
} else { } else {
log.info(`[WebSocket] 收到来自未知角色的消息: ${message}`); log.info(`[WebSocket] 收到来自未知角色的消息: ${message}`)
} }
} }
} catch (error) { } catch (error) {
log.error('[WebSocket] 处理消息错误:', error, message); log.error('[WebSocket] 处理消息错误:', error, message)
} }
} }
@ -490,31 +495,28 @@ export class ASRServerService {
* @param pid ID * @param pid ID
* @returns Promise<{success: boolean, error?: string}> * @returns Promise<{success: boolean, error?: string}>
*/ */
private async stopServer( private async stopServer(): Promise<{ success: boolean; error?: string }> {
_event: Electron.IpcMainInvokeEvent,
_pid?: number
): Promise<{ success: boolean; error?: string }> {
try { try {
// 关闭HTTP服务器 // 关闭HTTP服务器
if (this.httpServer) { if (this.httpServer) {
this.httpServer.close(); this.httpServer.close()
this.httpServer = null; this.httpServer = null
} }
// 重置客户端连接 // 重置客户端连接
this.wsClients = { browser: null, electron: null }; this.wsClients = { browser: null, electron: null }
// 重置服务器状态 // 重置服务器状态
this.isServerRunning = false; this.isServerRunning = false
log.info('ASR服务器已停止'); log.info('ASR服务器已停止')
return { success: true }; return { success: true }
} catch (error) { } catch (error) {
log.error('停止ASR服务器失败:', error); log.error('停止ASR服务器失败:', error)
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message }
} }
} }
} }
// 创建并导出单例 // 创建并导出单例
export const asrServerService = new ASRServerService(); export const asrServerService = new ASRServerService()

View 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()

View File

@ -1,13 +1,12 @@
import { spawn } from 'child_process' import { spawn } from 'child_process'
// 如果将来需要使用这些工具函数,可以取消注释
// import { getBinaryPath, isBinaryExists } from '@main/utils/process'
import log from 'electron-log'
import fs from 'fs' import fs from 'fs'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
// 如果将来需要使用这些工具函数,可以取消注释
// import { getBinaryPath, isBinaryExists } from '@main/utils/process'
import log from 'electron-log'
// 支持的语言类型 // 支持的语言类型
export enum CodeLanguage { export enum CodeLanguage {
JavaScript = 'javascript', JavaScript = 'javascript',

View File

@ -1,8 +1,10 @@
import axios, { AxiosRequestConfig } from 'axios' import { AxiosRequestConfig } from 'axios'
import { app, safeStorage } from 'electron' import { app, safeStorage } from 'electron'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import aoxisProxy from './AxiosProxy'
// 配置常量,集中管理 // 配置常量,集中管理
const CONFIG = { const CONFIG = {
GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98', 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 { return {
login: response.data.login, login: response.data.login,
avatar: response.data.avatar_url avatar: response.data.avatar_url
@ -114,7 +116,7 @@ class CopilotService {
try { try {
this.updateHeaders(headers) this.updateHeaders(headers)
const response = await axios.post<AuthResponse>( const response = await aoxisProxy.axios.post<AuthResponse>(
CONFIG.API_URLS.GITHUB_DEVICE_CODE, CONFIG.API_URLS.GITHUB_DEVICE_CODE,
{ {
client_id: CONFIG.GITHUB_CLIENT_ID, client_id: CONFIG.GITHUB_CLIENT_ID,
@ -146,7 +148,7 @@ class CopilotService {
await this.delay(currentDelay) await this.delay(currentDelay)
try { try {
const response = await axios.post<TokenResponse>( const response = await aoxisProxy.axios.post<TokenResponse>(
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN, CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
{ {
client_id: CONFIG.GITHUB_CLIENT_ID, 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 return response.data
} catch (error) { } catch (error) {

View File

@ -1,3 +1,4 @@
import crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
@ -22,9 +23,12 @@ import {
} from '@types' } from '@types'
import { app } from 'electron' import { app } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
import { EventEmitter } from 'events'
import { memoize } from 'lodash' import { memoize } from 'lodash'
import { CacheService } from './CacheService' import { CacheService } from './CacheService'
import { CallBackServer } from './mcp/oauth/callback'
import { McpOAuthClientProvider } from './mcp/oauth/provider'
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient' import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
// Generic type for caching wrapped functions // Generic type for caching wrapped functions
@ -117,103 +121,172 @@ class McpService {
const args = [...(server.args || [])] const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport const authProvider = new McpOAuthClientProvider({
serverUrlHash: crypto
.createHash('md5')
.update(server.baseUrl || '')
.digest('hex')
})
try { try {
// Create appropriate transport based on configuration const initTransport = async (): Promise<
if (server.type === 'inMemory') { StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`) > => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() // Create appropriate transport based on configuration
// start the in-memory server with the given name and environment variables if (server.type === 'inMemory') {
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {}) Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
try { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await inMemoryServer.connect(serverTransport) // start the in-memory server with the given name and environment variables
Logger.info(`[MCP] In-memory server started: ${server.name}`) const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
} catch (error: Error | any) { try {
Logger.error(`[MCP] Error starting in-memory server: ${error}`) await inMemoryServer.connect(serverTransport)
throw new Error(`Failed to start in-memory server: ${error.message}`) Logger.info(`[MCP] In-memory server started: ${server.name}`)
} } catch (error: Error | any) {
// set the client transport to the client Logger.error(`[MCP] Error starting in-memory server: ${error}`)
transport = clientTransport throw new Error(`Failed to start in-memory server: ${error.message}`)
} else if (server.baseUrl) { }
if (server.type === 'streamableHttp') { // return the client transport
const options: StreamableHTTPClientTransportOptions = { return clientTransport
requestInit: { } else if (server.baseUrl) {
headers: server.headers || {} 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') { Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
const options: SSEClientTransportOptions = { // Logger.info(`[MCP] Environment variables for server:`, server.env)
requestInit: {
headers: server.headers || {} const stdioTransport = new StdioClientTransport({
} command: cmd,
} args,
transport = new SSEClientTransport(new URL(server.baseUrl!), options) 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 { } else {
throw new Error('Invalid server type') throw new Error('Either baseUrl or command must be provided')
} }
} else if (server.command) { // This line is unreachable
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')
} }
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 // Store the new client in the cache
this.clients.set(serverKey, client) this.clients.set(serverKey, client)
@ -514,15 +587,12 @@ class McpService {
// 根据不同的 shell 构建不同的命令 // 根据不同的 shell 构建不同的命令
if (userShell.includes('zsh')) { if (userShell.includes('zsh')) {
shell = '/bin/zsh'
command = 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' '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')) { } else if (userShell.includes('bash')) {
shell = '/bin/bash'
command = 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' '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')) { } else if (userShell.includes('fish')) {
shell = '/bin/fish'
command = 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' '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 { } else {
@ -540,15 +610,15 @@ class McpService {
}) })
let path = '' let path = ''
child.stdout.on('data', (data) => { child.stdout.on('data', (data: Buffer) => {
path += data.toString() path += data.toString()
}) })
child.stderr.on('data', (data) => { child.stderr.on('data', (data: Buffer) => {
console.error('Error getting PATH:', data.toString()) console.error('Error getting PATH:', data.toString())
}) })
child.on('close', (code) => { child.on('close', (code: number) => {
if (code === 0) { if (code === 0) {
const trimmedPath = path.trim() const trimmedPath = path.trim()
resolve(trimmedPath) resolve(trimmedPath)

View File

@ -405,6 +405,7 @@ export class WindowService {
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
//make miniWindow always on top of fullscreen apps with level set //make miniWindow always on top of fullscreen apps with level set
//[mac] level higher than 'floating' will cover the pinyin input method //[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.setAlwaysOnTop(true, 'floating')
this.miniWindow.on('ready-to-show', () => { this.miniWindow.on('ready-to-show', () => {

View 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)
})
})
}
}

View 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()
}
}

View 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)}`)
}
}
}
}

View 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
}

View File

@ -33,6 +33,7 @@ declare global {
clearCache: () => Promise<{ success: boolean; error?: string }> clearCache: () => Promise<{ success: boolean; error?: string }>
system: { system: {
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'> getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
getHostname: () => Promise<string>
} }
zip: { zip: {
compress: (text: string) => Promise<Buffer> compress: (text: string) => Promise<Buffer>
@ -207,11 +208,11 @@ declare global {
deleteShortMemoryById: (id: string) => Promise<boolean> deleteShortMemoryById: (id: string) => Promise<boolean>
loadLongTermData: () => Promise<any> loadLongTermData: () => Promise<any>
saveLongTermData: (data: any, forceOverwrite?: boolean) => Promise<boolean> saveLongTermData: (data: any, forceOverwrite?: boolean) => Promise<boolean>
}, }
asrServer: { asrServer: {
startServer: () => Promise<{ success: boolean; pid?: number; port?: number; error?: string }> startServer: () => Promise<{ success: boolean; pid?: number; port?: number; error?: string }>
stopServer: (pid: number) => Promise<{ success: boolean; error?: string }> stopServer: (pid: number) => Promise<{ success: boolean; error?: string }>
}, }
pdf: { pdf: {
splitPDF: (file: FileType, pageRange: string) => Promise<FileType> splitPDF: (file: FileType, pageRange: string) => Promise<FileType>
} }

View File

@ -22,7 +22,8 @@ const api = {
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
system: { system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType) getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)
}, },
zip: { zip: {
compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text), compress: (text: string) => ipcRenderer.invoke(IpcChannel.Zip_Compress, text),

View 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==

View File

@ -260,6 +260,7 @@ body,
.markdown, .markdown,
.anticon, .anticon,
.iconfont, .iconfont,
.lucide,
.message-tokens { .message-tokens {
color: var(--chat-text-user) !important; color: var(--chat-text-user) !important;
} }

View File

@ -1,11 +1,11 @@
import { DeleteOutlined } from '@ant-design/icons'
import { addAssistantMemoryItem } from '@renderer/services/MemoryService' import { addAssistantMemoryItem } from '@renderer/services/MemoryService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { deleteAssistantMemory } from '@renderer/store/memory' import { deleteAssistantMemory } from '@renderer/store/memory'
import { Button, Empty, Input, List, Modal, Tooltip, Typography } from 'antd' import { Button, Empty, Input, List, Modal, Tooltip, Typography } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Provider } from 'react-redux'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Provider } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
const { Text } = Typography const { Text } = Typography

View File

@ -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 { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
export interface ExecutionResultProps { export interface ExecutionResultProps {
success: boolean success: boolean

View File

@ -1,5 +1,5 @@
import { openSearchPanel, search } from '@codemirror/search'
import { Extension } from '@codemirror/state' import { Extension } from '@codemirror/state'
import { search, openSearchPanel } from '@codemirror/search'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
// 创建中文搜索面板 // 创建中文搜索面板

View File

@ -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 './styles.css'
import './ChineseSearchPanel.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([ const lightThemeHighlightStyle = HighlightStyle.define([
@ -54,7 +53,7 @@ const lightThemeHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, color: '#800000', fontWeight: 'bold' }, { tag: tags.heading, color: '#800000', fontWeight: 'bold' },
{ tag: tags.link, color: '#0000ff', textDecoration: 'underline' }, { tag: tags.link, color: '#0000ff', textDecoration: 'underline' },
{ tag: tags.emphasis, fontStyle: 'italic' }, { 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.heading, color: '#569cd6', fontWeight: 'bold' },
{ tag: tags.link, color: '#569cd6', textDecoration: 'underline' }, { tag: tags.link, color: '#569cd6', textDecoration: 'underline' },
{ tag: tags.emphasis, fontStyle: 'italic' }, { tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' }, { tag: tags.strong, fontWeight: 'bold' }
]) ])
export interface CodeMirrorEditorRef { export interface CodeMirrorEditorRef {
@ -149,18 +148,16 @@ const getLanguageExtension = (language: string) => {
} }
} }
const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(( const CodeMirrorEditor = ({
{ ref,
code, code,
language, language,
onChange, onChange,
readOnly = false, readOnly = false,
showLineNumbers = true, showLineNumbers = true,
fontSize = 14, fontSize = 14,
height = 'auto' height = 'auto'
}, }: CodeMirrorEditorProps & { ref?: React.RefObject<CodeMirrorEditorRef | null> }) => {
ref
) => {
const editorRef = useRef<HTMLDivElement>(null) const editorRef = useRef<HTMLDivElement>(null)
const editorViewRef = useRef<EditorView | null>(null) const editorViewRef = useRef<EditorView | null>(null)
const { theme } = useTheme() const { theme } = useTheme()
@ -223,13 +220,11 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
const languageExtension = getLanguageExtension(language) const languageExtension = getLanguageExtension(language)
// 监听编辑器所有更新 // 监听编辑器所有更新
const updateListener = EditorView.updateListener.of(update => { const updateListener = EditorView.updateListener.of((update) => {
// 当文档变化时更新内部状态 // 当文档变化时更新内部状态
if (update.docChanged) { if (update.docChanged) {
// 检查是否是撤销/重做操作 // 检查是否是撤销/重做操作
const isUndoRedo = update.transactions.some(tr => const isUndoRedo = update.transactions.some((tr) => tr.isUserEvent('undo') || tr.isUserEvent('redo'))
tr.isUserEvent('undo') || tr.isUserEvent('redo')
)
// 记录所有文档变化,但只在撤销/重做时触发 onChange // 记录所有文档变化,但只在撤销/重做时触发 onChange
if (isUndoRedo && onChange) { if (isUndoRedo && onChange) {
@ -247,9 +242,9 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
...historyKeymap, ...historyKeymap,
...searchKeymap, ...searchKeymap,
indentWithTab, indentWithTab,
{ key: "Mod-z", run: undo }, { key: 'Mod-z', run: undo },
{ key: "Mod-y", run: redo }, { key: 'Mod-y', run: redo },
{ key: "Mod-Shift-z", run: redo } { key: 'Mod-Shift-z', run: redo }
]), ]),
syntaxHighlighting(highlightStyle), syntaxHighlighting(highlightStyle),
languageExtension, languageExtension,
@ -298,7 +293,7 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
}, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height]) }, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height])
return <EditorContainer ref={editorRef} /> return <EditorContainer ref={editorRef} />
}); }
const EditorContainer = styled.div` const EditorContainer = styled.div`
width: 100%; width: 100%;

View File

@ -1,8 +1,11 @@
import { useEffect } from 'react'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addProvider, removeProvider } from '@renderer/store/llm' import { addProvider, removeProvider } from '@renderer/store/llm'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { createAllDeepClaudeProviders, checkModelCombinationsInLocalStorage } from '@renderer/utils/createDeepClaudeProvider' import {
checkModelCombinationsInLocalStorage,
createAllDeepClaudeProviders
} from '@renderer/utils/createDeepClaudeProvider'
import { useEffect } from 'react'
/** /**
* DeepClaudeProvider组件 * DeepClaudeProvider组件
@ -10,7 +13,7 @@ import { createAllDeepClaudeProviders, checkModelCombinationsInLocalStorage } fr
*/ */
const DeepClaudeProvider = () => { const DeepClaudeProvider = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const providers = useAppSelector(state => state.llm.providers) const providers = useAppSelector((state) => state.llm.providers)
// 监听localStorage中的modelCombinations变化 // 监听localStorage中的modelCombinations变化
useEffect(() => { useEffect(() => {
@ -41,9 +44,9 @@ const DeepClaudeProvider = () => {
checkModelCombinationsInLocalStorage() checkModelCombinationsInLocalStorage()
// 移除所有现有的DeepClaude提供商 // 移除所有现有的DeepClaude提供商
const existingDeepClaudeProviders = providers.filter(p => p.type === 'deepclaude') const existingDeepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
console.log('[DeepClaudeProvider] 移除现有DeepClaude提供商数量:', existingDeepClaudeProviders.length) console.log('[DeepClaudeProvider] 移除现有DeepClaude提供商数量:', existingDeepClaudeProviders.length)
existingDeepClaudeProviders.forEach(provider => { existingDeepClaudeProviders.forEach((provider) => {
dispatch(removeProvider(provider)) dispatch(removeProvider(provider))
}) })
@ -52,21 +55,30 @@ const DeepClaudeProvider = () => {
console.log('[DeepClaudeProvider] 创建的DeepClaude提供商数量:', deepClaudeProviders.length) console.log('[DeepClaudeProvider] 创建的DeepClaude提供商数量:', deepClaudeProviders.length)
// 列出所有提供商,便于调试 // 列出所有提供商,便于调试
console.log('[DeepClaudeProvider] 当前所有提供商:', console.log(
providers.map(p => ({ id: p.id, name: p.name, type: p.type }))) '[DeepClaudeProvider] 当前所有提供商:',
providers.map((p) => ({ id: p.id, name: p.name, type: p.type }))
)
// 添加DeepClaude提供商 // 添加DeepClaude提供商
deepClaudeProviders.forEach(provider => { deepClaudeProviders.forEach((provider) => {
console.log('[DeepClaudeProvider] 添加DeepClaude提供商:', provider.id, provider.name, provider.type, console.log(
provider.models.length > 0 ? `包含${provider.models.length}个模型` : '无模型') '[DeepClaudeProvider] 添加DeepClaude提供商:',
provider.id,
provider.name,
provider.type,
provider.models.length > 0 ? `包含${provider.models.length}个模型` : '无模型'
)
dispatch(addProvider(provider)) dispatch(addProvider(provider))
}) })
// 再次列出所有提供商,确认添加成功 // 再次列出所有提供商,确认添加成功
setTimeout(() => { setTimeout(() => {
const currentProviders = store.getState().llm.providers const currentProviders = store.getState().llm.providers
console.log('[DeepClaudeProvider] 添加后的所有提供商:', console.log(
currentProviders.map((p: Provider) => ({ id: p.id, name: p.name, type: p.type }))) '[DeepClaudeProvider] 添加后的所有提供商:',
currentProviders.map((p: Provider) => ({ id: p.id, name: p.name, type: p.type }))
)
console.log('[DeepClaudeProvider] DeepClaude提供商加载完成') console.log('[DeepClaudeProvider] DeepClaude提供商加载完成')
}, 100) }, 100)
} }

View File

@ -1,8 +1,8 @@
import { useEffect } from 'react' import { useSettings } from '@renderer/hooks/useSettings'
import { useDispatch } from 'react-redux'
import store from '@renderer/store' import store from '@renderer/store'
import { setPdfSettings } from '@renderer/store/settings' import { setPdfSettings } from '@renderer/store/settings'
import { useSettings } from '@renderer/hooks/useSettings' import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
/** /**
* PDF设置 * PDF设置
@ -41,10 +41,12 @@ const PDFSettingsInitializer = () => {
// 如果设置仍然不正确,再次强制设置 // 如果设置仍然不正确,再次强制设置
if (!state.settings.pdfSettings?.enablePdfSplitting) { if (!state.settings.pdfSettings?.enablePdfSplitting) {
console.log('[PDFSettingsInitializer] Settings still incorrect, forcing again') console.log('[PDFSettingsInitializer] Settings still incorrect, forcing again')
dispatch(setPdfSettings({ dispatch(
...state.settings.pdfSettings, setPdfSettings({
enablePdfSplitting: true ...state.settings.pdfSettings,
})) enablePdfSplitting: true
})
)
} }
}, 1000) }, 1000)

View File

@ -58,7 +58,9 @@ const PopupContainer: React.FC<Props> = ({ assistantId, resolve }) => {
createdAt: string 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) 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> </MemoriesList>
</Modal> </Modal>

View File

@ -89,8 +89,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
// 缓存所有模型列表只在providers变化时重新计算 // 缓存所有模型列表只在providers变化时重新计算
const allModels = useMemo(() => { const allModels = useMemo(() => {
return providers.flatMap((p) => p.models || []) return providers.flatMap((p) => p.models || []).filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
}, [providers]) }, [providers])
// --- Filter Models for Right Column --- // --- Filter Models for Right Column ---
@ -131,10 +130,13 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
setSelectedProviderId(providerId) setSelectedProviderId(providerId)
}, []) }, [])
const handleModelSelect = useCallback((model: Model) => { const handleModelSelect = useCallback(
resolve(model) (model: Model) => {
setOpen(false) resolve(model)
}, [resolve, setOpen]) setOpen(false)
},
[resolve, setOpen]
)
const onCancel = useCallback(() => { const onCancel = useCallback(() => {
setOpen(false) setOpen(false)
@ -202,14 +204,17 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
ref={inputRef} ref={inputRef}
placeholder={t('models.search')} placeholder={t('models.search')}
value={searchText} value={searchText}
onChange={useCallback((e: React.ChangeEvent<HTMLInputElement>) => { onChange={useCallback(
const value = e.target.value (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(value) const value = e.target.value
// 当搜索时,自动选择"all"供应商,以显示所有匹配的模型 setSearchText(value)
if (value.trim() && selectedProviderId !== 'all') { // 当搜索时,自动选择"all"供应商,以显示所有匹配的模型
setSelectedProviderId('all') if (value.trim() && selectedProviderId !== 'all') {
} setSelectedProviderId('all')
}, [selectedProviderId, t])} }
},
[selectedProviderId, t]
)}
// 移除焦点事件处理 // 移除焦点事件处理
allowClear allowClear
autoFocus autoFocus
@ -266,7 +271,9 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model: activeModel, res
</Tooltip> </Tooltip>
{/* Show provider only if not in pinned view or if search is active */} {/* Show provider only if not in pinned view or if search is active */}
{(selectedProviderId !== PINNED_PROVIDER_ID || searchText) && ( {(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"> <span className="provider-name">
| {providers.find((p) => p.id === m.provider)?.name ?? m.provider} | {providers.find((p) => p.id === m.provider)?.name ?? m.provider}
</span> </span>
@ -382,12 +389,14 @@ const ModelListItem = styled.div<{ $selected: boolean }>`
&:hover { &:hover {
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
.pin-button, .settings-button { .pin-button,
.settings-button {
opacity: 0.5; // Show buttons on hover 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 opacity: ${(props) => (props.$selected ? 0.5 : 0)}; // Show if selected or hovered
transition: opacity 0.2s; transition: opacity 0.2s;
&:hover { &:hover {

View File

@ -82,11 +82,18 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return true return true
} }
const pattern = lowerSearchText.split('').join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true) try {
if (pinyinText.toLowerCase().includes(lowerSearchText)) { const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
const regex = new RegExp(pattern, 'ig')
return regex.test(pinyinText)
} catch (error) {
return true return true
} }
} else {
const regex = new RegExp(pattern, 'ig')
return regex.test(filterText.toLowerCase())
} }
return false return false
@ -206,6 +213,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
const handleInput = (e: Event) => { const handleInput = (e: Event) => {
if (isComposing.current) return
const target = e.target as HTMLTextAreaElement const target = e.target as HTMLTextAreaElement
const cursorPosition = target.selectionStart const cursorPosition = target.selectionStart
const textBeforeCursor = target.value.slice(0, cursorPosition) const textBeforeCursor = target.value.slice(0, cursorPosition)
@ -225,8 +233,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
isComposing.current = true isComposing.current = true
} }
const handleCompositionEnd = () => { const handleCompositionEnd = (e: CompositionEvent) => {
isComposing.current = false isComposing.current = false
handleInput(e)
} }
textArea.addEventListener('input', handleInput) textArea.addEventListener('input', handleInput)

View File

@ -42,8 +42,9 @@ export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof b
const showBackupModal = useCallback(async () => { const showBackupModal = useCallback(async () => {
// 获取默认文件名 // 获取默认文件名
const deviceType = await window.api.system.getDeviceType() const deviceType = await window.api.system.getDeviceType()
const hostname = await window.api.system.getHostname()
const timestamp = dayjs().format('YYYYMMDDHHmmss') const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip` const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
setCustomFileName(defaultFileName) setCustomFileName(defaultFileName)
setIsModalVisible(true) setIsModalVisible(true)
}, []) }, [])

View File

@ -58,7 +58,8 @@ const NavbarCenterContainer = styled.div`
color: var(--color-text-1); color: var(--color-text-1);
/* 确保标题区域的按钮可点击 */ /* 确保标题区域的按钮可点击 */
& button, & a { & button,
& a {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
` `
@ -70,7 +71,6 @@ const NavbarRightContainer = styled.div`
padding: 0 12px; padding: 0 12px;
padding-right: ${isWindows ? '140px' : 12}; padding-right: ${isWindows ? '140px' : 12};
justify-content: flex-end; justify-content: flex-end;
-webkit-app-region: no-drag; /* 确保按钮可点击 */
/* 确保所有子元素都可点击 */ /* 确保所有子元素都可点击 */
& > * { & > * {

View File

@ -158,10 +158,13 @@ const visionAllowedModels = [
'grok-vision-beta', 'grok-vision-beta',
'pixtral', 'pixtral',
'gpt-4(?:-[\\w-]+)', 'gpt-4(?:-[\\w-]+)',
'gpt-4.1(?:-[\\w-]+)?',
'gpt-4o(?:-[\\w-]+)?', 'gpt-4o(?:-[\\w-]+)?',
'gpt-4.5(?:-[\\w-]+)', 'gpt-4.5(?:-[\\w-]+)',
'chatgpt-4o(?:-[\\w-]+)?', 'chatgpt-4o(?:-[\\w-]+)?',
'o1(?:-[\\w-]+)?', 'o1(?:-[\\w-]+)?',
'o3(?:-[\\w-]+)?',
'o4(?:-[\\w-]+)?',
'deepseek-vl(?:[\\w-]+)?', 'deepseek-vl(?:[\\w-]+)?',
'kimi-latest', 'kimi-latest',
'gemma-3(?:-[\\w-]+)' 'gemma-3(?:-[\\w-]+)'
@ -173,6 +176,7 @@ const visionExcludedModels = [
'gpt-4-32k', 'gpt-4-32k',
'gpt-4-\\d+', 'gpt-4-\\d+',
'o1-mini', 'o1-mini',
'o3-mini',
'o1-preview', 'o1-preview',
'AIDC-AI/Marco-o1' 'AIDC-AI/Marco-o1'
] ]
@ -260,6 +264,7 @@ export function getModelLogo(modelId: string) {
minimax: isLight ? MinimaxModelLogo : MinimaxModelLogoDark, minimax: isLight ? MinimaxModelLogo : MinimaxModelLogoDark,
o3: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark, o3: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
o1: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark, o1: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
o4: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
'gpt-3': isLight ? ChatGPT35ModelLogo : ChatGPT35ModelLogoDark, 'gpt-3': isLight ? ChatGPT35ModelLogo : ChatGPT35ModelLogoDark,
'gpt-4': isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark, 'gpt-4': isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark, gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
@ -1084,16 +1089,22 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
], ],
zhipu: [ zhipu: [
{ {
id: 'glm-zero-preview', id: 'glm-z1-air',
provider: 'zhipu', provider: 'zhipu',
name: 'GLM-Zero-Preview', name: 'GLM-Z1-AIR',
group: 'GLM-Zero' group: 'GLM-Z1'
}, },
{ {
id: 'glm-4-0520', id: 'glm-z1-airx',
provider: 'zhipu', provider: 'zhipu',
name: 'GLM-4-0520', name: 'GLM-Z1-AIRX',
group: 'GLM-4' group: 'GLM-Z1'
},
{
id: 'glm-z1-flash',
provider: 'zhipu',
name: 'GLM-Z1-FLASH',
group: 'GLM-Z1'
}, },
{ {
id: 'glm-4-long', id: 'glm-4-long',
@ -1108,9 +1119,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'GLM-4' group: 'GLM-4'
}, },
{ {
id: 'glm-4-air', id: 'glm-4-air-250414',
provider: 'zhipu', provider: 'zhipu',
name: 'GLM-4-Air', name: 'GLM-4-Air-250414',
group: 'GLM-4' group: 'GLM-4'
}, },
{ {
@ -1120,9 +1131,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'GLM-4' group: 'GLM-4'
}, },
{ {
id: 'glm-4-flash', id: 'glm-4-flash-250414',
provider: 'zhipu', provider: 'zhipu',
name: 'GLM-4-Flash', name: 'GLM-4-Flash-250414',
group: 'GLM-4' group: 'GLM-4'
}, },
{ {
@ -1144,9 +1155,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'GLM-4v' group: 'GLM-4v'
}, },
{ {
id: 'glm-4v-plus', id: 'glm-4v-plus-0111',
provider: 'zhipu', provider: 'zhipu',
name: 'GLM-4V-Plus', name: 'GLM-4V-Plus-0111',
group: 'GLM-4v' group: 'GLM-4v'
}, },
{ {
@ -2211,7 +2222,7 @@ export function isVisionModel(model: Model): boolean {
} }
export function isOpenAIoSeries(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 { export function isOpenAIWebSearch(model: Model): boolean {
return model.id.includes('gpt-4o-search-preview') || model.id.includes('gpt-4o-mini-search-preview') 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 return false
} }
export function isGrokModel(model?: Model): boolean {
if (!model) {
return false
}
return model.id.includes('grok')
}
export function isGrokReasoningModel(model?: Model): boolean { export function isGrokReasoningModel(model?: Model): boolean {
if (!model) { if (!model) {
return false return false
@ -2263,6 +2281,10 @@ export function isReasoningModel(model?: Model): boolean {
return true return true
} }
if (model.id.includes('glm-z1')) {
return true
}
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
} }

View File

@ -15,7 +15,8 @@ import {
setTrayOnClose, setTrayOnClose,
setWindowStyle setWindowStyle
} from '@renderer/store/settings' } 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() { export function useSettings() {
const settings = useAppSelector((state) => state.settings) const settings = useAppSelector((state) => state.settings)

View File

@ -1,4 +1,4 @@
import { SidebarIcon } from '@renderer/types' import { SidebarIcon } from '@renderer/store/settings'
import { useSettings } from './useSettings' import { useSettings } from './useSettings'

View File

@ -97,7 +97,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
const data = { ...topic, name: topicName } const data = { ...topic, name: topicName }
// Check if _setActiveTopic exists and is a function before calling // Check if _setActiveTopic exists and is a function before calling
if (typeof _setActiveTopic === 'function') { if (typeof _setActiveTopic === 'function') {
_setActiveTopic(data) _setActiveTopic(data)
} }
store.dispatch(updateTopic({ assistantId: assistant.id, topic: 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 // Ensure topic is defined before using it
if (summaryText && topic) { if (summaryText && topic) {
const data = { ...topic, name: summaryText } 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') { if (typeof _setActiveTopic === 'function') {
_setActiveTopic(data) _setActiveTopic(data)
} }
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data })) store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
} }

View File

@ -374,7 +374,7 @@
"no_api_key": "API key is not configured", "no_api_key": "API key is not configured",
"provider_disabled": "Model provider is not enabled", "provider_disabled": "Model provider is not enabled",
"render": { "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" "title": "Render Error"
}, },
"user_message_not_found": "Cannot find original user message to resend", "user_message_not_found": "Cannot find original user message to resend",
@ -1582,7 +1582,7 @@
"subscribe_name": "Alternative name", "subscribe_name": "Alternative name",
"subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.", "subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.",
"subscribe_add_success": "Subscription feed added successfully!", "subscribe_add_success": "Subscription feed added successfully!",
"subscribe_delete": "Delete subscription source", "subscribe_delete": "Delete",
"overwrite": "Override search service", "overwrite": "Override search service",
"overwrite_tooltip": "Force use search service instead of LLM", "overwrite_tooltip": "Force use search service instead of LLM",
"apikey": "API key", "apikey": "API key",

View File

@ -335,7 +335,7 @@
"no_api_key": "APIキーが設定されていません", "no_api_key": "APIキーが設定されていません",
"provider_disabled": "モデルプロバイダーが有効になっていません", "provider_disabled": "モデルプロバイダーが有効になっていません",
"render": { "render": {
"description": "数式のレンダリングに失敗しました。数式の形式が正しいか確認してください", "description": "メッセージの内容のレンダリングに失敗しました。メッセージの内容の形式が正しいか確認してください",
"title": "レンダリングエラー" "title": "レンダリングエラー"
}, },
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした", "user_message_not_found": "元のユーザーメッセージを見つけることができませんでした",
@ -1363,7 +1363,7 @@
"subscribe_name": "代替名", "subscribe_name": "代替名",
"subscribe_name.placeholder": "ダウンロードしたフィードに名前がない場合に使用される代替名", "subscribe_name.placeholder": "ダウンロードしたフィードに名前がない場合に使用される代替名",
"subscribe_add_success": "フィードの追加が成功しました!", "subscribe_add_success": "フィードの追加が成功しました!",
"subscribe_delete": "フィードの削除", "subscribe_delete": "削除",
"overwrite": "サービス検索を上書き", "overwrite": "サービス検索を上書き",
"overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する", "overwrite_tooltip": "大規模言語モデルではなく、サービス検索を使用する",
"apikey": "API キー", "apikey": "API キー",

View File

@ -338,7 +338,7 @@
"no_api_key": "Ключ API не настроен", "no_api_key": "Ключ API не настроен",
"provider_disabled": "Провайдер моделей не включен", "provider_disabled": "Провайдер моделей не включен",
"render": { "render": {
"description": "Не удалось рендерить формулу. Пожалуйста, проверьте, правильно ли формат формулы", "description": "Не удалось рендерить содержимое сообщения. Пожалуйста, проверьте, правильно ли формат содержимого сообщения",
"title": "Ошибка рендеринга" "title": "Ошибка рендеринга"
}, },
"user_message_not_found": "Не удалось найти исходное сообщение пользователя", "user_message_not_found": "Не удалось найти исходное сообщение пользователя",
@ -1366,7 +1366,7 @@
"subscribe_name": "альтернативное имя", "subscribe_name": "альтернативное имя",
"subscribe_name.placeholder": "替代名称, используемый, когда загружаемый подписочный источник не имеет названия", "subscribe_name.placeholder": "替代名称, используемый, когда загружаемый подписочный источник не имеет названия",
"subscribe_add_success": "Подписка добавлена успешно!", "subscribe_add_success": "Подписка добавлена успешно!",
"subscribe_delete": "Удалить источник подписки", "subscribe_delete": "Удалить",
"overwrite": "Переопределить поставщика поиска", "overwrite": "Переопределить поставщика поиска",
"overwrite_tooltip": "Использовать поставщика поиска вместо LLM", "overwrite_tooltip": "Использовать поставщика поиска вместо LLM",
"apikey": "Ключ API", "apikey": "Ключ API",

View File

@ -375,7 +375,7 @@
"no_api_key": "API 密钥未配置", "no_api_key": "API 密钥未配置",
"provider_disabled": "模型提供商未启用", "provider_disabled": "模型提供商未启用",
"render": { "render": {
"description": "渲染公式失败,请检查公式格式是否正确", "description": "渲染消息内容失败,请检查消息内容格式是否正确",
"title": "渲染错误" "title": "渲染错误"
}, },
"user_message_not_found": "无法找到原始用户消息", "user_message_not_found": "无法找到原始用户消息",
@ -1372,6 +1372,20 @@
"description": "描述", "description": "描述",
"duplicateName": "已存在同名服务器", "duplicateName": "已存在同名服务器",
"editJson": "编辑JSON", "editJson": "编辑JSON",
"importServer": "导入服务器",
"importServerDesc": "从JSON文件或文本导入单个MCP服务器配置",
"dropJsonFile": "拖拽JSON文件到此处",
"clickOrDrop": "点击或拖拽文件上传",
"orPasteJson": "或粘贴JSON配置",
"jsonRequired": "请输入JSON配置",
"noServerFound": "未找到服务器配置",
"importSuccess": "服务器导入成功",
"invalidServerFormat": "无效的服务器格式",
"jsonImportError": "导入JSON配置失败",
"fileReadError": "读取文件失败",
"importedServer": "导入的服务器",
"import": "导入",
"importModeHint": "支持两种格式单个服务器配置或完整的mcpServers配置",
"editServer": "编辑服务器", "editServer": "编辑服务器",
"env": "环境变量", "env": "环境变量",
"envTooltip": "格式KEY=value每行一个", "envTooltip": "格式KEY=value每行一个",

View File

@ -335,7 +335,7 @@
"no_api_key": "API 金鑰未設定", "no_api_key": "API 金鑰未設定",
"provider_disabled": "模型供應商未啟用", "provider_disabled": "模型供應商未啟用",
"render": { "render": {
"description": "渲染公式失敗,請檢查公式格式是否正確", "description": "渲染訊息內容失敗,請檢查訊息內容格式是否正確",
"title": "渲染錯誤" "title": "渲染錯誤"
}, },
"user_message_not_found": "無法找到原始用戶訊息", "user_message_not_found": "無法找到原始用戶訊息",
@ -1362,7 +1362,7 @@
"subscribe_name": "替代名稱", "subscribe_name": "替代名稱",
"subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱", "subscribe_name.placeholder": "當下載的訂閱源沒有名稱時所使用的替代名稱",
"subscribe_add_success": "訂閱源添加成功!", "subscribe_add_success": "訂閱源添加成功!",
"subscribe_delete": "刪除訂閱源", "subscribe_delete": "刪除",
"title": "網路搜尋", "title": "網路搜尋",
"overwrite": "覆蓋搜尋服務商", "overwrite": "覆蓋搜尋服務商",
"overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋", "overwrite_tooltip": "強制使用搜尋服務商而不是大語言模型進行搜尋",

View File

@ -118,7 +118,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
prompt: AGENT_PROMPT, prompt: AGENT_PROMPT,
content: promptText content: promptText
}) })
formRef.current?.setFieldValue('prompt', generatedText) form.setFieldsValue({ prompt: generatedText })
} catch (error) { } catch (error) {
console.error('Error fetching data:', error) console.error('Error fetching data:', error)
} }
@ -170,11 +170,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
label={t('agents.add.prompt')} label={t('agents.add.prompt')}
rules={[{ required: true }]} rules={[{ required: true }]}
style={{ position: 'relative' }}> style={{ position: 'relative' }}>
<TextAreaContainer> <TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
<TokenCount>Tokens: {tokenCount}</TokenCount>
</TextAreaContainer>
</Form.Item> </Form.Item>
<TokenCount>Tokens: {tokenCount}</TokenCount>
<Button <Button
icon={loading ? <LoadingOutlined /> : <ThunderboltOutlined />} icon={loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
onClick={handleButtonClick} onClick={handleButtonClick}
@ -203,11 +201,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
) )
} }
const TextAreaContainer = styled.div`
position: relative;
width: 100%;
`
const TokenCount = styled.div` const TokenCount = styled.div`
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;

View File

@ -38,7 +38,10 @@ export function useSystemAgents() {
} }
// 处理Uint8Array类型二进制数据 // 处理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类型转换为字符串') console.log('文件内容是Uint8Array类型转换为字符串')
// 将Uint8Array转换为字符串 // 将Uint8Array转换为字符串
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')

View File

@ -1,3 +1,4 @@
import PDFSplitter from '@renderer/components/PDFSplitter'
import { isVisionModel } from '@renderer/config/models' import { isVisionModel } from '@renderer/config/models'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { setPdfSettings } from '@renderer/store/settings' import { setPdfSettings } from '@renderer/store/settings'
@ -8,7 +9,6 @@ import { Paperclip } from 'lucide-react'
import { FC, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { FC, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import PDFSplitter from '@renderer/components/PDFSplitter'
export interface AttachmentButtonRef { export interface AttachmentButtonRef {
openQuickPanel: () => void openQuickPanel: () => void
@ -54,41 +54,47 @@ const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButto
return pdfSettings return pdfSettings
}, [dispatch, pdfSettings]) }, [dispatch, pdfSettings])
const handlePdfFile = useCallback((file: FileType) => { const handlePdfFile = useCallback(
console.log('[AttachmentButton] handlePdfFile called with file:', file) (file: FileType) => {
console.log('[AttachmentButton] handlePdfFile called with file:', file)
// 强制初始化PDF设置 // 强制初始化PDF设置
const settings = forcePdfSettingsInitialization() const settings = forcePdfSettingsInitialization()
console.log('[AttachmentButton] PDF settings after initialization:', settings) console.log('[AttachmentButton] PDF settings after initialization:', settings)
if (settings.enablePdfSplitting && file.ext.toLowerCase() === '.pdf') { if (settings.enablePdfSplitting && file.ext.toLowerCase() === '.pdf') {
console.log('[AttachmentButton] PDF splitting enabled, showing splitter dialog') console.log('[AttachmentButton] PDF splitting enabled, showing splitter dialog')
setSelectedPdfFile(file) setSelectedPdfFile(file)
setPdfSplitterVisible(true) setPdfSplitterVisible(true)
return true // 返回true表示我们已经处理了这个文件 return true // 返回true表示我们已经处理了这个文件
} }
console.log('[AttachmentButton] PDF splitting disabled or not a PDF file, returning false') console.log('[AttachmentButton] PDF splitting disabled or not a PDF file, returning false')
return false // 返回false表示这个文件需要正常处理 return false // 返回false表示这个文件需要正常处理
}, [forcePdfSettingsInitialization]) },
[forcePdfSettingsInitialization]
)
const handlePdfSplitterConfirm = useCallback(async (file: FileType, pageRange: string) => { const handlePdfSplitterConfirm = useCallback(
console.log('[AttachmentButton] handlePdfSplitterConfirm called with file:', file, 'pageRange:', pageRange) async (file: FileType, pageRange: string) => {
try { console.log('[AttachmentButton] handlePdfSplitterConfirm called with file:', file, 'pageRange:', pageRange)
// 调用主进程的PDF分割功能 try {
console.log('[AttachmentButton] Calling window.api.pdf.splitPDF') // 调用主进程的PDF分割功能
const newFile = await window.api.pdf.splitPDF(file, pageRange) console.log('[AttachmentButton] Calling window.api.pdf.splitPDF')
console.log('[AttachmentButton] PDF split successful, new file:', newFile) const newFile = await window.api.pdf.splitPDF(file, pageRange)
setFiles([...files, newFile]) console.log('[AttachmentButton] PDF split successful, new file:', newFile)
setPdfSplitterVisible(false) setFiles([...files, newFile])
setSelectedPdfFile(null) setPdfSplitterVisible(false)
} catch (error) { setSelectedPdfFile(null)
console.error('[AttachmentButton] Error splitting PDF:', error) } catch (error) {
window.message.error({ console.error('[AttachmentButton] Error splitting PDF:', error)
content: t('pdf.error_splitting'), window.message.error({
key: 'pdf-error-splitting' content: t('pdf.error_splitting'),
}) key: 'pdf-error-splitting'
} })
}, [files, setFiles, t]) }
},
[files, setFiles, t]
)
const onSelectFile = useCallback(async () => { const onSelectFile = useCallback(async () => {
// 强制初始化PDF设置 // 强制初始化PDF设置
@ -107,8 +113,8 @@ const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButto
if (_files) { if (_files) {
// 检查是否有PDF文件需要特殊处理 // 检查是否有PDF文件需要特殊处理
const pdfFiles = _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') const nonPdfFiles = _files.filter((file) => file.ext.toLowerCase() !== '.pdf')
// 添加非PDF文件 // 添加非PDF文件
if (nonPdfFiles.length > 0) { if (nonPdfFiles.length > 0) {

View File

@ -62,6 +62,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
{first(m.name)} {first(m.name)}
</Avatar> </Avatar>
), ),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m), action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m)) isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
})) }))
@ -89,6 +90,7 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
{first(m.name)} {first(m.name)}
</Avatar> </Avatar>
), ),
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
action: () => onMentionModel(m), action: () => onMentionModel(m),
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m)) isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
})) }))

View File

@ -7,7 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import type { Message } from '@renderer/types' import type { Message } from '@renderer/types'
import { parseJSON } from '@renderer/utils' import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats' 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 { isEmpty } from 'lodash'
import { type FC, useMemo } from 'react' import { type FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -16,6 +16,8 @@ import rehypeKatex from 'rehype-katex'
// @ts-ignore next-line // @ts-ignore next-line
import rehypeMathjax from 'rehype-mathjax' import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
// @ts-ignore next-line
import rehypeSanitize from 'rehype-sanitize'
import remarkCjkFriendly from 'remark-cjk-friendly' import remarkCjkFriendly from 'remark-cjk-friendly'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
@ -24,21 +26,16 @@ import EditableCodeBlock from './EditableCodeBlock'
import ImagePreview from './ImagePreview' import ImagePreview from './ImagePreview'
import Link from './Link' 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 { interface Props {
message: Message message: Message
} }
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly] const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
const disallowedElements = ['iframe']
const Markdown: FC<Props> = ({ message }) => { const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { renderInputMessageAsMarkdown, mathEngine } = useSettings() const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
const rehypeMath = useMemo(() => (mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax), [mathEngine])
const messageContent = useMemo(() => { const messageContent = useMemo(() => {
const empty = isEmpty(message.content) const empty = isEmpty(message.content)
const paused = message.status === 'paused' const paused = message.status === 'paused'
@ -47,9 +44,8 @@ const Markdown: FC<Props> = ({ message }) => {
}, [message, t]) }, [message, t])
const rehypePlugins = useMemo(() => { const rehypePlugins = useMemo(() => {
const hasElements = ALLOWED_ELEMENTS.test(messageContent) return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath] }, [mathEngine])
}, [messageContent, rehypeMath])
const components = useMemo(() => { const components = useMemo(() => {
const baseComponents = { const baseComponents = {
@ -95,7 +91,6 @@ const Markdown: FC<Props> = ({ message }) => {
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
className="markdown" className="markdown"
components={components} components={components}
disallowedElements={disallowedElements}
remarkRehypeOptions={{ remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'), footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4', footnoteLabelTagName: 'h4',

View File

@ -1,52 +1,53 @@
import TTSProgressBar from '@renderer/components/TTSProgressBar'; import TTSProgressBar from '@renderer/components/TTSProgressBar'
import { FONT_FAMILY } from '@renderer/config/constant'; import { FONT_FAMILY } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'; import { useAssistant } from '@renderer/hooks/useAssistant'
import { useModel } from '@renderer/hooks/useModel'; import { useModel } from '@renderer/hooks/useModel'
import { useRuntime } from '@renderer/hooks/useRuntime'; import { useRuntime } from '@renderer/hooks/useRuntime'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'; import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'; import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageModelId } from '@renderer/services/MessagesService'; import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelUniqId } from '@renderer/services/ModelService'; import { getModelUniqId } from '@renderer/services/ModelService'
import TTSService from '@renderer/services/TTSService'; import TTSService from '@renderer/services/TTSService'
import { useAppDispatch, useAppSelector } from '@renderer/store'; import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setLastPlayedMessageId, setSkipNextAutoTTS } from '@renderer/store/settings'; import { setLastPlayedMessageId, setSkipNextAutoTTS } from '@renderer/store/settings'
import { Assistant, Message, Topic } from '@renderer/types'; import { Assistant, Message, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'; import { classNames } from '@renderer/utils'
import { Divider, Dropdown } from 'antd'; import { Divider, Dropdown } from 'antd'
import { ItemType } from 'antd/es/menu/interface'; import { ItemType } from 'antd/es/menu/interface'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next'
// import { useSelector } from 'react-redux'; // Removed unused import // 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 MessageContent from './MessageContent'
import MessageErrorBoundary from './MessageErrorBoundary'; import MessageErrorBoundary from './MessageErrorBoundary'
import MessageHeader from './MessageHeader'; import MessageHeader from './MessageHeader'
import MessageMenubar from './MessageMenubar'; import MessageMenubar from './MessageMenubar'
import MessageTokens from './MessageTokens'; import MessageTokens from './MessageTokens'
interface Props { interface Props {
message: Message; message: Message
topic: Topic; topic: Topic
assistant?: Assistant; assistant?: Assistant
index?: number; index?: number
total?: number; total?: number
hidePresetMessages?: boolean; hidePresetMessages?: boolean
style?: React.CSSProperties; style?: React.CSSProperties
isGrouped?: boolean; isGrouped?: boolean
isStreaming?: boolean; isStreaming?: boolean
onSetMessages?: Dispatch<SetStateAction<Message[]>>; onSetMessages?: Dispatch<SetStateAction<Message[]>>
} }
// Function definition moved before its first use, fixing potential TS issue & improving readability // Function definition moved before its first use, fixing potential TS issue & improving readability
// FIX 1: Added explicit else to satisfy TS7030 // FIX 1: Added explicit else to satisfy TS7030
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean): string | undefined => { const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean): string | undefined => {
if (!isBubbleStyle) { if (!isBubbleStyle) {
return undefined; return undefined
} else { // Explicit else block } else {
return isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)'; // Explicit else block
return isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)'
} }
}; }
// FIX 2: Define styled component for the context menu trigger div // FIX 2: Define styled component for the context menu trigger div
const ContextMenuTriggerDiv = styled.div<{ x: number; y: number }>` 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 */ /* Optional: Ensure it doesn't interfere with other elements */
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
`; `
const MessageItem: FC<Props> = ({ const MessageItem: FC<Props> = ({
message, message,
@ -71,74 +71,74 @@ const MessageItem: FC<Props> = ({
isStreaming = false, isStreaming = false,
style style
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId); const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model; const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { isBubbleStyle } = useMessageStyle(); const { isBubbleStyle } = useMessageStyle()
const { showMessageDivider, messageFont, fontSize } = useSettings(); const { showMessageDivider, messageFont, fontSize } = useSettings()
const { generating } = useRuntime(); const { generating } = useRuntime()
const messageContainerRef = useRef<HTMLDivElement>(null); const messageContainerRef = useRef<HTMLDivElement>(null)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedQuoteText, setSelectedQuoteText] = useState<string>(''); const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string>(''); const [selectedText, setSelectedText] = useState<string>('')
const dispatch = useAppDispatch(); const dispatch = useAppDispatch()
const playTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// --- Consolidated State Selection --- // --- Consolidated State Selection ---
const ttsEnabled = useAppSelector((state) => state.settings.ttsEnabled); const ttsEnabled = useAppSelector((state) => state.settings.ttsEnabled)
const voiceCallEnabled = useAppSelector((state) => state.settings.voiceCallEnabled); const voiceCallEnabled = useAppSelector((state) => state.settings.voiceCallEnabled)
const autoPlayTTSOutsideVoiceCall = useAppSelector((state) => state.settings.autoPlayTTSOutsideVoiceCall); const autoPlayTTSOutsideVoiceCall = useAppSelector((state) => state.settings.autoPlayTTSOutsideVoiceCall)
const isVoiceCallActive = useAppSelector((state) => state.settings.isVoiceCallActive); const isVoiceCallActive = useAppSelector((state) => state.settings.isVoiceCallActive)
const lastPlayedMessageId = useAppSelector((state) => state.settings.lastPlayedMessageId); const lastPlayedMessageId = useAppSelector((state) => state.settings.lastPlayedMessageId)
const skipNextAutoTTS = useAppSelector((state) => state.settings.skipNextAutoTTS); const skipNextAutoTTS = useAppSelector((state) => state.settings.skipNextAutoTTS)
// --------------------------------- // ---------------------------------
const isLastMessage = index === 0; const isLastMessage = index === 0
const isAssistantMessage = message.role === 'assistant'; const isAssistantMessage = message.role === 'assistant'
const showMenubar = !isStreaming && !message.status.includes('ing'); const showMenubar = !isStreaming && !message.status.includes('ing')
const fontFamily = useMemo(() => { const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY; return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
}, [messageFont]); }, [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 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) => { const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault()
const _selectedText = window.getSelection()?.toString() || ''; const _selectedText = window.getSelection()?.toString() || ''
setContextMenuPosition({ x: e.clientX, y: e.clientY }); setContextMenuPosition({ x: e.clientX, y: e.clientY })
if (_selectedText) { if (_selectedText) {
const quotedText = const quotedText =
_selectedText _selectedText
.split('\n') .split('\n')
.map((line) => `> ${line}`) .map((line) => `> ${line}`)
.join('\n') + '\n-------------'; .join('\n') + '\n-------------'
setSelectedQuoteText(quotedText); setSelectedQuoteText(quotedText)
setSelectedText(_selectedText); setSelectedText(_selectedText)
} else { } else {
setSelectedQuoteText(''); setSelectedQuoteText('')
setSelectedText(''); setSelectedText('')
} }
}, []); }, [])
// Close context menu on click outside // Close context menu on click outside
useEffect(() => { useEffect(() => {
const handleClick = () => { const handleClick = () => {
setContextMenuPosition(null); setContextMenuPosition(null)
}; }
document.addEventListener('click', handleClick); document.addEventListener('click', handleClick)
return () => { return () => {
document.removeEventListener('click', handleClick); document.removeEventListener('click', handleClick)
}; }
}, []); }, [])
// --- Reset skipNextAutoTTS on New Message Completion --- // --- Reset skipNextAutoTTS on New Message Completion ---
const prevGeneratingRef = useRef(generating); const prevGeneratingRef = useRef(generating)
useEffect(() => { useEffect(() => {
prevGeneratingRef.current = generating; prevGeneratingRef.current = generating
}, [generating]); }, [generating])
useEffect(() => { useEffect(() => {
if ( if (
@ -149,87 +149,110 @@ const MessageItem: FC<Props> = ({
message.status === 'success' message.status === 'success'
) { ) {
// 简化日志输出 // 简化日志输出
console.log('消息生成完成重置skipNextAutoTTS为false, 消息ID:', message.id); console.log('消息生成完成重置skipNextAutoTTS为false, 消息ID:', message.id)
dispatch(setSkipNextAutoTTS(false)); dispatch(setSkipNextAutoTTS(false))
} }
}, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch]); }, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch])
// --- Auto-play TTS Logic --- // --- Auto-play TTS Logic ---
useEffect(() => { useEffect(() => {
// 基本条件检查 // 基本条件检查
if (!isLastMessage || !isAssistantMessage || message.status !== 'success' || generating) { if (!isLastMessage || !isAssistantMessage || message.status !== 'success' || generating) {
return; return
} }
if (!ttsEnabled) { if (!ttsEnabled) {
return; return
} }
// 语音通话相关条件检查 // 语音通话相关条件检查
if (voiceCallEnabled === false && autoPlayTTSOutsideVoiceCall === false) { if (voiceCallEnabled === false && autoPlayTTSOutsideVoiceCall === false) {
// 简化日志输出 // 简化日志输出
console.log('不自动播放TTS: 语音通话功能未启用 + 不允许在语音通话模式外自动播放'); console.log('不自动播放TTS: 语音通话功能未启用 + 不允许在语音通话模式外自动播放')
return; return
} }
if (voiceCallEnabled === true && isVoiceCallActive === false && autoPlayTTSOutsideVoiceCall === false) { if (voiceCallEnabled === true && isVoiceCallActive === false && autoPlayTTSOutsideVoiceCall === false) {
// 简化日志输出 // 简化日志输出
console.log('不自动播放TTS: 语音通话窗口未打开 + 不允许在语音通话模式外自动播放'); console.log('不自动播放TTS: 语音通话窗口未打开 + 不允许在语音通话模式外自动播放')
return; return
} }
// 检查是否需要跳过自动TTS // 检查是否需要跳过自动TTS
if (skipNextAutoTTS === true) { if (skipNextAutoTTS === true) {
console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id); console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id)
return; return
} }
// 检查消息是否有内容,且消息是新的(不是上次播放过的消息) // 检查消息是否有内容,且消息是新的(不是上次播放过的消息)
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) { if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) {
// 简化日志输出 // 简化日志输出
console.log('准备自动播放TTS, 消息ID:', message.id); console.log('准备自动播放TTS, 消息ID:', message.id)
dispatch(setLastPlayedMessageId(message.id));
const playTimeout = setTimeout(() => { // 先设置状态,防止重复播放
console.log('自动播放TTS: 消息ID:', message.id); const currentMessageId = message.id
TTSService.speakFromMessage(message); dispatch(setLastPlayedMessageId(currentMessageId))
}, 500);
return () => clearTimeout(playTimeout); // 只有当没有设置过定时器时才设置
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) { } else if (message.id === lastPlayedMessageId) {
// 简化日志输出 // 简化日志输出
console.log('不自动播放TTS: 消息已播放过 (lastPlayedMessageId), ID:', message.id); console.log('不自动播放TTS: 消息已播放过 (lastPlayedMessageId), ID:', message.id)
return; // 添加返回语句解决TypeScript错误 return // 添加返回语句解决TypeScript错误
} }
// 添加默认返回值,确保所有代码路径都有返回值 // 添加默认返回值,确保所有代码路径都有返回值
return; return
}, [ }, [
isLastMessage, isAssistantMessage, message, generating, ttsEnabled, isLastMessage,
voiceCallEnabled, autoPlayTTSOutsideVoiceCall, isVoiceCallActive, isAssistantMessage,
skipNextAutoTTS, lastPlayedMessageId, dispatch message,
]); generating,
ttsEnabled,
voiceCallEnabled,
autoPlayTTSOutsideVoiceCall,
isVoiceCallActive,
skipNextAutoTTS,
lastPlayedMessageId,
dispatch
])
// --- Highlight message on event --- // --- Highlight message on event ---
const messageHighlightHandler = useCallback((highlight: boolean = true) => { const messageHighlightHandler = useCallback((highlight: boolean = true) => {
if (messageContainerRef.current) { if (messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
if (highlight) { if (highlight) {
const element = messageContainerRef.current; const element = messageContainerRef.current
element.classList.add('message-highlight'); element.classList.add('message-highlight')
setTimeout(() => { setTimeout(() => {
element?.classList.remove('message-highlight'); element?.classList.remove('message-highlight')
}, 2500); }, 2500)
} }
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}`; const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}`
const unsubscribe = EventEmitter.on(eventName, messageHighlightHandler); const unsubscribe = EventEmitter.on(eventName, messageHighlightHandler)
return () => unsubscribe(); return () => unsubscribe()
}, [message.id, messageHighlightHandler]); }, [message.id, messageHighlightHandler])
// --- Component Rendering --- // --- Component Rendering ---
if (hidePresetMessages && message.isPreset) { if (hidePresetMessages && message.isPreset) {
return null; return null
} }
if (message.type === 'clear') { if (message.type === 'clear') {
@ -239,7 +262,7 @@ const MessageItem: FC<Props> = ({
{t('chat.message.new.context')} {t('chat.message.new.context')}
</Divider> </Divider>
</NewContextMessage> </NewContextMessage>
); )
} }
return ( return (
@ -258,8 +281,7 @@ const MessageItem: FC<Props> = ({
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }} overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }} menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
open={true} open={true}
trigger={['contextMenu']} trigger={['contextMenu']}>
>
{/* FIX 2: Use the styled component instead of inline style */} {/* FIX 2: Use the styled component instead of inline style */}
<ContextMenuTriggerDiv x={contextMenuPosition.x} y={contextMenuPosition.y} /> <ContextMenuTriggerDiv x={contextMenuPosition.x} y={contextMenuPosition.y} />
</Dropdown> </Dropdown>
@ -299,75 +321,79 @@ const MessageItem: FC<Props> = ({
)} )}
</MessageContentContainer> </MessageContentContainer>
</MessageContainer> </MessageContainer>
); )
}; }
// Updated context menu items function // Updated context menu items function
const getContextMenuItems = ( const getContextMenuItems = (
t: (key: string) => string, t: (key: string) => string,
selectedQuoteText: string, selectedQuoteText: string,
selectedText: string, selectedText: string,
message: Message, message: Message
): ItemType[] => { ): ItemType[] => {
const items: ItemType[] = []; const items: ItemType[] = []
if (selectedText) { if (selectedText) {
items.push({ items.push({
key: 'copy', key: 'copy',
label: t('common.copy'), label: t('common.copy'),
onClick: () => { onClick: () => {
navigator.clipboard.writeText(selectedText) navigator.clipboard
.writeText(selectedText)
.then(() => window.message.success({ content: t('message.copied'), key: 'copy-message' })) .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({ items.push({
key: 'quote', key: 'quote',
label: t('chat.message.quote'), label: t('chat.message.quote'),
onClick: () => { onClick: () => {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText); EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
} }
}); })
items.push({ items.push({
key: 'speak_selected', key: 'speak_selected',
label: t('chat.message.speak_selection') || '朗读选中部分', label: t('chat.message.speak_selection') || '朗读选中部分',
onClick: () => { onClick: () => {
// 首先手动关闭菜单 // 首先手动关闭菜单
document.dispatchEvent(new MouseEvent('click')); document.dispatchEvent(new MouseEvent('click'))
// 使用setTimeout确保菜单关闭后再执行TTS功能 // 使用setTimeout确保菜单关闭后再执行TTS功能
setTimeout(() => { setTimeout(() => {
import('@renderer/services/TTSService').then(({ default: TTSServiceInstance }) => { import('@renderer/services/TTSService')
let textToSpeak = selectedText; .then(({ default: TTSServiceInstance }) => {
let textToSpeak = selectedText
if (message.content) { if (message.content) {
const startIndex = message.content.indexOf(selectedText); const startIndex = message.content.indexOf(selectedText)
if (startIndex !== -1) { if (startIndex !== -1) {
textToSpeak = selectedText; // Just speak selection textToSpeak = selectedText // Just speak selection
} }
} }
// 传递消息ID确保进度条和停止按钮正常工作 // 传递消息ID确保进度条和停止按钮正常工作
TTSServiceInstance.speak(textToSpeak, false, message.id); // 使用普通播放模式而非分段播放 TTSServiceInstance.speak(textToSpeak, false, message.id) // 使用普通播放模式而非分段播放
}).catch(err => console.error('Failed to load or use TTSService:', err)); })
}, 100); .catch((err) => console.error('Failed to load or use TTSService:', err))
}, 100)
} }
}); })
items.push({ type: 'divider' }); items.push({ type: 'divider' })
} }
items.push({ items.push({
key: 'copy_id', key: 'copy_id',
label: t('message.copy_id') || '复制消息ID', label: t('message.copy_id') || '复制消息ID',
onClick: () => { onClick: () => {
navigator.clipboard.writeText(message.id) navigator.clipboard
.then(() => window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' })) .writeText(message.id)
.catch(err => console.error('Failed to copy message ID: ', err)); .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 // Styled components definitions
const MessageContainer = styled.div` const MessageContainer = styled.div`
@ -395,7 +421,7 @@ const MessageContainer = styled.div`
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
`; `
const MessageContentContainer = styled.div` const MessageContentContainer = styled.div`
max-width: 100%; max-width: 100%;
@ -404,7 +430,7 @@ const MessageContentContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-left: 46px; margin-left: 46px;
margin-top: 5px; margin-top: 5px;
`; `
const MessageFooter = styled.div` const MessageFooter = styled.div`
display: flex; display: flex;
@ -415,16 +441,16 @@ const MessageFooter = styled.div`
margin-top: 8px; margin-top: 8px;
/* borderTop applied via style prop based on showMessageDivider */ /* borderTop applied via style prop based on showMessageDivider */
gap: 16px; gap: 16px;
`; `
const NewContextMessage = styled.div` const NewContextMessage = styled.div`
cursor: pointer; cursor: pointer;
`; `
const ProgressBarWrapper = styled.div` const ProgressBarWrapper = styled.div`
width: calc(100% - 20px); width: calc(100% - 20px);
padding: 5px 10px; padding: 5px 10px;
margin-left: -10px; margin-left: -10px;
`; `
export default memo(MessageItem); export default memo(MessageItem)

View File

@ -1,53 +1,53 @@
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'; import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import TTSHighlightedText from '@renderer/components/TTSHighlightedText'; import TTSHighlightedText from '@renderer/components/TTSHighlightedText'
import { isOpenAIWebSearch } from '@renderer/config/models'; import { isOpenAIWebSearch } from '@renderer/config/models'
import { getModelUniqId } from '@renderer/services/ModelService'; import { getModelUniqId } from '@renderer/services/ModelService'
import { Message, Model } from '@renderer/types'; import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils'; import { getBriefInfo } from '@renderer/utils'
import { withMessageThought } from '@renderer/utils/formats'; import { withMessageThought } from '@renderer/utils/formats'
import { Collapse, Divider, Flex } from 'antd'; import { Collapse, Divider, Flex } from 'antd'
import { clone } from 'lodash'; import { clone } from 'lodash'
import { Search } from 'lucide-react'; import { Search } from 'lucide-react'
import React, { Fragment, useEffect, useMemo, useState } from 'react'; import React, { Fragment, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader'; import BarLoader from 'react-spinners/BarLoader'
import BeatLoader from 'react-spinners/BeatLoader'; import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'; import styled from 'styled-components'
import Markdown from '../Markdown/Markdown'; import Markdown from '../Markdown/Markdown'
import CitationsList from './CitationsList'; import CitationsList from './CitationsList'
import MessageAttachments from './MessageAttachments'; import MessageAttachments from './MessageAttachments'
import MessageError from './MessageError'; import MessageError from './MessageError'
import MessageImage from './MessageImage'; import MessageImage from './MessageImage'
import MessageThought from './MessageThought'; import MessageThought from './MessageThought'
import MessageTools from './MessageTools'; import MessageTools from './MessageTools'
interface Props { interface Props {
message: Message; message: Message
model?: Model; model?: Model
} }
const MessageContent: React.FC<Props> = ({ message: _message, model }) => { const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation(); const { t } = useTranslation()
const message = withMessageThought(clone(_message)); const message = withMessageThought(clone(_message))
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter'); const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false); const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false)
// 监听分段播放状态变化 // 监听分段播放状态变化
useEffect(() => { useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => { const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
const { isSegmentedPlayback } = event.detail; const { isSegmentedPlayback } = event.detail
setIsSegmentedPlayback(isSegmentedPlayback); setIsSegmentedPlayback(isSegmentedPlayback)
}; }
// 添加事件监听器 // 添加事件监听器
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener); window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
// 组件卸载时移除事件监听器 // 组件卸载时移除事件监听器
return () => { return () => {
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener); window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
}; }
}, []); }, [])
// HTML实体编码辅助函数 // HTML实体编码辅助函数
const encodeHTML = (str: string) => { const encodeHTML = (str: string) => {
@ -58,47 +58,47 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
'>': '&gt;', '>': '&gt;',
'"': '&quot;', '"': '&quot;',
"'": '&apos;' "'": '&apos;'
}; }
return entities[match]; return entities[match]
}); })
}; }
// Format citations for display // Format citations for display
const formattedCitations = useMemo(() => { 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)) { if (model && isOpenAIWebSearch(model)) {
citations = citations =
message.metadata.annotations?.map((url, index) => { 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 { } else {
citations = citations =
message.metadata?.citations?.map((url, index) => { message.metadata?.citations?.map((url, index) => {
try { try {
const hostname = new URL(url).hostname; const hostname = new URL(url).hostname
return { number: index + 1, url, hostname }; return { number: index + 1, url, hostname }
} catch { } catch {
return { number: index + 1, url, hostname: url }; return { number: index + 1, url, hostname: url }
} }
}) || []; }) || []
} }
// Deduplicate by URL // Deduplicate by URL
const urlSet = new Set(); const urlSet = new Set()
return citations return citations
.filter((citation) => { .filter((citation) => {
if (!citation.url || urlSet.has(citation.url)) return false; if (!citation.url || urlSet.has(citation.url)) return false
urlSet.add(citation.url); urlSet.add(citation.url)
return true; return true
}) })
.map((citation, index) => ({ .map((citation, index) => ({
...citation, ...citation,
number: index + 1 // Renumber citations sequentially after deduplication 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(() => { const citationsData = useMemo(() => {
@ -107,11 +107,11 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
message?.metadata?.webSearchInfo || message?.metadata?.webSearchInfo ||
message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) || message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) ||
message?.metadata?.annotations?.map((annotation) => annotation.url_citation) || message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ||
[]; []
const citationsUrls = formattedCitations || []; const citationsUrls = formattedCitations || []
// 合并引用数据 // 合并引用数据
const data = new Map(); const data = new Map()
// 添加webSearch结果 // 添加webSearch结果
searchResults.forEach((result) => { searchResults.forEach((result) => {
@ -119,8 +119,8 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
url: result.url || result.uri || result.link, url: result.url || result.uri || result.link,
title: result.title || result.hostname, title: result.title || result.hostname,
content: result.content content: result.content
}); })
}); })
// 添加citations // 添加citations
citationsUrls.forEach((result) => { citationsUrls.forEach((result) => {
@ -129,18 +129,19 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
url: result.url, url: result.url,
title: result.title || result.hostname || undefined, title: result.title || result.hostname || undefined,
content: result.content || undefined content: result.content || undefined
}); })
} }
}); })
return data; return data
}, [ }, [
formattedCitations, formattedCitations,
message?.metadata?.annotations, message?.metadata?.annotations,
message?.metadata?.groundingMetadata?.groundingChunks, message?.metadata?.groundingMetadata?.groundingChunks,
message?.metadata?.webSearch?.results, message?.metadata?.webSearch?.results,
message?.metadata?.webSearchInfo message?.metadata?.webSearchInfo
]); // knowledge 依赖已移除,因为它在 useMemo 中没有被使用
])
// Process content to make citation numbers clickable // Process content to make citation numbers clickable
const processedContent = useMemo(() => { const processedContent = useMemo(() => {
@ -149,55 +150,63 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
message.metadata?.citations || message.metadata?.citations ||
message.metadata?.webSearch || message.metadata?.webSearch ||
message.metadata?.webSearchInfo || 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 // Convert [n] format to superscript numbers and make them clickable
// Use <sup> tag for superscript and make it a link with citation data // 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) => { content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => {
const num = num1 || num2; const num = num1 || num2
const index = parseInt(num) - 1; const index = parseInt(num) - 1
if (index >= 0 && index < citations.length) { if (index >= 0 && index < citations.length) {
const link = citations[index]; const link = citations[index]
const citationData = link ? encodeHTML(JSON.stringify(citationsData.get(link) || { url: link })) : null; const isWebLink = link && (link.startsWith('http://') || link.startsWith('https://'))
return link ? `[<sup data-citation='${citationData}'>${num}</sup>](${link})` : `<sup>${num}</sup>`; 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 { } else {
// Handle other citation formats if necessary, potentially adjusting this logic // Handle other citation formats if necessary, potentially adjusting this logic
// The original else block seemed specific, ensure it covers necessary cases or adjust // The original else block seemed specific, ensure it covers necessary cases or adjust
content = content.replace(/\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => { content = content.replace(/\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => {
const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null; const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null
return `[<sup data-citation='${citationData}'>${num}</sup>](${url})` return `[<sup data-citation='${citationData}'>${num}</sup>](${url})`
}); })
} }
return content; return content
}, [ }, [
message.metadata?.citations, message.metadata?.citations,
message.metadata?.webSearch, message.metadata?.webSearch,
message.metadata?.knowledge,
message.metadata?.webSearchInfo, message.metadata?.webSearchInfo,
message.metadata?.annotations, message.metadata?.annotations,
message.content, message.content,
citationsData citationsData
]); ])
if (message.status === 'sending') { if (message.status === 'sending') {
return ( return (
<MessageContentLoading> <MessageContentLoading>
<SyncOutlined spin size={24} /> <SyncOutlined spin size={24} />
</MessageContentLoading> </MessageContentLoading>
); )
} }
if (message.status === 'searching') { if (message.status === 'searching') {
@ -207,22 +216,22 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
<SearchingText>{t('message.searching')}</SearchingText> <SearchingText>{t('message.searching')}</SearchingText>
<BarLoader color="#1677ff" /> <BarLoader color="#1677ff" />
</SearchingContainer> </SearchingContainer>
); )
} }
if (message.status === 'error') { if (message.status === 'error') {
return <MessageError message={message} />; return <MessageError message={message} />
} }
if (message.type === '@' && model) { if (message.type === '@' && model) {
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`; const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
return <Markdown message={{ ...message, content }} />; return <Markdown message={{ ...message, content }} />
} }
// --- MODIFIED LINE BELOW --- // --- MODIFIED LINE BELOW ---
// This regex now matches <tool_use ...> OR <XML ...> tags (case-insensitive) // 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. // 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 ( return (
<Fragment> <Fragment>
@ -255,12 +264,12 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
<span <span
className="reference-id" className="reference-id"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
navigator.clipboard.writeText(refMsg.id); navigator.clipboard.writeText(refMsg.id)
window.message.success({ window.message.success({
content: t('message.id_copied') || '消息ID已复制', content: t('message.id_copied') || '消息ID已复制',
key: 'copy-reference-id' key: 'copy-reference-id'
}); })
}}> }}>
ID: {refMsg.id} ID: {refMsg.id}
</span> </span>
@ -299,12 +308,12 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
<span <span
className="reference-id" className="reference-id"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
navigator.clipboard.writeText((message as any).referencedMessage.id); navigator.clipboard.writeText((message as any).referencedMessage.id)
window.message.success({ window.message.success({
content: t('message.id_copied') || '消息ID已复制', content: t('message.id_copied') || '消息ID已复制',
key: 'copy-reference-id' key: 'copy-reference-id'
}); })
}}> }}>
ID: {(message as any).referencedMessage.id} ID: {(message as any).referencedMessage.id}
</span> </span>
@ -325,10 +334,10 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
<MessageTools message={message} /> <MessageTools message={message} />
</div> </div>
{isSegmentedPlayback ? ( {isSegmentedPlayback ? (
// Apply regex replacement here for TTS // Apply regex replacement here for TTS
<TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} /> <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, '') }} /> <Markdown message={{ ...message, content: processedContent.replace(tagsToRemoveRegex, '') }} />
)} )}
{message.metadata?.generateImage && <MessageImage message={message} />} {message.metadata?.generateImage && <MessageImage message={message} />}
@ -340,7 +349,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
{message.translatedContent === t('translate.processing') ? ( {message.translatedContent === t('translate.processing') ? (
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 15 }} /> <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 }} /> <Markdown message={{ ...message, content: message.translatedContent }} />
)} )}
</Fragment> </Fragment>
@ -400,8 +409,8 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
)} )}
<MessageAttachments message={message} /> <MessageAttachments message={message} />
</Fragment> </Fragment>
); )
}; }
// Styled components and global styles remain the same... // Styled components and global styles remain the same...
@ -412,7 +421,7 @@ const MessageContentLoading = styled.div`
height: 32px; height: 32px;
margin-top: -5px; margin-top: -5px;
margin-bottom: 5px; margin-bottom: 5px;
`; `
const SearchingContainer = styled.div` const SearchingContainer = styled.div`
display: flex; display: flex;
@ -423,22 +432,22 @@ const SearchingContainer = styled.div`
border-radius: 10px; border-radius: 10px;
margin-bottom: 10px; margin-bottom: 10px;
gap: 10px; gap: 10px;
`; `
const MentionTag = styled.span` const MentionTag = styled.span`
color: var(--color-link); color: var(--color-link);
`; `
const SearchingText = styled.div` const SearchingText = styled.div`
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
text-decoration: none; text-decoration: none;
color: var(--color-text-1); color: var(--color-text-1);
`; `
const SearchEntryPoint = styled.div` const SearchEntryPoint = styled.div`
margin: 10px 2px; margin: 10px 2px;
`; `
// 引用消息样式 - 使用全局样式 // 引用消息样式 - 使用全局样式
const referenceStyles = ` const referenceStyles = `
@ -549,29 +558,29 @@ const referenceStyles = `
} }
} }
} }
`; `
// 将样式添加到文档中 // 将样式添加到文档中
try { try {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
// Check if style already exists to prevent duplicates during HMR // 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) { if (!styleElement) {
styleElement = document.createElement('style'); styleElement = document.createElement('style')
styleElement.id = 'message-content-reference-styles'; styleElement.id = 'message-content-reference-styles'
styleElement.textContent = styleElement.textContent =
referenceStyles + referenceStyles +
` `
.message-content-tools { .message-content-tools {
margin-top: 20px; /* Adjust as needed */ margin-top: 20px; /* Adjust as needed */
margin-bottom: 10px; /* Add space before main content */ margin-bottom: 10px; /* Add space before main content */
} }
`; `
document.head.appendChild(styleElement); document.head.appendChild(styleElement)
} }
} }
} catch (error) { } 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)

View File

@ -9,16 +9,19 @@ interface Props {
interface State { interface State {
hasError: boolean hasError: boolean
errorMessage?: string error?: Error
} }
const ErrorFallback = ({ fallback }: { fallback?: React.ReactNode }) => { const ErrorFallback = ({ fallback, error }: { fallback?: React.ReactNode; error?: Error }) => {
const { t } = useTranslation() 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> { class MessageErrorBoundary extends React.Component<Props, State> {
@ -28,21 +31,7 @@ class MessageErrorBoundary extends React.Component<Props, State> {
} }
static getDerivedStateFromError(error: Error) { static getDerivedStateFromError(error: Error) {
// 检查是否是特定错误 return { hasError: true, 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 }
} }
// 正确缩进 componentDidCatch // 正确缩进 componentDidCatch
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
@ -65,11 +54,7 @@ class MessageErrorBoundary extends React.Component<Props, State> {
// 正确缩进 render // 正确缩进 render
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
// 如果有特定错误消息,显示自定义错误 return <ErrorFallback fallback={this.props.fallback} error={this.state.error} />
if (this.state.errorMessage) {
return <Alert message="渲染错误" description={this.state.errorMessage} type="error" showIcon />
}
return <ErrorFallback fallback={this.props.fallback} />
} }
return this.props.children return this.props.children
} }

View File

@ -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 AssistantMemoryPopup from '@renderer/components/AssistantMemoryPopup'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'

View File

@ -50,9 +50,9 @@ const MessageStream: React.FC<MessageStreamProps> = ({
// 使用useMemo缓存计算结果 // 使用useMemo缓存计算结果
const { isStreaming, message } = useMemo(() => { const { isStreaming, message } = useMemo(() => {
const isStreaming = !!(streamMessage && streamMessage.id === _message.id); const isStreaming = !!(streamMessage && streamMessage.id === _message.id)
const message = isStreaming ? streamMessage : regularMessage; const message = isStreaming ? streamMessage : regularMessage
return { isStreaming, message }; return { isStreaming, message }
}, [streamMessage, regularMessage, _message.id]) }, [streamMessage, regularMessage, _message.id])
return ( return (
<MessageStreamContainer> <MessageStreamContainer>
@ -78,5 +78,5 @@ export default memo(MessageStream, (prevProps, nextProps) => {
prevProps.message.content === nextProps.message.content && prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status && prevProps.message.status === nextProps.message.status &&
prevProps.topic.id === nextProps.topic.id prevProps.topic.id === nextProps.topic.id
); )
}) })

View File

@ -11,7 +11,6 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings' import { setNarrowMode } from '@renderer/store/settings'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
@ -64,8 +63,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, activeTopic }) => {
} }
} }
return ( return (
<Navbar className="home-navbar"> <Navbar className="home-navbar">
{showAssistants && ( {showAssistants && (

View File

@ -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 { useTheme } from '@renderer/context/ThemeProvider'
import { useProviders } from '@renderer/hooks/useProvider' 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' import { Model } from '@renderer/types'
// 不再需要 useAppDispatch // 不再需要 useAppDispatch
import { createAllDeepClaudeProviders } from '@renderer/utils/createDeepClaudeProvider' 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 { interface ModelCombination {
id: string id: string
@ -29,9 +30,9 @@ const DeepClaudeSettings: FC = () => {
// 本地状态 // 本地状态
const [combinations, setCombinations] = useState<ModelCombination[]>([]) const [combinations, setCombinations] = useState<ModelCombination[]>([])
const [newCombination, setNewCombination] = useState<{ const [newCombination, setNewCombination] = useState<{
name: string; name: string
reasonerModel: string; reasonerModel: string
targetModel: string; targetModel: string
}>({ }>({
name: '', name: '',
reasonerModel: '', reasonerModel: '',
@ -41,9 +42,9 @@ const DeepClaudeSettings: FC = () => {
// 编辑状态 // 编辑状态
const [editingCombination, setEditingCombination] = useState<string | null>(null) const [editingCombination, setEditingCombination] = useState<string | null>(null)
const [editForm, setEditForm] = useState<{ const [editForm, setEditForm] = useState<{
name: string; name: string
reasonerModel: string; reasonerModel: string
targetModel: string; targetModel: string
}>({ }>({
name: '', name: '',
reasonerModel: '', reasonerModel: '',
@ -51,8 +52,8 @@ const DeepClaudeSettings: FC = () => {
}) })
// 获取所有可用的模型 // 获取所有可用的模型
const allModels = providers.flatMap(provider => const allModels = providers.flatMap((provider) =>
provider.models.map(model => ({ provider.models.map((model) => ({
...model, ...model,
providerName: provider.name, providerName: provider.name,
providerId: provider.id providerId: provider.id
@ -60,21 +61,25 @@ const DeepClaudeSettings: FC = () => {
) )
// 推荐的推理模型 // 推荐的推理模型
const recommendedReasonerModels = allModels.filter(model => { const recommendedReasonerModels = allModels.filter((model) => {
// 推荐 DeepSeek 模型作为推理模型 // 推荐 DeepSeek 模型作为推理模型
return model.name.toLowerCase().includes('deepseek') || return (
model.name.toLowerCase().includes('deep-seek') || model.name.toLowerCase().includes('deepseek') ||
model.name.toLowerCase().includes('yi') || model.name.toLowerCase().includes('deep-seek') ||
model.name.toLowerCase().includes('qwen') || model.name.toLowerCase().includes('yi') ||
model.name.toLowerCase().includes('glm') model.name.toLowerCase().includes('qwen') ||
model.name.toLowerCase().includes('glm')
)
}) })
// 推荐的目标模型 // 推荐的目标模型
const recommendedTargetModels = allModels.filter(model => { const recommendedTargetModels = allModels.filter((model) => {
// 推荐 Claude 和 Gemini 模型作为目标模型 // 推荐 Claude 和 Gemini 模型作为目标模型
return model.name.toLowerCase().includes('claude') || return (
model.name.toLowerCase().includes('gemini') || model.name.toLowerCase().includes('claude') ||
model.name.toLowerCase().includes('gpt') model.name.toLowerCase().includes('gemini') ||
model.name.toLowerCase().includes('gpt')
)
}) })
// 创建提供商 // 创建提供商
@ -132,7 +137,7 @@ const DeepClaudeSettings: FC = () => {
// 开始编辑组合 // 开始编辑组合
const startEditCombination = (id: string) => { const startEditCombination = (id: string) => {
const combination = combinations.find(c => c.id === id) const combination = combinations.find((c) => c.id === id)
if (!combination) return if (!combination) return
setEditingCombination(id) setEditingCombination(id)
@ -149,7 +154,7 @@ const DeepClaudeSettings: FC = () => {
return return
} }
const newCombinations = combinations.map(c => const newCombinations = combinations.map((c) =>
c.id === editingCombination c.id === editingCombination
? { ? {
...c, ...c,
@ -178,28 +183,26 @@ const DeepClaudeSettings: FC = () => {
// 删除组合 // 删除组合
const deleteCombination = (id: string) => { const deleteCombination = (id: string) => {
const newCombinations = combinations.filter(c => c.id !== id) const newCombinations = combinations.filter((c) => c.id !== id)
setCombinations(newCombinations) setCombinations(newCombinations)
} }
// 更新组合状态 // 更新组合状态
const updateCombinationStatus = (id: string, enabled: boolean) => { const updateCombinationStatus = (id: string, enabled: boolean) => {
const newCombinations = combinations.map(c => const newCombinations = combinations.map((c) => (c.id === id ? { ...c, enabled } : c))
c.id === id ? { ...c, enabled } : c
)
setCombinations(newCombinations) setCombinations(newCombinations)
} }
// 获取模型名称 // 获取模型名称
const getModelFullName = (modelId: string) => { const getModelFullName = (modelId: string) => {
const model = allModels.find(m => m.id === modelId) const model = allModels.find((m) => m.id === modelId)
if (!model) return modelId if (!model) return modelId
return `${model.name} (${model.providerName})` return `${model.name} (${model.providerName})`
} }
// 获取模型对象 // 获取模型对象
const getModelById = (modelId: string): Model | undefined => { 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> <SettingRowTitle>{t('settings.deepclaude.combinations')}</SettingRowTitle>
</SettingRow> </SettingRow>
{combinations.map(combination => ( {combinations.map((combination) => (
<CombinationItem key={combination.id}> <CombinationItem key={combination.id}>
<VStack gap={10}> <VStack gap={10}>
<HStack justifyContent="space-between" alignItems="center"> <HStack justifyContent="space-between" alignItems="center">
@ -327,26 +330,13 @@ const DeepClaudeSettings: FC = () => {
</ModelInfo> </ModelInfo>
</HStack> </HStack>
<HStack justifyContent="flex-end" gap={8}> <HStack justifyContent="flex-end" gap={8}>
<Button <Button type="primary" size="small" icon={<ApiOutlined />} onClick={() => createProvider()}>
type="primary"
size="small"
icon={<ApiOutlined />}
onClick={() => createProvider()}
>
{t('settings.deepclaude.create_provider')} {t('settings.deepclaude.create_provider')}
</Button> </Button>
<Button <Button type="default" size="small" onClick={() => startEditCombination(combination.id)}>
type="default"
size="small"
onClick={() => startEditCombination(combination.id)}
>
{t('common.edit')} {t('common.edit')}
</Button> </Button>
<Button <Button danger size="small" onClick={() => deleteCombination(combination.id)}>
danger
size="small"
onClick={() => deleteCombination(combination.id)}
>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
</HStack> </HStack>
@ -360,11 +350,7 @@ const DeepClaudeSettings: FC = () => {
{combinations.length > 0 && ( {combinations.length > 0 && (
<HStack justifyContent="flex-end" style={{ marginBottom: '20px' }}> <HStack justifyContent="flex-end" style={{ marginBottom: '20px' }}>
<Button <Button type="primary" icon={<ApiOutlined />} onClick={createAllProviders}>
type="primary"
icon={<ApiOutlined />}
onClick={createAllProviders}
>
{t('settings.deepclaude.create_all_providers')} {t('settings.deepclaude.create_all_providers')}
</Button> </Button>
</HStack> </HStack>
@ -381,7 +367,7 @@ const DeepClaudeSettings: FC = () => {
<Form.Item label={t('settings.deepclaude.combination_name')}> <Form.Item label={t('settings.deepclaude.combination_name')}>
<Input <Input
value={editForm.name} 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')} placeholder={t('settings.deepclaude.combination_name_placeholder')}
/> />
</Form.Item> </Form.Item>
@ -391,7 +377,7 @@ const DeepClaudeSettings: FC = () => {
model={getModelById(editForm.reasonerModel)} model={getModelById(editForm.reasonerModel)}
onClick={selectEditReasonerModel} onClick={selectEditReasonerModel}
placeholder={t('settings.deepclaude.select_reasoner_placeholder')} 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> </Form.Item>
<ModelTip>{t('settings.deepclaude.reasoner_tip')}</ModelTip> <ModelTip>{t('settings.deepclaude.reasoner_tip')}</ModelTip>
@ -401,7 +387,7 @@ const DeepClaudeSettings: FC = () => {
model={getModelById(editForm.targetModel)} model={getModelById(editForm.targetModel)}
onClick={selectEditTargetModel} onClick={selectEditTargetModel}
placeholder={t('settings.deepclaude.select_target_placeholder')} 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> </Form.Item>
<ModelTip>{t('settings.deepclaude.target_tip')}</ModelTip> <ModelTip>{t('settings.deepclaude.target_tip')}</ModelTip>
@ -411,13 +397,10 @@ const DeepClaudeSettings: FC = () => {
<Button <Button
type="primary" type="primary"
onClick={saveEditCombination} onClick={saveEditCombination}
disabled={!editForm.name || !editForm.reasonerModel || !editForm.targetModel} disabled={!editForm.name || !editForm.reasonerModel || !editForm.targetModel}>
>
{t('common.save')} {t('common.save')}
</Button> </Button>
<Button onClick={cancelEdit}> <Button onClick={cancelEdit}>{t('common.cancel')}</Button>
{t('common.cancel')}
</Button>
</HStack> </HStack>
</Form.Item> </Form.Item>
</Form> </Form>
@ -437,7 +420,7 @@ const DeepClaudeSettings: FC = () => {
<Form.Item label={t('settings.deepclaude.combination_name')}> <Form.Item label={t('settings.deepclaude.combination_name')}>
<Input <Input
value={newCombination.name} 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')} placeholder={t('settings.deepclaude.combination_name_placeholder')}
/> />
</Form.Item> </Form.Item>
@ -447,7 +430,7 @@ const DeepClaudeSettings: FC = () => {
model={getModelById(newCombination.reasonerModel)} model={getModelById(newCombination.reasonerModel)}
onClick={selectReasonerModel} onClick={selectReasonerModel}
placeholder={t('settings.deepclaude.select_reasoner_placeholder')} 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> </Form.Item>
<ModelTip>{t('settings.deepclaude.reasoner_tip')}</ModelTip> <ModelTip>{t('settings.deepclaude.reasoner_tip')}</ModelTip>
@ -457,7 +440,7 @@ const DeepClaudeSettings: FC = () => {
model={getModelById(newCombination.targetModel)} model={getModelById(newCombination.targetModel)}
onClick={selectTargetModel} onClick={selectTargetModel}
placeholder={t('settings.deepclaude.select_target_placeholder')} 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> </Form.Item>
<ModelTip>{t('settings.deepclaude.target_tip')}</ModelTip> <ModelTip>{t('settings.deepclaude.target_tip')}</ModelTip>
@ -467,8 +450,7 @@ const DeepClaudeSettings: FC = () => {
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={addCombination} onClick={addCombination}
disabled={!newCombination.name || !newCombination.reasonerModel || !newCombination.targetModel} disabled={!newCombination.name || !newCombination.reasonerModel || !newCombination.targetModel}>
>
{t('settings.deepclaude.add')} {t('settings.deepclaude.add')}
</Button> </Button>
</Form.Item> </Form.Item>
@ -510,10 +492,10 @@ const ModelTip = styled.div`
` `
interface ModelSelectButtonProps { interface ModelSelectButtonProps {
model?: Model; model?: Model
onClick: () => void; onClick: () => void
placeholder: string; placeholder: string
recommended?: string; recommended?: string
} }
const ModelSelectButton: FC<ModelSelectButtonProps> = ({ model, onClick, placeholder, recommended }) => { const ModelSelectButton: FC<ModelSelectButtonProps> = ({ model, onClick, placeholder, recommended }) => {

View File

@ -82,6 +82,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
window.message.success(t('settings.mcp.jsonSaveSuccess')) window.message.success(t('settings.mcp.jsonSaveSuccess'))
setJsonError('') setJsonError('')
setOpen(false) setOpen(false)
resolve({})
TopView.hide(TopViewKey)
} catch (error: any) { } catch (error: any) {
console.error('Failed to save JSON config:', error) console.error('Failed to save JSON config:', error)
setJsonError(error.message || t('settings.mcp.jsonSaveError')) setJsonError(error.message || t('settings.mcp.jsonSaveError'))
@ -93,6 +95,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const onCancel = () => { const onCancel = () => {
setOpen(false) setOpen(false)
resolve({})
TopView.hide(TopViewKey)
} }
const onClose = () => { const onClose = () => {

View File

@ -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
)
})
}
}

View File

@ -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 { NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import { isWindows } from '@renderer/config/constant' import { isWindows } from '@renderer/config/constant'
@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import EditMcpJsonPopup from './EditMcpJsonPopup' import EditMcpJsonPopup from './EditMcpJsonPopup'
import ImportMcpServerPopup from './ImportMcpServerPopup'
import InstallNpxUv from './InstallNpxUv' import InstallNpxUv from './InstallNpxUv'
export const McpSettingsNavbar = () => { export const McpSettingsNavbar = () => {
@ -27,6 +28,15 @@ export const McpSettingsNavbar = () => {
style={{ fontSize: 13, height: 28, borderRadius: 20 }}> style={{ fontSize: 13, height: 28, borderRadius: 20 }}>
{t('settings.mcp.searchNpx')} {t('settings.mcp.searchNpx')}
</Button> </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 <Button
size="small" size="small"
type="text" type="text"

View File

@ -5,6 +5,7 @@ import { Center, HStack } from '@renderer/components/Layout'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { builtinMCPServers } from '@renderer/store/mcp' import { builtinMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { getMcpConfigSampleFromReadme } from '@renderer/utils'
import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd' import { Button, Card, Flex, Input, Space, Spin, Tag, Typography } from 'antd'
import { npxFinder } from 'npx-scope-finder' import { npxFinder } from 'npx-scope-finder'
import { type FC, useEffect, useState } from 'react' import { type FC, useEffect, useState } from 'react'
@ -19,6 +20,7 @@ interface SearchResult {
npmLink: string npmLink: string
fullName: string fullName: string
type: MCPServer['type'] type: MCPServer['type']
configSample?: MCPServer['configSample']
} }
const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket'] const npmScopes = ['@cherry', '@modelcontextprotocol', '@gongrzhe', '@mcpmarket']
@ -76,6 +78,11 @@ const NpxSearch: FC<{
// Map the packages to our desired format // Map the packages to our desired format
const formattedResults: SearchResult[] = packages.map((pkg) => { const formattedResults: SearchResult[] = packages.map((pkg) => {
let configSample
if (pkg.original?.readme) {
configSample = getMcpConfigSampleFromReadme(pkg.original.readme)
}
return { return {
key: pkg.name, key: pkg.name,
name: pkg.name?.split('/')[1] || '', name: pkg.name?.split('/')[1] || '',
@ -84,7 +91,8 @@ const NpxSearch: FC<{
usage: `npx ${pkg.name}`, usage: `npx ${pkg.name}`,
npmLink: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`, npmLink: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
fullName: pkg.name || '', fullName: pkg.name || '',
type: 'stdio' type: 'stdio',
configSample
} }
}) })
@ -199,9 +207,11 @@ const NpxSearch: FC<{
name: record.name, 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}`, description: `${record.description}\n\n${t('settings.mcp.npx_list.usage')}: ${record.usage}\n${t('settings.mcp.npx_list.npm')}: ${record.npmLink}`,
command: 'npx', command: 'npx',
args: ['-y', record.fullName], args: record.configSample?.args ?? ['-y', record.fullName],
env: record.configSample?.env,
isActive: false, isActive: false,
type: record.type type: record.type,
configSample: record.configSample
} }
addMCPServer(newServer) addMCPServer(newServer)

View File

@ -59,6 +59,8 @@ const MCPSettings: FC = () => {
} }
}, [mcpServers, selectedMcpServer]) }, [mcpServers, selectedMcpServer])
// 这些函数已移至顶部工具栏,不再需要
const McpServersList = useCallback( const McpServersList = useCallback(
() => ( () => (
<GridContainer> <GridContainer>
@ -266,4 +268,6 @@ const BackButton = styled.div`
} }
` `
// 这些样式组件已不再需要,因为按钮已移至顶部工具栏
export default MCPSettings export default MCPSettings

View File

@ -35,7 +35,9 @@ const AssistantMemoryManager = () => {
const assistantMemories = useAppSelector((state) => { const assistantMemories = useAppSelector((state) => {
const allAssistantMemories = state.memory?.assistantMemories || [] 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} onChange={setSelectedAssistantId}
placeholder={t('settings.memory.selectAssistant') || '选择助手'} placeholder={t('settings.memory.selectAssistant') || '选择助手'}
style={{ width: '100%', marginBottom: 16 }} style={{ width: '100%', marginBottom: 16 }}
disabled={!assistantMemoryActive} disabled={!assistantMemoryActive}>
>
{assistants.map((assistant) => ( {assistants.map((assistant) => (
<Select.Option key={assistant.id} value={assistant.id}> <Select.Option key={assistant.id} value={assistant.id}>
{assistant.name} {assistant.name}
@ -165,7 +166,11 @@ const AssistantMemoryManager = () => {
/> />
) : ( ) : (
<Empty <Empty
description={!selectedAssistantId ? t('settings.memory.selectAssistantFirst') || '请先选择助手' : t('settings.memory.noAssistantMemories') || '无助手记忆'} description={
!selectedAssistantId
? t('settings.memory.selectAssistantFirst') || '请先选择助手'
: t('settings.memory.noAssistantMemories') || '无助手记忆'
}
/> />
)} )}
</div> </div>

View File

@ -691,7 +691,10 @@ const MemorySettings: FC = () => {
<SettingDivider /> <SettingDivider />
<SettingTitle>{t('settings.memory.assistantMemorySettings') || '助手记忆设置'}</SettingTitle> <SettingTitle>{t('settings.memory.assistantMemorySettings') || '助手记忆设置'}</SettingTitle>
<SettingHelpText>{t('settings.memory.assistantMemoryDescription') || '助手记忆是与特定助手关联的记忆,可以帮助助手记住重要信息。'}</SettingHelpText> <SettingHelpText>
{t('settings.memory.assistantMemoryDescription') ||
'助手记忆是与特定助手关联的记忆,可以帮助助手记住重要信息。'}
</SettingHelpText>
<SettingDivider /> <SettingDivider />
{/* 助手记忆设置 */} {/* 助手记忆设置 */}

View File

@ -1,17 +1,29 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useProviders } from '@renderer/hooks/useProvider'
import { useAppDispatch } from '@renderer/store' 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 { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..' 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 { interface ModelCombination {
@ -41,8 +53,8 @@ const ModelCombinationSettings: FC = () => {
const [activeTab, setActiveTab] = useState('combinations') const [activeTab, setActiveTab] = useState('combinations')
// 获取所有可用的模型 // 获取所有可用的模型
const allModels = providers.flatMap(provider => const allModels = providers.flatMap((provider) =>
provider.models.map(model => ({ provider.models.map((model) => ({
...model, ...model,
providerName: provider.name providerName: provider.name
})) }))
@ -51,7 +63,7 @@ const ModelCombinationSettings: FC = () => {
// 根据ID查找模型 // 根据ID查找模型
const findModelById = (id: string): Model | null => { const findModelById = (id: string): Model | null => {
for (const provider of providers) { 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 if (model) return model
} }
return null return null
@ -110,41 +122,47 @@ const ModelCombinationSettings: FC = () => {
// 保存模型组合到localStorage // 保存模型组合到localStorage
const saveCombinations = (newCombinations: ModelCombination[]) => { const saveCombinations = (newCombinations: ModelCombination[]) => {
console.log('[ModelCombinationSettings] 保存模型组合:', console.log(
newCombinations.map(c => ({ '[ModelCombinationSettings] 保存模型组合:',
id: c.id, newCombinations.map((c) => ({
name: c.name, id: c.id,
reasonerModel: { name: c.name,
id: c.reasonerModel?.id, reasonerModel: {
name: c.reasonerModel?.name, id: c.reasonerModel?.id,
provider: c.reasonerModel?.provider name: c.reasonerModel?.name,
}, provider: c.reasonerModel?.provider
targetModel: { },
id: c.targetModel?.id, targetModel: {
name: c.targetModel?.name, id: c.targetModel?.id,
provider: c.targetModel?.provider name: c.targetModel?.name,
}, provider: c.targetModel?.provider
isActive: c.isActive },
}))) isActive: c.isActive
}))
)
// 确保模型组合中的模型对象是完整的 // 确保模型组合中的模型对象是完整的
const combinationsToSave = newCombinations.map(c => ({ const combinationsToSave = newCombinations.map((c) => ({
id: c.id, id: c.id,
name: c.name, name: c.name,
reasonerModel: c.reasonerModel ? { reasonerModel: c.reasonerModel
id: c.reasonerModel.id, ? {
name: c.reasonerModel.name, id: c.reasonerModel.id,
provider: c.reasonerModel.provider, name: c.reasonerModel.name,
group: c.reasonerModel.group, provider: c.reasonerModel.provider,
type: c.reasonerModel.type group: c.reasonerModel.group,
} : null, type: c.reasonerModel.type
targetModel: c.targetModel ? { }
id: c.targetModel.id, : null,
name: c.targetModel.name, targetModel: c.targetModel
provider: c.targetModel.provider, ? {
group: c.targetModel.group, id: c.targetModel.id,
type: c.targetModel.type name: c.targetModel.name,
} : null, provider: c.targetModel.provider,
group: c.targetModel.group,
type: c.targetModel.type
}
: null,
isActive: c.isActive isActive: c.isActive
})) }))
@ -162,44 +180,48 @@ const ModelCombinationSettings: FC = () => {
// 使用setTimeout来避免在渲染周期内进行多次状态更新 // 使用setTimeout来避免在渲染周期内进行多次状态更新
setTimeout(() => { setTimeout(() => {
// 移除所有现有的DeepClaude提供商 // 移除所有现有的DeepClaude提供商
const existingDeepClaudeProviders = providers.filter(p => p.type === 'deepclaude') const existingDeepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
console.log('[ModelCombinationSettings] 移除现有DeepClaude提供商数量:', existingDeepClaudeProviders.length) console.log('[ModelCombinationSettings] 移除现有DeepClaude提供商数量:', existingDeepClaudeProviders.length)
existingDeepClaudeProviders.forEach(provider => { existingDeepClaudeProviders.forEach((provider) => {
dispatch(removeProvider(provider)) dispatch(removeProvider(provider))
}) })
// 创建并添加新的DeepClaude提供商 // 创建并添加新的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.length)
console.log('[ModelCombinationSettings] 激活的模型组合详情:', console.log(
activeCombinations.map(c => ({ '[ModelCombinationSettings] 激活的模型组合详情:',
id: c.id, activeCombinations.map((c) => ({
name: c.name, id: c.id,
reasonerModel: { name: c.name,
id: c.reasonerModel?.id, reasonerModel: {
name: c.reasonerModel?.name, id: c.reasonerModel?.id,
provider: c.reasonerModel?.provider name: c.reasonerModel?.name,
}, provider: c.reasonerModel?.provider
targetModel: { },
id: c.targetModel?.id, targetModel: {
name: c.targetModel?.name, id: c.targetModel?.id,
provider: c.targetModel?.provider name: c.targetModel?.name,
} provider: c.targetModel?.provider
}))) }
}))
)
if (activeCombinations.length > 0) { if (activeCombinations.length > 0) {
// 创建一个单一的DeepClaude提供商包含所有激活的模型组合 // 创建一个单一的DeepClaude提供商包含所有激活的模型组合
const provider = createDeepClaudeProvider(activeCombinations) const provider = createDeepClaudeProvider(activeCombinations)
console.log('[ModelCombinationSettings] 创建的DeepClaude提供商:', console.log(
provider.id, provider.name, provider.type, '[ModelCombinationSettings] 创建的DeepClaude提供商:',
provider.models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))) provider.id,
provider.name,
provider.type,
provider.models.map((m) => ({ id: m.id, name: m.name, provider: m.provider }))
)
dispatch(addProvider(provider)) dispatch(addProvider(provider))
} }
}, 0) }, 0)
} }
// 添加或编辑模型组合 // 添加或编辑模型组合
const handleAddOrEditCombination = (values: any) => { const handleAddOrEditCombination = (values: any) => {
const { name, reasonerModelId, targetModelId, isActive, thinkingLibraryId } = values const { name, reasonerModelId, targetModelId, isActive, thinkingLibraryId } = values
@ -214,7 +236,7 @@ const ModelCombinationSettings: FC = () => {
if (editingCombination) { if (editingCombination) {
// 编辑现有组合 // 编辑现有组合
const updatedCombinations = combinations.map(comb => const updatedCombinations = combinations.map((comb) =>
comb.id === editingCombination.id comb.id === editingCombination.id
? { ...comb, name, reasonerModel, targetModel, isActive: isActive !== false, thinkingLibraryId } ? { ...comb, name, reasonerModel, targetModel, isActive: isActive !== false, thinkingLibraryId }
: comb : comb
@ -246,7 +268,7 @@ const ModelCombinationSettings: FC = () => {
title: t('settings.modelCombination.confirmDelete'), title: t('settings.modelCombination.confirmDelete'),
content: t('settings.modelCombination.confirmDeleteContent'), content: t('settings.modelCombination.confirmDeleteContent'),
onOk: () => { onOk: () => {
const updatedCombinations = combinations.filter(comb => comb.id !== id) const updatedCombinations = combinations.filter((comb) => comb.id !== id)
saveCombinations(updatedCombinations) saveCombinations(updatedCombinations)
message.success(t('settings.modelCombination.deleteSuccess')) message.success(t('settings.modelCombination.deleteSuccess'))
} }
@ -268,9 +290,7 @@ const ModelCombinationSettings: FC = () => {
// 切换模型组合的激活状态 // 切换模型组合的激活状态
const toggleCombinationActive = (id: string, isActive: boolean) => { const toggleCombinationActive = (id: string, isActive: boolean) => {
const updatedCombinations = combinations.map(comb => const updatedCombinations = combinations.map((comb) => (comb.id === id ? { ...comb, isActive } : comb))
comb.id === id ? { ...comb, isActive } : comb
)
saveCombinations(updatedCombinations) saveCombinations(updatedCombinations)
} }
@ -351,7 +371,7 @@ const ModelCombinationSettings: FC = () => {
console.log('[ModelCombinationSettings] 当前思考库数量:', currentLibraries.length) 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) console.log('[ModelCombinationSettings] 过滤后思考库数量:', filteredLibraries.length)
// 保存到localStorage // 保存到localStorage
@ -395,58 +415,59 @@ const ModelCombinationSettings: FC = () => {
label: t('settings.modelCombination.title'), label: t('settings.modelCombination.title'),
children: ( children: (
<SettingGroup theme={theme}> <SettingGroup theme={theme}>
<SettingTitle> <SettingTitle>
{t('settings.modelCombination.title')} {t('settings.modelCombination.title')}
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => { onClick={() => {
setEditingCombination(null) setEditingCombination(null)
form.resetFields() form.resetFields()
setIsModalVisible(true) setIsModalVisible(true)
}} }}>
> {t('settings.modelCombination.add')}
{t('settings.modelCombination.add')} </Button>
</Button> </SettingTitle>
</SettingTitle> <SettingDivider />
<SettingDivider />
{combinations.length === 0 ? ( {combinations.length === 0 ? (
<EmptyState>{t('settings.modelCombination.empty')}</EmptyState> <EmptyState>{t('settings.modelCombination.empty')}</EmptyState>
) : ( ) : (
<CombinationList> <CombinationList>
{combinations.map(combination => ( {combinations.map((combination) => (
<CombinationItem key={combination.id}> <CombinationItem key={combination.id}>
<CombinationInfo> <CombinationInfo>
<CombinationName>{combination.name}</CombinationName> <CombinationName>{combination.name}</CombinationName>
<CombinationDetail> <CombinationDetail>
{t('settings.modelCombination.reasoner')}: {combination.reasonerModel?.name || t('settings.modelCombination.notSelected')} {t('settings.modelCombination.reasoner')}:{' '}
</CombinationDetail> {combination.reasonerModel?.name || t('settings.modelCombination.notSelected')}
<CombinationDetail> </CombinationDetail>
{t('settings.modelCombination.target')}: {combination.targetModel?.name || t('settings.modelCombination.notSelected')} <CombinationDetail>
</CombinationDetail> {t('settings.modelCombination.target')}:{' '}
</CombinationInfo> {combination.targetModel?.name || t('settings.modelCombination.notSelected')}
<CombinationActions> </CombinationDetail>
<Switch </CombinationInfo>
checked={combination.isActive} <CombinationActions>
onChange={(checked) => toggleCombinationActive(combination.id, checked)} <Switch
/> checked={combination.isActive}
<Button onChange={(checked) => toggleCombinationActive(combination.id, checked)}
icon={<EditOutlined />} />
type="text" <Button
onClick={() => handleEditCombination(combination)} icon={<EditOutlined />}
/> type="text"
<Button onClick={() => handleEditCombination(combination)}
icon={<DeleteOutlined />} />
type="text" <Button
danger icon={<DeleteOutlined />}
onClick={() => handleDeleteCombination(combination.id)} type="text"
/> danger
</CombinationActions> onClick={() => handleDeleteCombination(combination.id)}
</CombinationItem> />
))} </CombinationActions>
</CombinationList> </CombinationItem>
)} ))}
</CombinationList>
)}
</SettingGroup> </SettingGroup>
) )
}, },
@ -465,8 +486,7 @@ const ModelCombinationSettings: FC = () => {
setEditingLibrary(null) setEditingLibrary(null)
libraryForm.resetFields() libraryForm.resetFields()
setIsLibraryModalVisible(true) setIsLibraryModalVisible(true)
}} }}>
>
{t('settings.thinkingLibrary.add')} {t('settings.thinkingLibrary.add')}
</Button> </Button>
<Button <Button
@ -493,8 +513,7 @@ const ModelCombinationSettings: FC = () => {
} }
} }
}) })
}} }}>
>
</Button> </Button>
<Button <Button
@ -502,8 +521,7 @@ const ModelCombinationSettings: FC = () => {
// 调用调试函数,在控制台显示思考库数据 // 调用调试函数,在控制台显示思考库数据
debugThinkingLibraries() debugThinkingLibraries()
message.info('思考库调试信息已输出到控制台请按F12查看') message.info('思考库调试信息已输出到控制台请按F12查看')
}} }}>
>
</Button> </Button>
<Button <Button
@ -520,8 +538,10 @@ const ModelCombinationSettings: FC = () => {
console.log('[ModelCombinationSettings] 当前思考库数量:', currentLibraries.length) console.log('[ModelCombinationSettings] 当前思考库数量:', currentLibraries.length)
// 获取默认思考库中缺失的思考库 // 获取默认思考库中缺失的思考库
const existingIds = new Set(currentLibraries.map(lib => lib.id)) const existingIds = new Set(currentLibraries.map((lib) => lib.id))
const missingLibraries = DEFAULT_THINKING_LIBRARIES.filter((lib: ThinkingLibrary) => !existingIds.has(lib.id)) const missingLibraries = DEFAULT_THINKING_LIBRARIES.filter(
(lib: ThinkingLibrary) => !existingIds.has(lib.id)
)
console.log('[ModelCombinationSettings] 缺失的默认思考库数量:', missingLibraries.length) console.log('[ModelCombinationSettings] 缺失的默认思考库数量:', missingLibraries.length)
if (missingLibraries.length > 0) { if (missingLibraries.length > 0) {
@ -548,8 +568,7 @@ const ModelCombinationSettings: FC = () => {
} }
} }
}) })
}} }}>
>
</Button> </Button>
</ButtonGroup> </ButtonGroup>
@ -560,7 +579,7 @@ const ModelCombinationSettings: FC = () => {
<EmptyState>{t('settings.thinkingLibrary.empty')}</EmptyState> <EmptyState>{t('settings.thinkingLibrary.empty')}</EmptyState>
) : ( ) : (
<CombinationList> <CombinationList>
{thinkingLibraries.map(library => ( {thinkingLibraries.map((library) => (
<CombinationItem key={library.id}> <CombinationItem key={library.id}>
<CombinationInfo> <CombinationInfo>
<CombinationName>{library.name}</CombinationName> <CombinationName>{library.name}</CombinationName>
@ -572,11 +591,7 @@ const ModelCombinationSettings: FC = () => {
</CombinationDetail> </CombinationDetail>
</CombinationInfo> </CombinationInfo>
<CombinationActions> <CombinationActions>
<Button <Button icon={<EditOutlined />} type="text" onClick={() => handleEditLibrary(library)} />
icon={<EditOutlined />}
type="text"
onClick={() => handleEditLibrary(library)}
/>
<Button <Button
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
type="text" type="text"
@ -596,47 +611,29 @@ const ModelCombinationSettings: FC = () => {
{/* 添加/编辑模型组合的模态框 */} {/* 添加/编辑模型组合的模态框 */}
<Modal <Modal
title={editingCombination title={editingCombination ? t('settings.modelCombination.editTitle') : t('settings.modelCombination.addTitle')}
? t('settings.modelCombination.editTitle')
: t('settings.modelCombination.addTitle')
}
open={isModalVisible} open={isModalVisible}
onCancel={() => { onCancel={() => {
setIsModalVisible(false) setIsModalVisible(false)
setEditingCombination(null) setEditingCombination(null)
form.resetFields() form.resetFields()
}} }}
footer={null} footer={null}>
> <Form form={form} layout="vertical" onFinish={handleAddOrEditCombination}>
<Form
form={form}
layout="vertical"
onFinish={handleAddOrEditCombination}
>
<Form.Item <Form.Item
name="name" name="name"
label={t('settings.modelCombination.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')} /> <Input placeholder={t('settings.modelCombination.namePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="reasonerModelId" name="reasonerModelId"
label={t('settings.modelCombination.reasonerModel')} label={t('settings.modelCombination.reasonerModel')}
rules={[{ required: true, message: t('settings.modelCombination.reasonerModelRequired') }]} rules={[{ required: true, message: t('settings.modelCombination.reasonerModelRequired') }]}>
> <Select placeholder={t('settings.modelCombination.selectModel')} showSearch optionFilterProp="label">
<Select {allModels.map((model) => (
placeholder={t('settings.modelCombination.selectModel')} <Select.Option key={model.id} value={model.id} label={`${model.name} (${model.providerName})`}>
showSearch
optionFilterProp="label"
>
{allModels.map(model => (
<Select.Option
key={model.id}
value={model.id}
label={`${model.name} (${model.providerName})`}
>
{model.name} ({model.providerName}) {model.name} ({model.providerName})
</Select.Option> </Select.Option>
))} ))}
@ -646,50 +643,27 @@ const ModelCombinationSettings: FC = () => {
<Form.Item <Form.Item
name="targetModelId" name="targetModelId"
label={t('settings.modelCombination.targetModel')} label={t('settings.modelCombination.targetModel')}
rules={[{ required: true, message: t('settings.modelCombination.targetModelRequired') }]} rules={[{ required: true, message: t('settings.modelCombination.targetModelRequired') }]}>
> <Select placeholder={t('settings.modelCombination.selectModel')} showSearch optionFilterProp="label">
<Select {allModels.map((model) => (
placeholder={t('settings.modelCombination.selectModel')} <Select.Option key={model.id} value={model.id} label={`${model.name} (${model.providerName})`}>
showSearch
optionFilterProp="label"
>
{allModels.map(model => (
<Select.Option
key={model.id}
value={model.id}
label={`${model.name} (${model.providerName})`}
>
{model.name} ({model.providerName}) {model.name} ({model.providerName})
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="thinkingLibraryId" label="思考库">
name="thinkingLibraryId" <Select placeholder="选择思考库(可选)" allowClear>
label="思考库" {thinkingLibraries.map((library) => (
> <Select.Option key={library.id} value={library.id} label={`${library.name} (${library.category})`}>
<Select
placeholder="选择思考库(可选)"
allowClear
>
{thinkingLibraries.map(library => (
<Select.Option
key={library.id}
value={library.id}
label={`${library.name} (${library.category})`}
>
{library.name} ({library.category}) {library.name} ({library.category})
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="isActive" valuePropName="checked" initialValue={true}>
name="isActive"
valuePropName="checked"
initialValue={true}
>
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} /> <Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
</Form.Item> </Form.Item>
@ -703,59 +677,41 @@ const ModelCombinationSettings: FC = () => {
{/* 添加/编辑思考库的模态框 */} {/* 添加/编辑思考库的模态框 */}
<Modal <Modal
title={editingLibrary title={editingLibrary ? t('settings.thinkingLibrary.editTitle') : t('settings.thinkingLibrary.addTitle')}
? t('settings.thinkingLibrary.editTitle')
: t('settings.thinkingLibrary.addTitle')
}
open={isLibraryModalVisible} open={isLibraryModalVisible}
onCancel={() => { onCancel={() => {
setIsLibraryModalVisible(false) setIsLibraryModalVisible(false)
setEditingLibrary(null) setEditingLibrary(null)
libraryForm.resetFields() libraryForm.resetFields()
}} }}
footer={null} footer={null}>
> <Form form={libraryForm} layout="vertical" onFinish={handleAddOrEditLibrary}>
<Form
form={libraryForm}
layout="vertical"
onFinish={handleAddOrEditLibrary}
>
<Form.Item <Form.Item
name="name" name="name"
label={t('settings.thinkingLibrary.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')} /> <Input placeholder={t('settings.thinkingLibrary.namePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="description" name="description"
label={t('settings.thinkingLibrary.description')} label={t('settings.thinkingLibrary.description')}
rules={[{ required: true, message: t('settings.thinkingLibrary.descriptionRequired') }]} rules={[{ required: true, message: t('settings.thinkingLibrary.descriptionRequired') }]}>
> <Input.TextArea placeholder={t('settings.thinkingLibrary.descriptionPlaceholder')} rows={2} />
<Input.TextArea
placeholder={t('settings.thinkingLibrary.descriptionPlaceholder')}
rows={2}
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="category" name="category"
label={t('settings.thinkingLibrary.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')} /> <Input placeholder={t('settings.thinkingLibrary.categoryPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="prompt" name="prompt"
label={t('settings.thinkingLibrary.prompt')} label={t('settings.thinkingLibrary.prompt')}
rules={[{ required: true, message: t('settings.thinkingLibrary.promptRequired') }]} rules={[{ required: true, message: t('settings.thinkingLibrary.promptRequired') }]}>
> <Input.TextArea placeholder={t('settings.thinkingLibrary.promptPlaceholder')} rows={10} />
<Input.TextArea
placeholder={t('settings.thinkingLibrary.promptPlaceholder')}
rows={10}
/>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>

View File

@ -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 { formatApiKeys } from '@renderer/services/ApiService'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api'
import { Button, Input, Modal, Space, Typography, Upload } from 'antd' import { Button, Input, Modal, Space, Typography, Upload } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -22,7 +23,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
const [importText, setImportText] = useState('') const [importText, setImportText] = useState('')
// 当前密钥列表 // 当前密钥列表
const currentKeys = currentApiKey.split(',').filter(key => key.trim() !== '') const currentKeys = currentApiKey.split(',').filter((key) => key.trim() !== '')
// 添加新密钥 // 添加新密钥
const handleAddKey = () => { const handleAddKey = () => {
@ -42,8 +43,8 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
const importedKeys = importText const importedKeys = importText
.split('\n') .split('\n')
.map(line => line.trim()) .map((line) => line.trim())
.filter(line => line !== '') .filter((line) => line !== '')
const allKeys = [...currentKeys, ...importedKeys] const allKeys = [...currentKeys, ...importedKeys]
const uniqueKeys = [...new Set(allKeys)] const uniqueKeys = [...new Set(allKeys)]
@ -67,42 +68,57 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
reader.readAsText(file) reader.readAsText(file)
} }
// 复制密钥到剪贴板
const copyKey = (key: string) => {
navigator.clipboard.writeText(key)
window.message.success({
content: t('common.copied'),
duration: 2
})
}
return ( return (
<> <>
<KeyManagerContainer> <KeyManagerContainer>
<Space> <Space>
<Button <Button type="primary" icon={<PlusOutlined />} onClick={() => setIsAddKeyModalVisible(true)}>
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsAddKeyModalVisible(true)}
>
{t('settings.provider.gemini.add_key')} {t('settings.provider.gemini.add_key')}
</Button> </Button>
<Button <Button icon={<UploadOutlined />} onClick={() => setIsImportModalVisible(true)}>
icon={<UploadOutlined />}
onClick={() => setIsImportModalVisible(true)}
>
{t('settings.provider.gemini.import_keys')} {t('settings.provider.gemini.import_keys')}
</Button> </Button>
</Space> </Space>
<KeyCountInfo> <KeyCountInfo>
{currentKeys.length > 0 && ( {currentKeys.length > 0 && (
<Text type="secondary"> <Text type="secondary">{t('settings.provider.gemini.key_count', { count: currentKeys.length })}</Text>
{t('settings.provider.gemini.key_count', { count: currentKeys.length })}
</Text>
)} )}
</KeyCountInfo> </KeyCountInfo>
</KeyManagerContainer> </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 <Modal
title={t('settings.provider.gemini.add_key_title')} title={t('settings.provider.gemini.add_key_title')}
open={isAddKeyModalVisible} open={isAddKeyModalVisible}
onOk={handleAddKey} onOk={handleAddKey}
onCancel={() => setIsAddKeyModalVisible(false)} onCancel={() => setIsAddKeyModalVisible(false)}
okButtonProps={{ disabled: !newKey.trim() }} okButtonProps={{ disabled: !newKey.trim() }}>
>
<Input.Password <Input.Password
value={newKey} value={newKey}
onChange={(e) => setNewKey(formatApiKeys(e.target.value))} onChange={(e) => setNewKey(formatApiKeys(e.target.value))}
@ -118,8 +134,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
onOk={handleImportKeys} onOk={handleImportKeys}
onCancel={() => setIsImportModalVisible(false)} onCancel={() => setIsImportModalVisible(false)}
okButtonProps={{ disabled: !importText.trim() }} okButtonProps={{ disabled: !importText.trim() }}
width={600} width={600}>
>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<Text>{t('settings.provider.gemini.import_keys_desc')}</Text> <Text>{t('settings.provider.gemini.import_keys_desc')}</Text>
@ -128,8 +143,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
beforeUpload={() => false} beforeUpload={() => false}
onChange={handleFileImport} onChange={handleFileImport}
showUploadList={false} showUploadList={false}
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}>
>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<UploadOutlined /> <UploadOutlined />
</p> </p>
@ -161,4 +175,26 @@ const KeyCountInfo = styled.div`
align-items: center; 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 export default GeminiKeyManager

View File

@ -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 { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import OAuthButton from '@renderer/components/OAuth/OAuthButton' 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 { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService' import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types' 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 { 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 Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash' import { debounce, isEmpty } from 'lodash'
import { Settings, SquareArrowOutUpRight } from 'lucide-react' import { Settings, SquareArrowOutUpRight } from 'lucide-react'
@ -344,6 +344,31 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText> <SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow> </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> <SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}> <Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input <Input
@ -384,11 +409,17 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{provider.id === 'lmstudio' && <LMStudioSettings />} {provider.id === 'lmstudio' && <LMStudioSettings />}
{provider.id === 'gpustack' && <GPUStackSettings />} {provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />} {provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
{provider.id === 'gemini' && <GeminiKeyManager provider={provider} currentApiKey={apiKey} onApiKeyChange={(newApiKey) => { {provider.id === 'gemini' && (
setApiKey(newApiKey) <GeminiKeyManager
setInputValue(newApiKey) provider={provider}
updateProvider({ ...provider, apiKey: newApiKey }) currentApiKey={apiKey}
}} />} onApiKeyChange={(newApiKey) => {
setApiKey(newApiKey)
setInputValue(newApiKey)
updateProvider({ ...provider, apiKey: newApiKey })
}}
/>
)}
<SettingSubtitle style={{ marginBottom: 5 }}> <SettingSubtitle style={{ marginBottom: 5 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}> <Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<HStack alignItems="center" gap={5}> <HStack alignItems="center" gap={5}>
@ -418,4 +449,25 @@ const ProviderName = styled.span`
font-weight: 500; 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 export default ProviderSetting

View File

@ -171,9 +171,7 @@ const ProvidersList: FC = () => {
} }
// 系统内置的供应商不允许删除,但以 provider 开头的自动生成供应商或DeepClaude供应商允许删除 // 系统内置的供应商不允许删除,但以 provider 开头的自动生成供应商或DeepClaude供应商允许删除
if (provider.isSystem && if (provider.isSystem && !provider.id.includes('provider') && provider.type !== 'deepclaude') {
!provider.id.includes('provider') &&
provider.type !== 'deepclaude') {
return [] return []
} }
@ -255,7 +253,10 @@ const ProvidersList: FC = () => {
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
style={{ ...provided.draggableProps.style, marginBottom: 5 }}> style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']} destroyPopupOnHide> <Dropdown
menu={{ items: getDropdownMenus(provider) }}
trigger={['contextMenu']}
destroyPopupOnHide>
<ProviderListItem <ProviderListItem
key={JSON.stringify(provider)} key={JSON.stringify(provider)}
className={provider.id === selectedProvider?.id ? 'active' : ''} className={provider.id === selectedProvider?.id ? 'active' : ''}

View File

@ -65,7 +65,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
centered> centered>
<Form <Form
form={form} form={form}
labelCol={{ flex: '110px' }} labelCol={{ flex: '80px' }}
labelAlign="left" labelAlign="left"
colon={false} colon={false}
style={{ marginTop: 25 }} style={{ marginTop: 25 }}
@ -90,7 +90,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
</Form.Item> </Form.Item>
<Form.Item label=" "> <Form.Item label=" ">
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
{t('settings.websearch.subscribe_add')} {t('common.add')}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>

View File

@ -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 { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { formatApiKeys } from '@renderer/services/ApiService' import { formatApiKeys } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService' import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider } from '@renderer/types' import { WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils' 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 Link from 'antd/es/typography/Link'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
@ -154,6 +155,31 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
</SettingHelpLink> </SettingHelpLink>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText> <SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow> </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') && ( {hasObjectKey(provider, 'apiHost') && (
@ -190,4 +216,25 @@ const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border); 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 export default WebSearchProviderSetting

View File

@ -1,5 +1,5 @@
import styled from 'styled-components'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import styled from 'styled-components'
export const SettingContainer = styled.div<{ theme: ThemeMode }>` export const SettingContainer = styled.div<{ theme: ThemeMode }>`
padding: 20px; padding: 20px;

View File

@ -1,9 +1,9 @@
import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
import { isEmpty, takeRight } from 'lodash'
import store from '@renderer/store' import store from '@renderer/store'
import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
import { addAbortController, removeAbortController } from '@renderer/utils/abortController' import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
import { buildSystemPrompt } from '@renderer/utils/prompt' import { buildSystemPrompt } from '@renderer/utils/prompt'
import { getThinkingLibraryById } from '@renderer/utils/thinkingLibrary' import { getThinkingLibraryById } from '@renderer/utils/thinkingLibrary'
import { isEmpty, takeRight } from 'lodash'
import { CompletionsParams } from '.' import { CompletionsParams } from '.'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
@ -26,10 +26,17 @@ export default class DeepClaudeProvider extends BaseProvider {
constructor(provider: Provider, modelCombination: ModelCombination) { constructor(provider: Provider, modelCombination: ModelCombination) {
super(provider) super(provider)
console.log('[DeepClaudeProvider] 构造函数被调用,接收到的模型组合:', console.log(
modelCombination.id, modelCombination.name, '[DeepClaudeProvider] 构造函数被调用,接收到的模型组合:',
'推理模型:', modelCombination.reasonerModel?.id, modelCombination.reasonerModel?.name, modelCombination.id,
'目标模型:', modelCombination.targetModel?.id, modelCombination.targetModel?.name) modelCombination.name,
'推理模型:',
modelCombination.reasonerModel?.id,
modelCombination.reasonerModel?.name,
'目标模型:',
modelCombination.targetModel?.id,
modelCombination.targetModel?.name
)
// 查找推理模型和目标模型的提供商 // 查找推理模型和目标模型的提供商
const providers = store.getState().llm.providers const providers = store.getState().llm.providers
@ -58,21 +65,36 @@ export default class DeepClaudeProvider extends BaseProvider {
console.log('[DeepClaudeProvider] 提供商实例创建完成') console.log('[DeepClaudeProvider] 提供商实例创建完成')
this.modelCombination = modelCombination this.modelCombination = modelCombination
console.log('[DeepClaudeProvider] 初始化完成,推理模型:', this.modelCombination.reasonerModel.name, console.log(
'推理模型提供商:', reasonerModelProvider.name, '[DeepClaudeProvider] 初始化完成,推理模型:',
'目标模型:', this.modelCombination.targetModel.name, this.modelCombination.reasonerModel.name,
'目标模型提供商:', targetModelProvider.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 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) { if (onFilterMessages) {
onFilterMessages(filteredMessages) onFilterMessages(filteredMessages)
@ -95,12 +117,12 @@ export default class DeepClaudeProvider extends BaseProvider {
try { try {
// 创建状态对象来跟踪推理过程 // 创建状态对象来跟踪推理过程
const state = { const state = {
isReasoningStarted: false, // 是否已经开始显示思考过程 isReasoningStarted: false, // 是否已经开始显示思考过程
isReasoningFinished: false, // 推理模型是否已完成 isReasoningFinished: false, // 推理模型是否已完成
isTargetStarted: false, // 目标模型是否已开始 isTargetStarted: false, // 目标模型是否已开始
accumulatedThinking: '', // 累积的思考过程 accumulatedThinking: '', // 累积的思考过程
extractedThinking: '', // 提取的思考过程 extractedThinking: '', // 提取的思考过程
isFirstTargetChunk: true // 是否是目标模型的第一个chunk isFirstTargetChunk: true // 是否是目标模型的第一个chunk
} }
// 同时启动两个模型的调用 // 同时启动两个模型的调用
@ -108,48 +130,53 @@ export default class DeepClaudeProvider extends BaseProvider {
// 推理模型任务 // 推理模型任务
(async () => { (async () => {
try { try {
console.log('[DeepClaudeProvider] 启动推理模型任务,使用模型:', console.log(
this.modelCombination.reasonerModel.name, '[DeepClaudeProvider] 启动推理模型任务,使用模型:',
'模型ID:', this.modelCombination.reasonerModel.id, this.modelCombination.reasonerModel.name,
'提供商:', this.modelCombination.reasonerModel.provider) '模型ID:',
this.modelCombination.reasonerModel.id,
'提供商:',
this.modelCombination.reasonerModel.provider
)
// 检查推理模型是否是专门的推理模型 // 检查推理模型是否是专门的推理模型
const isSpecialReasonerModel = this.modelCombination.reasonerModel.group === 'DeepSeek' || const isSpecialReasonerModel =
this.modelCombination.reasonerModel.name.toLowerCase().includes('reason'); this.modelCombination.reasonerModel.group === 'DeepSeek' ||
this.modelCombination.reasonerModel.name.toLowerCase().includes('reason')
// 根据模型类型和思考库选择不同的提示词 // 根据模型类型和思考库选择不同的提示词
let reasoningPrompt = ''; let reasoningPrompt = ''
if (isSpecialReasonerModel) { if (isSpecialReasonerModel) {
// 专门的推理模型使用简单提示词 // 专门的推理模型使用简单提示词
reasoningPrompt = `你是一个思考助手。请对以下问题进行深入思考,分析问题的各个方面,并提供详细的推理过程。 reasoningPrompt = `你是一个思考助手。请对以下问题进行深入思考,分析问题的各个方面,并提供详细的推理过程。
<thinking></thinking> <thinking></thinking>
问题: ${lastUserMessage.content}`; 问题: ${lastUserMessage.content}`
} else { } else {
// 普通模型使用思考库提示词或默认提示词 // 普通模型使用思考库提示词或默认提示词
const thinkingLibrary = getThinkingLibraryById(this.modelCombination.thinkingLibraryId); const thinkingLibrary = getThinkingLibraryById(this.modelCombination.thinkingLibraryId)
if (thinkingLibrary) { if (thinkingLibrary) {
// 使用选定的思考库提示词 // 使用选定的思考库提示词
console.log('[DeepClaudeProvider] 使用思考库:', thinkingLibrary.name); console.log('[DeepClaudeProvider] 使用思考库:', thinkingLibrary.name)
reasoningPrompt = thinkingLibrary.prompt.replace('{question}', lastUserMessage.content); reasoningPrompt = thinkingLibrary.prompt.replace('{question}', lastUserMessage.content)
} else { } else {
// 使用默认提示词 // 使用默认提示词
console.log('[DeepClaudeProvider] 使用默认思考提示词'); console.log('[DeepClaudeProvider] 使用默认思考提示词')
reasoningPrompt = `你是一个思考助手。请对以下问题进行深入思考,分析问题的各个方面,并提供详细的推理过程。 reasoningPrompt = `你是一个思考助手。请对以下问题进行深入思考,分析问题的各个方面,并提供详细的推理过程。
AI助手的思考基础 AI助手的思考基础
<think></think> <think></think>
问题: ${lastUserMessage.content}`; 问题: ${lastUserMessage.content}`
} }
} }
// 创建推理模型的消息列表 // 创建推理模型的消息列表
// 保留历史消息,但修改最后一条用户消息 // 保留历史消息,但修改最后一条用户消息
console.log('[DeepClaudeProvider] 推理模型使用原始对话历史消息数量:', filteredMessages.length); console.log('[DeepClaudeProvider] 推理模型使用原始对话历史消息数量:', filteredMessages.length)
// 复制历史消息,但修改最后一条用户消息 // 复制历史消息,但修改最后一条用户消息
const reasoningMessages = filteredMessages.map((msg, index) => { const reasoningMessages = filteredMessages.map((msg, index) => {
@ -161,7 +188,7 @@ export default class DeepClaudeProvider extends BaseProvider {
} }
} }
return msg return msg
}); })
// 使用completions方法调用推理模型 // 使用completions方法调用推理模型
await this.reasonerProvider.completions({ await this.reasonerProvider.completions({
@ -169,72 +196,79 @@ export default class DeepClaudeProvider extends BaseProvider {
assistant: { assistant: {
...assistant, ...assistant,
model: this.modelCombination.reasonerModel, model: this.modelCombination.reasonerModel,
prompt: '' // 不使用assistant的prompt而是使用我们自定义的reasoningPrompt prompt: '' // 不使用assistant的prompt而是使用我们自定义的reasoningPrompt
}, },
mcpTools: [], // 不使用工具,避免干扰推理过程 mcpTools: [], // 不使用工具,避免干扰推理过程
onChunk: (chunk) => { onChunk: (chunk) => {
// 累积推理过程 // 累积推理过程
if (chunk.text) { if (chunk.text) {
state.accumulatedThinking += chunk.text; state.accumulatedThinking += chunk.text
// 实时将思考过程传递给前端 // 实时将思考过程传递给前端
if (!state.isTargetStarted) { if (!state.isTargetStarted) {
// 只有在目标模型尚未开始时才发送思考过程 // 只有在目标模型尚未开始时才发送思考过程
if (!state.isReasoningStarted) { if (!state.isReasoningStarted) {
state.isReasoningStarted = true; state.isReasoningStarted = true
// 第一次发送思考过程使用reasoning_content字段 // 第一次发送思考过程使用reasoning_content字段
onChunk({ onChunk({
reasoning_content: chunk.text, reasoning_content: chunk.text,
text: '' // 不显示文本,只显示思考过程 text: '' // 不显示文本,只显示思考过程
}); })
} else { } else {
// 后续发送思考过程继续使用reasoning_content字段 // 后续发送思考过程继续使用reasoning_content字段
onChunk({ onChunk({
reasoning_content: chunk.text, reasoning_content: chunk.text,
text: '' // 不显示文本,只显示思考过程 text: '' // 不显示文本,只显示思考过程
}); })
} }
} }
// 输出日志,让用户看到推理过程 // 输出日志,让用户看到推理过程
console.log('[DeepClaudeProvider] 推理模型输出:', chunk.text.length, '字符'); console.log('[DeepClaudeProvider] 推理模型输出:', chunk.text.length, '字符')
} }
}, },
onFilterMessages: () => {} onFilterMessages: () => {}
}); })
// 如果不是专门的推理模型,将其输出包装在<think></think>标签中 // 如果不是专门的推理模型,将其输出包装在<think></think>标签中
if (!isSpecialReasonerModel && if (
!state.accumulatedThinking.includes('<think>') && !isSpecialReasonerModel &&
!state.accumulatedThinking.includes('<thinking>')) { !state.accumulatedThinking.includes('<think>') &&
state.accumulatedThinking = `<think>${state.accumulatedThinking}</think>`; !state.accumulatedThinking.includes('<thinking>')
) {
state.accumulatedThinking = `<think>${state.accumulatedThinking}</think>`
} }
// 提取思考过程 // 提取思考过程
let extractedThinking = ''; let extractedThinking = ''
// 检查是否是Gemini模型的JSON格式输出 // 检查是否是Gemini模型的JSON格式输出
if (state.accumulatedThinking.includes('data: {"candidates"') || state.accumulatedThinking.includes('data: {\"candidates\"')) { if (
console.log('[DeepClaudeProvider] 检测到Gemini模型的JSON格式输出'); state.accumulatedThinking.includes('data: {"candidates"') ||
state.accumulatedThinking.includes('data: {"candidates"')
) {
console.log('[DeepClaudeProvider] 检测到Gemini模型的JSON格式输出')
try { try {
// 尝试提取JSON中的文本内容 // 尝试提取JSON中的文本内容
const lines = state.accumulatedThinking.split('\n'); const lines = state.accumulatedThinking.split('\n')
let combinedText = ''; let combinedText = ''
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
try { try {
const jsonStr = line.substring(6); const jsonStr = line.substring(6)
const jsonData = JSON.parse(jsonStr); const jsonData = JSON.parse(jsonStr)
if (jsonData.candidates && if (
jsonData.candidates[0] && jsonData.candidates &&
jsonData.candidates[0].content && jsonData.candidates[0] &&
jsonData.candidates[0].content.parts && jsonData.candidates[0].content &&
jsonData.candidates[0].content.parts[0] && jsonData.candidates[0].content.parts &&
jsonData.candidates[0].content.parts[0].text) { jsonData.candidates[0].content.parts[0] &&
combinedText += jsonData.candidates[0].content.parts[0].text; jsonData.candidates[0].content.parts[0].text
) {
combinedText += jsonData.candidates[0].content.parts[0].text
} }
} catch (e) { } catch (e) {
// 忽略JSON解析错误 // 忽略JSON解析错误
@ -244,60 +278,64 @@ export default class DeepClaudeProvider extends BaseProvider {
if (combinedText) { if (combinedText) {
// 尝试从组合的文本中提取<think>标签 // 尝试从组合的文本中提取<think>标签
const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>'); const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>')
const thinkMatch = combinedText.match(thinkRegex); const thinkMatch = combinedText.match(thinkRegex)
if (thinkMatch && thinkMatch[1]) { if (thinkMatch && thinkMatch[1]) {
extractedThinking = thinkMatch[1].trim(); extractedThinking = thinkMatch[1].trim()
console.log('[DeepClaudeProvider] 成功从 Gemini JSON 输出中提取<think>标签的思考过程'); console.log('[DeepClaudeProvider] 成功从 Gemini JSON 输出中提取<think>标签的思考过程')
} else { } else {
// 如果没有标签,使用整个文本作为思考过程 // 如果没有标签,使用整个文本作为思考过程
extractedThinking = combinedText.trim(); extractedThinking = combinedText.trim()
console.log('[DeepClaudeProvider] 从 Gemini JSON 输出中提取了思考过程,但没有<think>标签'); console.log('[DeepClaudeProvider] 从 Gemini JSON 输出中提取了思考过程,但没有<think>标签')
} }
} }
} catch (error) { } catch (error) {
console.error('[DeepClaudeProvider] 解析 Gemini JSON 输出时出错:', error); console.error('[DeepClaudeProvider] 解析 Gemini JSON 输出时出错:', error)
extractedThinking = state.accumulatedThinking; extractedThinking = state.accumulatedThinking
} }
} else { } else {
// 常规模型输出处理 // 常规模型输出处理
// 先尝试匹配<think>标签 // 先尝试匹配<think>标签
const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>'); const thinkRegex = new RegExp('<think>([\\s\\S]*?)</think>')
const thinkMatch = state.accumulatedThinking.match(thinkRegex); const thinkMatch = state.accumulatedThinking.match(thinkRegex)
// 如果没有匹配到<think>标签,尝试匹配<thinking>标签 // 如果没有匹配到<think>标签,尝试匹配<thinking>标签
if (thinkMatch && thinkMatch[1]) { if (thinkMatch && thinkMatch[1]) {
extractedThinking = thinkMatch[1].trim(); extractedThinking = thinkMatch[1].trim()
console.log('[DeepClaudeProvider] 成功提取<think>标签中的思考过程'); console.log('[DeepClaudeProvider] 成功提取<think>标签中的思考过程')
} else { } else {
const thinkingRegex = new RegExp('<thinking>([\\s\\S]*?)</thinking>'); const thinkingRegex = new RegExp('<thinking>([\\s\\S]*?)</thinking>')
const thinkingMatch = state.accumulatedThinking.match(thinkingRegex); const thinkingMatch = state.accumulatedThinking.match(thinkingRegex)
if (thinkingMatch && thinkingMatch[1]) { if (thinkingMatch && thinkingMatch[1]) {
extractedThinking = thinkingMatch[1].trim(); extractedThinking = thinkingMatch[1].trim()
console.log('[DeepClaudeProvider] 成功提取<thinking>标签中的思考过程'); console.log('[DeepClaudeProvider] 成功提取<thinking>标签中的思考过程')
} else { } else {
extractedThinking = state.accumulatedThinking; extractedThinking = state.accumulatedThinking
console.log('[DeepClaudeProvider] 未能提取思考过程,使用原始输出'); console.log('[DeepClaudeProvider] 未能提取思考过程,使用原始输出')
} }
} }
} }
// 更新思考过程 // 更新思考过程
state.extractedThinking = extractedThinking; state.extractedThinking = extractedThinking
console.log('[DeepClaudeProvider] 推理模型完成,思考过程长度:', state.extractedThinking.length); console.log('[DeepClaudeProvider] 推理模型完成,思考过程长度:', state.extractedThinking.length)
console.log('[DeepClaudeProvider] 推理模型输出示例:', state.extractedThinking.substring(0, 100) + '...'); console.log('[DeepClaudeProvider] 推理模型输出示例:', state.extractedThinking.substring(0, 100) + '...')
console.log('[DeepClaudeProvider] 推理模型信息:', this.modelCombination.reasonerModel.name, this.modelCombination.reasonerModel.id); console.log(
'[DeepClaudeProvider] 推理模型信息:',
this.modelCombination.reasonerModel.name,
this.modelCombination.reasonerModel.id
)
// 标记推理模型已完成 // 标记推理模型已完成
state.isReasoningFinished = true; state.isReasoningFinished = true
} catch (error) { } catch (error) {
console.error('[DeepClaudeProvider] 推理模型错误:', error); console.error('[DeepClaudeProvider] 推理模型错误:', error)
// 即使出错,也要标记推理模型已完成,以便目标模型可以继续 // 即使出错,也要标记推理模型已完成,以便目标模型可以继续
state.isReasoningFinished = true; state.isReasoningFinished = true
state.extractedThinking = '推理模型出错,无法获取思考过程。'; state.extractedThinking = '推理模型出错,无法获取思考过程。'
} }
})(), })(),
@ -308,21 +346,25 @@ export default class DeepClaudeProvider extends BaseProvider {
// 等待推理模型开始生成思考过程 // 等待推理模型开始生成思考过程
while (!state.isReasoningStarted && !state.isReasoningFinished) { while (!state.isReasoningStarted && !state.isReasoningFinished) {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100))
} }
// 等待推理模型完成 // 等待推理模型完成
while (!state.isReasoningFinished) { while (!state.isReasoningFinished) {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100))
} }
console.log('[DeepClaudeProvider] 推理模型已完成,立即启动目标模型任务') console.log('[DeepClaudeProvider] 推理模型已完成,立即启动目标模型任务')
// 标记目标模型已开始 // 标记目标模型已开始
state.isTargetStarted = true; state.isTargetStarted = true
console.log('[DeepClaudeProvider] 启动目标模型任务') 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 = `以下是对这个问题的思考过程,请基于这个思考过程回答我的问题,但不要重复思考过程,不要在回答中包含“思考过程”或类似的标题,直接给出清晰、准确的回答: const targetPrompt = `以下是对这个问题的思考过程,请基于这个思考过程回答我的问题,但不要重复思考过程,不要在回答中包含“思考过程”或类似的标题,直接给出清晰、准确的回答:
@ -385,10 +427,14 @@ ${state.extractedThinking}`
*/ */
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> { public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
// 使用目标模型进行翻译 // 使用目标模型进行翻译
return this.targetProvider.translate(message, { return this.targetProvider.translate(
...assistant, message,
model: this.modelCombination.targetModel {
}, onResponse) ...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) const reasonerCheck = await this.reasonerProvider.check(this.modelCombination.reasonerModel)
if (!reasonerCheck.valid) { if (!reasonerCheck.valid) {

View File

@ -19,7 +19,13 @@ import {
TextPart, TextPart,
Tool Tool
} from '@google/generative-ai' } 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 { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
@ -823,7 +829,14 @@ export default class GeminiProvider extends BaseProvider {
const thinkingBudget = assistant?.settings?.thinkingBudget const thinkingBudget = assistant?.settings?.thinkingBudget
if (!streamOutput) { 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) const { isValid, message } = this.isValidGeminiResponse(response)
if (!isValid) { if (!isValid) {
@ -833,7 +846,14 @@ export default class GeminiProvider extends BaseProvider {
this.processGeminiImageResponse(response, onChunk) this.processGeminiImageResponse(response, onChunk)
return 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) { for await (const chunk of response) {
this.processGeminiImageResponse(chunk, onChunk) this.processGeminiImageResponse(chunk, onChunk)
@ -870,7 +890,8 @@ export default class GeminiProvider extends BaseProvider {
contents: ContentListUnion, contents: ContentListUnion,
maxTokens?: number, maxTokens?: number,
sdk?: GoogleGenAI, sdk?: GoogleGenAI,
thinkingBudget?: number thinkingBudget?: number,
model?: Model
): Promise<GenerateContentResponse> { ): Promise<GenerateContentResponse> {
try { try {
// 获取新的API密钥实现轮流使用多个密钥 // 获取新的API密钥实现轮流使用多个密钥
@ -889,8 +910,8 @@ export default class GeminiProvider extends BaseProvider {
// 构建请求配置 // 构建请求配置
const config = { const config = {
responseModalities: ['Text', 'Image'], responseModalities: model && isGenerateImageModel(model) ? ['Text', 'Image'] : undefined,
responseMimeType: 'text/plain', responseMimeType: model && isGenerateImageModel(model) ? 'text/plain' : undefined,
maxOutputTokens: maxTokens, maxOutputTokens: maxTokens,
...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {}) ...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {})
} }
@ -913,7 +934,8 @@ export default class GeminiProvider extends BaseProvider {
contents: ContentListUnion, contents: ContentListUnion,
maxTokens?: number, maxTokens?: number,
sdk?: GoogleGenAI, sdk?: GoogleGenAI,
thinkingBudget?: number thinkingBudget?: number,
model?: Model
): Promise<AsyncGenerator<GenerateContentResponse>> { ): Promise<AsyncGenerator<GenerateContentResponse>> {
try { try {
// 获取新的API密钥实现轮流使用多个密钥 // 获取新的API密钥实现轮流使用多个密钥
@ -932,8 +954,8 @@ export default class GeminiProvider extends BaseProvider {
// 构建请求配置 // 构建请求配置
const config = { const config = {
responseModalities: ['Text', 'Image'], responseModalities: model && isGenerateImageModel(model) ? ['Text', 'Image'] : undefined,
responseMimeType: 'text/plain', responseMimeType: model && isGenerateImageModel(model) ? 'text/plain' : undefined,
maxOutputTokens: maxTokens, maxOutputTokens: maxTokens,
...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {}) ...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {})
} }

View File

@ -19,6 +19,7 @@ import {
filterEmptyMessages, filterEmptyMessages,
filterUserRoleStartMessages filterUserRoleStartMessages
} from '@renderer/services/MessagesService' } from '@renderer/services/MessagesService'
import { processReqMessages } from '@renderer/services/ModelMessageService'
import store from '@renderer/store' import store from '@renderer/store'
import { getActiveServers } from '@renderer/store/mcp' import { getActiveServers } from '@renderer/store/mcp'
import { import {
@ -294,7 +295,7 @@ export default class OpenAIProvider extends BaseProvider {
* @returns True if the model is an OpenAI reasoning model, false otherwise * @returns True if the model is an OpenAI reasoning model, false otherwise
*/ */
private isOpenAIReasoning(model: Model) { 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 const { signal } = abortController
await this.checkIsCopilot() await this.checkIsCopilot()
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter( //当 systemMessage 内容为空时不发送 systemMessage
Boolean let reqMessages: ChatCompletionMessageParam[]
) as ChatCompletionMessageParam[] if (!systemMessage.content) {
reqMessages = [...userMessages]
} else {
reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[]
}
// 处理连续的相同角色消息,例如 deepseek-reasoner 模型不支持连续的用户或助手消息
reqMessages = processReqMessages(model, reqMessages)
const toolResponses: MCPToolResponse[] = [] const toolResponses: MCPToolResponse[] = []
let firstChunk = true let firstChunk = true

View File

@ -1,5 +1,5 @@
import { Model, Provider } from '@renderer/types'
import store from '@renderer/store' import store from '@renderer/store'
import { Model, Provider } from '@renderer/types'
import AnthropicProvider from './AnthropicProvider' import AnthropicProvider from './AnthropicProvider'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
@ -34,23 +34,28 @@ export default class ProviderFactory {
let combination: ModelCombination | undefined = undefined let combination: ModelCombination | undefined = undefined
if (selectedModelId) { if (selectedModelId) {
// 在provider的models中查找匹配的模型 // 在provider的models中查找匹配的模型
const selectedModel = provider.models.find(m => m.id === selectedModelId) const selectedModel = provider.models.find((m) => m.id === selectedModelId)
if (selectedModel) { if (selectedModel) {
// 直接使用模型ID查找对应的组合 // 直接使用模型ID查找对应的组合
// 在DeepClaude中模型ID就是组合ID // 在DeepClaude中模型ID就是组合ID
combination = combinations.find(c => c.id === selectedModelId && c.isActive) combination = combinations.find((c) => c.id === selectedModelId && c.isActive)
} }
} }
// 如果没有找到匹配的组合,使用第一个激活的组合 // 如果没有找到匹配的组合,使用第一个激活的组合
if (!combination) { if (!combination) {
combination = combinations.find(c => c.isActive) || undefined combination = combinations.find((c) => c.isActive) || undefined
} }
if (combination) { if (combination) {
console.log('[ProviderFactory] 创建DeepClaudeProvider使用模型组合:', combination.name, console.log(
'推理模型:', combination.reasonerModel?.name, '[ProviderFactory] 创建DeepClaudeProvider使用模型组合:',
'目标模型:', combination.targetModel?.name) combination.name,
'推理模型:',
combination.reasonerModel?.name,
'目标模型:',
combination.targetModel?.name
)
// 确保reasonerModel和targetModel是完整的模型对象 // 确保reasonerModel和targetModel是完整的模型对象
const allProviders = store.getState().llm.providers const allProviders = store.getState().llm.providers
@ -94,10 +99,17 @@ export default class ProviderFactory {
targetModel: fullTargetModel targetModel: fullTargetModel
} }
console.log('[ProviderFactory] 创建完整的模型组合:', console.log(
fullCombination.id, fullCombination.name, '[ProviderFactory] 创建完整的模型组合:',
'推理模型:', fullCombination.reasonerModel.id, fullCombination.reasonerModel.name, fullCombination.id,
'目标模型:', fullCombination.targetModel.id, fullCombination.targetModel.name) fullCombination.name,
'推理模型:',
fullCombination.reasonerModel.id,
fullCombination.reasonerModel.name,
'目标模型:',
fullCombination.targetModel.id,
fullCombination.targetModel.name
)
return new DeepClaudeProvider(provider, fullCombination) return new DeepClaudeProvider(provider, fullCombination)
} }

View File

@ -1,5 +1,6 @@
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import ASRServerService from './ASRServerService' import ASRServerService from './ASRServerService'
/** /**

View File

@ -330,7 +330,7 @@ export async function fetchChatCompletion({
} }
} }
} }
// console.log('message', message) // console.log('message', message) // 注释掉以避免日志过多
} catch (error: any) { } catch (error: any) {
if (isAbortError(error)) { if (isAbortError(error)) {
message.status = 'paused' message.status = 'paused'

View File

@ -26,19 +26,19 @@ export const analyzeAndAddAssistantMemories = async (assistantId: string, messag
// 获取当前助手的记忆 // 获取当前助手的记忆
const assistantMemories = state.memory?.assistantMemories || [] const assistantMemories = state.memory?.assistantMemories || []
const currentAssistantMemories = assistantMemories.filter(memory => memory.assistantId === assistantId) const currentAssistantMemories = assistantMemories.filter((memory) => memory.assistantId === assistantId)
// 获取已分析过的消息ID // 获取已分析过的消息ID
const analyzedMessageIds = new Set<string>() const analyzedMessageIds = new Set<string>()
currentAssistantMemories.forEach(memory => { currentAssistantMemories.forEach((memory) => {
if (memory.analyzedMessageIds) { if (memory.analyzedMessageIds) {
memory.analyzedMessageIds.forEach(id => analyzedMessageIds.add(id)) memory.analyzedMessageIds.forEach((id) => analyzedMessageIds.add(id))
} }
}) })
// 过滤出未分析的消息 // 过滤出未分析的消息
const newMessages = messages.filter(msg => const newMessages = messages.filter(
msg.id && !analyzedMessageIds.has(msg.id) && msg.content && msg.content.trim() !== '' (msg) => msg.id && !analyzedMessageIds.has(msg.id) && msg.content && msg.content.trim() !== ''
) )
if (newMessages.length === 0) { 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) console.log('[Assistant Memory Analysis] New conversation length:', newConversation.length)
// 构建助手记忆分析提示词 // 构建助手记忆分析提示词
let prompt = ` const prompt = `
@ -107,9 +107,9 @@ ${newConversation}
// 先尝试根据供应商和模型ID查找 // 先尝试根据供应商和模型ID查找
let model: any = null let model: any = null
if (providerId) { if (providerId) {
const provider = state.llm.providers.find(p => p.id === providerId) const provider = state.llm.providers.find((p) => p.id === providerId)
if (provider) { if (provider) {
const foundModel = provider.models.find(m => m.id === modelId) const foundModel = provider.models.find((m) => m.id === modelId)
if (foundModel) { if (foundModel) {
model = foundModel model = foundModel
} }
@ -118,9 +118,7 @@ ${newConversation}
// 如果没找到,尝试在所有模型中查找 // 如果没找到,尝试在所有模型中查找
if (!model) { if (!model) {
const foundModel = state.llm.providers const foundModel = state.llm.providers.flatMap((provider) => provider.models).find((m) => m.id === modelId)
.flatMap((provider) => provider.models)
.find((m) => m.id === modelId)
if (foundModel) { if (foundModel) {
model = foundModel model = foundModel
} }
@ -156,37 +154,37 @@ ${newConversation}
// 如果没有找到JSON数组尝试按行分割并处理 // 如果没有找到JSON数组尝试按行分割并处理
memories = result memories = result
.split('\n') .split('\n')
.filter(line => line.trim().startsWith('"') || line.trim().startsWith('-')) .filter((line) => line.trim().startsWith('"') || line.trim().startsWith('-'))
.map(line => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, '')) .map((line) => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, ''))
} }
} catch (error) { } catch (error) {
console.error('[Assistant Memory Analysis] Failed to parse memories:', error) console.error('[Assistant Memory Analysis] Failed to parse memories:', error)
// 尝试使用正则表达式提取引号中的内容 // 尝试使用正则表达式提取引号中的内容
const quotedStrings = result.match(/"([^"]*)"/g) const quotedStrings = result.match(/"([^"]*)"/g)
if (quotedStrings) { if (quotedStrings) {
memories = quotedStrings.map(str => str.slice(1, -1)) memories = quotedStrings.map((str) => str.slice(1, -1))
} else { } else {
// 最后尝试按行分割 // 最后尝试按行分割
memories = result memories = result
.split('\n') .split('\n')
.filter(line => line.trim() && !line.includes('```')) .filter((line) => line.trim() && !line.includes('```'))
.map(line => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, '')) .map((line) => line.trim().replace(/^["'\-\s]+|["'\s]+$/g, ''))
} }
} }
// 过滤空字符串和已存在的记忆 // 过滤空字符串和已存在的记忆
memories = memories.filter( memories = memories.filter(
memory => (memory) =>
memory && memory &&
memory.trim() !== '' && 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`) console.log(`[Assistant Memory Analysis] Extracted ${memories.length} new memories`)
// 添加新记忆 // 添加新记忆
const addedMemories: string[] = [] 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 const lastMessageId = newMessages.length > 0 ? newMessages[newMessages.length - 1].id : undefined
for (const memoryContent of memories) { for (const memoryContent of memories) {
@ -240,7 +238,7 @@ export const resetAssistantMemoryAnalyzedMessageIds = async (assistantId: string
const assistantMemories = state.memory?.assistantMemories || [] 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) { if (currentAssistantMemories.length === 0) {
console.log(`[Assistant Memory] No memories found for assistant ${assistantId}`) 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) { if (memory.assistantId === assistantId) {
return { return {
...memory, ...memory,
@ -260,14 +258,16 @@ export const resetAssistantMemoryAnalyzedMessageIds = async (assistantId: string
}) })
// 保存更新后的记忆 // 保存更新后的记忆
await store.dispatch( await store
saveMemoryData({ .dispatch(
assistantMemories: updatedMemories, saveMemoryData({
assistantMemoryActive: state.memory?.assistantMemoryActive, assistantMemories: updatedMemories,
assistantMemoryAnalyzeModel: state.memory?.assistantMemoryAnalyzeModel, assistantMemoryActive: state.memory?.assistantMemoryActive,
forceOverwrite: true assistantMemoryAnalyzeModel: state.memory?.assistantMemoryAnalyzeModel,
}) forceOverwrite: true
).unwrap() })
)
.unwrap()
console.log(`[Assistant Memory] Reset analysis markers for assistant ${assistantId}`) console.log(`[Assistant Memory] Reset analysis markers for assistant ${assistantId}`)
return true return true

View File

@ -76,23 +76,29 @@ export function getAssistantProvider(assistant: Assistant): Provider {
console.log('[getAssistantProvider] 检测到DeepClaude模型:', assistant.model.id, assistant.model.name) console.log('[getAssistantProvider] 检测到DeepClaude模型:', assistant.model.id, assistant.model.name)
// 列出所有提供商,便于调试 // 列出所有提供商,便于调试
console.log('[getAssistantProvider] 当前所有提供商:', console.log(
providers.map(p => ({ id: p.id, name: p.name, type: p.type }))) '[getAssistantProvider] 当前所有提供商:',
providers.map((p) => ({ id: p.id, name: p.name, type: p.type }))
)
// 查找所有DeepClaude类型的提供商 // 查找所有DeepClaude类型的提供商
const deepClaudeProviders = providers.filter(p => p.type === 'deepclaude') const deepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
console.log('[getAssistantProvider] 找到DeepClaude类型的提供商数量:', deepClaudeProviders.length) console.log('[getAssistantProvider] 找到DeepClaude类型的提供商数量:', deepClaudeProviders.length)
if (deepClaudeProviders.length > 0) { if (deepClaudeProviders.length > 0) {
// 先尝试查找与model.id匹配的提供商 // 先尝试查找与model.id匹配的提供商
const matchingProvider = deepClaudeProviders.find(p => p.id === assistant.model?.id) const matchingProvider = deepClaudeProviders.find((p) => p.id === assistant.model?.id)
if (matchingProvider) { if (matchingProvider) {
console.log('[getAssistantProvider] 找到匹配的DeepClaude提供商:', matchingProvider.id, matchingProvider.name) console.log('[getAssistantProvider] 找到匹配的DeepClaude提供商:', matchingProvider.id, matchingProvider.name)
return matchingProvider return matchingProvider
} }
// 如果没有找到匹配的使用第一个DeepClaude提供商 // 如果没有找到匹配的使用第一个DeepClaude提供商
console.log('[getAssistantProvider] 使用第一个DeepClaude提供商:', deepClaudeProviders[0].id, deepClaudeProviders[0].name) console.log(
'[getAssistantProvider] 使用第一个DeepClaude提供商:',
deepClaudeProviders[0].id,
deepClaudeProviders[0].name
)
return deepClaudeProviders[0] return deepClaudeProviders[0]
} }

View File

@ -84,13 +84,15 @@ export async function backupToWebdav({
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let deviceType = 'unknown' let deviceType = 'unknown'
let hostname = 'unknown'
try { try {
deviceType = (await window.api.system.getDeviceType()) || 'unknown' deviceType = (await window.api.system.getDeviceType()) || 'unknown'
hostname = (await window.api.system.getHostname()) || 'unknown'
} catch (error) { } 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 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 finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
const backupData = await getBackupData() const backupData = await getBackupData()

View File

@ -160,10 +160,12 @@ ${memoriesToCheck}
/-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*"([^"]+)"\s*-\s*分类:\s*"([^"]+)"\s*(?:-\s*重要性:\s*"([^"]+)")?\s*(?:-\s*关键词:\s*"([^"]+)")?/g /-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*"([^"]+)"\s*-\s*分类:\s*"([^"]+)"\s*(?:-\s*重要性:\s*"([^"]+)")?\s*(?:-\s*关键词:\s*"([^"]+)")?/g
// 新增正则表达式匹配AI返回的不同格式 // 新增正则表达式匹配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] const regexesToTry = [simpleGroupRegex, alternativeGroupRegex, originalGroupRegex]
@ -180,7 +182,7 @@ ${memoriesToCheck}
found = true found = true
const groupId = match[1] || String(similarGroups.length + 1) 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 memoryIndices = memoryIndicesStr.split(',').map((s: string) => s.trim())
const mergedContent = match[3].trim().replace(/^["']|["']$/g, '') // 移除首尾的引号 const mergedContent = match[3].trim().replace(/^["']|["']$/g, '') // 移除首尾的引号
const category = match[4]?.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) const independentMatch = result.match(regex)
if (independentMatch && independentMatch[1]) { 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()) 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) independentMemories.push(...items)
independentFound = true independentFound = true
@ -241,11 +246,11 @@ ${memoriesToCheck}
if (numberMatches) { if (numberMatches) {
// 过滤出不在相似组中的数字 // 过滤出不在相似组中的数字
const usedIndices = new Set() const usedIndices = new Set()
similarGroups.forEach(group => { similarGroups.forEach((group) => {
group.memoryIds.forEach(id => usedIndices.add(id)) 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) { if (unusedIndices.length > 0) {
console.log('[Memory Deduplication] Extracted independent memories from numbers in result:', unusedIndices) console.log('[Memory Deduplication] Extracted independent memories from numbers in result:', unusedIndices)
independentMemories.push(...unusedIndices) independentMemories.push(...unusedIndices)
@ -256,13 +261,14 @@ ${memoriesToCheck}
// 如果没有解析到相似组和独立记忆项,但结果中包含“组”字样,尝试使用更宽松的正则表达式 // 如果没有解析到相似组和独立记忆项,但结果中包含“组”字样,尝试使用更宽松的正则表达式
if (similarGroups.length === 0 && independentMemories.length === 0 && result.includes('组')) { 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 let looseMatch: RegExpExecArray | null
while ((looseMatch = looseGroupRegex.exec(result)) !== null) { while ((looseMatch = looseGroupRegex.exec(result)) !== null) {
const groupId = looseMatch[1] || String(similarGroups.length + 1) 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 memoryIndices = memoryIndicesStr.split(',').map((s: string) => s.trim())
const mergedContent = looseMatch[3].trim() const mergedContent = looseMatch[3].trim()

View File

@ -9,9 +9,9 @@ import store from '@renderer/store' // Import store
import { import {
accessMemory, accessMemory,
addAnalysisLatency, addAnalysisLatency,
addAssistantMemory,
addMemory, addMemory,
addShortMemory, addShortMemory,
addAssistantMemory,
clearCurrentRecommendations, clearCurrentRecommendations,
Memory, Memory,
MemoryRecommendation, MemoryRecommendation,
@ -28,8 +28,8 @@ import {
import { Message } from '@renderer/types' // Import Message type import { Message } from '@renderer/types' // Import Message type
import { useCallback, useEffect, useRef } from 'react' // Add useRef back 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 { analyzeAndAddAssistantMemories, resetAssistantMemoryAnalyzedMessageIds } from './AssistantMemoryService' // Import assistant memory service
import { contextualMemoryService } from './ContextualMemoryService' // Import contextual memory service
// calculateConversationComplexity is unused, removing its definition // calculateConversationComplexity is unused, removing its definition
/* /*
@ -794,9 +794,12 @@ ${existingMemoriesContent}
}, [analyzeAndAddMemories]) }, [analyzeAndAddMemories])
// 记录记忆访问 // 记录记忆访问
const recordMemoryAccess = useCallback((memoryId: string, isShortMemory: boolean = false, isAssistantMemory: boolean = false) => { const recordMemoryAccess = useCallback(
store.dispatch(accessMemory({ id: memoryId, isShortMemory, isAssistantMemory })) (memoryId: string, isShortMemory: boolean = false, isAssistantMemory: boolean = false) => {
}, []) store.dispatch(accessMemory({ id: memoryId, isShortMemory, isAssistantMemory }))
},
[]
)
// Effect 来设置/清除定时器,只依赖于启动条件 // Effect 来设置/清除定时器,只依赖于启动条件
useEffect(() => { useEffect(() => {
@ -1317,9 +1320,9 @@ ${newConversation}
// 先尝试根据供应商和模型ID查找 // 先尝试根据供应商和模型ID查找
let model: any = null let model: any = null
if (providerId) { 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) { if (provider) {
const foundModel = provider.models.find(m => m.id === modelId) const foundModel = provider.models.find((m) => m.id === modelId)
if (foundModel) { if (foundModel) {
model = foundModel model = foundModel
} }
@ -1604,7 +1607,7 @@ export const applyMemoriesToPrompt = async (systemPrompt: string, topicId?: stri
// 从当前状态中获取话题的助手ID // 从当前状态中获取话题的助手ID
const assistants = state.assistants.assistants const assistants = state.assistants.assistants
for (const assistant of 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) { if (topic) {
topicAssistantId = assistant.id topicAssistantId = assistant.id
console.log('[Memory] Using topic assistant ID:', topicAssistantId) console.log('[Memory] Using topic assistant ID:', topicAssistantId)

View 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
}

View 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'
})
})
})

View File

@ -629,7 +629,9 @@ const memorySlice = createSlice({
// 如果找到了要删除的记忆并且它有分析过的消息ID // 如果找到了要删除的记忆并且它有分析过的消息ID
if (memoryToDelete && memoryToDelete.analyzedMessageIds && memoryToDelete.analyzedMessageIds.length > 0) { 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 { id, isShortMemory, isAssistantMemory } = action.payload
const now = new Date().toISOString() const now = new Date().toISOString()
@ -1000,7 +1005,10 @@ const memorySlice = createSlice({
// 助手记忆分析模型 // 助手记忆分析模型
if (action.payload.assistantMemoryAnalyzeModel) { if (action.payload.assistantMemoryAnalyzeModel) {
state.assistantMemoryAnalyzeModel = 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') console.log('Short-term memory data loaded into state')

View File

@ -83,15 +83,13 @@ const messagesSlice = createSlice({
// 为了兼容多模型新发消息,一次性添加多个助手消息 // 为了兼容多模型新发消息,一次性添加多个助手消息
// 不是什么好主意,不符合语义 // 不是什么好主意,不符合语义
// 检查每条消息是否已存在,避免重复添加 // 检查每条消息是否已存在,避免重复添加
const messagesToAdd = messages.filter(msg => const messagesToAdd = messages.filter((msg) => !currentMessages.some((existing) => existing.id === msg.id))
!currentMessages.some(existing => existing.id === msg.id)
)
if (messagesToAdd.length > 0) { if (messagesToAdd.length > 0) {
state.messagesByTopic[topicId].push(...messagesToAdd) state.messagesByTopic[topicId].push(...messagesToAdd)
} }
} else { } else {
// 添加单条消息,先检查是否已存在 // 添加单条消息,先检查是否已存在
if (!currentMessages.some(existing => existing.id === messages.id)) { if (!currentMessages.some((existing) => existing.id === messages.id)) {
state.messagesByTopic[topicId].push(messages) state.messagesByTopic[topicId].push(messages)
} }
} }
@ -111,8 +109,8 @@ const messagesSlice = createSlice({
// 要插入的消息,先过滤掉已存在的消息 // 要插入的消息,先过滤掉已存在的消息
const messagesToInsert = Array.isArray(messages) ? messages : [messages] const messagesToInsert = Array.isArray(messages) ? messages : [messages]
const uniqueMessagesToInsert = messagesToInsert.filter(msg => const uniqueMessagesToInsert = messagesToInsert.filter(
!messagesList.some(existing => existing.id === msg.id) (msg) => !messagesList.some((existing) => existing.id === msg.id)
) )
// 如果没有新消息需要插入,直接返回 // 如果没有新消息需要插入,直接返回
@ -192,9 +190,7 @@ const messagesSlice = createSlice({
} else { } else {
// 检查是否有重复的消息相同的askId和内容 // 检查是否有重复的消息相同的askId和内容
const duplicateMessage = state.messagesByTopic[topicId].find( const duplicateMessage = state.messagesByTopic[topicId].find(
(m) => m.role === 'assistant' && (m) => m.role === 'assistant' && m.askId === streamMessage.askId && m.content === streamMessage.content
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) const topicWithDB = await TopicManager.getTopic(topic.id)
if (topicWithDB && topicWithDB.messages) { if (topicWithDB && topicWithDB.messages) {
// 只加载最近的N条消息而不是全部加载 // 只加载最近的N条消息而不是全部加载
const initialLoadCount = state.messages.displayCount * 2; // 初始加载显示数量的2倍 const initialLoadCount = state.messages.displayCount * 2 // 初始加载显示数量的2倍
const recentMessages = topicWithDB.messages.length > initialLoadCount const recentMessages =
? topicWithDB.messages.slice(-initialLoadCount) topicWithDB.messages.length > initialLoadCount
: topicWithDB.messages; ? 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 })) dispatch(loadTopicMessages({ topicId: topic.id, messages: recentMessages }))
} else { } else {
@ -561,7 +560,7 @@ export const loadTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDisp
} }
dispatch(setCurrentTopic(topic)) dispatch(setCurrentTopic(topic))
} catch (error) { } 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')) dispatch(setError(error instanceof Error ? error.message : 'Failed to load messages'))
} finally { } finally {
dispatch(setTopicLoading({ topicId: topic.id, loading: false })) dispatch(setTopicLoading({ topicId: topic.id, loading: false }))

View File

@ -7,7 +7,15 @@ import { WebDAVSyncState } from './backup'
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' 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[] = [ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'assistants', 'assistants',

View File

@ -96,6 +96,8 @@ export type Message = {
mcpTools?: MCPToolResponse[] mcpTools?: MCPToolResponse[]
// Generate Image // Generate Image
generateImage?: GenerateImageResponse generateImage?: GenerateImageResponse
// Knowledge base results
knowledge?: KnowledgeReference[]
} }
// 多模型消息样式 // 多模型消息样式
multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid' multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -341,7 +343,15 @@ export interface TranslateHistory {
createdAt: string 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 = { export type WebSearchProvider = {
id: string id: string
@ -384,6 +394,12 @@ export interface MCPServerParameter {
description: string description: string
} }
export interface MCPConfigSample {
command: string
args: string[]
env?: Record<string, string> | undefined
}
export interface MCPServer { export interface MCPServer {
id: string id: string
name: string name: string
@ -396,6 +412,7 @@ export interface MCPServer {
env?: Record<string, string> env?: Record<string, string>
isActive: boolean isActive: boolean
disabledTools?: string[] // List of tool names that are disabled for this server 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 headers?: Record<string, string> // Custom headers to be sent with requests to this server
} }

View 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
)
}

View File

@ -27,8 +27,10 @@ export function checkModelCombinationsInLocalStorage() {
} }
const combinations = JSON.parse(savedCombinations) const combinations = JSON.parse(savedCombinations)
console.log('[checkModelCombinationsInLocalStorage] localStorage中的模型组合数据:', console.log(
JSON.stringify(combinations, null, 2)) '[checkModelCombinationsInLocalStorage] localStorage中的模型组合数据:',
JSON.stringify(combinations, null, 2)
)
} catch (e) { } catch (e) {
console.error('[checkModelCombinationsInLocalStorage] 解析localStorage中的模型组合数据失败:', e) console.error('[checkModelCombinationsInLocalStorage] 解析localStorage中的模型组合数据失败:', e)
} }
@ -41,10 +43,18 @@ export function checkModelCombinationsInLocalStorage() {
*/ */
// 创建模型对象用于添加到DeepClaude提供商中 // 创建模型对象用于添加到DeepClaude提供商中
export function createDeepClaudeModel(combination: ModelCombination): Model { export function createDeepClaudeModel(combination: ModelCombination): Model {
console.log('[createDeepClaudeModel] 创建DeepClaude模型组合ID:', combination.id, console.log(
'组合名称:', combination.name, '[createDeepClaudeModel] 创建DeepClaude模型组合ID:',
'推理模型:', combination.reasonerModel?.id, combination.reasonerModel?.name, combination.id,
'目标模型:', combination.targetModel?.id, combination.targetModel?.name) '组合名称:',
combination.name,
'推理模型:',
combination.reasonerModel?.id,
combination.reasonerModel?.name,
'目标模型:',
combination.targetModel?.id,
combination.targetModel?.name
)
// 使用组合ID作为模型ID // 使用组合ID作为模型ID
console.log('[createDeepClaudeModel] 使用组合ID作为模型ID:', combination.id) console.log('[createDeepClaudeModel] 使用组合ID作为模型ID:', combination.id)
@ -80,9 +90,12 @@ export function createDeepClaudeProvider(combinations: ModelCombination[]): Prov
isSystem: false isSystem: false
} }
console.log('[createDeepClaudeProvider] 创建的提供商详情:', console.log('[createDeepClaudeProvider] 创建的提供商详情:', {
{ id: provider.id, name: provider.name, type: provider.type, id: provider.id,
models: provider.models.map(m => ({ id: m.id, name: m.name, provider: m.provider })) }) name: provider.name,
type: provider.type,
models: provider.models.map((m) => ({ id: m.id, name: m.name, provider: m.provider }))
})
return provider return provider
} }
@ -100,23 +113,25 @@ export function getActiveModelCombinations(): ModelCombination[] {
} }
const combinations = JSON.parse(savedCombinations) as 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.length)
console.log('[getActiveModelCombinations] 激活的模型组合详情:', console.log(
activeCombinations.map(c => ({ '[getActiveModelCombinations] 激活的模型组合详情:',
id: c.id, activeCombinations.map((c) => ({
name: c.name, id: c.id,
reasonerModel: { name: c.name,
id: c.reasonerModel?.id, reasonerModel: {
name: c.reasonerModel?.name, id: c.reasonerModel?.id,
provider: c.reasonerModel?.provider name: c.reasonerModel?.name,
}, provider: c.reasonerModel?.provider
targetModel: { },
id: c.targetModel?.id, targetModel: {
name: c.targetModel?.name, id: c.targetModel?.id,
provider: c.targetModel?.provider name: c.targetModel?.name,
} provider: c.targetModel?.provider
}))) }
}))
)
return activeCombinations return activeCombinations
} catch (e) { } catch (e) {
console.error('[getActiveModelCombinations] Failed to parse model combinations:', e) console.error('[getActiveModelCombinations] Failed to parse model combinations:', e)
@ -138,6 +153,11 @@ export function createAllDeepClaudeProviders(): Provider[] {
// 创建一个单一的DeepClaude提供商 // 创建一个单一的DeepClaude提供商
const provider = createDeepClaudeProvider(activeCombinations) 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] return [provider]
} }

View File

@ -522,4 +522,30 @@ export function hasObjectKey(obj: any, key: string) {
return Object.keys(obj).includes(key) 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 } export { classNames }

View File

@ -17,3 +17,68 @@ export const findCitationInChildren = (children) => {
return null 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']
}
}

View File

@ -497,20 +497,25 @@ export function getThinkingLibraries(): ThinkingLibrary[] {
const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[] const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[]
console.log('[ThinkingLibrary] 解析思考库数量:', parsed.length) 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] 存储的思考库需要更新,与默认库合并') console.log('[ThinkingLibrary] 存储的思考库需要更新,与默认库合并')
const librariesToMerge = DEFAULT_THINKING_LIBRARIES.map(defaultLib => { const librariesToMerge = DEFAULT_THINKING_LIBRARIES.map((defaultLib) => {
const existingLib = parsed.find(lib => lib.id === defaultLib.id); const existingLib = parsed.find((lib) => lib.id === defaultLib.id)
return existingLib || defaultLib; return existingLib || defaultLib
}); })
const customLibraries = parsed.filter(lib => !DEFAULT_THINKING_LIBRARIES.some(defLib => defLib.id === lib.id)); const customLibraries = parsed.filter(
const updatedLibraries = [...librariesToMerge, ...customLibraries]; (lib) => !DEFAULT_THINKING_LIBRARIES.some((defLib) => defLib.id === lib.id)
)
const updatedLibraries = [...librariesToMerge, ...customLibraries]
console.log('[ThinkingLibrary] 更新后思考库数量:', updatedLibraries.length); console.log('[ThinkingLibrary] 更新后思考库数量:', updatedLibraries.length)
saveThinkingLibraries(updatedLibraries); saveThinkingLibraries(updatedLibraries)
return updatedLibraries; return updatedLibraries
} }
return parsed return parsed
} }
@ -533,9 +538,9 @@ export function saveThinkingLibraries(libraries: ThinkingLibrary[]): void {
const savedLibraries = localStorage.getItem('thinkingLibraries') const savedLibraries = localStorage.getItem('thinkingLibraries')
if (savedLibraries) { if (savedLibraries) {
console.log('[ThinkingLibrary] 验证保存结果 - 数据已写入localStorage'); console.log('[ThinkingLibrary] 验证保存结果 - 数据已写入localStorage')
} else { } else {
console.warn('[ThinkingLibrary] 验证保存结果 - 未在localStorage中找到数据'); console.warn('[ThinkingLibrary] 验证保存结果 - 未在localStorage中找到数据')
} }
} catch (e) { } catch (e) {
console.error('[ThinkingLibrary] 保存思考库失败:', e) console.error('[ThinkingLibrary] 保存思考库失败:', e)
@ -547,7 +552,7 @@ export function getThinkingLibraryById(id: string | undefined): ThinkingLibrary
if (!id) return undefined if (!id) return undefined
const libraries = getThinkingLibraries() 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 { try {
const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[] const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[]
console.log('[ThinkingLibrary] DEBUG - 解析后的思考库数量:', parsed.length) 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) { } catch (e) {
console.error('[ThinkingLibrary] DEBUG - 解析思考库JSON失败:', e) console.error('[ThinkingLibrary] DEBUG - 解析思考库JSON失败:', e)
} }
@ -582,44 +587,44 @@ export function addThinkingLibrary(library: Omit<ThinkingLibrary, 'id'>): Thinki
} }
console.log('[ThinkingLibrary] 添加前思考库数量:', libraries.length) console.log('[ThinkingLibrary] 添加前思考库数量:', libraries.length)
const updatedLibraries = [...libraries, newLibrary]; const updatedLibraries = [...libraries, newLibrary]
console.log('[ThinkingLibrary] 添加后思考库数量:', updatedLibraries.length) console.log('[ThinkingLibrary] 添加后思考库数量:', updatedLibraries.length)
saveThinkingLibraries(updatedLibraries) saveThinkingLibraries(updatedLibraries)
console.log('[ThinkingLibrary] 新增库ID:', newLibrary.id); console.log('[ThinkingLibrary] 新增库ID:', newLibrary.id)
return newLibrary return newLibrary
} }
// 更新思考库 // 更新思考库
export function updateThinkingLibrary(library: ThinkingLibrary): boolean { 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 libraries = getThinkingLibraries()
const index = libraries.findIndex(lib => lib.id === library.id) const index = libraries.findIndex((lib) => lib.id === library.id)
if (index !== -1) { if (index !== -1) {
const updatedLibraries = [...libraries]; const updatedLibraries = [...libraries]
updatedLibraries[index] = library; updatedLibraries[index] = library
saveThinkingLibraries(updatedLibraries); saveThinkingLibraries(updatedLibraries)
console.log('[ThinkingLibrary] 思考库更新成功'); console.log('[ThinkingLibrary] 思考库更新成功')
return true; return true
} else { } else {
console.warn('[ThinkingLibrary] 更新失败未找到ID为', library.id, '的思考库'); console.warn('[ThinkingLibrary] 更新失败未找到ID为', library.id, '的思考库')
return false; return false
} }
} }
// 删除思考库 // 删除思考库
export function deleteThinkingLibrary(id: string): boolean { export function deleteThinkingLibrary(id: string): boolean {
console.log('[ThinkingLibrary] 删除思考库 ID:', id); console.log('[ThinkingLibrary] 删除思考库 ID:', id)
const libraries = getThinkingLibraries() const libraries = getThinkingLibraries()
const initialLength = libraries.length; const initialLength = libraries.length
const filteredLibraries = libraries.filter(lib => lib.id !== id) const filteredLibraries = libraries.filter((lib) => lib.id !== id)
if (filteredLibraries.length < initialLength) { if (filteredLibraries.length < initialLength) {
saveThinkingLibraries(filteredLibraries); saveThinkingLibraries(filteredLibraries)
console.log('[ThinkingLibrary] 思考库删除成功,剩余数量:', filteredLibraries.length); console.log('[ThinkingLibrary] 思考库删除成功,剩余数量:', filteredLibraries.length)
return true; return true
} else { } else {
console.warn('[ThinkingLibrary] 删除失败未找到ID为', id, '的思考库'); console.warn('[ThinkingLibrary] 删除失败未找到ID为', id, '的思考库')
return false; return false
} }
} }

View File

@ -98,7 +98,6 @@ const HomeWindow: FC = () => {
setRoute('chat') setRoute('chat')
onSendMessage().then() onSendMessage().then()
focusInput() focusInput()
setTimeout(() => setText(''), 100)
} }
} }
} }
@ -161,6 +160,7 @@ const HomeWindow: FC = () => {
} }
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message) EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
setIsFirstMessage(false) setIsFirstMessage(false)
setText('') // ✅ 清除输入框内容
}, 0) }, 0)
}, },
[content, defaultAssistant.id, defaultAssistant.topics] [content, defaultAssistant.id, defaultAssistant.topics]

View File

@ -3144,9 +3144,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@modelcontextprotocol/sdk@npm:^1.9.0": "@modelcontextprotocol/sdk@npm:^1.10.1":
version: 1.9.0 version: 1.10.1
resolution: "@modelcontextprotocol/sdk@npm:1.9.0" resolution: "@modelcontextprotocol/sdk@npm:1.10.1"
dependencies: dependencies:
content-type: "npm:^1.0.5" content-type: "npm:^1.0.5"
cors: "npm:^2.8.5" cors: "npm:^2.8.5"
@ -3158,7 +3158,7 @@ __metadata:
raw-body: "npm:^3.0.0" raw-body: "npm:^3.0.0"
zod: "npm:^3.23.8" zod: "npm:^3.23.8"
zod-to-json-schema: "npm:^3.24.1" zod-to-json-schema: "npm:^3.24.1"
checksum: 10c0/d93653990c114690c20db606076afdc1836cdf41e1b0249fb6c3432877caad1577ef2ff9bf9476e259bfaaf422a281cda2b77e9b61eaa9b64b359f3b511b2074 checksum: 10c0/375a7a7c2753f3bbf9b0cb57d5515d74be1845c5d42567cfc83ed48ca853cb37c195b830580e72df9969632b2cc53f70d37440ef5531c0bd2cb031bbb0aaf2e2
languageName: node languageName: node
linkType: hard linkType: hard
@ -4444,13 +4444,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/json-schema@npm:^7.0.15":
version: 7.0.15 version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15" resolution: "@types/json-schema@npm:7.0.15"
@ -4972,7 +4965,7 @@ __metadata:
"@langchain/community": "npm:^0.3.36" "@langchain/community": "npm:^0.3.36"
"@langchain/core": "npm:^0.3.44" "@langchain/core": "npm:^0.3.44"
"@lezer/highlight": "npm:^1.2.1" "@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" "@monaco-editor/react": "npm:^4.7.0"
"@mozilla/readability": "npm:^0.6.0" "@mozilla/readability": "npm:^0.6.0"
"@notionhq/client": "npm:^2.2.15" "@notionhq/client": "npm:^2.2.15"
@ -4984,7 +4977,6 @@ __metadata:
"@types/d3": "npm:^7" "@types/d3": "npm:^7"
"@types/diff": "npm:^7" "@types/diff": "npm:^7"
"@types/fs-extra": "npm:^11" "@types/fs-extra": "npm:^11"
"@types/js-yaml": "npm:^4"
"@types/lodash": "npm:^4.17.16" "@types/lodash": "npm:^4.17.16"
"@types/markdown-it": "npm:^14" "@types/markdown-it": "npm:^14"
"@types/md5": "npm:^2.3.5" "@types/md5": "npm:^2.3.5"
@ -5039,7 +5031,6 @@ __metadata:
husky: "npm:^9.1.7" husky: "npm:^9.1.7"
i18next: "npm:^23.11.5" i18next: "npm:^23.11.5"
js-tiktoken: "npm:^1.0.19" js-tiktoken: "npm:^1.0.19"
js-yaml: "npm:^4.1.0"
jsdom: "npm:^26.0.0" jsdom: "npm:^26.0.0"
lint-staged: "npm:^15.5.0" lint-staged: "npm:^15.5.0"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
@ -5074,6 +5065,7 @@ __metadata:
rehype-katex: "npm:^7.0.1" rehype-katex: "npm:^7.0.1"
rehype-mathjax: "npm:^7.0.0" rehype-mathjax: "npm:^7.0.0"
rehype-raw: "npm:^7.0.0" rehype-raw: "npm:^7.0.0"
rehype-sanitize: "npm:^6.0.0"
remark-cjk-friendly: "npm:^1.1.0" remark-cjk-friendly: "npm:^1.1.0"
remark-gfm: "npm:^4.0.0" remark-gfm: "npm:^4.0.0"
remark-math: "npm:^6.0.0" remark-math: "npm:^6.0.0"
@ -10233,6 +10225,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "hast-util-to-html@npm:^9.0.5":
version: 9.0.5 version: 9.0.5
resolution: "hast-util-to-html@npm:9.0.5" resolution: "hast-util-to-html@npm:9.0.5"
@ -15991,6 +15994,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "remark-cjk-friendly@npm:^1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "remark-cjk-friendly@npm:1.1.0" resolution: "remark-cjk-friendly@npm:1.1.0"