diff --git a/package.json b/package.json index 5940113334..78b29d06f7 100644 --- a/package.json +++ b/package.json @@ -62,16 +62,44 @@ "@cherrystudio/embedjs-loader-web": "^0.1.28", "@cherrystudio/embedjs-loader-xml": "^0.1.28", "@cherrystudio/embedjs-openai": "^0.1.28", + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/closebrackets": "^0.19.2", + "@codemirror/commands": "^6.8.1", + "@codemirror/comment": "^0.19.1", + "@codemirror/fold": "^0.19.4", + "@codemirror/lang-cpp": "^6.0.2", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-java": "^6.0.1", + "@codemirror/lang-javascript": "^6.2.3", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/lang-php": "^6.0.1", + "@codemirror/lang-python": "^6.1.7", + "@codemirror/lang-rust": "^6.0.1", + "@codemirror/lang-sql": "^6.8.0", + "@codemirror/lang-vue": "^0.1.3", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.11.0", + "@codemirror/lint": "^6.8.5", + "@codemirror/matchbrackets": "^0.19.4", + "@codemirror/search": "^6.5.10", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.36.5", "@electron-toolkit/utils": "^3.0.0", "@electron/notarize": "^2.5.0", "@google/generative-ai": "^0.24.0", "@langchain/community": "^0.3.36", "@langchain/core": "^0.3.44", + "@lezer/highlight": "^1.2.1", + "@monaco-editor/react": "^4.7.0", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@strongtz/win32-arm64-msvc": "^0.4.7", "@tryfabric/martian": "^1.2.4", "@types/react-infinite-scroll-component": "^5.0.0", + "@types/react-transition-group": "^4.4.12", "@xyflow/react": "^12.4.4", "adm-zip": "^0.5.16", "async-mutex": "^0.5.0", @@ -80,6 +108,7 @@ "diff": "^7.0.0", "docx": "^9.0.2", "edge-tts-node": "^1.5.7", + "electron-chrome-extensions": "^4.7.0", "electron-log": "^5.1.5", "electron-store": "^8.2.0", "electron-updater": "^6.3.9", @@ -93,9 +122,13 @@ "js-yaml": "^4.1.0", "jsdom": "^26.0.0", "markdown-it": "^14.1.0", + "monaco-editor": "^0.52.2", "node-edge-tts": "^1.2.8", "officeparser": "^4.1.1", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^5.1.91", "proxy-agent": "^6.5.0", + "react-transition-group": "^4.4.5", "tar": "^7.4.3", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", @@ -126,7 +159,7 @@ "@reduxjs/toolkit": "^2.2.5", "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", "@tryfabric/martian": "^1.2.4", - "@types/adm-zip": "^0", + "@types/adm-zip": "^0.5.7", "@types/d3": "^7", "@types/diff": "^7", "@types/fs-extra": "^11", @@ -158,7 +191,7 @@ "electron-vite": "^2.3.0", "emittery": "^1.0.3", "emoji-picker-element": "^1.22.1", - "eslint": "^9.22.0", + "eslint": "^9.24.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index b0e25c106f..4355b93acf 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -176,5 +176,14 @@ export enum IpcChannel { // Long-term Memory File Storage LongTermMemory_LoadData = 'long-term-memory:load-data', - LongTermMemory_SaveData = 'long-term-memory:save-data' + LongTermMemory_SaveData = 'long-term-memory:save-data', + + // Code Executor + CodeExecutor_ExecuteJS = 'code-executor:execute-js', + CodeExecutor_ExecutePython = 'code-executor:execute-python', + CodeExecutor_GetSupportedLanguages = 'code-executor:get-supported-languages', + + // PDF + PDF_SplitPDF = 'pdf:split-pdf', + PDF_GetPageCount = 'pdf:get-page-count' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7ddf4b773d..ec7d7b5cd4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,6 +14,7 @@ import { titleBarOverlayDark, titleBarOverlayLight } from './config' import AppUpdater from './services/AppUpdater' import { asrServerService } from './services/ASRServerService' import BackupManager from './services/BackupManager' +import { codeExecutorService } from './services/CodeExecutorService' import { configManager } from './services/ConfigManager' import CopilotService from './services/CopilotService' import { ExportService } from './services/ExportService' @@ -26,6 +27,7 @@ import { memoryFileService } from './services/MemoryFileService' import * as MsTTSService from './services/MsTTSService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' +import { PDFService } from './services/PDFService' import { ProxyConfig, proxyManager } from './services/ProxyManager' import { searchService } from './services/SearchService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' @@ -342,4 +344,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.MsTTS_Synthesize, (_, text: string, voice: string, outputFormat: string) => MsTTSService.synthesize(text, voice, outputFormat) ) + + // 注册代码执行器IPC处理程序 + ipcMain.handle(IpcChannel.CodeExecutor_GetSupportedLanguages, async () => { + return await codeExecutorService.getSupportedLanguages() + }) + ipcMain.handle(IpcChannel.CodeExecutor_ExecuteJS, async (_, code: string) => { + return await codeExecutorService.executeJavaScript(code) + }) + ipcMain.handle(IpcChannel.CodeExecutor_ExecutePython, async (_, code: string) => { + return await codeExecutorService.executePython(code) + }) + + // PDF服务 + ipcMain.handle(IpcChannel.PDF_SplitPDF, PDFService.splitPDF.bind(PDFService)) + ipcMain.handle(IpcChannel.PDF_GetPageCount, PDFService.getPDFPageCount.bind(PDFService)) } diff --git a/src/main/services/CodeExecutorService.ts b/src/main/services/CodeExecutorService.ts new file mode 100644 index 0000000000..6224dba43d --- /dev/null +++ b/src/main/services/CodeExecutorService.ts @@ -0,0 +1,257 @@ +import { spawn } from 'child_process' +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', + Python = 'python' +} + +// 执行结果接口 +export interface ExecutionResult { + success: boolean + output: string + error?: string +} + +/** + * 代码执行器服务 + * 提供安全的代码执行环境,支持 JavaScript 和 Python + */ +export class CodeExecutorService { + private readonly tempDir: string + + constructor() { + // 创建临时目录用于存放执行的代码文件 + this.tempDir = path.join(os.tmpdir(), 'cherry-code-executor') + this.ensureTempDir() + } + + /** + * 确保临时目录存在 + */ + private ensureTempDir(): void { + if (!fs.existsSync(this.tempDir)) { + fs.mkdirSync(this.tempDir, { recursive: true }) + } + } + + /** + * 获取支持的编程语言列表 + */ + public async getSupportedLanguages(): Promise { + const languages = [CodeLanguage.JavaScript] + + // 检查是否安装了 Python + if (await this.isPythonAvailable()) { + languages.push(CodeLanguage.Python) + } + + return languages + } + + /** + * 检查 Python 是否可用 + */ + private async isPythonAvailable(): Promise { + try { + const pythonProcess = spawn('python', ['--version']) + return new Promise((resolve) => { + pythonProcess.on('close', (code) => { + resolve(code === 0) + }) + + // 设置超时 + setTimeout(() => resolve(false), 1000) + }) + } catch (error) { + return false + } + } + + /** + * 执行 JavaScript 代码 + * @param code JavaScript 代码 + * @returns 执行结果 + */ + public async executeJavaScript(code: string): Promise { + const fileId = uuidv4() + const tempFilePath = path.join(this.tempDir, `${fileId}.js`) + + try { + // 写入临时文件 + fs.writeFileSync(tempFilePath, code) + + // 使用 Node.js 执行代码 + return await this.runNodeScript(tempFilePath) + } catch (error) { + log.error('[CodeExecutor] Error executing JavaScript:', error) + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error) + } + } finally { + // 清理临时文件 + this.cleanupTempFile(tempFilePath) + } + } + + /** + * 执行 Python 代码 + * @param code Python 代码 + * @returns 执行结果 + */ + public async executePython(code: string): Promise { + const fileId = uuidv4() + const tempFilePath = path.join(this.tempDir, `${fileId}.py`) + + try { + // 写入临时文件 + fs.writeFileSync(tempFilePath, code) + + // 执行 Python 代码 + return await this.runPythonScript(tempFilePath) + } catch (error) { + log.error('[CodeExecutor] Error executing Python:', error) + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error) + } + } finally { + // 清理临时文件 + this.cleanupTempFile(tempFilePath) + } + } + + /** + * 运行 Node.js 脚本 + * @param scriptPath 脚本路径 + * @returns 执行结果 + */ + private async runNodeScript(scriptPath: string): Promise { + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + + // 使用 Node.js 执行脚本 + const nodeProcess = spawn(process.execPath, [scriptPath], { + env: { + ...process.env, + // 设置为 Node.js 模式,确保在 Electron 环境中正确执行 + ELECTRON_RUN_AS_NODE: '1', + // 限制访问权限 + NODE_OPTIONS: '--no-warnings --experimental-permission --allow-fs-read=* --allow-fs-write=' + this.tempDir + } + }) + + nodeProcess.stdout.on('data', (data) => { + stdout += data.toString() + }) + + nodeProcess.stderr.on('data', (data) => { + stderr += data.toString() + }) + + nodeProcess.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + output: stdout + }) + } else { + resolve({ + success: false, + output: stdout, + error: stderr + }) + } + }) + + // 设置超时(10秒) + setTimeout(() => { + nodeProcess.kill() + resolve({ + success: false, + output: stdout, + error: 'Execution timed out after 10 seconds' + }) + }, 10000) + }) + } + + /** + * 运行 Python 脚本 + * @param scriptPath 脚本路径 + * @returns 执行结果 + */ + private async runPythonScript(scriptPath: string): Promise { + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + + // 使用 Python 执行脚本 + const pythonProcess = spawn('python', [scriptPath], { + env: { ...process.env } + }) + + pythonProcess.stdout.on('data', (data) => { + stdout += data.toString() + }) + + pythonProcess.stderr.on('data', (data) => { + stderr += data.toString() + }) + + pythonProcess.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + output: stdout + }) + } else { + resolve({ + success: false, + output: stdout, + error: stderr + }) + } + }) + + // 设置超时(10秒) + setTimeout(() => { + pythonProcess.kill() + resolve({ + success: false, + output: stdout, + error: 'Execution timed out after 10 seconds' + }) + }, 10000) + }) + } + + /** + * 清理临时文件 + * @param filePath 文件路径 + */ + private cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch (error) { + log.error('[CodeExecutor] Error cleaning up temp file:', error) + } + } +} + +// 创建单例 +export const codeExecutorService = new CodeExecutorService() diff --git a/src/main/services/PDFService.ts b/src/main/services/PDFService.ts new file mode 100644 index 0000000000..f6fc2c3e24 --- /dev/null +++ b/src/main/services/PDFService.ts @@ -0,0 +1,204 @@ +import { getFileType } from '@main/utils/file' +import { FileType } from '@types' +import { app } from 'electron' +import logger from 'electron-log' +import * as fs from 'fs' +import * as path from 'path' +import { PDFDocument } from 'pdf-lib' +import { v4 as uuidv4 } from 'uuid' + +export class PDFService { + // 使用方法而不是静态属性来获取目录路径 + private static getTempDir(): string { + return path.join(app.getPath('temp'), 'CherryStudio') + } + + private static getStorageDir(): string { + return path.join(app.getPath('userData'), 'files') + } + + /** + * 获取PDF文件的页数 + * @param _ Electron IPC事件 + * @param filePath PDF文件路径 + * @returns PDF文件的页数 + */ + static async getPDFPageCount(_: Electron.IpcMainInvokeEvent, filePath: string): Promise { + try { + logger.info(`[PDFService] Getting page count for PDF: ${filePath}`) + const pdfBytes = fs.readFileSync(filePath) + const pdfDoc = await PDFDocument.load(pdfBytes) + const pageCount = pdfDoc.getPageCount() + logger.info(`[PDFService] PDF page count: ${pageCount}`) + return pageCount + } catch (error) { + logger.error('[PDFService] Error getting PDF page count:', error) + throw error + } + } + + /** + * 分割PDF文件 + * @param _ Electron IPC事件 + * @param file 原始PDF文件 + * @param pageRange 页码范围,例如:1-5,8,10-15 + * @returns 分割后的PDF文件信息 + */ + static async splitPDF(_: Electron.IpcMainInvokeEvent, file: FileType, pageRange: string): Promise { + try { + logger.info(`[PDFService] Splitting PDF: ${file.path}, page range: ${pageRange}`) + logger.info(`[PDFService] File details:`, JSON.stringify(file)) + + // 确保临时目录存在 + const tempDir = PDFService.getTempDir() + if (!fs.existsSync(tempDir)) { + logger.info(`[PDFService] Creating temp directory: ${tempDir}`) + fs.mkdirSync(tempDir, { recursive: true }) + } + + // 确保存储目录存在 + const storageDir = PDFService.getStorageDir() + if (!fs.existsSync(storageDir)) { + logger.info(`[PDFService] Creating storage directory: ${storageDir}`) + fs.mkdirSync(storageDir, { recursive: true }) + } + + // 读取原始PDF文件 + logger.info(`[PDFService] Reading PDF file: ${file.path}`) + const pdfBytes = fs.readFileSync(file.path) + logger.info(`[PDFService] PDF file read, size: ${pdfBytes.length} bytes`) + const pdfDoc = await PDFDocument.load(pdfBytes) + logger.info(`[PDFService] PDF document loaded, page count: ${pdfDoc.getPageCount()}`) + + // 创建新的PDF文档 + const newPdfDoc = await PDFDocument.create() + logger.info(`[PDFService] New PDF document created`) + + // 解析页码范围 + const pageIndexes = this.parsePageRange(pageRange, pdfDoc.getPageCount()) + logger.info(`[PDFService] Page range parsed, indexes: ${pageIndexes.join(', ')}`) + + // 复制指定页面到新文档 + const copiedPages = await newPdfDoc.copyPages(pdfDoc, pageIndexes) + logger.info(`[PDFService] Pages copied, count: ${copiedPages.length}`) + copiedPages.forEach((page, index) => { + logger.info(`[PDFService] Adding page ${index + 1} to new document`) + newPdfDoc.addPage(page) + }) + + // 保存新文档 + logger.info(`[PDFService] Saving new PDF document`) + const newPdfBytes = await newPdfDoc.save() + logger.info(`[PDFService] New PDF document saved, size: ${newPdfBytes.length} bytes`) + + // 生成新文件ID和路径 + const uuid = uuidv4() + const ext = '.pdf' + // 使用之前已经声明的storageDir变量 + const destPath = path.join(storageDir, uuid + ext) + logger.info(`[PDFService] Destination path: ${destPath}`) + + // 写入新文件 + logger.info(`[PDFService] Writing new PDF file`) + fs.writeFileSync(destPath, newPdfBytes) + logger.info(`[PDFService] New PDF file written`) + + // 获取文件状态 + const stats = fs.statSync(destPath) + logger.info(`[PDFService] File stats: size=${stats.size}, created=${stats.birthtime}`) + + // 创建新文件信息 + const newFile: FileType = { + id: uuid, + origin_name: `${path.basename(file.origin_name, '.pdf')}_pages_${pageRange}.pdf`, + name: uuid + ext, + path: destPath, + created_at: stats.birthtime.toISOString(), + size: stats.size, + ext: ext, + type: getFileType(ext), + count: 1, + pdf_page_range: pageRange + } + + logger.info(`[PDFService] PDF split successful: ${newFile.path}`) + logger.info(`[PDFService] New file details:`, JSON.stringify(newFile)) + return newFile + } catch (error) { + logger.error('[PDFService] Error splitting PDF:', error) + throw error + } + } + + /** + * 解析页码范围字符串为页码索引数组 + * @param pageRange 页码范围字符串,例如:1-5,8,10-15 + * @param totalPages PDF文档总页数 + * @returns 页码索引数组(从0开始) + */ + private static parsePageRange(pageRange: string, totalPages: number): number[] { + logger.info(`[PDFService] Parsing page range: ${pageRange}, total pages: ${totalPages}`) + const pageIndexes: number[] = [] + const parts = pageRange.split(',') + logger.info(`[PDFService] Page range parts: ${JSON.stringify(parts)}`) + + try { + for (const part of parts) { + const trimmed = part.trim() + if (!trimmed) { + logger.info(`[PDFService] Empty part, skipping`) + continue + } + + logger.info(`[PDFService] Processing part: ${trimmed}`) + + if (trimmed.includes('-')) { + const [startStr, endStr] = trimmed.split('-') + const start = parseInt(startStr.trim()) + const end = parseInt(endStr.trim()) + logger.info(`[PDFService] Range part: ${trimmed}, start: ${start}, end: ${end}`) + + if (isNaN(start) || isNaN(end)) { + logger.error(`[PDFService] Invalid range part (NaN): ${trimmed}`) + continue + } + + if (start < 1 || end > totalPages || start > end) { + logger.warn(`[PDFService] Invalid range: ${start}-${end}, totalPages: ${totalPages}`) + continue + } + + for (let i = start; i <= end; i++) { + pageIndexes.push(i - 1) // PDF页码从0开始,但用户输入从1开始 + logger.info(`[PDFService] Added page index: ${i - 1} (page ${i})`) + } + } else { + const page = parseInt(trimmed) + logger.info(`[PDFService] Single page: ${page}`) + + if (isNaN(page)) { + logger.error(`[PDFService] Invalid page number (NaN): ${trimmed}`) + continue + } + + if (page < 1 || page > totalPages) { + logger.warn(`[PDFService] Page ${page} out of range, totalPages: ${totalPages}`) + continue + } + + pageIndexes.push(page - 1) // PDF页码从0开始,但用户输入从1开始 + logger.info(`[PDFService] Added page index: ${page - 1} (page ${page})`) + } + } + + // 去重并排序 + const result = [...new Set(pageIndexes)].sort((a, b) => a - b) + logger.info(`[PDFService] Final page indexes: ${result.join(', ')}`) + return result + } catch (error) { + logger.error(`[PDFService] Error parsing page range: ${error}`) + // 如果解析出错,返回空数组 + return [] + } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index bc3d483e34..67a01ac50b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -207,6 +207,13 @@ declare global { deleteShortMemoryById: (id: string) => Promise loadLongTermData: () => Promise saveLongTermData: (data: any, forceOverwrite?: boolean) => Promise + }, + 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 } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 20bd1cf375..9a5ccc845e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -199,6 +199,15 @@ const api = { asrServer: { startServer: () => ipcRenderer.invoke(IpcChannel.Asr_StartServer), stopServer: (pid: number) => ipcRenderer.invoke(IpcChannel.Asr_StopServer, pid) + }, + pdf: { + splitPDF: (file: FileType, pageRange: string) => ipcRenderer.invoke(IpcChannel.PDF_SplitPDF, file, pageRange), + getPageCount: (filePath: string) => ipcRenderer.invoke(IpcChannel.PDF_GetPageCount, filePath) + }, + codeExecutor: { + getSupportedLanguages: () => ipcRenderer.invoke(IpcChannel.CodeExecutor_GetSupportedLanguages), + executeJS: (code: string) => ipcRenderer.invoke(IpcChannel.CodeExecutor_ExecuteJS, code), + executePython: (code: string) => ipcRenderer.invoke(IpcChannel.CodeExecutor_ExecutePython, code) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 045fc08940..b178e5ec6e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import { PersistGate } from 'redux-persist/integration/react' import Sidebar from './components/app/Sidebar' import DeepClaudeProvider from './components/DeepClaudeProvider' import MemoryProvider from './components/MemoryProvider' +import PDFSettingsInitializer from './components/PDFSettingsInitializer' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' import StyleSheetManager from './context/StyleSheetManager' @@ -33,6 +34,7 @@ function App(): React.ReactElement { + diff --git a/src/renderer/src/components/CodeExecutorButton/ExecutionResult.tsx b/src/renderer/src/components/CodeExecutorButton/ExecutionResult.tsx new file mode 100644 index 0000000000..6d23919f01 --- /dev/null +++ b/src/renderer/src/components/CodeExecutorButton/ExecutionResult.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import styled from 'styled-components' +import { useTranslation } from 'react-i18next' +import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons' + +export interface ExecutionResultProps { + success: boolean + output: string + error?: string +} + +const ExecutionResult: React.FC = ({ success, output, error }) => { + const { t } = useTranslation() + + return ( + + + {success ? ( + <> + {t('code.execution.success')} + + ) : ( + <> + {t('code.execution.error')} + + )} + + + {output && ( + + {t('code.execution.output')} + {output} + + )} + {error && ( + + {t('code.execution.error')} + {error} + + )} + + + ) +} + +const ResultContainer = styled.div` + margin-top: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + overflow: hidden; + font-family: monospace; + font-size: 14px; +` + +const ResultHeader = styled.div<{ success: boolean }>` + padding: 8px 12px; + background-color: ${(props) => (props.success ? 'rgba(82, 196, 26, 0.1)' : 'rgba(255, 77, 79, 0.1)')}; + color: ${(props) => (props.success ? 'var(--color-success)' : 'var(--color-error)')}; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +` + +const ResultContent = styled.div` + padding: 12px; + background-color: var(--color-code-background); + max-height: 300px; + overflow: auto; +` + +const OutputSection = styled.div` + margin-bottom: 12px; +` + +const OutputTitle = styled.div` + font-weight: 500; + margin-bottom: 4px; + color: var(--color-text-2); +` + +const OutputText = styled.pre` + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--color-text); +` + +const ErrorSection = styled.div` + margin-top: 8px; +` + +const ErrorTitle = styled.div` + font-weight: 500; + margin-bottom: 4px; + color: var(--color-error); +` + +const ErrorText = styled.pre` + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--color-error); +` + +export default ExecutionResult diff --git a/src/renderer/src/components/CodeExecutorButton/index.tsx b/src/renderer/src/components/CodeExecutorButton/index.tsx new file mode 100644 index 0000000000..6bd5833eda --- /dev/null +++ b/src/renderer/src/components/CodeExecutorButton/index.tsx @@ -0,0 +1,65 @@ +import { LoadingOutlined, PlayCircleOutlined } from '@ant-design/icons' +import { Tooltip } from 'antd' +import React from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface CodeExecutorButtonProps { + language: string + code: string + onClick: () => void + isLoading?: boolean + disabled?: boolean +} + +const CodeExecutorButton: React.FC = ({ + language, + // code 参数在组件内部未使用,但在接口中保留以便将来可能的扩展 + // code, + onClick, + isLoading = false, + disabled = false +}) => { + const { t } = useTranslation() + const supportedLanguages = ['javascript', 'js', 'python', 'py'] + + // 检查语言是否支持 + const isSupported = supportedLanguages.includes(language.toLowerCase()) + + if (!isSupported) { + return null + } + + return ( + + + {isLoading ? : } + + + ) +} + +const StyledButton = styled.button` + background: none; + border: none; + cursor: pointer; + color: var(--color-text); + font-size: 16px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +` + +export default CodeExecutorButton diff --git a/src/renderer/src/components/CodeMirrorEditor/ChineseSearchPanel.css b/src/renderer/src/components/CodeMirrorEditor/ChineseSearchPanel.css new file mode 100644 index 0000000000..2d730cb76b --- /dev/null +++ b/src/renderer/src/components/CodeMirrorEditor/ChineseSearchPanel.css @@ -0,0 +1,134 @@ +/* 中文搜索面板样式 */ +.cm-panel.cm-search { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +/* 修改按钮文本 */ +.cm-panel.cm-search button[name="find"] { + font-size: 0; +} +.cm-panel.cm-search button[name="find"]::after { + content: '查找'; + font-size: 14px; + display: inline-block; + width: 100%; +} + +.cm-panel.cm-search button[name="next"] { + font-size: 0; +} +.cm-panel.cm-search button[name="next"]::after { + content: '下一个'; + font-size: 14px; + display: inline-block; + width: 100%; +} + +.cm-panel.cm-search button[name="prev"] { + font-size: 0; +} +.cm-panel.cm-search button[name="prev"]::after { + content: '上一个'; + font-size: 14px; + display: inline-block; + width: 100%; +} + +.cm-panel.cm-search button[name="replace"] { + font-size: 0; +} +.cm-panel.cm-search button[name="replace"]::after { + content: '替换'; + font-size: 14px; + display: inline-block; + width: 100%; +} + +.cm-panel.cm-search button[name="replaceAll"] { + font-size: 0; +} +.cm-panel.cm-search button[name="replaceAll"]::after { + content: '全部替换'; + font-size: 14px; + display: inline-block; + width: 100%; +} + +/* 修改标签文本 */ +.cm-panel.cm-search label[for="case"] { + font-size: 0; +} +.cm-panel.cm-search label[for="case"] input { + font-size: 14px; + margin-right: 4px; +} +.cm-panel.cm-search label[for="case"]::after { + content: '区分大小写'; + font-size: 14px; + display: inline-block; +} + +.cm-panel.cm-search label[for="regexp"] { + font-size: 0; +} +.cm-panel.cm-search label[for="regexp"] input { + font-size: 14px; + margin-right: 4px; +} +.cm-panel.cm-search label[for="regexp"]::after { + content: '正则表达式'; + font-size: 14px; + display: inline-block; +} + +.cm-panel.cm-search label[for="word"] { + font-size: 0; +} +.cm-panel.cm-search label[for="word"] input { + font-size: 14px; + margin-right: 4px; +} +.cm-panel.cm-search label[for="word"]::after { + content: '全词匹配'; + font-size: 14px; + display: inline-block; +} + +/* 修改输入框占位符 */ +.cm-panel.cm-search input[name="search"]::placeholder { + color: #999; +} + +.cm-panel.cm-search input[name="replace"]::placeholder { + color: #999; +} + +/* 修改输入框标签 */ +.cm-panel.cm-search label[for="search"] { + display: none; +} + +.cm-panel.cm-search label[for="replace"] { + display: none; +} + +/* 添加中文占位符 */ +.cm-panel.cm-search input[name="search"] { + position: relative; +} +.cm-panel.cm-search input[name="search"]::before { + content: '查找'; + position: absolute; + color: #999; + pointer-events: none; +} + +.cm-panel.cm-search input[name="replace"] { + position: relative; +} +.cm-panel.cm-search input[name="replace"]::before { + content: '替换'; + position: absolute; + color: #999; + pointer-events: none; +} diff --git a/src/renderer/src/components/CodeMirrorEditor/ChineseSearchPanel.ts b/src/renderer/src/components/CodeMirrorEditor/ChineseSearchPanel.ts new file mode 100644 index 0000000000..3ec04ed34e --- /dev/null +++ b/src/renderer/src/components/CodeMirrorEditor/ChineseSearchPanel.ts @@ -0,0 +1,15 @@ +import { Extension } from '@codemirror/state' +import { search, openSearchPanel } from '@codemirror/search' +import { EditorView } from '@codemirror/view' + +// 创建中文搜索面板 +export function createChineseSearchPanel(): Extension { + return search({ + top: true + }) +} + +// 打开中文搜索面板 +export function openChineseSearchPanel(view: EditorView) { + openSearchPanel(view) +} diff --git a/src/renderer/src/components/CodeMirrorEditor/index.tsx b/src/renderer/src/components/CodeMirrorEditor/index.tsx new file mode 100644 index 0000000000..4a2a65d540 --- /dev/null +++ b/src/renderer/src/components/CodeMirrorEditor/index.tsx @@ -0,0 +1,317 @@ +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' + + + +// 自定义语法高亮样式 +const lightThemeHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: '#0000ff' }, + { tag: tags.comment, color: '#008000', fontStyle: 'italic' }, + { tag: tags.string, color: '#a31515' }, + { tag: tags.number, color: '#098658' }, + { tag: tags.operator, color: '#000000' }, + { tag: tags.variableName, color: '#001080' }, + { tag: tags.propertyName, color: '#001080' }, + { tag: tags.className, color: '#267f99' }, + { tag: tags.typeName, color: '#267f99' }, + { tag: tags.definition(tags.variableName), color: '#001080' }, + { tag: tags.definition(tags.propertyName), color: '#001080' }, + { tag: tags.definition(tags.className), color: '#267f99' }, + { tag: tags.definition(tags.typeName), color: '#267f99' }, + { tag: tags.function(tags.variableName), color: '#795e26' }, + { tag: tags.function(tags.propertyName), color: '#795e26' }, + { tag: tags.angleBracket, color: '#800000' }, + { tag: tags.tagName, color: '#800000' }, + { tag: tags.attributeName, color: '#ff0000' }, + { tag: tags.attributeValue, color: '#0000ff' }, + { tag: tags.heading, color: '#800000', fontWeight: 'bold' }, + { tag: tags.link, color: '#0000ff', textDecoration: 'underline' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strong, fontWeight: 'bold' }, +]) + +// 暗色主题语法高亮样式 +const darkThemeHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: '#569cd6' }, + { tag: tags.comment, color: '#6a9955', fontStyle: 'italic' }, + { tag: tags.string, color: '#ce9178' }, + { tag: tags.number, color: '#b5cea8' }, + { tag: tags.operator, color: '#d4d4d4' }, + { tag: tags.variableName, color: '#9cdcfe' }, + { tag: tags.propertyName, color: '#9cdcfe' }, + { tag: tags.className, color: '#4ec9b0' }, + { tag: tags.typeName, color: '#4ec9b0' }, + { tag: tags.definition(tags.variableName), color: '#9cdcfe' }, + { tag: tags.definition(tags.propertyName), color: '#9cdcfe' }, + { tag: tags.definition(tags.className), color: '#4ec9b0' }, + { tag: tags.definition(tags.typeName), color: '#4ec9b0' }, + { tag: tags.function(tags.variableName), color: '#dcdcaa' }, + { tag: tags.function(tags.propertyName), color: '#dcdcaa' }, + { tag: tags.angleBracket, color: '#808080' }, + { tag: tags.tagName, color: '#569cd6' }, + { tag: tags.attributeName, color: '#9cdcfe' }, + { tag: tags.attributeValue, color: '#ce9178' }, + { tag: tags.heading, color: '#569cd6', fontWeight: 'bold' }, + { tag: tags.link, color: '#569cd6', textDecoration: 'underline' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strong, fontWeight: 'bold' }, +]) + +export interface CodeMirrorEditorRef { + undo: () => boolean + redo: () => boolean + openSearch: () => void + getContent: () => string +} + +interface CodeMirrorEditorProps { + code: string + language: string + onChange?: (value: string) => void + readOnly?: boolean + showLineNumbers?: boolean + fontSize?: number + height?: string +} + +const getLanguageExtension = (language: string) => { + switch (language.toLowerCase()) { + case 'javascript': + case 'js': + case 'jsx': + case 'typescript': + case 'ts': + case 'tsx': + return javascript() + case 'python': + case 'py': + return python() + case 'html': + return html() + case 'css': + case 'scss': + case 'less': + return css() + case 'json': + return json() + case 'markdown': + case 'md': + return markdown() + case 'cpp': + case 'c': + case 'c++': + case 'h': + case 'hpp': + return cpp() + case 'java': + return java() + case 'php': + return php() + case 'rust': + case 'rs': + return rust() + case 'sql': + return sql() + case 'xml': + case 'svg': + return xml() + case 'vue': + return vue() + default: + return javascript() + } +} + +const CodeMirrorEditor = forwardRef(( + { + code, + language, + onChange, + readOnly = false, + showLineNumbers = true, + fontSize = 14, + height = 'auto' + }, + ref +) => { + const editorRef = useRef(null) + const editorViewRef = useRef(null) + const { theme } = useTheme() + + // 根据当前主题选择高亮样式 + const highlightStyle = useMemo(() => { + return theme === ThemeMode.dark ? darkThemeHighlightStyle : lightThemeHighlightStyle + }, [theme]) + + // 暴露撤销/重做方法和获取内容方法 + useImperativeHandle(ref, () => ({ + undo: () => { + if (editorViewRef.current) { + try { + // 使用用户事件标记来触发撤销 + const success = undo({ state: editorViewRef.current.state, dispatch: editorViewRef.current.dispatch }) + // 返回是否成功撤销 + return success + } catch (error) { + return false + } + } + return false + }, + redo: () => { + if (editorViewRef.current) { + try { + // 使用用户事件标记来触发重做 + const success = redo({ state: editorViewRef.current.state, dispatch: editorViewRef.current.dispatch }) + // 返回是否成功重做 + return success + } catch (error) { + return false + } + } + return false + }, + openSearch: () => { + if (editorViewRef.current) { + openChineseSearchPanel(editorViewRef.current) + } + }, + // 获取当前编辑器内容 + getContent: () => { + if (editorViewRef.current) { + return editorViewRef.current.state.doc.toString() + } + return code + } + })) + + useEffect(() => { + if (!editorRef.current) return + + // 清除之前的编辑器实例 + if (editorViewRef.current) { + editorViewRef.current.destroy() + } + + const languageExtension = getLanguageExtension(language) + + // 监听编辑器所有更新 + const updateListener = EditorView.updateListener.of(update => { + // 当文档变化时更新内部状态 + if (update.docChanged) { + // 检查是否是撤销/重做操作 + const isUndoRedo = update.transactions.some(tr => + tr.isUserEvent('undo') || tr.isUserEvent('redo') + ) + + // 记录所有文档变化,但只在撤销/重做时触发 onChange + if (isUndoRedo && onChange) { + // 如果是撤销/重做操作,则触发 onChange + onChange(update.state.doc.toString()) + } + } + }) + + const extensions = [ + // 配置历史记录 + history(), + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + ...searchKeymap, + indentWithTab, + { key: "Mod-z", run: undo }, + { key: "Mod-y", run: redo }, + { key: "Mod-Shift-z", run: redo } + ]), + syntaxHighlighting(highlightStyle), + languageExtension, + EditorView.editable.of(!readOnly), + updateListener, + EditorState.readOnly.of(readOnly), + highlightActiveLine(), + autocompletion(), + createChineseSearchPanel(), + EditorView.theme({ + '&': { + fontSize: `${fontSize}px`, + height: height + }, + '.cm-content': { + fontFamily: 'monospace' + } + }) + ] + + // 添加行号 + if (showLineNumbers) { + extensions.push(lineNumbers()) + } + + // 添加主题 + if (theme === ThemeMode.dark) { + extensions.push(oneDark) + } + + const state = EditorState.create({ + doc: code, + extensions + }) + + const view = new EditorView({ + state, + parent: editorRef.current + }) + + editorViewRef.current = view + + return () => { + view.destroy() + } + }, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height]) + + return +}); + +const EditorContainer = styled.div` + width: 100%; + border-radius: 4px; + overflow: hidden; + + .cm-editor { + height: 100%; + } + + .cm-scroller { + overflow: auto; + } +` + +export default CodeMirrorEditor diff --git a/src/renderer/src/components/CodeMirrorEditor/styles.css b/src/renderer/src/components/CodeMirrorEditor/styles.css new file mode 100644 index 0000000000..dc27b0aeb6 --- /dev/null +++ b/src/renderer/src/components/CodeMirrorEditor/styles.css @@ -0,0 +1,285 @@ +.cm-editor { + height: 100%; + font-family: monospace; + border-radius: 4px; + overflow: hidden; +} + +.cm-scroller { + overflow: auto; +} + +.cm-content { + padding: 10px; +} + +.cm-line { + padding: 0 4px; + line-height: 1.6; +} + +.cm-activeLineGutter { + background-color: rgba(0, 0, 0, 0.1); +} + +.cm-gutters { + border-right: 1px solid var(--color-border); + background-color: var(--color-code-background); + color: var(--color-text-3); +} + +.cm-gutterElement { + padding: 0 3px 0 5px; +} + +/* Dark theme specific styles */ +.dark-theme .cm-editor { + background-color: #282c34; + color: #abb2bf; +} + +.dark-theme .cm-gutters { + background-color: #21252b; + color: #636d83; +} + +.dark-theme .cm-activeLineGutter { + background-color: rgba(255, 255, 255, 0.1); +} + +/* 自动补全样式 */ +.cm-tooltip { + border: 1px solid var(--color-border); + background-color: var(--color-background); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 100; +} + +.cm-tooltip.cm-tooltip-autocomplete { + min-width: 200px; +} + +.cm-tooltip-autocomplete ul { + font-family: monospace; + padding: 0; + margin: 0; +} + +.cm-tooltip-autocomplete li { + padding: 4px 8px; + cursor: pointer; +} + +.cm-tooltip-autocomplete li:hover { + background-color: var(--color-hover); +} + +.cm-tooltip-autocomplete .cm-completionLabel { + color: var(--color-text); +} + +.cm-tooltip-autocomplete .cm-completionDetail { + color: var(--color-text-3); + font-size: 0.9em; + margin-left: 8px; +} + +.cm-tooltip-autocomplete .cm-completionIcon { + display: none; +} + +.cm-tooltip-autocomplete .cm-completionMatchedText { + color: var(--color-primary); + text-decoration: none; + font-weight: bold; +} + +.dark-theme .cm-tooltip { + background-color: #282c34; + border-color: #3e4451; +} + +.dark-theme .cm-tooltip-autocomplete .cm-completionLabel { + color: #abb2bf; +} + +.dark-theme .cm-tooltip-autocomplete .cm-completionDetail { + color: #636d83; +} + +.dark-theme .cm-tooltip-autocomplete .cm-completionMatchedText { + color: #61afef; +} + +.dark-theme .cm-tooltip-autocomplete li:hover { + background-color: #3e4451; +} + +/* 查找和替换面板样式 */ +.cm-search { + max-width: 300px; + display: flex; + flex-direction: column; + padding: 8px; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 200; +} + +.cm-search input { + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 4px 8px; + margin-bottom: 4px; + background-color: var(--color-background); + color: var(--color-text); +} + +.cm-search button { + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 2px 8px; + margin: 2px; + background-color: var(--color-background); + color: var(--color-text); + cursor: pointer; +} + +.cm-search button:hover { + background-color: var(--color-hover); +} + +.cm-search label { + display: flex; + align-items: center; + margin: 2px 0; + font-size: 0.9em; + color: var(--color-text); +} + +.cm-search input[type="checkbox"] { + margin-right: 4px; +} + +.cm-searchMatch { + background-color: rgba(250, 166, 26, 0.2); +} + +.cm-searchMatch-selected { + background-color: rgba(250, 166, 26, 0.5); +} + +.dark-theme .cm-search { + background-color: #282c34; + border-color: #3e4451; +} + +.dark-theme .cm-search input { + background-color: #21252b; + border-color: #3e4451; + color: #abb2bf; +} + +.dark-theme .cm-search button { + background-color: #21252b; + border-color: #3e4451; + color: #abb2bf; +} + +.dark-theme .cm-search button:hover { + background-color: #3e4451; +} + +.dark-theme .cm-search label { + color: #abb2bf; +} + +.dark-theme .cm-searchMatch { + background-color: rgba(229, 192, 123, 0.3); +} + +.dark-theme .cm-searchMatch-selected { + background-color: rgba(229, 192, 123, 0.6); +} + +/* 中文搜索面板样式覆盖 */ +.cm-panel.cm-search { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +.cm-panel.cm-search input[name="search"] { + width: 10em; +} + +.cm-panel.cm-search input[name="replace"] { + width: 10em; +} + +.cm-panel.cm-search button[name="find"] { + content: '查找'; +} + +.cm-panel.cm-search button[name="next"] { + content: '下一个'; +} + +.cm-panel.cm-search button[name="prev"] { + content: '上一个'; +} + +.cm-panel.cm-search button[name="replace"] { + content: '替换'; +} + +.cm-panel.cm-search button[name="replaceAll"] { + content: '全部替换'; +} + +.cm-panel.cm-search label[for="case"] { + content: '区分大小写'; +} + +.cm-panel.cm-search label[for="regexp"] { + content: '正则表达式'; +} + +.cm-panel.cm-search label[for="word"] { + content: '全词匹配'; +} + +/* 修改按钮文本 */ +.cm-panel.cm-search button[name="find"]::before { + content: '查找'; +} + +.cm-panel.cm-search button[name="next"]::before { + content: '下一个'; +} + +.cm-panel.cm-search button[name="prev"]::before { + content: '上一个'; +} + +.cm-panel.cm-search button[name="replace"]::before { + content: '替换'; +} + +.cm-panel.cm-search button[name="replaceAll"]::before { + content: '全部替换'; +} + +/* 修改标签文本 */ +.cm-panel.cm-search label[for="case"]::after { + content: '区分大小写'; +} + +.cm-panel.cm-search label[for="regexp"]::after { + content: '正则表达式'; +} + +.cm-panel.cm-search label[for="word"]::after { + content: '全词匹配'; +} diff --git a/src/renderer/src/components/MemoryProvider.tsx b/src/renderer/src/components/MemoryProvider.tsx index 54b3e6103d..5787cbe1f4 100644 --- a/src/renderer/src/components/MemoryProvider.tsx +++ b/src/renderer/src/components/MemoryProvider.tsx @@ -22,7 +22,7 @@ import { setRecommendationThreshold, setShortMemoryActive } from '@renderer/store/memory' -import { FC, ReactNode, useEffect, useRef } from 'react' +import { FC, ReactNode, useEffect, useMemo, useRef } from 'react' interface MemoryProviderProps { children: ReactNode @@ -45,12 +45,17 @@ const MemoryProvider: FC = ({ children }) => { // 获取当前对话 const currentTopic = useAppSelector((state) => state.messages?.currentTopic?.id) - const messages = useAppSelector((state) => { - if (!currentTopic || !state.messages?.messagesByTopic) { + + // 使用 useMemo 记忆化选择器的结果,避免返回新的数组引用 + const messagesByTopic = useAppSelector((state) => state.messages?.messagesByTopic) + + // 使用 useMemo 记忆化消息数组 + const messages = useMemo(() => { + if (!currentTopic || !messagesByTopic) { return [] } - return state.messages.messagesByTopic[currentTopic] || [] - }) + return messagesByTopic[currentTopic] || [] + }, [currentTopic, messagesByTopic]) // 存储上一次的话题ID const previousTopicRef = useRef(null) diff --git a/src/renderer/src/components/PDFSettingsInitializer.tsx b/src/renderer/src/components/PDFSettingsInitializer.tsx new file mode 100644 index 0000000000..6d542a229b --- /dev/null +++ b/src/renderer/src/components/PDFSettingsInitializer.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import store from '@renderer/store' +import { setPdfSettings } from '@renderer/store/settings' +import { useSettings } from '@renderer/hooks/useSettings' + +/** + * 用于在应用启动时初始化PDF设置 + */ +const PDFSettingsInitializer = () => { + const dispatch = useDispatch() + const { pdfSettings } = useSettings() + + // 默认PDF设置 + const defaultPdfSettings = { + enablePdfSplitting: true, + defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15' + } + + useEffect(() => { + console.log('[PDFSettingsInitializer] Initializing PDF settings') + // 强制初始化PDF设置,确保 enablePdfSplitting 存在且为 true + console.log('[PDFSettingsInitializer] Current pdfSettings:', pdfSettings) + + // 创建合并的设置 + const mergedSettings = { + ...defaultPdfSettings, + ...pdfSettings, + // 强制设置 enablePdfSplitting 为 true + enablePdfSplitting: true + } + + console.log('[PDFSettingsInitializer] Forcing initialization with settings:', mergedSettings) + dispatch(setPdfSettings(mergedSettings)) + + // 延迟1秒后再次检查设置,确保它们已经被正确应用 + const timer = setTimeout(() => { + const state = store.getState() + console.log('[PDFSettingsInitializer] Checking settings after delay:', state.settings.pdfSettings) + + // 如果设置仍然不正确,再次强制设置 + if (!state.settings.pdfSettings?.enablePdfSplitting) { + console.log('[PDFSettingsInitializer] Settings still incorrect, forcing again') + dispatch(setPdfSettings({ + ...state.settings.pdfSettings, + enablePdfSplitting: true + })) + } + }, 1000) + + return () => clearTimeout(timer) + }, []) + + return null +} + +export default PDFSettingsInitializer diff --git a/src/renderer/src/components/PDFSplitter.tsx b/src/renderer/src/components/PDFSplitter.tsx new file mode 100644 index 0000000000..a5fcddb534 --- /dev/null +++ b/src/renderer/src/components/PDFSplitter.tsx @@ -0,0 +1,265 @@ +import { useSettings } from '@renderer/hooks/useSettings' +import { reloadTranslations } from '@renderer/i18n' +import { FileType } from '@renderer/types' +import { Button, Input, message, Modal, Spin } from 'antd' +import * as pdfjsLib from 'pdfjs-dist' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +// 设置 PDF.js worker 路径 +pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js` + +interface PDFSplitterProps { + file: FileType + visible: boolean + onCancel: () => void + onConfirm: (file: FileType, pageRange: string) => void +} + +const PDFSplitter: FC = ({ file, visible, onCancel, onConfirm }) => { + const { t } = useTranslation() + const { pdfSettings } = useSettings() + const [pageRange, setPageRange] = useState( + pdfSettings?.defaultPageRangePrompt || t('pdf.page_range_placeholder') + ) + const [totalPages, setTotalPages] = useState(0) + const [loading, setLoading] = useState(true) + const [processing, setProcessing] = useState(false) + // 页面选择相关状态在 PDFPreview 组件中实现 + + // 强制重新加载翻译 + useEffect(() => { + if (visible) { + console.log('[PDFSplitter] Reloading translations') + reloadTranslations() + } + }, [visible]) + + useEffect(() => { + const loadPdf = async () => { + console.log('[PDFSplitter] loadPdf called, visible:', visible, 'file:', file) + if (visible && file) { + setLoading(true) + try { + console.log('[PDFSplitter] Loading PDF file:', file.path) + + // 使用文件路径直接加载PDF + console.log('[PDFSplitter] Using PDFService to split PDF') + + // 尝试从文件属性中获取页数 + if (file.pdf_page_count) { + console.log('[PDFSplitter] Using page count from file properties:', file.pdf_page_count) + setTotalPages(file.pdf_page_count) + setLoading(false) + } else { + // 如果文件属性中没有页数信息,则使用默认值 + // 注意:如果应用程序没有重新加载,getPageCount方法可能不可用 + console.log('[PDFSplitter] No page count in file properties, checking if getPageCount is available') + + if (window.api.pdf.getPageCount && typeof window.api.pdf.getPageCount === 'function') { + console.log('[PDFSplitter] getPageCount method is available, fetching from server') + try { + window.api.pdf + .getPageCount(file.path) + .then((pageCount: number) => { + console.log('[PDFSplitter] Got page count from server:', pageCount) + setTotalPages(pageCount) + // 更新文件属性,以便下次使用 + file.pdf_page_count = pageCount + }) + .catch((error: unknown) => { + console.error('[PDFSplitter] Error getting page count:', error) + // 如果出错,使用默认值 + const defaultPages = 100 + console.log('[PDFSplitter] Using default page count:', defaultPages) + setTotalPages(defaultPages) + }) + .finally(() => { + setLoading(false) + }) + } catch (error) { + console.error('[PDFSplitter] Error calling getPageCount:', error) + const defaultPages = 100 + console.log('[PDFSplitter] Using default page count:', defaultPages) + setTotalPages(defaultPages) + setLoading(false) + } + } else { + console.log('[PDFSplitter] getPageCount method is not available, using default value') + const defaultPages = 100 + console.log('[PDFSplitter] Using default page count:', defaultPages) + setTotalPages(defaultPages) + setLoading(false) + } + } + // Loading state will be set in the finally block + } catch (error) { + console.error('[PDFSplitter] Error loading PDF:', error) + message.error(t('error.unknown')) + onCancel() + } + } + } + + loadPdf() + }, [visible, file, onCancel, t]) + + // 处理预览按钮点击 + const handlePreview = () => { + console.log('[PDFSplitter] handlePreview called, file:', file) + // 使用系统默认的 PDF 查看器打开 PDF 文件 + window.api.file + .openPath(file.path) + .then(() => { + console.log('[PDFSplitter] PDF file opened successfully') + }) + .catch((error: unknown) => { + console.error('[PDFSplitter] Error opening PDF file:', error) + message.error(t('error.unknown')) + }) + } + + const handleConfirm = async () => { + console.log('[PDFSplitter] handleConfirm called, pageRange:', pageRange, 'totalPages:', totalPages) + if (!validatePageRange(pageRange, totalPages)) { + console.log('[PDFSplitter] Invalid page range') + message.error(t('settings.pdf.invalid_range')) + return + } + + setProcessing(true) + try { + console.log('[PDFSplitter] Processing PDF with page range:', pageRange) + console.log('[PDFSplitter] File to process:', file) + // 将页码范围传递给父组件,实际的PDF分割将在主进程中完成 + onConfirm(file, pageRange) + console.log('[PDFSplitter] onConfirm called successfully') + } catch (error) { + console.error('[PDFSplitter] Error processing PDF:', error) + message.error(t('error.unknown')) + } finally { + setProcessing(false) + } + } + + // 验证页码范围格式 + const validatePageRange = (range: string, total: number): boolean => { + console.log('[PDFSplitter] Validating page range:', range, 'total pages:', total) + if (!range.trim()) { + console.log('[PDFSplitter] Empty page range') + return false + } + + try { + // 支持的格式: 1,2,3-5,7-9 + const parts = range.split(',') + console.log('[PDFSplitter] Page range parts:', parts) + + for (const part of parts) { + const trimmed = part.trim() + if (!trimmed) { + console.log('[PDFSplitter] Empty part, skipping') + continue + } + + if (trimmed.includes('-')) { + const [start, end] = trimmed.split('-').map((n) => parseInt(n.trim())) + console.log('[PDFSplitter] Range part:', trimmed, 'start:', start, 'end:', end) + if (isNaN(start) || isNaN(end) || start < 1 || end > total || start > end) { + console.log('[PDFSplitter] Invalid range part:', trimmed, 'start:', start, 'end:', end) + return false + } + } else { + const page = parseInt(trimmed) + console.log('[PDFSplitter] Single page:', page) + if (isNaN(page) || page < 1 || page > total) { + console.log('[PDFSplitter] Invalid page number:', page) + return false + } + } + } + + console.log('[PDFSplitter] Page range is valid') + return true + } catch (error) { + console.error('[PDFSplitter] Error validating page range:', error) + return false + } + } + + return ( + + {loading ? ( + + + + ) : ( + + +
{file.name}
+
{t('settings.pdf.total_pages', { count: totalPages })}
+
+ + + + setPageRange(e.target.value)} + placeholder={t('settings.pdf.page_range_placeholder')} + /> + + + +
+ +
+
+ + +
+
+
+ )} +
+ ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +` + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 200px; +` + +const FileInfo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background-color: var(--color-bg-2); + border-radius: 4px; +` + +const InputContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` + +const ButtonContainer = styled.div` + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 8px; +` + +export default PDFSplitter diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 9b0bb823a0..6d5844bc2e 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -56,6 +56,11 @@ const NavbarCenterContainer = styled.div` padding: 0 ${isMac ? '20px' : 0}; font-weight: bold; color: var(--color-text-1); + + /* 确保标题区域的按钮可点击 */ + & button, & a { + -webkit-app-region: no-drag; + } ` const NavbarRightContainer = styled.div` @@ -65,4 +70,10 @@ const NavbarRightContainer = styled.div` padding: 0 12px; padding-right: ${isWindows ? '140px' : 12}; justify-content: flex-end; + -webkit-app-region: no-drag; /* 确保按钮可点击 */ + + /* 确保所有子元素都可点击 */ + & > * { + -webkit-app-region: no-drag; + } ` diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 3adebf7552..ea65a999b9 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -672,6 +672,18 @@ export const SYSTEM_MODELS: Record = { provider: 'gemini', name: 'Gemini 2.0 Flash', group: 'Gemini 2.0' + }, + { + id: 'gemini-2.5-flash-preview-04-17', + provider: 'gemini', + name: 'Gemini 2.5 Flash', + group: 'Gemini 2.5' + }, + { + id: 'gemini-2.5-pro-preview-03-25', + provider: 'gemini', + name: 'Gemini 2.5 Pro', + group: 'Gemini 2.5' } ], anthropic: [ @@ -2150,7 +2162,9 @@ export const GEMINI_SEARCH_MODELS = [ 'gemini-2.0-pro-exp-02-05', 'gemini-2.0-pro-exp', 'gemini-2.5-pro-exp', - 'gemini-2.5-pro-exp-03-25' + 'gemini-2.5-pro-exp-03-25', + 'gemini-2.5-flash-preview-04-17', + 'gemini-2.5-pro-preview-03-25' ] export function isTextToImageModel(model: Model): boolean { @@ -2393,6 +2407,20 @@ export function isGemmaModel(model?: Model): boolean { return model.id.includes('gemma-') || model.group === 'Gemma' } +/** + * 检查模型是否支持思考预算功能 + * @param model 模型 + * @returns 是否支持思考预算 + */ +export function isSupportedThinkingBudgetModel(model?: Model): boolean { + if (!model) { + return false + } + + // 目前只有Gemini 2.5系列模型支持思考预算 + return model.id.includes('gemini-2.5') +} + export function isZhipuModel(model?: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 9ecc0581cd..dd8fc89d8e 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -43,4 +43,11 @@ i18n.use(initReactI18next).init({ } }) +// 强制重新加载翻译 +export const reloadTranslations = () => { + const currentLng = i18n.language + i18n.reloadResources(currentLng) + console.log('[i18n] Translations reloaded for language:', currentLng) +} + export default i18n diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 20218c0a22..028420f58a 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1,5 +1,15 @@ { "translation": { + "code": { + "execute": "Execute Code", + "executing": "Executing...", + "execution": { + "success": "Execution Successful", + "error": "Execution Error", + "output": "Output", + "unsupported_language": "Unsupported Programming Language" + } + }, "voice_call": { "title": "Voice Call", "start": "Start Voice Call", @@ -83,6 +93,8 @@ "settings.reasoning_effort.medium": "medium", "settings.reasoning_effort.off": "off", "settings.reasoning_effort.tip": "Only supported by OpenAI o-series, Anthropic, and Grok reasoning models", + "settings.thinking_budget": "Thinking Budget", + "settings.thinking_budget.tip": "Controls the maximum number of tokens used for thinking before generating the final answer (only supported by Gemini 2.5 series models)", "settings.more": "Assistant Settings" }, "auth": { @@ -282,7 +294,12 @@ "collapse": "Collapse", "disable_wrap": "Unwrap", "enable_wrap": "Wrap", - "expand": "Expand" + "expand": "Expand", + "edit": "Edit", + "done_editing": "Done Editing", + "undo": "Undo", + "redo": "Redo", + "search": "Search" }, "common": { "add": "Add", @@ -668,6 +685,8 @@ "number": "Number", "string": "Text" }, + "thinking_budget": "Thinking Budget", + "thinking_budget.tip": "Controls the maximum number of tokens used for thinking before generating the final answer (only supported by Gemini 2.5 series models)", "pinned": "Pinned", "rerank_model": "Reordering Model", "rerank_model_support_provider": "Currently, the reordering model only supports some providers ({{provider}})", @@ -1594,6 +1613,25 @@ "title": "Privacy Settings", "enable_privacy_mode": "Anonymous reporting of errors and statistics" }, + "pdf": { + "title": "PDF Settings", + "enable_splitting": "Enable PDF Splitting", + "default_page_range_prompt": "Default Page Range Prompt", + "default_page_range_prompt_placeholder": "Example: 1-5,8,10-15", + "description": "PDF splitting allows you to select specific page ranges to send to AI for more precise processing of PDF document content.", + "split": "Split PDF", + "page_range": "Page Range", + "page_range_placeholder": "Enter page range, e.g.: 1-5,8,10-15", + "total_pages": "Total {{count}} pages", + "preview": "Preview", + "cancel": "Cancel", + "confirm": "Confirm", + "invalid_range": "Invalid page range", + "processing": "Processing...", + "one_at_a_time": "Only one PDF file can be processed at a time, other PDF files will be ignored", + "error_loading": "Failed to load PDF file", + "error_splitting": "Failed to split PDF file" + }, "tts": { "title": "Text-to-Speech Settings", "enable": "Enable Text-to-Speech", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index cca567188e..491faeff7c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1,5 +1,15 @@ { "translation": { + "code": { + "execute": "执行代码", + "executing": "执行中...", + "execution": { + "success": "执行成功", + "error": "执行错误", + "output": "输出", + "unsupported_language": "不支持的编程语言" + } + }, "voice_call": { "title": "语音通话", "start": "开始语音通话", @@ -83,6 +93,8 @@ "settings.reasoning_effort.medium": "中", "settings.reasoning_effort.off": "关", "settings.reasoning_effort.tip": "仅支持 OpenAI o-series、Anthropic、Grok 推理模型", + "settings.thinking_budget": "思考预算", + "settings.thinking_budget.tip": "控制模型在生成最终回答前进行思考所使用的token数量上限(仅支持Gemini 2.5系列模型)", "settings.more": "助手设置" }, "auth": { @@ -283,7 +295,12 @@ "collapse": "收起", "disable_wrap": "取消换行", "enable_wrap": "换行", - "expand": "展开" + "expand": "展开", + "edit": "编辑", + "done_editing": "完成编辑", + "undo": "撤销", + "redo": "重做", + "search": "查找" }, "common": { "add": "添加", @@ -669,6 +686,8 @@ "number": "数字", "string": "文本" }, + "thinking_budget": "思考预算", + "thinking_budget.tip": "控制模型在生成最终回答前进行思考所使用的token数量上限(仅支持Gemini 2.5系列模型)", "pinned": "已固定", "rerank_model": "重排模型", "rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})", @@ -1683,6 +1702,25 @@ "title": "隐私设置", "enable_privacy_mode": "匿名发送错误报告和数据统计" }, + "pdf": { + "title": "PDF设置", + "enable_splitting": "启用PDF分割功能", + "default_page_range_prompt": "默认页码范围提示文本", + "default_page_range_prompt_placeholder": "例如:1-5,8,10-15", + "description": "PDF分割功能允许您选择特定页码范围发送给AI,以便更精确地处理PDF文档内容。", + "split": "分割PDF", + "page_range": "页码范围", + "page_range_placeholder": "输入页码范围,例如:1-5,8,10-15", + "preview": "预览", + "total_pages": "共 {{count}} 页", + "cancel": "取消", + "confirm": "确认", + "invalid_range": "无效的页码范围", + "processing": "处理中...", + "one_at_a_time": "一次只能处理一个PDF文件,其他PDF文件将被忽略", + "error_loading": "加载PDF文件失败", + "error_splitting": "分割PDF文件失败" + }, "voice": { "title": "语音功能", "help": "语音功能包括文本转语音(TTS)、语音识别(ASR)和语音通话。", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 051f5a5784..66ffc8babe 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,13 +1,18 @@ import { isVisionModel } from '@renderer/config/models' +import { useSettings } from '@renderer/hooks/useSettings' +import { setPdfSettings } from '@renderer/store/settings' import { FileType, Model } from '@renderer/types' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Tooltip } from 'antd' import { Paperclip } from 'lucide-react' -import { FC, useCallback, useImperativeHandle, useMemo } from '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 + handlePdfFile: (file: FileType) => boolean } interface Props { @@ -21,13 +26,75 @@ interface Props { const AttachmentButton: FC = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => { const { t } = useTranslation() + const { pdfSettings } = useSettings() + const dispatch = useDispatch() + const [pdfSplitterVisible, setPdfSplitterVisible] = useState(false) + const [selectedPdfFile, setSelectedPdfFile] = useState(null) const extensions = useMemo( () => (isVisionModel(model) ? [...imageExts, ...documentExts, ...textExts] : [...documentExts, ...textExts]), [model] ) + // 强制初始化PDF设置 + const forcePdfSettingsInitialization = useCallback(() => { + console.log('[AttachmentButton] Forcing PDF settings initialization') + // 如果pdfSettings为undefined或缺少enablePdfSplitting属性,则使用默认值初始化 + if (!pdfSettings || pdfSettings.enablePdfSplitting === undefined) { + const defaultPdfSettings = { + enablePdfSplitting: true, + defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15' + } + + console.log('[AttachmentButton] Dispatching setPdfSettings with:', defaultPdfSettings) + dispatch(setPdfSettings(defaultPdfSettings)) + return defaultPdfSettings + } + + return pdfSettings + }, [dispatch, pdfSettings]) + + 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) + + 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 onSelectFile = useCallback(async () => { + // 强制初始化PDF设置 + const settings = forcePdfSettingsInitialization() + console.log('[AttachmentButton] PDF settings before file selection:', settings) + const _files = await window.api.file.select({ properties: ['openFile', 'multiSelections'], filters: [ @@ -39,27 +106,76 @@ const AttachmentButton: FC = ({ ref, model, files, setFiles, ToolbarButto }) if (_files) { - setFiles([...files, ..._files]) + // 检查是否有PDF文件需要特殊处理 + const pdfFiles = _files.filter(file => file.ext.toLowerCase() === '.pdf') + const nonPdfFiles = _files.filter(file => file.ext.toLowerCase() !== '.pdf') + + // 添加非PDF文件 + if (nonPdfFiles.length > 0) { + setFiles([...files, ...nonPdfFiles]) + } + + // 处理PDF文件 + if (pdfFiles.length > 0) { + console.log('[AttachmentButton] PDF files selected:', pdfFiles) + console.log('[AttachmentButton] PDF settings after initialization:', settings) + + if (settings.enablePdfSplitting === true) { + console.log('[AttachmentButton] PDF splitting is enabled') + // 如果有多个PDF文件,只处理第一个 + setSelectedPdfFile(pdfFiles[0]) + setPdfSplitterVisible(true) + console.log('[AttachmentButton] Set PDF splitter visible with file:', pdfFiles[0]) + + // 如果有多个PDF文件,提示用户一次只能处理一个PDF文件 + if (pdfFiles.length > 1) { + console.log('[AttachmentButton] Multiple PDF files selected, showing info message') + window.message.info({ + content: t('pdf.one_at_a_time'), + key: 'pdf-one-at-a-time' + }) + } + } else { + console.log('[AttachmentButton] PDF splitting is disabled, adding all PDF files') + // 如果未启用PDF分割功能,直接添加所有PDF文件 + setFiles([...files, ...pdfFiles]) + } + } } - }, [extensions, files, setFiles]) + }, [extensions, files, setFiles, forcePdfSettingsInitialization, t]) const openQuickPanel = useCallback(() => { onSelectFile() }, [onSelectFile]) useImperativeHandle(ref, () => ({ - openQuickPanel + openQuickPanel, + handlePdfFile })) return ( - - - - - + <> + + + + + + + {selectedPdfFile && ( + { + setPdfSplitterVisible(false) + setSelectedPdfFile(null) + }} + onConfirm={handlePdfSplitterConfirm} + /> + )} + ) } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 4c2e54b164..db46bd2d2a 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -1,12 +1,4 @@ -import { - CodeOutlined as _CodeOutlined, - FileSearchOutlined as _FileSearchOutlined, - HolderOutlined, - PaperClipOutlined as _PaperClipOutlined, - PauseCircleOutlined as _PauseCircleOutlined, - ThunderboltOutlined as _ThunderboltOutlined, - TranslationOutlined as _TranslationOutlined -} from '@ant-design/icons' +import { HolderOutlined } from '@ant-design/icons' import ASRButton from '@renderer/components/ASRButton' import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import TranslateButton from '@renderer/components/TranslateButton' @@ -33,6 +25,7 @@ import WebSearchService from '@renderer/services/WebSearchService' import store, { useAppDispatch } from '@renderer/store' import { sendMessage as _sendMessage } from '@renderer/store/messages' import { setSearching } from '@renderer/store/runtime' +import { setPdfSettings } from '@renderer/store/settings' import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types' import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { getFilesFromDropEvent } from '@renderer/utils/input' @@ -375,7 +368,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = selectedKnowledgeBases, text, topic, - activedMcpServers + activedMcpServers, + t ]) const translate = useCallback(async () => { @@ -822,9 +816,38 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = e.stopPropagation() } + // 强制初始化PDF设置 + const forcePdfSettingsInitialization = () => { + const { pdfSettings } = store.getState().settings + console.log('[Inputbar] Current PDF settings:', pdfSettings) + + // 如果pdfSettings为undefined或缺少enablePdfSplitting属性,则使用默认值初始化 + if (!pdfSettings || pdfSettings.enablePdfSplitting === undefined) { + const defaultPdfSettings = { + enablePdfSplitting: true, + defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15' + } + + // 如果pdfSettings存在,则合并现有设置和默认设置 + const mergedSettings = { + ...defaultPdfSettings, + ...pdfSettings, + // 确保 enablePdfSplitting 存在且为 true + enablePdfSplitting: true + } + + console.log('[Inputbar] Forcing PDF settings initialization with:', mergedSettings) + dispatch(setPdfSettings(mergedSettings)) + return mergedSettings + } + + return pdfSettings + } + const handleDrop = async (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + console.log('[Inputbar] handleDrop called') const files = await getFilesFromDropEvent(e).catch((err) => { Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err) @@ -832,11 +855,50 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) if (files) { - files.forEach((file) => { - if (supportExts.includes(getFileExtension(file.path))) { - setFiles((prevFiles) => [...prevFiles, file]) + console.log('[Inputbar] Files from drop event:', files) + // 获取设置中的PDF设置,并强制初始化 + const pdfSettings = forcePdfSettingsInitialization() + console.log('[Inputbar] PDF settings after initialization:', pdfSettings) + + for (const file of files) { + const fileExt = getFileExtension(file.path) + console.log(`[Inputbar] Processing file: ${file.path} with extension: ${fileExt}`) + + // 如果是PDF文件 + if (fileExt === '.pdf') { + console.log('[Inputbar] PDF file detected, checking if splitting is enabled') + console.log('[Inputbar] pdfSettings?.enablePdfSplitting =', pdfSettings?.enablePdfSplitting) + + // 如果启用了PDF分割功能 + if (pdfSettings?.enablePdfSplitting === true) { + console.log('[Inputbar] PDF splitting is enabled, calling handlePdfFile') + // 检查attachmentButtonRef.current是否存在 + if (attachmentButtonRef.current) { + console.log('[Inputbar] attachmentButtonRef.current exists, calling handlePdfFile') + const handled = attachmentButtonRef.current.handlePdfFile(file) + console.log('[Inputbar] handlePdfFile result:', handled) + if (handled) { + // 如果文件已经被处理,则跳过后面的处理 + console.log('[Inputbar] File was handled by PDF splitter, skipping normal processing') + continue + } + } else { + console.log('[Inputbar] attachmentButtonRef.current is null or undefined') + } + } else { + console.log('[Inputbar] PDF splitting is disabled, processing as normal file') + } } - }) + // 其他支持的文件类型 + else if (supportExts.includes(fileExt)) { + console.log('[Inputbar] Adding file to files state:', file.path) + setFiles((prevFiles) => [...prevFiles, file]) + } else { + console.log('[Inputbar] File not supported or PDF splitting disabled:', file.path) + } + } + } else { + console.log('[Inputbar] No files from drop event') } } diff --git a/src/renderer/src/pages/home/Markdown/EditableCodeBlock.tsx b/src/renderer/src/pages/home/Markdown/EditableCodeBlock.tsx new file mode 100644 index 0000000000..2e4308bb6b --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/EditableCodeBlock.tsx @@ -0,0 +1,533 @@ +import { + CheckOutlined, + DownloadOutlined, + DownOutlined, + EditOutlined, + LoadingOutlined, + PlayCircleOutlined, + RedoOutlined, + SearchOutlined, + UndoOutlined +} from '@ant-design/icons' +import ExecutionResult, { ExecutionResultProps } from '@renderer/components/CodeExecutorButton/ExecutionResult' +import CodeMirrorEditor, { CodeMirrorEditorRef } from '@renderer/components/CodeMirrorEditor' +import CopyIcon from '@renderer/components/Icons/CopyIcon' +import UnWrapIcon from '@renderer/components/Icons/UnWrapIcon' +import WrapIcon from '@renderer/components/Icons/WrapIcon' +import { HStack } from '@renderer/components/Layout' +import { useSettings } from '@renderer/hooks/useSettings' +import { message, Tooltip } from 'antd' +import dayjs from 'dayjs' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import Mermaid from './Mermaid' +import { isValidPlantUML, PlantUML } from './PlantUML' +import SvgPreview from './SvgPreview' + +interface EditableCodeBlockProps { + children: string + className?: string + [key: string]: any +} + +const EditableCodeBlock: React.FC = ({ children, className }) => { + const match = /language-(\w+)/.exec(className || '') || children?.includes('\n') + const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() + const language = match?.[1] ?? 'text' + const [isExpanded, setIsExpanded] = useState(!codeCollapsible) + const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) + const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [code, setCode] = useState(children) + const [isExecuting, setIsExecuting] = useState(false) + const [executionResult, setExecutionResult] = useState(null) + const codeContentRef = useRef(null) + const editorRef = useRef(null) + const { t } = useTranslation() + + const showFooterCopyButton = children && children.length > 500 && !codeCollapsible + const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language) + + useEffect(() => { + setCode(children) + }, [children]) + + useEffect(() => { + setIsExpanded(!codeCollapsible) + setShouldShowExpandButton(codeCollapsible && (codeContentRef.current?.scrollHeight ?? 0) > 350) + }, [codeCollapsible]) + + useEffect(() => { + setIsUnwrapped(!codeWrappable) + }, [codeWrappable]) + + // 当点击编辑按钮时调用 + const handleEditToggle = useCallback(() => { + if (isEditing) { + // 如果当前是编辑状态,则保存代码 + if (editorRef.current) { + // 使用 getContent 方法获取编辑器内容 + const newCode = editorRef.current.getContent() + setCode(newCode) + } + } + // 切换编辑状态 + setIsEditing(!isEditing) + }, [isEditing]) + + // handleCodeChange 函数,只在撤销/重做操作时才会被调用 + const handleCodeChange = useCallback((newCode: string) => { + // 只在撤销/重做操作时才会被调用,所以可以安全地更新代码 + // 这不会影响普通的输入操作 + setCode(newCode) + }, []) + + // 执行代码 + const executeCode = useCallback(async () => { + if (!code) return + + setIsExecuting(true) + setExecutionResult(null) + + try { + let result + + // 根据语言类型选择执行方法 + if (language === 'javascript' || language === 'js') { + result = await window.api.codeExecutor.executeJS(code) + } else if (language === 'python' || language === 'py') { + result = await window.api.codeExecutor.executePython(code) + } else { + message.error(t('code.execution.unsupported_language')) + setIsExecuting(false) + return + } + + setExecutionResult(result) + } catch (error) { + console.error('Code execution error:', error) + setExecutionResult({ + success: false, + output: '', + error: error instanceof Error ? error.message : String(error) + }) + } finally { + setIsExecuting(false) + } + }, [code, language, t]) + + if (language === 'mermaid') { + return + } + + if (language === 'plantuml' && isValidPlantUML(children)) { + return + } + + if (language === 'svg') { + return ( + + + {''} + + + {children} + + ) + } + + return match ? ( + + + {'<' + language.toUpperCase() + '>'} + + + + {showDownloadButton && } + {codeWrappable && setIsUnwrapped(!isUnwrapped)} />} + {codeCollapsible && shouldShowExpandButton && ( + setIsExpanded(!isExpanded)} /> + )} + {isEditing && ( + <> + } + title={t('code_block.undo')} + onClick={() => editorRef.current?.undo()} + /> + } + title={t('code_block.redo')} + onClick={() => editorRef.current?.redo()} + /> + } + title={t('code_block.search')} + onClick={() => editorRef.current?.openSearch()} + /> + + )} + {(language === 'javascript' || language === 'js' || language === 'python' || language === 'py') && ( + + )} + + + + + {isEditing ? ( + + + + ) : ( + + {code} + + )} + {executionResult && ( + + )} + {codeCollapsible && ( + setIsExpanded(!isExpanded)} + showButton={shouldShowExpandButton} + /> + )} + {showFooterCopyButton && ( + + + + )} + + ) : ( + {children} + ) +} + +const EditButton: React.FC<{ isEditing: boolean; onClick: () => void }> = ({ isEditing, onClick }) => { + const { t } = useTranslation() + const editLabel = isEditing ? t('code_block.done_editing') : t('code_block.edit') + + return ( + + + {isEditing ? : } + + + ) +} + +const ExpandButton: React.FC<{ + isExpanded: boolean + onClick: () => void + showButton: boolean +}> = ({ isExpanded, onClick, showButton }) => { + const { t } = useTranslation() + if (!showButton) return null + + return ( + +
{isExpanded ? t('code_block.collapse') : t('code_block.expand')}
+
+ ) +} + +const UnwrapButton: React.FC<{ unwrapped: boolean; onClick: () => void }> = ({ unwrapped, onClick }) => { + const { t } = useTranslation() + const unwrapLabel = unwrapped ? t('code_block.enable_wrap') : t('code_block.disable_wrap') + return ( + + + {unwrapped ? ( + + ) : ( + + )} + + + ) +} + +const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => { + const [copied, setCopied] = useState(false) + const { t } = useTranslation() + const copy = t('common.copy') + + const onCopy = () => { + if (!text) return + navigator.clipboard.writeText(text) + window.message.success({ content: t('message.copied'), key: 'copy-code' }) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + {copied ? : } + + + ) +} + +const DownloadButton = ({ language, data }: { language: string; data: string }) => { + const onDownload = () => { + const fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` + window.api.file.save(fileName, data) + } + + return ( + + + + ) +} + +const CodeBlockWrapper = styled.div` + position: relative; +` + +const EditorContainer = styled.div` + border: 0.5px solid var(--color-code-background); + border-top-left-radius: 0; + border-top-right-radius: 0; + margin-top: 0; + position: relative; +` + +const CodeContent = styled.pre<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>` + padding: 1em; + background-color: var(--color-code-background); + border-radius: 4px; + overflow: auto; + font-family: monospace; + white-space: ${(props) => (props.isUnwrapped ? 'pre' : 'pre-wrap')}; + word-break: break-all; +` + +const CodeHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 1em; + background-color: var(--color-code-background); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: 0.5px solid var(--color-border); +` + +const CodeLanguage = styled.span` + font-family: monospace; + font-size: 0.8em; + color: var(--color-text-3); +` + +const StickyWrapper = styled.div` + position: sticky; + top: 0; + z-index: 10; +` + +const CodeFooter = styled.div` + display: flex; + justify-content: flex-end; + padding: 0.5em; +` + +const ExpandButtonWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 0.5em; + cursor: pointer; + background-color: var(--color-code-background); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-top: 0.5px solid var(--color-border); + + .button-text { + font-size: 0.8em; + color: var(--color-text-3); + } + + &:hover { + background-color: var(--color-code-background-hover); + } +` + +const UnwrapButtonWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } +` + +const CopyButtonWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } + + .copy { + width: 100%; + height: 100%; + } +` + +const EditButtonWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } +` + +const UndoRedoButton: React.FC<{ icon: React.ReactNode; title: string; onClick: () => void }> = ({ + icon, + title, + onClick +}) => { + return ( + + + {icon} + + + ) +} + +const UndoRedoButtonWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } +` + +const DownloadWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } +` + +const ExecuteButton: React.FC<{ isExecuting: boolean; onClick: () => void; title: string }> = ({ + isExecuting, + onClick, + title +}) => { + return ( + + + {isExecuting ? : } + + + ) +} + +const ExecuteButtonWrapper = styled.div<{ disabled: boolean }>` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')}; + color: var(--color-text-3); + opacity: ${(props) => (props.disabled ? 0.5 : 1)}; + + &:hover { + color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-primary)')}; + } +` + +const CollapseIcon = styled(({ expanded, ...props }: { expanded: boolean; onClick: () => void }) => ( +
{expanded ? : }
+))` + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; + color: var(--color-text-3); + + &:hover { + color: var(--color-primary); + } +` + +const WrappedCode = styled.code` + text-wrap: wrap; +` + +export default EditableCodeBlock diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index cb1d8bf859..c9d332e0bb 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -20,7 +20,7 @@ import remarkCjkFriendly from 'remark-cjk-friendly' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' -import CodeBlock from './CodeBlock' +import EditableCodeBlock from './EditableCodeBlock' import ImagePreview from './ImagePreview' import Link from './Link' @@ -54,7 +54,7 @@ const Markdown: FC = ({ message }) => { const components = useMemo(() => { const baseComponents = { a: (props: any) => , - code: CodeBlock, + code: EditableCodeBlock, img: ImagePreview, pre: (props: any) =>
,
       // 自定义处理think标签
diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx
index 9f39cb9bbb..e7aff43943 100644
--- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx
+++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx
@@ -6,7 +6,7 @@ import { MultiModelMessageStyle } from '@renderer/store/settings'
 import type { Message, Topic } from '@renderer/types'
 import { classNames } from '@renderer/utils'
 import { Popover } from 'antd'
-import { memo, useCallback, useEffect, useRef, useState } from 'react'
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import styled, { css } from 'styled-components'
 
 import MessageGroupMenuBar from './MessageGroupMenuBar'
@@ -145,8 +145,9 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
     }
   }, [messages, setSelectedMessage])
 
-  const renderMessage = useCallback(
-    (message: Message & { index: number }, index: number) => {
+  // 使用useMemo缓存消息渲染结果,减少重复计算
+  const renderedMessages = useMemo(() => {
+    return messages.map((message, index) => {
       const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped
       const messageProps = {
         isGrouped,
@@ -197,19 +198,19 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
       }
 
       return messageWrapper
-    },
-    [
-      isGrid,
-      isGrouped,
-      isHorizontal,
-      multiModelMessageStyle,
-      selectedIndex,
-      topic,
-      hidePresetMessages,
-      gridPopoverTrigger,
-      getSelectedMessageId
-    ]
-  )
+    })
+  }, [
+    messages,
+    isGrid,
+    isGrouped,
+    isHorizontal,
+    multiModelMessageStyle,
+    selectedIndex,
+    topic,
+    hidePresetMessages,
+    gridPopoverTrigger,
+    getSelectedMessageId
+  ])
 
   return (
      {
         $layout={multiModelMessageStyle}
         $gridColumns={gridColumns}
         className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
-        {messages.map((message, index) => renderMessage(message, index))}
+        {renderedMessages}
       
       {isGrouped && (
         `
   }}
 `
 
-export default memo(MessageGroup)
+// 使用自定义比较函数的memo包装组件,只在关键属性变化时重新渲染
+export default memo(MessageGroup, (prevProps, nextProps) => {
+  // 如果消息数组长度不同,需要重新渲染
+  if (prevProps.messages.length !== nextProps.messages.length) {
+    return false
+  }
+
+  // 检查消息内容是否变化
+  const messagesChanged = prevProps.messages.some((prevMsg, index) => {
+    const nextMsg = nextProps.messages[index]
+    return (
+      prevMsg.id !== nextMsg.id ||
+      prevMsg.content !== nextMsg.content ||
+      prevMsg.status !== nextMsg.status ||
+      prevMsg.foldSelected !== nextMsg.foldSelected ||
+      prevMsg.multiModelMessageStyle !== nextMsg.multiModelMessageStyle
+    )
+  })
+
+  if (messagesChanged) {
+    return false
+  }
+
+  // 检查其他关键属性
+  return prevProps.topic.id === nextProps.topic.id && prevProps.hidePresetMessages === nextProps.hidePresetMessages
+})
diff --git a/src/renderer/src/pages/home/Messages/MessageStream.tsx b/src/renderer/src/pages/home/Messages/MessageStream.tsx
index b8ee39833c..347e4be0d7 100644
--- a/src/renderer/src/pages/home/Messages/MessageStream.tsx
+++ b/src/renderer/src/pages/home/Messages/MessageStream.tsx
@@ -1,7 +1,7 @@
 import { useAppSelector } from '@renderer/store'
 import { selectStreamMessage } from '@renderer/store/messages'
 import { Assistant, Message, Topic } from '@renderer/types'
-import { memo } from 'react'
+import { memo, useMemo } from 'react'
 import styled from 'styled-components'
 
 import MessageItem from './Message'
@@ -31,9 +31,10 @@ const MessageStream: React.FC = ({
   isGrouped,
   style
 }) => {
-  // 获取流式消息
+  // 获取流式消息,使用选择器减少不必要的重新渲染
   const streamMessage = useAppSelector((state) => selectStreamMessage(state, _message.topicId, _message.id))
-  // 获取常规消息
+
+  // 获取常规消息,使用选择器减少不必要的重新渲染
   const regularMessage = useAppSelector((state) => {
     // 如果是用户消息,直接使用传入的_message
     if (_message.role === 'user') {
@@ -41,15 +42,18 @@ const MessageStream: React.FC = ({
     }
 
     // 对于助手消息,从store中查找最新状态
-    const topicMessages = state.messages.messagesByTopic[_message.topicId]
+    const topicMessages = state.messages?.messagesByTopic?.[_message.topicId]
     if (!topicMessages) return _message
 
     return topicMessages.find((m) => m.id === _message.id) || _message
   })
 
-  // 在hooks调用后进行条件判断
-  const isStreaming = !!(streamMessage && streamMessage.id === _message.id)
-  const message = isStreaming ? streamMessage : regularMessage
+  // 使用useMemo缓存计算结果
+  const { isStreaming, message } = useMemo(() => {
+    const isStreaming = !!(streamMessage && streamMessage.id === _message.id);
+    const message = isStreaming ? streamMessage : regularMessage;
+    return { isStreaming, message };
+  }, [streamMessage, regularMessage, _message.id])
   return (
     
        = ({
   )
 }
 
-export default memo(MessageStream)
+// 使用自定义比较函数的memo包装组件,只在关键属性变化时重新渲染
+export default memo(MessageStream, (prevProps, nextProps) => {
+  // 只在关键属性变化时重新渲染
+  return (
+    prevProps.message.id === nextProps.message.id &&
+    prevProps.message.content === nextProps.message.content &&
+    prevProps.message.status === nextProps.message.status &&
+    prevProps.topic.id === nextProps.topic.id
+  );
+})
diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx
index f150fabdea..2431defd7a 100644
--- a/src/renderer/src/pages/home/Messages/Messages.tsx
+++ b/src/renderer/src/pages/home/Messages/Messages.tsx
@@ -19,7 +19,7 @@ import {
   runAsyncFunction
 } from '@renderer/utils'
 import { flatten, last, take } from 'lodash'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import InfiniteScroll from 'react-infinite-scroll-component'
 import BeatLoader from 'react-spinners/BeatLoader'
@@ -198,14 +198,16 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic })
     if (!hasMore || isLoadingMore) return
 
     setIsLoadingMore(true)
-    setTimeout(() => {
+    // 使用requestAnimationFrame代替setTimeout,更好地与浏览器渲染周期同步
+    requestAnimationFrame(() => {
       const currentLength = displayMessages.length
       const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT)
 
+      // 批量更新状态,减少渲染次数
       setDisplayMessages((prev) => [...prev, ...newMessages])
       setHasMore(currentLength + LOAD_MORE_COUNT < messages.length)
       setIsLoadingMore(false)
-    }, 300)
+    })
   }, [displayMessages.length, hasMore, isLoadingMore, messages])
 
   useShortcut('copy_last_message', () => {
@@ -216,6 +218,19 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic })
     }
   })
 
+  // 使用记忆化渲染消息组,避免不必要的重渲染
+  const renderMessageGroups = useMemo(() => {
+    const groupedMessages = getGroupedMessages(displayMessages)
+    return Object.entries(groupedMessages).map(([key, groupMessages]) => (
+      
+    ))
+  }, [displayMessages, topic, assistant.settings?.hideMessages])
+
   return (
      = ({ assistant, topic, setActiveTopic })
           loader={null}
           scrollableTarget="messages"
           inverse
-          style={{ overflow: 'visible' }}>
+          style={{ overflow: 'visible' }}
+          scrollThreshold={0.8} // 提前触发加载更多
+          initialScrollY={0}>
           
             
               
             
-            {Object.entries(getGroupedMessages(displayMessages)).map(([key, groupMessages]) => (
-              
-            ))}
+            {renderMessageGroups}
           
         
         
@@ -255,7 +265,9 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic })
   )
 }
 
+// 优化的消息计算函数,使用Map代替多次数组查找,提高性能
 const computeDisplayMessages = (messages: Message[], startIndex: number, displayCount: number) => {
+  // 使用缓存避免不必要的重复计算
   const reversedMessages = [...messages].reverse()
 
   // 如果剩余消息数量小于 displayCount,直接返回所有剩余消息
@@ -267,6 +279,7 @@ const computeDisplayMessages = (messages: Message[], startIndex: number, display
   const assistantIdSet = new Set() // 助手消息 askId 集合
   const processedIds = new Set() // 用于跟踪已处理的消息ID
   const displayMessages: Message[] = []
+  const messageIdMap = new Map() // 用于快速查找消息ID是否存在
 
   // 处理单条消息的函数
   const processMessage = (message: Message) => {
@@ -285,19 +298,29 @@ const computeDisplayMessages = (messages: Message[], startIndex: number, display
     if (!idSet.has(messageId)) {
       idSet.add(messageId)
       displayMessages.push(message)
+      messageIdMap.set(message.id, true)
       return
     }
 
-    // 如果是相同 askId 的助手消息,检查是否已经有相同ID的消息
-    // 只有在没有相同ID的情况下才添加
-    if (message.role === 'assistant' && !displayMessages.some(m => m.id === message.id)) {
+    // 使用Map进行O(1)复杂度的查找,替代O(n)复杂度的数组some方法
+    if (message.role === 'assistant' && !messageIdMap.has(message.id)) {
       displayMessages.push(message)
+      messageIdMap.set(message.id, true)
     }
   }
 
-  // 遍历消息直到满足显示数量要求
+  // 使用批处理方式处理消息,每次处理一批,减少循环次数
+  const batchSize = Math.min(50, displayCount) // 每批处理的消息数量
+  let processedCount = 0
+
   for (let i = startIndex; i < reversedMessages.length && userIdSet.size + assistantIdSet.size < displayCount; i++) {
     processMessage(reversedMessages[i])
+    processedCount++
+
+    // 每处理一批消息,检查是否已满足显示数量要求
+    if (processedCount % batchSize === 0 && userIdSet.size + assistantIdSet.size >= displayCount) {
+      break
+    }
   }
 
   return displayMessages
@@ -333,4 +356,7 @@ const Container = styled(Scrollbar)`
   z-index: 1;
 `
 
-export default Messages
+export default memo(Messages, (prevProps, nextProps) => {
+  // 只在关键属性变化时重新渲染
+  return prevProps.assistant.id === nextProps.assistant.id && prevProps.topic.id === nextProps.topic.id
+})
diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
index d7604ed79f..b2bff9b96b 100644
--- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
+++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
@@ -24,6 +24,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA
   const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
   const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
   const [reasoningEffort, setReasoningEffort] = useState(assistant?.settings?.reasoning_effort)
+  const [thinkingBudget, setThinkingBudget] = useState(assistant?.settings?.thinkingBudget ?? 8192)
   const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
   const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
   const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
@@ -47,6 +48,14 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA
     updateAssistantSettings({ reasoning_effort: value })
   }
 
+  const onThinkingBudgetChange = (value) => {
+    // 确保值是数字,包括0
+    if (value !== null && value !== undefined && !isNaN(value as number)) {
+      console.log('[ThinkingBudget] 更新思考预算值:', value)
+      updateAssistantSettings({ thinkingBudget: value })
+    }
+  }
+
   const onContextCountChange = (value) => {
     if (!isNaN(value as number)) {
       updateAssistantSettings({ contextCount: value })
@@ -154,6 +163,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA
     setStreamOutput(true)
     setTopP(1)
     setReasoningEffort(undefined)
+    setThinkingBudget(8192)
     setCustomParameters([])
     updateAssistantSettings({
       temperature: DEFAULT_TEMPERATURE,
@@ -163,6 +173,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA
       streamOutput: true,
       topP: 1,
       reasoning_effort: undefined,
+      thinkingBudget: 8192,
       customParameters: []
     })
   }
@@ -404,6 +415,48 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA
         
       
       
+      {assistant?.model?.id?.includes('gemini-2.5') && (
+        <>
+          
+            
+            
+              
+            
+          
+          
+            
+              
+            
+            
+               {
+                  // 确保值是数字,包括0
+                  if (value !== null && value !== undefined && !isNaN(value as number)) {
+                    console.log('[ThinkingBudget] 输入框更新思考预算值:', value)
+                    setThinkingBudget(value)
+                    setTimeout(() => updateAssistantSettings({ thinkingBudget: value }), 500)
+                  }
+                }}
+                style={{ width: '100%' }}
+              />
+            
+          
+          
+        
+      )}