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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -139,6 +139,7 @@ export enum IpcChannel {
// system
System_GetDeviceType = 'system:getDeviceType',
System_GetHostname = 'system:getHostname',
// events
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, () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
ipcMain.handle(IpcChannel.System_GetHostname, () => {
return require('os').hostname()
})
})
registerProtocolClient(app)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,3 +1,4 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
@ -22,9 +23,12 @@ import {
} from '@types'
import { app } from 'electron'
import Logger from 'electron-log'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
import { CacheService } from './CacheService'
import { CallBackServer } from './mcp/oauth/callback'
import { McpOAuthClientProvider } from './mcp/oauth/provider'
import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient'
// Generic type for caching wrapped functions
@ -117,103 +121,172 @@ class McpService {
const args = [...(server.args || [])]
let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
const authProvider = new McpOAuthClientProvider({
serverUrlHash: crypto
.createHash('md5')
.update(server.baseUrl || '')
.digest('hex')
})
try {
// Create appropriate transport based on configuration
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
transport = clientTransport
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
const options: StreamableHTTPClientTransportOptions = {
requestInit: {
headers: server.headers || {}
const initTransport = async (): Promise<
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
> => {
// Create appropriate transport based on configuration
if (server.type === 'inMemory') {
Logger.info(`[MCP] Using in-memory transport for server: ${server.name}`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
Logger.info(`[MCP] In-memory server started: ${server.name}`)
} catch (error: Error | any) {
Logger.error(`[MCP] Error starting in-memory server: ${error}`)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// return the client transport
return clientTransport
} else if (server.baseUrl) {
if (server.type === 'streamableHttp') {
const options: StreamableHTTPClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
requestInit: {
headers: server.headers || {}
},
authProvider
}
return new SSEClientTransport(new URL(server.baseUrl!), options)
} else {
throw new Error('Invalid server type')
}
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
Logger.info(`[MCP] Using command: ${cmd}`)
// add -x to args if args exist
if (args && args.length > 0) {
if (!args.includes('-y')) {
!args.includes('-y') && args.unshift('-y')
}
if (!args.includes('x')) {
args.unshift('x')
}
}
if (server.registryUrl) {
server.env = {
...server.env,
NPM_CONFIG_REGISTRY: server.registryUrl
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name.includes('mcp-auto-install')) {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
cmd = await getBinaryPath(server.command)
if (server.registryUrl) {
server.env = {
...server.env,
UV_DEFAULT_INDEX: server.registryUrl,
PIP_INDEX_URL: server.registryUrl
}
}
}
transport = new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
requestInit: {
headers: server.headers || {}
}
}
transport = new SSEClientTransport(new URL(server.baseUrl!), options)
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
const stdioTransport = new StdioClientTransport({
command: cmd,
args,
env: {
...getDefaultEnvironment(),
PATH: await this.getEnhancedPath(process.env.PATH || ''),
...server.env
},
stderr: 'pipe'
})
stdioTransport.stderr?.on('data', (data: Buffer) =>
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
)
return stdioTransport
} else {
throw new Error('Invalid server type')
throw new Error('Either baseUrl or command must be provided')
}
} else if (server.command) {
let cmd = server.command
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
cmd = await getBinaryPath('bun')
Logger.info(`[MCP] Using command: ${cmd}`)
// add -x to args if args exist
if (args && args.length > 0) {
if (!args.includes('-y')) {
!args.includes('-y') && args.unshift('-y')
}
if (!args.includes('x')) {
args.unshift('x')
}
}
if (server.registryUrl) {
server.env = {
...server.env,
NPM_CONFIG_REGISTRY: server.registryUrl
}
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
if (server.name.includes('mcp-auto-install')) {
const binPath = await getBinaryPath()
makeSureDirExists(binPath)
server.env.MCP_REGISTRY_PATH = path.join(binPath, '..', 'config', 'mcp-registry.json')
}
}
} else if (server.command === 'uvx' || server.command === 'uv') {
cmd = await getBinaryPath(server.command)
if (server.registryUrl) {
server.env = {
...server.env,
UV_DEFAULT_INDEX: server.registryUrl,
PIP_INDEX_URL: server.registryUrl
}
}
}
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
// Logger.info(`[MCP] Environment variables for server:`, server.env)
transport = new StdioClientTransport({
command: cmd,
args,
env: {
...getDefaultEnvironment(),
PATH: await this.getEnhancedPath(process.env.PATH || ''),
...server.env
},
stderr: 'pipe'
})
transport.stderr?.on('data', (data) =>
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
)
} else {
throw new Error('Either baseUrl or command must be provided')
// This line is unreachable
}
await client.connect(transport)
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
Logger.info(`[MCP] Starting OAuth flow for server: ${server.name}`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
// Create a callback server
const callbackServer = new CallBackServer({
port: authProvider.config.callbackPort,
path: authProvider.config.callbackPath || '/oauth/callback',
events
})
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
Logger.warn(`[MCP] OAuth flow timed out for server: ${server.name}`)
callbackServer.close()
}, 300000) // 5 minutes timeout
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
Logger.info(`[MCP] Received auth code: ${authCode}`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
await client.connect(transport)
Logger.info(`[MCP] OAuth flow completed for server: ${server.name}`)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
Logger.info(`[MCP] Successfully authenticated with server: ${server.name}`)
} catch (oauthError) {
Logger.error(`[MCP] OAuth authentication failed for server ${server.name}:`, oauthError)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
} finally {
// Clear the timeout and close the callback server
clearTimeout(timeoutId)
callbackServer.close()
}
}
const transport = await initTransport()
try {
await client.connect(transport)
} catch (error: Error | any) {
if (error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized'))) {
Logger.info(`[MCP] Authentication required for server: ${server.name}`)
await handleAuth(client, transport as SSEClientTransport | StreamableHTTPClientTransport)
} else {
throw error
}
}
// Store the new client in the cache
this.clients.set(serverKey, client)
@ -514,15 +587,12 @@ class McpService {
// 根据不同的 shell 构建不同的命令
if (userShell.includes('zsh')) {
shell = '/bin/zsh'
command =
'source /etc/zshenv 2>/dev/null || true; source ~/.zshenv 2>/dev/null || true; source /etc/zprofile 2>/dev/null || true; source ~/.zprofile 2>/dev/null || true; source /etc/zshrc 2>/dev/null || true; source ~/.zshrc 2>/dev/null || true; source /etc/zlogin 2>/dev/null || true; source ~/.zlogin 2>/dev/null || true; echo $PATH'
} else if (userShell.includes('bash')) {
shell = '/bin/bash'
command =
'source /etc/profile 2>/dev/null || true; source ~/.bash_profile 2>/dev/null || true; source ~/.bash_login 2>/dev/null || true; source ~/.profile 2>/dev/null || true; source ~/.bashrc 2>/dev/null || true; echo $PATH'
} else if (userShell.includes('fish')) {
shell = '/bin/fish'
command =
'source /etc/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.fish 2>/dev/null || true; source ~/.config/fish/config.local.fish 2>/dev/null || true; echo $PATH'
} else {
@ -540,15 +610,15 @@ class McpService {
})
let path = ''
child.stdout.on('data', (data) => {
child.stdout.on('data', (data: Buffer) => {
path += data.toString()
})
child.stderr.on('data', (data) => {
child.stderr.on('data', (data: Buffer) => {
console.error('Error getting PATH:', data.toString())
})
child.on('close', (code) => {
child.on('close', (code: number) => {
if (code === 0) {
const trimmedPath = path.trim()
resolve(trimmedPath)

View File

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

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

View File

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

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,
.anticon,
.iconfont,
.lucide,
.message-tokens {
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 store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { deleteAssistantMemory } from '@renderer/store/memory'
import { Button, Empty, Input, List, Modal, Tooltip, Typography } from 'antd'
import { DeleteOutlined } from '@ant-design/icons'
import { useCallback, useState } from 'react'
import { Provider } from 'react-redux'
import { useTranslation } from 'react-i18next'
import { Provider } from 'react-redux'
import styled from 'styled-components'
const { Text } = Typography

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 React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
export interface ExecutionResultProps {
success: boolean

View File

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

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 './ChineseSearchPanel.css'
import { autocompletion } from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap, indentWithTab, redo, undo } from '@codemirror/commands'
import { cpp } from '@codemirror/lang-cpp'
import { css } from '@codemirror/lang-css'
import { html } from '@codemirror/lang-html'
import { java } from '@codemirror/lang-java'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { markdown } from '@codemirror/lang-markdown'
import { php } from '@codemirror/lang-php'
import { python } from '@codemirror/lang-python'
import { rust } from '@codemirror/lang-rust'
import { sql } from '@codemirror/lang-sql'
import { vue } from '@codemirror/lang-vue'
import { xml } from '@codemirror/lang-xml'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { searchKeymap } from '@codemirror/search'
import { EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, highlightActiveLine, keymap, lineNumbers } from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import styled from 'styled-components'
import { createChineseSearchPanel, openChineseSearchPanel } from './ChineseSearchPanel'
// 自定义语法高亮样式
const lightThemeHighlightStyle = HighlightStyle.define([
@ -54,7 +53,7 @@ const lightThemeHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, color: '#800000', fontWeight: 'bold' },
{ tag: tags.link, color: '#0000ff', textDecoration: 'underline' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.strong, fontWeight: 'bold' }
])
// 暗色主题语法高亮样式
@ -81,7 +80,7 @@ const darkThemeHighlightStyle = HighlightStyle.define([
{ tag: tags.heading, color: '#569cd6', fontWeight: 'bold' },
{ tag: tags.link, color: '#569cd6', textDecoration: 'underline' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.strong, fontWeight: 'bold' }
])
export interface CodeMirrorEditorRef {
@ -149,18 +148,16 @@ const getLanguageExtension = (language: string) => {
}
}
const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>((
{
code,
language,
onChange,
readOnly = false,
showLineNumbers = true,
fontSize = 14,
height = 'auto'
},
ref
) => {
const CodeMirrorEditor = ({
ref,
code,
language,
onChange,
readOnly = false,
showLineNumbers = true,
fontSize = 14,
height = 'auto'
}: CodeMirrorEditorProps & { ref?: React.RefObject<CodeMirrorEditorRef | null> }) => {
const editorRef = useRef<HTMLDivElement>(null)
const editorViewRef = useRef<EditorView | null>(null)
const { theme } = useTheme()
@ -223,13 +220,11 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
const languageExtension = getLanguageExtension(language)
// 监听编辑器所有更新
const updateListener = EditorView.updateListener.of(update => {
const updateListener = EditorView.updateListener.of((update) => {
// 当文档变化时更新内部状态
if (update.docChanged) {
// 检查是否是撤销/重做操作
const isUndoRedo = update.transactions.some(tr =>
tr.isUserEvent('undo') || tr.isUserEvent('redo')
)
const isUndoRedo = update.transactions.some((tr) => tr.isUserEvent('undo') || tr.isUserEvent('redo'))
// 记录所有文档变化,但只在撤销/重做时触发 onChange
if (isUndoRedo && onChange) {
@ -247,9 +242,9 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
...historyKeymap,
...searchKeymap,
indentWithTab,
{ key: "Mod-z", run: undo },
{ key: "Mod-y", run: redo },
{ key: "Mod-Shift-z", run: redo }
{ key: 'Mod-z', run: undo },
{ key: 'Mod-y', run: redo },
{ key: 'Mod-Shift-z', run: redo }
]),
syntaxHighlighting(highlightStyle),
languageExtension,
@ -298,7 +293,7 @@ const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>(
}, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height])
return <EditorContainer ref={editorRef} />
});
}
const EditorContainer = styled.div`
width: 100%;

View File

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

View File

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

View File

@ -58,7 +58,9 @@ const PopupContainer: React.FC<Props> = ({ assistantId, resolve }) => {
createdAt: string
}
const assistantMemories = useAppSelector((state) => selectAssistantMemoriesByAssistantId(state, assistantId)) as AssistantMemory[]
const assistantMemories = useAppSelector((state) =>
selectAssistantMemoriesByAssistantId(state, assistantId)
) as AssistantMemory[]
// 获取分析统计数据
const totalAnalyses = useAppSelector((state) => state.memory?.analysisStats?.totalAnalyses || 0)
@ -217,7 +219,13 @@ const PopupContainer: React.FC<Props> = ({ assistantId, resolve }) => {
)}
/>
) : (
<Empty description={!assistantId ? t('settings.memory.noCurrentAssistant') || '无当前助手' : t('settings.memory.noAssistantMemories') || '无助手记忆'} />
<Empty
description={
!assistantId
? t('settings.memory.noCurrentAssistant') || '无当前助手'
: t('settings.memory.noAssistantMemories') || '无助手记忆'
}
/>
)}
</MemoriesList>
</Modal>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,8 @@ import {
setTrayOnClose,
setWindowStyle
} from '@renderer/store/settings'
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
import { SidebarIcon } from '@renderer/store/settings'
import { ThemeMode, TranslateLanguageVarious } from '@renderer/types'
export function useSettings() {
const settings = useAppSelector((state) => state.settings)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,10 @@ export function useSystemAgents() {
}
// 处理Uint8Array类型二进制数据
if (fileContent instanceof Uint8Array || Object.prototype.toString.call(fileContent) === '[object Uint8Array]') {
if (
fileContent instanceof Uint8Array ||
Object.prototype.toString.call(fileContent) === '[object Uint8Array]'
) {
console.log('文件内容是Uint8Array类型转换为字符串')
// 将Uint8Array转换为字符串
const decoder = new TextDecoder('utf-8')

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import type { Message } from '@renderer/types'
import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
import { findCitationInChildren } from '@renderer/utils/markdown'
import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -16,6 +16,8 @@ import rehypeKatex from 'rehype-katex'
// @ts-ignore next-line
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
// @ts-ignore next-line
import rehypeSanitize from 'rehype-sanitize'
import remarkCjkFriendly from 'remark-cjk-friendly'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
@ -24,21 +26,16 @@ import EditableCodeBlock from './EditableCodeBlock'
import ImagePreview from './ImagePreview'
import Link from './Link'
const ALLOWED_ELEMENTS =
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup|think)/i
interface Props {
message: Message
}
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
const disallowedElements = ['iframe']
const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation()
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
const rehypeMath = useMemo(() => (mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax), [mathEngine])
const messageContent = useMemo(() => {
const empty = isEmpty(message.content)
const paused = message.status === 'paused'
@ -47,9 +44,8 @@ const Markdown: FC<Props> = ({ message }) => {
}, [message, t])
const rehypePlugins = useMemo(() => {
const hasElements = ALLOWED_ELEMENTS.test(messageContent)
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath]
}, [messageContent, rehypeMath])
return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
}, [mathEngine])
const components = useMemo(() => {
const baseComponents = {
@ -95,7 +91,6 @@ const Markdown: FC<Props> = ({ message }) => {
remarkPlugins={remarkPlugins}
className="markdown"
components={components}
disallowedElements={disallowedElements}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',

View File

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

View File

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

View File

@ -9,16 +9,19 @@ interface Props {
interface State {
hasError: boolean
errorMessage?: string
error?: Error
}
const ErrorFallback = ({ fallback }: { fallback?: React.ReactNode }) => {
const ErrorFallback = ({ fallback, error }: { fallback?: React.ReactNode; error?: Error }) => {
const { t } = useTranslation()
return (
fallback || (
<Alert message={t('error.render.title')} description={t('error.render.description')} type="error" showIcon />
)
)
// 如果有详细错误信息,添加到描述中
const errorDescription =
process.env.NODE_ENV !== 'production' && error
? `${t('error.render.description')}: ${error.message}`
: t('error.render.description')
return fallback || <Alert message={t('error.render.title')} description={errorDescription} type="error" showIcon />
}
class MessageErrorBoundary extends React.Component<Props, State> {
@ -28,21 +31,7 @@ class MessageErrorBoundary extends React.Component<Props, State> {
}
static getDerivedStateFromError(error: Error) {
// 检查是否是特定错误
let errorMessage: string | undefined = undefined
if (error.message === 'rememberInstructions is not defined') {
errorMessage = '消息加载时发生错误'
} else if (error.message === 'network error') {
errorMessage = '网络连接错误,请检查您的网络连接并重试'
} else if (
typeof error.message === 'string' &&
(error.message.includes('network') || error.message.includes('timeout') || error.message.includes('connection'))
) {
errorMessage = '网络连接问题'
}
return { hasError: true, errorMessage }
return { hasError: true, error }
}
// 正确缩进 componentDidCatch
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
@ -65,11 +54,7 @@ class MessageErrorBoundary extends React.Component<Props, State> {
// 正确缩进 render
render() {
if (this.state.hasError) {
// 如果有特定错误消息,显示自定义错误
if (this.state.errorMessage) {
return <Alert message="渲染错误" description={this.state.errorMessage} type="error" showIcon />
}
return <ErrorFallback fallback={this.props.fallback} />
return <ErrorFallback fallback={this.props.fallback} error={this.state.error} />
}
return this.props.children
}

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@ -35,7 +35,9 @@ const AssistantMemoryManager = () => {
const assistantMemories = useAppSelector((state) => {
const allAssistantMemories = state.memory?.assistantMemories || []
// 只显示选中助手的记忆
return selectedAssistantId ? allAssistantMemories.filter((memory) => memory.assistantId === selectedAssistantId) : []
return selectedAssistantId
? allAssistantMemories.filter((memory) => memory.assistantId === selectedAssistantId)
: []
})
// 添加助手记忆的状态
@ -112,8 +114,7 @@ const AssistantMemoryManager = () => {
onChange={setSelectedAssistantId}
placeholder={t('settings.memory.selectAssistant') || '选择助手'}
style={{ width: '100%', marginBottom: 16 }}
disabled={!assistantMemoryActive}
>
disabled={!assistantMemoryActive}>
{assistants.map((assistant) => (
<Select.Option key={assistant.id} value={assistant.id}>
{assistant.name}
@ -165,7 +166,11 @@ const AssistantMemoryManager = () => {
/>
) : (
<Empty
description={!selectedAssistantId ? t('settings.memory.selectAssistantFirst') || '请先选择助手' : t('settings.memory.noAssistantMemories') || '无助手记忆'}
description={
!selectedAssistantId
? t('settings.memory.selectAssistantFirst') || '请先选择助手'
: t('settings.memory.noAssistantMemories') || '无助手记忆'
}
/>
)}
</div>

View File

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

View File

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

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

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 { HStack } from '@renderer/components/Layout'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
@ -12,9 +12,9 @@ import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { formatApiHost, maskApiKey } from '@renderer/utils/api'
import { providerCharge } from '@renderer/utils/oauth'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip, Typography } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
import { Settings, SquareArrowOutUpRight } from 'lucide-react'
@ -344,6 +344,31 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
)}
{/* 显示API密钥列表 */}
{provider.id !== 'gemini' && provider.id !== 'ollama' && provider.id !== 'lmstudio' && provider.id !== 'copilot' && apiKey.includes(',') && (
<KeysListContainer>
{apiKey
.split(',')
.map((key) => key.trim())
.filter((key) => key !== '')
.map((key, index) => (
<KeyItem key={index}>
<Typography.Text>{maskApiKey(key)}</Typography.Text>
<Button
type="text"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(key)
window.message.success({
content: t('common.copied'),
duration: 2
})
}}
/>
</KeyItem>
))}
</KeysListContainer>
)}
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
@ -384,11 +409,17 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{provider.id === 'lmstudio' && <LMStudioSettings />}
{provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
{provider.id === 'gemini' && <GeminiKeyManager provider={provider} currentApiKey={apiKey} onApiKeyChange={(newApiKey) => {
setApiKey(newApiKey)
setInputValue(newApiKey)
updateProvider({ ...provider, apiKey: newApiKey })
}} />}
{provider.id === 'gemini' && (
<GeminiKeyManager
provider={provider}
currentApiKey={apiKey}
onApiKeyChange={(newApiKey) => {
setApiKey(newApiKey)
setInputValue(newApiKey)
updateProvider({ ...provider, apiKey: newApiKey })
}}
/>
)}
<SettingSubtitle style={{ marginBottom: 5 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<HStack alignItems="center" gap={5}>
@ -418,4 +449,25 @@ const ProviderName = styled.span`
font-weight: 500;
`
const KeysListContainer = styled.div`
margin-top: 8px;
padding: 8px;
border-radius: 6px;
background-color: var(--color-background-soft);
`
const KeyItem = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 4px;
background-color: var(--color-background);
&:last-child {
margin-bottom: 0;
}
`
export default ProviderSetting

View File

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

View File

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

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 { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { formatApiKeys } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Avatar, Button, Divider, Flex, Input } from 'antd'
import { maskApiKey } from '@renderer/utils/api'
import { Avatar, Button, Divider, Flex, Input, Typography } from 'antd'
import Link from 'antd/es/typography/Link'
import { Info } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
@ -154,6 +155,31 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
{/* 显示API密钥列表 */}
{apiKey.includes(',') && (
<KeysListContainer>
{apiKey
.split(',')
.map((key) => key.trim())
.filter((key) => key !== '')
.map((key, index) => (
<KeyItem key={index}>
<Typography.Text>{maskApiKey(key)}</Typography.Text>
<Button
type="text"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(key)
window.message.success({
content: t('common.copied'),
duration: 2
})
}}
/>
</KeyItem>
))}
</KeysListContainer>
)}
</>
)}
{hasObjectKey(provider, 'apiHost') && (
@ -190,4 +216,25 @@ const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const KeysListContainer = styled.div`
margin-top: 8px;
padding: 8px;
border-radius: 6px;
background-color: var(--color-background-soft);
`
const KeyItem = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 4px;
background-color: var(--color-background);
&:last-child {
margin-bottom: 0;
}
`
export default WebSearchProviderSetting

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import i18n from '@renderer/i18n'
import store from '@renderer/store'
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) {
if (isAbortError(error)) {
message.status = 'paused'

View File

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

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] 当前所有提供商:',
providers.map(p => ({ id: p.id, name: p.name, type: p.type })))
console.log(
'[getAssistantProvider] 当前所有提供商:',
providers.map((p) => ({ id: p.id, name: p.name, type: p.type }))
)
// 查找所有DeepClaude类型的提供商
const deepClaudeProviders = providers.filter(p => p.type === 'deepclaude')
const deepClaudeProviders = providers.filter((p) => p.type === 'deepclaude')
console.log('[getAssistantProvider] 找到DeepClaude类型的提供商数量:', deepClaudeProviders.length)
if (deepClaudeProviders.length > 0) {
// 先尝试查找与model.id匹配的提供商
const matchingProvider = deepClaudeProviders.find(p => p.id === assistant.model?.id)
const matchingProvider = deepClaudeProviders.find((p) => p.id === assistant.model?.id)
if (matchingProvider) {
console.log('[getAssistantProvider] 找到匹配的DeepClaude提供商:', matchingProvider.id, matchingProvider.name)
return matchingProvider
}
// 如果没有找到匹配的使用第一个DeepClaude提供商
console.log('[getAssistantProvider] 使用第一个DeepClaude提供商:', deepClaudeProviders[0].id, deepClaudeProviders[0].name)
console.log(
'[getAssistantProvider] 使用第一个DeepClaude提供商:',
deepClaudeProviders[0].id,
deepClaudeProviders[0].name
)
return deepClaudeProviders[0]
}

View File

@ -84,13 +84,15 @@ export async function backupToWebdav({
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let deviceType = 'unknown'
let hostname = 'unknown'
try {
deviceType = (await window.api.system.getDeviceType()) || 'unknown'
hostname = (await window.api.system.getHostname()) || 'unknown'
} catch (error) {
Logger.error('[Backup] Failed to get device type:', error)
Logger.error('[Backup] Failed to get device type or hostname:', error)
}
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
const backupData = await getBackupData()

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
// 新增正则表达式匹配AI返回的不同格式
const alternativeGroupRegex = /-\s*组(\d+)?:\s*(?:\*\*)?["\[]?([\d,\s]+)["\]]?(?:\*\*)?\s*-\s*合并建议:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?\s*-\s*分类:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?/g
const alternativeGroupRegex =
/-\s*组(\d+)?:\s*(?:\*\*)?["[]?([\d,\s]+)["]?(?:\*\*)?\s*-\s*合并建议:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?\s*-\s*分类:\s*(?:\*\*)?["']?([^"'\n-]+)["']?(?:\*\*)?/g
// 简化的正则表达式,直接匹配组号和方括号内的数字
const simpleGroupRegex = /-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*(.+?)\s*-\s*分类:\s*(.+?)(?=\s*$|\s*-\s*组|\s*\n)/gm
const simpleGroupRegex =
/-\s*组(\d+)?:\s*\[([\d,\s]+)\]\s*-\s*合并建议:\s*(.+?)\s*-\s*分类:\s*(.+?)(?=\s*$|\s*-\s*组|\s*\n)/gm
// 尝试所有正则表达式
const regexesToTry = [simpleGroupRegex, alternativeGroupRegex, originalGroupRegex]
@ -180,7 +182,7 @@ ${memoriesToCheck}
found = true
const groupId = match[1] || String(similarGroups.length + 1)
// 清理引号和方括号
const memoryIndicesStr = match[2].replace(/["'\[\]]/g, '')
const memoryIndicesStr = match[2].replace(/["'[\]]/g, '')
const memoryIndices = memoryIndicesStr.split(',').map((s: string) => s.trim())
const mergedContent = match[3].trim().replace(/^["']|["']$/g, '') // 移除首尾的引号
const category = match[4]?.trim().replace(/^["']|["']$/g, '') // 移除首尾的引号
@ -201,7 +203,7 @@ ${memoriesToCheck}
}
// 如果找到了匹配项,就不再尝试其他正则表达式
if (found) break;
if (found) break
}
// 旧的解析代码已被上面的新代码替代
@ -223,10 +225,13 @@ ${memoriesToCheck}
const independentMatch = result.match(regex)
if (independentMatch && independentMatch[1]) {
// 处理可能包含引号的情况
const cleanedIndependentStr = independentMatch[1].replace(/["'\[\]]/g, '')
const cleanedIndependentStr = independentMatch[1].replace(/["'[\]]/g, '')
const items = cleanedIndependentStr.split(',').map((s: string) => s.trim())
console.log(`[Memory Deduplication] Found independent memories with regex ${regex.toString().substring(0, 30)}...`, items)
console.log(
`[Memory Deduplication] Found independent memories with regex ${regex.toString().substring(0, 30)}...`,
items
)
independentMemories.push(...items)
independentFound = true
@ -241,11 +246,11 @@ ${memoriesToCheck}
if (numberMatches) {
// 过滤出不在相似组中的数字
const usedIndices = new Set()
similarGroups.forEach(group => {
group.memoryIds.forEach(id => usedIndices.add(id))
similarGroups.forEach((group) => {
group.memoryIds.forEach((id) => usedIndices.add(id))
})
const unusedIndices = numberMatches.filter(num => !usedIndices.has(num))
const unusedIndices = numberMatches.filter((num) => !usedIndices.has(num))
if (unusedIndices.length > 0) {
console.log('[Memory Deduplication] Extracted independent memories from numbers in result:', unusedIndices)
independentMemories.push(...unusedIndices)
@ -256,13 +261,14 @@ ${memoriesToCheck}
// 如果没有解析到相似组和独立记忆项,但结果中包含“组”字样,尝试使用更宽松的正则表达式
if (similarGroups.length === 0 && independentMemories.length === 0 && result.includes('组')) {
// 尝试使用更宽松的正则表达式提取组信息
const looseGroupRegex = /-\s*组\s*(\d+)?\s*:\s*["\[]?\s*([\d,\s"]+)\s*["\]]?\s*-\s*合并建议\s*:\s*["']?([^"'\n-]+)["']?/g
const looseGroupRegex =
/-\s*组\s*(\d+)?\s*:\s*["[]?\s*([\d,\s"]+)\s*["]?\s*-\s*合并建议\s*:\s*["']?([^"'\n-]+)["']?/g
let looseMatch: RegExpExecArray | null
while ((looseMatch = looseGroupRegex.exec(result)) !== null) {
const groupId = looseMatch[1] || String(similarGroups.length + 1)
// 清理引号和方括号
const memoryIndicesStr = looseMatch[2].replace(/["'\[\]]/g, '')
const memoryIndicesStr = looseMatch[2].replace(/["'[\]]/g, '')
const memoryIndices = memoryIndicesStr.split(',').map((s: string) => s.trim())
const mergedContent = looseMatch[3].trim()

View File

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

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
if (memoryToDelete && memoryToDelete.analyzedMessageIds && memoryToDelete.analyzedMessageIds.length > 0) {
// 记录日志,方便调试
console.log(`[Memory] Deleting assistant memory with ${memoryToDelete.analyzedMessageIds.length} analyzed message IDs`)
console.log(
`[Memory] Deleting assistant memory with ${memoryToDelete.analyzedMessageIds.length} analyzed message IDs`
)
}
// 删除记忆
@ -902,7 +904,10 @@ const memorySlice = createSlice({
},
// 记录记忆访问
accessMemory: (state, action: PayloadAction<{ id: string; isShortMemory?: boolean; isAssistantMemory?: boolean }>) => {
accessMemory: (
state,
action: PayloadAction<{ id: string; isShortMemory?: boolean; isAssistantMemory?: boolean }>
) => {
const { id, isShortMemory, isAssistantMemory } = action.payload
const now = new Date().toISOString()
@ -1000,7 +1005,10 @@ const memorySlice = createSlice({
// 助手记忆分析模型
if (action.payload.assistantMemoryAnalyzeModel) {
state.assistantMemoryAnalyzeModel = action.payload.assistantMemoryAnalyzeModel
console.log('[Memory Reducer] Loaded assistant memory analyze model:', action.payload.assistantMemoryAnalyzeModel)
console.log(
'[Memory Reducer] Loaded assistant memory analyze model:',
action.payload.assistantMemoryAnalyzeModel
)
}
console.log('Short-term memory data loaded into state')

View File

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

View File

@ -7,7 +7,15 @@ import { WebDAVSyncState } from './backup'
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
export type SidebarIcon =
| 'assistants'
| 'agents'
| 'paintings'
| 'translate'
| 'minapp'
| 'knowledge'
| 'files'
| 'projects'
export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'assistants',

View File

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

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

View File

@ -522,4 +522,30 @@ export function hasObjectKey(obj: any, key: string) {
return Object.keys(obj).includes(key)
}
/**
* npm readme中提取 npx mcp config
* @param readme readme字符串
* @returns mcp config sample
*/
export function getMcpConfigSampleFromReadme(readme: string) {
if (readme) {
// 使用正则表达式匹配 mcpServers 对象内容
const regex = /"mcpServers"\s*:\s*({(?:[^{}]*|{(?:[^{}]*|{[^{}]*})*})*})/
const match = readme.match(regex)
console.log('match', match)
if (match && match[1]) {
// 添加缺失的闭合括号检测
try {
let orgSample = JSON.parse(match[1])
orgSample = orgSample[Object.keys(orgSample)[0] ?? '']
if (orgSample.command === 'npx') {
return orgSample
}
} catch (e) {
console.log(e)
}
}
}
}
export { classNames }

View File

@ -17,3 +17,68 @@ export const findCitationInChildren = (children) => {
return null
}
export const MARKDOWN_ALLOWED_TAGS = [
'style',
'p',
'div',
'span',
'b',
'i',
'strong',
'em',
'ul',
'ol',
'li',
'table',
'tr',
'td',
'th',
'thead',
'tbody',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'pre',
'code',
'br',
'hr',
'svg',
'path',
'circle',
'rect',
'line',
'polyline',
'polygon',
'text',
'g',
'defs',
'title',
'desc',
'tspan',
'sub',
'sup',
'think'
]
// rehype-sanitize配置
export const sanitizeSchema = {
tagNames: MARKDOWN_ALLOWED_TAGS,
attributes: {
'*': ['className', 'style', 'id', 'title'],
svg: ['viewBox', 'width', 'height', 'xmlns', 'fill', 'stroke'],
path: ['d', 'fill', 'stroke', 'strokeWidth', 'strokeLinecap', 'strokeLinejoin'],
circle: ['cx', 'cy', 'r', 'fill', 'stroke'],
rect: ['x', 'y', 'width', 'height', 'fill', 'stroke'],
line: ['x1', 'y1', 'x2', 'y2', 'stroke'],
polyline: ['points', 'fill', 'stroke'],
polygon: ['points', 'fill', 'stroke'],
text: ['x', 'y', 'fill', 'textAnchor', 'dominantBaseline'],
g: ['transform', 'fill', 'stroke'],
a: ['href', 'target', 'rel']
}
}

View File

@ -44,8 +44,8 @@ export const DEFAULT_THINKING_LIBRARIES: ThinkingLibrary[] = [
: {question}`
},
{
id: 'scientific_rigorous',
name: '严谨科学分析',
id: 'scientific_rigorous',
name: '严谨科学分析',
description: '运用系统化的科学方法论,对问题进行深入、严谨的分析、假设检验与评估。', // 更新描述
category: '专业',
prompt: `你是一位严谨的科学分析师,遵循科学方法论对问题进行系统性探究。请对以下问题,按照结构化的科学探究过程进行深入思考和分析:
@ -497,20 +497,25 @@ export function getThinkingLibraries(): ThinkingLibrary[] {
const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[]
console.log('[ThinkingLibrary] 解析思考库数量:', parsed.length)
if (parsed.length < DEFAULT_THINKING_LIBRARIES.length || !parsed.every(lib => DEFAULT_THINKING_LIBRARIES.some(defLib => defLib.id === lib.id))) {
if (
parsed.length < DEFAULT_THINKING_LIBRARIES.length ||
!parsed.every((lib) => DEFAULT_THINKING_LIBRARIES.some((defLib) => defLib.id === lib.id))
) {
console.log('[ThinkingLibrary] 存储的思考库需要更新,与默认库合并')
const librariesToMerge = DEFAULT_THINKING_LIBRARIES.map(defaultLib => {
const existingLib = parsed.find(lib => lib.id === defaultLib.id);
return existingLib || defaultLib;
});
const librariesToMerge = DEFAULT_THINKING_LIBRARIES.map((defaultLib) => {
const existingLib = parsed.find((lib) => lib.id === defaultLib.id)
return existingLib || defaultLib
})
const customLibraries = parsed.filter(lib => !DEFAULT_THINKING_LIBRARIES.some(defLib => defLib.id === lib.id));
const updatedLibraries = [...librariesToMerge, ...customLibraries];
const customLibraries = parsed.filter(
(lib) => !DEFAULT_THINKING_LIBRARIES.some((defLib) => defLib.id === lib.id)
)
const updatedLibraries = [...librariesToMerge, ...customLibraries]
console.log('[ThinkingLibrary] 更新后思考库数量:', updatedLibraries.length);
saveThinkingLibraries(updatedLibraries);
return updatedLibraries;
console.log('[ThinkingLibrary] 更新后思考库数量:', updatedLibraries.length)
saveThinkingLibraries(updatedLibraries)
return updatedLibraries
}
return parsed
}
@ -533,9 +538,9 @@ export function saveThinkingLibraries(libraries: ThinkingLibrary[]): void {
const savedLibraries = localStorage.getItem('thinkingLibraries')
if (savedLibraries) {
console.log('[ThinkingLibrary] 验证保存结果 - 数据已写入localStorage');
console.log('[ThinkingLibrary] 验证保存结果 - 数据已写入localStorage')
} else {
console.warn('[ThinkingLibrary] 验证保存结果 - 未在localStorage中找到数据');
console.warn('[ThinkingLibrary] 验证保存结果 - 未在localStorage中找到数据')
}
} catch (e) {
console.error('[ThinkingLibrary] 保存思考库失败:', e)
@ -547,7 +552,7 @@ export function getThinkingLibraryById(id: string | undefined): ThinkingLibrary
if (!id) return undefined
const libraries = getThinkingLibraries()
return libraries.find(lib => lib.id === id)
return libraries.find((lib) => lib.id === id)
}
// 调试函数:显示思考库数据
@ -560,7 +565,7 @@ export function debugThinkingLibraries(): void {
try {
const parsed = JSON.parse(savedLibraries) as ThinkingLibrary[]
console.log('[ThinkingLibrary] DEBUG - 解析后的思考库数量:', parsed.length)
console.log('[ThinkingLibrary] DEBUG - 思考库列表详情:', JSON.stringify(parsed, null, 2));
console.log('[ThinkingLibrary] DEBUG - 思考库列表详情:', JSON.stringify(parsed, null, 2))
} catch (e) {
console.error('[ThinkingLibrary] DEBUG - 解析思考库JSON失败:', e)
}
@ -582,44 +587,44 @@ export function addThinkingLibrary(library: Omit<ThinkingLibrary, 'id'>): Thinki
}
console.log('[ThinkingLibrary] 添加前思考库数量:', libraries.length)
const updatedLibraries = [...libraries, newLibrary];
const updatedLibraries = [...libraries, newLibrary]
console.log('[ThinkingLibrary] 添加后思考库数量:', updatedLibraries.length)
saveThinkingLibraries(updatedLibraries)
console.log('[ThinkingLibrary] 新增库ID:', newLibrary.id);
console.log('[ThinkingLibrary] 新增库ID:', newLibrary.id)
return newLibrary
}
// 更新思考库
export function updateThinkingLibrary(library: ThinkingLibrary): boolean {
console.log('[ThinkingLibrary] 更新思考库 ID:', library.id, '名称:', library.name);
console.log('[ThinkingLibrary] 更新思考库 ID:', library.id, '名称:', library.name)
const libraries = getThinkingLibraries()
const index = libraries.findIndex(lib => lib.id === library.id)
const index = libraries.findIndex((lib) => lib.id === library.id)
if (index !== -1) {
const updatedLibraries = [...libraries];
updatedLibraries[index] = library;
saveThinkingLibraries(updatedLibraries);
console.log('[ThinkingLibrary] 思考库更新成功');
return true;
const updatedLibraries = [...libraries]
updatedLibraries[index] = library
saveThinkingLibraries(updatedLibraries)
console.log('[ThinkingLibrary] 思考库更新成功')
return true
} else {
console.warn('[ThinkingLibrary] 更新失败未找到ID为', library.id, '的思考库');
return false;
console.warn('[ThinkingLibrary] 更新失败未找到ID为', library.id, '的思考库')
return false
}
}
// 删除思考库
export function deleteThinkingLibrary(id: string): boolean {
console.log('[ThinkingLibrary] 删除思考库 ID:', id);
console.log('[ThinkingLibrary] 删除思考库 ID:', id)
const libraries = getThinkingLibraries()
const initialLength = libraries.length;
const filteredLibraries = libraries.filter(lib => lib.id !== id)
const initialLength = libraries.length
const filteredLibraries = libraries.filter((lib) => lib.id !== id)
if (filteredLibraries.length < initialLength) {
saveThinkingLibraries(filteredLibraries);
console.log('[ThinkingLibrary] 思考库删除成功,剩余数量:', filteredLibraries.length);
return true;
saveThinkingLibraries(filteredLibraries)
console.log('[ThinkingLibrary] 思考库删除成功,剩余数量:', filteredLibraries.length)
return true
} else {
console.warn('[ThinkingLibrary] 删除失败未找到ID为', id, '的思考库');
return false;
console.warn('[ThinkingLibrary] 删除失败未找到ID为', id, '的思考库')
return false
}
}

View File

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

View File

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