diff --git a/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch b/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch new file mode 100644 index 0000000000..d4381aa11c --- /dev/null +++ b/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch @@ -0,0 +1,159 @@ +diff --git a/out/macPackager.js b/out/macPackager.js +index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644 +--- a/out/macPackager.js ++++ b/out/macPackager.js +@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager { + } + appPlist.CFBundleName = appInfo.productName; + appPlist.CFBundleDisplayName = appInfo.productName; +- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion; ++ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion; + if (minimumSystemVersion != null) { + appPlist.LSMinimumSystemVersion = minimumSystemVersion; + } +diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js +index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644 +--- a/out/publish/updateInfoBuilder.js ++++ b/out/publish/updateInfoBuilder.js +@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) { + const customUpdateInfo = event.updateInfo; + const url = path.basename(event.file); + const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file)); ++ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion; + const files = [{ url, sha512 }]; + const result = { + // @ts-ignore +@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) { + path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */, + // @ts-ignore + sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */, ++ minimumSystemVersion, + ...releaseInfo, + }; + if (customUpdateInfo != null) { ++ if (customUpdateInfo.minimumSystemVersion) { ++ delete customUpdateInfo.minimumSystemVersion; ++ } + // file info or nsis web installer packages info + Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo); + } +diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js +index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644 +--- a/out/targets/ArchiveTarget.js ++++ b/out/targets/ArchiveTarget.js +@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target { + } + } + } ++ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) { ++ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion; ++ } + await packager.info.emitArtifactBuildCompleted({ + updateInfo, + file: artifactPath, +diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js +index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644 +--- a/out/targets/nsis/NsisTarget.js ++++ b/out/targets/nsis/NsisTarget.js +@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target { + if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) { + updateInfo.isAdminRightsRequired = true; + } ++ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) { ++ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion; ++ } + await packager.info.emitArtifactBuildCompleted({ + file: installerPath, + updateInfo, +diff --git a/scheme.json b/scheme.json +index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..a89c7a9b0b608fef67902c49106a43ebd0fa8b61 100644 +--- a/scheme.json ++++ b/scheme.json +@@ -1975,6 +1975,13 @@ + ], + "description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing." + }, ++ "minimumSystemVersion": { ++ "description": "The minimum os kernel version required to install the application.", ++ "type": [ ++ "null", ++ "string" ++ ] ++ }, + "packageCategory": { + "description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place", + "type": [ +@@ -2327,6 +2334,13 @@ + "MacConfiguration": { + "additionalProperties": false, + "properties": { ++ "LSMinimumSystemVersion": { ++ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", ++ "type": [ ++ "null", ++ "string" ++ ] ++ }, + "additionalArguments": { + "anyOf": [ + { +@@ -2737,7 +2751,7 @@ + "type": "boolean" + }, + "minimumSystemVersion": { +- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", ++ "description": "The minimum os kernel version required to install the application.", + "type": [ + "null", + "string" +@@ -2959,6 +2973,13 @@ + "MasConfiguration": { + "additionalProperties": false, + "properties": { ++ "LSMinimumSystemVersion": { ++ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", ++ "type": [ ++ "null", ++ "string" ++ ] ++ }, + "additionalArguments": { + "anyOf": [ + { +@@ -3369,7 +3390,7 @@ + "type": "boolean" + }, + "minimumSystemVersion": { +- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", ++ "description": "The minimum os kernel version required to install the application.", + "type": [ + "null", + "string" +@@ -6507,6 +6528,13 @@ + "string" + ] + }, ++ "minimumSystemVersion": { ++ "description": "The minimum os kernel version required to install the application.", ++ "type": [ ++ "null", ++ "string" ++ ] ++ }, + "protocols": { + "anyOf": [ + { +@@ -7376,6 +7404,13 @@ + ], + "description": "MAS (Mac Application Store) development options (`mas-dev` target)." + }, ++ "minimumSystemVersion": { ++ "description": "The minimum os kernel version required to install the application.", ++ "type": [ ++ "null", ++ "string" ++ ] ++ }, + "msi": { + "anyOf": [ + { diff --git a/docs/README.zh.md b/docs/README.zh.md index 2dd938e386..1e4876a820 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -18,6 +18,14 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客 ❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️ +# GitCode✖️Cherry Studio【新源力】贡献挑战赛 + +

+ + banner + +

+ # 📖 使用教程 https://docs.cherry-ai.com @@ -147,4 +155,4 @@ yinsenho@cherry-ai.com # ⭐️ Star 记录 -[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) +[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline) \ No newline at end of file diff --git a/electron-builder.yml b/electron-builder.yml index 3cf7e6af6f..4598455544 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -61,6 +61,7 @@ mac: entitlementsInherit: build/entitlements.mac.plist notarize: false artifactName: ${productName}-${version}-${arch}.${ext} + minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0 extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. - NSMicrophoneUsageDescription: Application requests access to the device's microphone. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 73729b5d00..bcbb53d079 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -73,7 +73,10 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: [] + exclude: ['pyodide'] + }, + worker: { + format: 'es' }, build: { rollupOptions: { @@ -82,11 +85,18 @@ export default defineConfig({ miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html') }, output: { - manualChunks(id: string) { + manualChunks: (id: string) => { + // 检测所有 worker 文件,提取 worker 名称作为 chunk 名 + if (id.includes('.worker') && id.endsWith('?worker')) { + const workerName = id.split('/').pop()?.split('.')[0] || 'worker' + return `workers/${workerName}` + } + // All node_modules are in the vendor chunk if (id.includes('node_modules')) { return 'vendor' } + // Other modules use default chunk splitting strategy return undefined } diff --git a/package.json b/package.json index 07258ff7cf..fb900e0650 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "electron-updater": "6.6.4", "electron-window-state": "^5.0.3", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", + "fast-diff": "^1.3.0", "fast-xml-parser": "^5.2.0", "fetch-socks": "^1.3.2", "fs-extra": "^11.2.0", @@ -94,8 +95,6 @@ "officeparser": "^4.1.1", "os-proxy-config": "^1.1.2", "proxy-agent": "^6.5.0", - "rc-virtual-list": "^3.18.6", - "react-window": "^1.8.11", "tar": "^7.4.3", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", @@ -118,17 +117,14 @@ "@eslint/js": "^9.22.0", "@google/genai": "^0.13.0", "@hello-pangea/dnd": "^16.6.0", - "@iconify-json/svg-spinners": "^1.2.2", "@kangfenmao/keyv-storage": "^0.1.0", - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.3", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.2.2", - "@swc/plugin-styled-components": "^7.1.3", - "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", + "@swc/plugin-styled-components": "^7.1.5", "@tryfabric/martian": "^1.2.4", - "@types/adm-zip": "^0", "@types/diff": "^7", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", @@ -142,24 +138,25 @@ "@types/react-window": "^1", "@types/tinycolor2": "^1", "@types/ws": "^8", + "@uiw/codemirror-extensions-langs": "^4.23.12", + "@uiw/codemirror-themes-all": "^4.23.12", + "@uiw/react-codemirror": "^4.23.12", "@vitejs/plugin-react-swc": "^3.9.0", - "@vitest/coverage-v8": "^3.1.1", "@vitest/ui": "^3.1.1", + "@vitest/web-worker": "^3.1.3", "@xyflow/react": "^12.4.4", "antd": "^5.22.5", - "applescript": "^1.0.0", "axios": "^1.7.3", - "babel-plugin-styled-components": "^2.1.4", "browser-image-compression": "^2.0.2", "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", "dotenv-cli": "^7.4.2", - "electron": "31.7.6", + "electron": "35.2.2", "electron-builder": "26.0.15", "electron-devtools-installer": "^3.2.0", "electron-icon-builder": "^2.0.1", - "electron-vite": "^2.3.0", + "electron-vite": "^3.1.0", "emittery": "^1.0.3", "emoji-picker-element": "^1.22.1", "eslint": "^9.22.0", @@ -173,12 +170,14 @@ "lodash": "^4.17.21", "lru-cache": "^11.1.0", "lucide-react": "^0.487.0", + "mermaid": "^11.6.0", "mime": "^4.0.4", "motion": "^12.10.5", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "p-queue": "^8.1.0", "prettier": "^3.5.3", + "rc-virtual-list": "^3.18.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hotkeys-hook": "^4.6.1", @@ -189,6 +188,7 @@ "react-router": "6", "react-router-dom": "6", "react-spinners": "^0.14.1", + "react-window": "^1.8.11", "redux": "^5.0.1", "redux-persist": "^6.0.0", "rehype-katex": "^7.0.1", @@ -198,12 +198,11 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "rollup-plugin-visualizer": "^5.12.0", - "sass": "^1.77.2", + "sass": "^1.88.0", "shiki": "^3.2.2", "string-width": "^7.2.0", "styled-components": "^6.1.11", "tiny-pinyin": "^1.3.2", - "tinycolor2": "^1.6.0", "tokenx": "^0.4.1", "typescript": "^5.6.2", "uuid": "^10.0.0", @@ -220,7 +219,8 @@ "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", "shiki": "3.2.2", - "openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch" + "openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", + "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch" }, "packageManager": "yarn@4.6.0", "lint-staged": { diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index ea8521aa16..4d951f3698 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -77,7 +77,8 @@ class BackupManager { _: Electron.IpcMainInvokeEvent, fileName: string, data: string, - destinationPath: string = this.backupDir + destinationPath: string = this.backupDir, + skipBackupFile: boolean = false ): Promise { const mainWindow = windowService.getMainWindow() @@ -104,23 +105,30 @@ class BackupManager { onProgress({ stage: 'writing_data', progress: 20, total: 100 }) - // 复制 Data 目录到临时目录 - const sourcePath = path.join(app.getPath('userData'), 'Data') - const tempDataDir = path.join(this.tempDir, 'Data') + Logger.log('[BackupManager IPC] ', skipBackupFile) - // 获取源目录总大小 - const totalSize = await this.getDirSize(sourcePath) - let copiedSize = 0 + if (!skipBackupFile) { + // 复制 Data 目录到临时目录 + const sourcePath = path.join(app.getPath('userData'), 'Data') + const tempDataDir = path.join(this.tempDir, 'Data') - // 使用流式复制 - await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => { - copiedSize += size - const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50)) - onProgress({ stage: 'copying_files', progress, total: 100 }) - }) + // 获取源目录总大小 + const totalSize = await this.getDirSize(sourcePath) + let copiedSize = 0 - await this.setWritableRecursive(tempDataDir) - onProgress({ stage: 'preparing_compression', progress: 50, total: 100 }) + // 使用流式复制 + await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => { + copiedSize += size + const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50)) + onProgress({ stage: 'copying_files', progress, total: 100 }) + }) + + await this.setWritableRecursive(tempDataDir) + onProgress({ stage: 'preparing_compression', progress: 50, total: 100 }) + } else { + Logger.log('[BackupManager] Skip the backup of the file') + await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败 + } // 创建输出文件流 const backupedFilePath = path.join(destinationPath, fileName) @@ -279,7 +287,7 @@ class BackupManager { async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' - const backupedFilePath = await this.backup(_, filename, data) + const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile) const webdavClient = new WebDav(webdavConfig) try { const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), { diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 5ea91343f5..90e50ec65a 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { createInMemoryMCPServer } from '@main/mcpServers/factory' import { makeSureDirExists } from '@main/utils' +import { buildFunctionCallToolName } from '@main/utils/mcp' import { getBinaryName, getBinaryPath } from '@main/utils/process' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' @@ -404,7 +405,7 @@ class McpService { tools.map((tool: any) => { const serverTool: MCPTool = { ...tool, - id: `f${nanoid()}`, + id: buildFunctionCallToolName(server.name, tool.name), serverId: server.id, serverName: server.name } diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index f033cc82bf..ba508d7048 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -76,6 +76,7 @@ export class WindowService { webSecurity: false, webviewTag: true, allowRunningInsecureContent: true, + zoomFactor: configManager.getZoomFactor(), backgroundThrottling: false } }) @@ -185,6 +186,12 @@ export class WindowService { mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) }) + // set the zoom factor again when the window is going to restore + // minimize and restore will cause zoom reset + mainWindow.on('restore', () => { + mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + }) + // ARCH: as `will-resize` is only for Win & Mac, // linux has the same problem, use `resize` listener instead // but `resize` will fliker the ui diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts new file mode 100644 index 0000000000..23d19806d9 --- /dev/null +++ b/src/main/utils/mcp.ts @@ -0,0 +1,34 @@ +export function buildFunctionCallToolName(serverName: string, toolName: string) { + const sanitizedServer = serverName.trim().replace(/-/g, '_') + const sanitizedTool = toolName.trim().replace(/-/g, '_') + + // Combine server name and tool name + let name = sanitizedTool + if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) { + name = `${sanitizedServer.slice(0, 7) || ''}-${sanitizedTool || ''}` + } + + // Replace invalid characters with underscores or dashes + // Keep a-z, A-Z, 0-9, underscores and dashes + name = name.replace(/[^a-zA-Z0-9_-]/g, '_') + + // Ensure name starts with a letter or underscore (for valid JavaScript identifier) + if (!/^[a-zA-Z]/.test(name)) { + name = `tool-${name}` + } + + // Remove consecutive underscores/dashes (optional improvement) + name = name.replace(/[_-]{2,}/g, '_') + + // Truncate to 63 characters maximum + if (name.length > 63) { + name = name.slice(0, 63) + } + + // Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges + if (name.endsWith('_') || name.endsWith('-')) { + name = name.slice(0, -1) + } + + return name +} diff --git a/src/main/utils/windowUtil.ts b/src/main/utils/windowUtil.ts index d64929deb1..4000156fff 100644 --- a/src/main/utils/windowUtil.ts +++ b/src/main/utils/windowUtil.ts @@ -1,5 +1,7 @@ import { BrowserWindow } from 'electron' +import { isDev, isWin } from '../constant' + function isTilingWindowManager() { if (process.platform === 'darwin') { return false @@ -15,31 +17,59 @@ function isTilingWindowManager() { return tilingSystems.some((system) => desktopEnv?.includes(system)) } +//see: https://github.com/electron/electron/issues/42055#issuecomment-2449365647 export const replaceDevtoolsFont = (browserWindow: BrowserWindow) => { - if (process.platform === 'win32') { + //only for windows and dev, don't do this in production to avoid performance issues + if (isWin && isDev) { browserWindow.webContents.on('devtools-opened', () => { const css = ` :root { --sys-color-base: var(--ref-palette-neutral100); - --source-code-font-family: consolas; + --source-code-font-family: consolas !important; --source-code-font-size: 12px; - --monospace-font-family: consolas; + --monospace-font-family: consolas !important; --monospace-font-size: 12px; --default-font-family: system-ui, sans-serif; --default-font-size: 12px; + --ref-palette-neutral99: #ffffffff; } - .-theme-with-dark-background { + .theme-with-dark-background { --sys-color-base: var(--ref-palette-secondary25); } body { - --default-font-family: system-ui,sans-serif; - }` - + --default-font-family: system-ui, sans-serif; + } + ` browserWindow.webContents.devToolsWebContents?.executeJavaScript(` const overriddenStyle = document.createElement('style'); overriddenStyle.innerHTML = '${css.replaceAll('\n', ' ')}'; document.body.append(overriddenStyle); - document.body.classList.remove('platform-windows');`) + document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows')); + addStyleToAutoComplete(); + const observer = new MutationObserver((mutationList, observer) => { + for (const mutation of mutationList) { + if (mutation.type === 'childList') { + for (let i = 0; i < mutation.addedNodes.length; i++) { + const item = mutation.addedNodes[i]; + if (item.classList.contains('editor-tooltip-host')) { + addStyleToAutoComplete(); + } + } + } + } + }); + observer.observe(document.body, {childList: true}); + function addStyleToAutoComplete() { + document.querySelectorAll('.editor-tooltip-host').forEach(element => { + if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) { + const overriddenStyle = document.createElement('style'); + overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font'); + overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}'; + element.shadowRoot.append(overriddenStyle); + } + }); + } + `) }) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a2f378fa2..3679dd0802 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' import { IpcChannel } from '@shared/IpcChannel' import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types' -import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron' +import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { CreateDirectoryOptions } from 'webdav' // Custom APIs for renderer @@ -37,8 +37,8 @@ const api = { decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text) }, backup: { - backup: (fileName: string, data: string, destinationPath?: string) => - ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath), + backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) => + ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile), restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig), @@ -73,7 +73,8 @@ const api = { download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), - base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId) + base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), + getPathForFile: (file: File) => webUtils.getPathForFile(file) }, fs: { read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path) diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts index f847a40826..70b9cd70b0 100644 --- a/src/renderer/__tests__/setup.ts +++ b/src/renderer/__tests__/setup.ts @@ -18,3 +18,32 @@ vi.mock('electron-log/renderer', () => { } } }) + +vi.stubGlobal('window', { + electron: { + ipcRenderer: { + on: vi.fn(), // Mocking ipcRenderer.on + send: vi.fn() // Mocking ipcRenderer.send + } + }, + api: { + file: { + read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this) + writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing + } + } +}) + +vi.mock('axios', () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request + post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request + // You can add other axios methods like put, delete etc. as needed + } +})) + +vi.stubGlobal('window', { + ...global.window, // Copy other global properties + addEventListener: vi.fn(), // Mock addEventListener + removeEventListener: vi.fn() // You can also mock removeEventListener if needed +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 52b098c957..24024374ec 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,8 +8,8 @@ import { PersistGate } from 'redux-persist/integration/react' import Sidebar from './components/app/Sidebar' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' +import { CodeStyleProvider } from './context/CodeStyleProvider' import StyleSheetManager from './context/StyleSheetManager' -import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider' import { ThemeProvider } from './context/ThemeProvider' import NavigationHandler from './handler/NavigationHandler' import AgentsPage from './pages/agents/AgentsPage' @@ -27,7 +27,7 @@ function App(): React.ReactElement { - + @@ -46,7 +46,7 @@ function App(): React.ReactElement { - + diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss new file mode 100644 index 0000000000..c142e8e270 --- /dev/null +++ b/src/renderer/src/assets/styles/color.scss @@ -0,0 +1,130 @@ +:root { + --color-white: #ffffff; + --color-white-soft: rgba(255, 255, 255, 0.8); + --color-white-mute: rgba(255, 255, 255, 0.94); + + --color-black: #181818; + --color-black-soft: #222222; + --color-black-mute: #333333; + + --color-gray-1: #515c67; + --color-gray-2: #414853; + --color-gray-3: #32363f; + + --color-text-1: rgba(255, 255, 245, 0.9); + --color-text-2: rgba(235, 235, 245, 0.6); + --color-text-3: rgba(235, 235, 245, 0.38); + + --color-background: var(--color-black); + --color-background-soft: var(--color-black-soft); + --color-background-mute: var(--color-black-mute); + --color-background-opacity: rgba(34, 34, 34, 0.7); + --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu + + --color-primary: #00b96b; + --color-primary-soft: #00b96b99; + --color-primary-mute: #00b96b33; + + --color-text: var(--color-text-1); + --color-icon: #ffffff99; + --color-icon-white: #ffffff; + --color-border: #ffffff19; + --color-border-soft: #ffffff10; + --color-border-mute: #ffffff05; + --color-error: #f44336; + --color-link: #338cff; + --color-code-background: #323232; + --color-hover: rgba(40, 40, 40, 1); + --color-active: rgba(55, 55, 55, 1); + --color-frame-border: #333; + --color-group-background: var(--color-background-soft); + + --color-reference: #404040; + --color-reference-text: #ffffff; + --color-reference-background: #0b0e12; + + --modal-background: #1f1f1f; + + --color-highlight: rgba(0, 0, 0, 1); + --color-background-highlight: rgba(255, 255, 0, 0.9); + --color-background-highlight-accent: rgba(255, 150, 50, 0.9); + + --navbar-background-mac: rgba(20, 20, 20, 0.55); + --navbar-background: #1f1f1f; + + --navbar-height: 40px; + --sidebar-width: 50px; + --status-bar-height: 40px; + --input-bar-height: 100px; + + --assistants-width: 275px; + --topic-list-width: 275px; + --settings-width: 250px; + + --chat-background: #111111; + --chat-background-user: #28b561; + --chat-background-assistant: #2c2c2c; + --chat-text-user: var(--color-black); + + --list-item-border-radius: 16px; +} + +[theme-mode='light'] { + --color-white: #ffffff; + --color-white-soft: rgba(0, 0, 0, 0.04); + --color-white-mute: #eee; + + --color-black: #1b1b1f; + --color-black-soft: #262626; + --color-black-mute: #363636; + + --color-gray-1: #8e8e93; + --color-gray-2: #aeaeb2; + --color-gray-3: #c7c7cc; + + --color-text-1: rgba(0, 0, 0, 1); + --color-text-2: rgba(0, 0, 0, 0.6); + --color-text-3: rgba(0, 0, 0, 0.38); + + --color-background: var(--color-white); + --color-background-soft: var(--color-white-soft); + --color-background-mute: var(--color-white-mute); + --color-background-opacity: rgba(235, 235, 235, 0.7); + --inner-glow-opacity: 0.1; + + --color-primary: #00b96b; + --color-primary-soft: #00b96b99; + --color-primary-mute: #00b96b33; + + --color-text: var(--color-text-1); + --color-icon: #00000099; + --color-icon-white: #000000; + --color-border: #00000019; + --color-border-soft: #00000010; + --color-border-mute: #00000005; + --color-error: #f44336; + --color-link: #1677ff; + --color-code-background: #e3e3e3; + --color-hover: var(--color-white-mute); + --color-active: var(--color-white-soft); + --color-frame-border: #ddd; + --color-group-background: var(--color-white); + + --color-reference: #cfe1ff; + --color-reference-text: #000000; + --color-reference-background: #f1f7ff; + + --modal-background: var(--color-white); + + --color-highlight: initial; + --color-background-highlight: rgba(255, 255, 0, 0.5); + --color-background-highlight-accent: rgba(255, 150, 50, 0.5); + + --navbar-background-mac: rgba(255, 255, 255, 0.55); + --navbar-background: rgba(244, 244, 244); + + --chat-background: #f3f3f3; + --chat-background-user: #95ec69; + --chat-background-assistant: #ffffff; + --chat-text-user: var(--color-text); +} diff --git a/src/renderer/src/assets/styles/font.scss b/src/renderer/src/assets/styles/font.scss new file mode 100644 index 0000000000..9d2d139b53 --- /dev/null +++ b/src/renderer/src/assets/styles/font.scss @@ -0,0 +1,12 @@ +:root { + --font-family: + Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + + --font-family-serif: + serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; +} diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 3b5d98e941..67ebf80082 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -1,3 +1,5 @@ +@use './color.scss'; +@use './font.scss'; @use './markdown.scss'; @use './ant.scss'; @use './scrollbar.scss'; @@ -6,136 +8,6 @@ @import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/ubuntu/ubuntu.css'; -:root { - --color-white: #ffffff; - --color-white-soft: rgba(255, 255, 255, 0.8); - --color-white-mute: rgba(255, 255, 255, 0.94); - - --color-black: #181818; - --color-black-soft: #222222; - --color-black-mute: #333333; - - --color-gray-1: #515c67; - --color-gray-2: #414853; - --color-gray-3: #32363f; - - --color-text-1: rgba(255, 255, 245, 0.9); - --color-text-2: rgba(235, 235, 245, 0.6); - --color-text-3: rgba(235, 235, 245, 0.38); - - --color-background: var(--color-black); - --color-background-soft: var(--color-black-soft); - --color-background-mute: var(--color-black-mute); - --color-background-opacity: rgba(34, 34, 34, 0.7); - --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu - - --color-primary: #00b96b; - --color-primary-soft: #00b96b99; - --color-primary-mute: #00b96b33; - - --color-text: var(--color-text-1); - --color-icon: #ffffff99; - --color-icon-white: #ffffff; - --color-border: #ffffff19; - --color-border-soft: #ffffff10; - --color-border-mute: #ffffff05; - --color-error: #f44336; - --color-link: #338cff; - --color-code-background: #323232; - --color-hover: rgba(40, 40, 40, 1); - --color-active: rgba(55, 55, 55, 1); - --color-frame-border: #333; - --color-group-background: var(--color-background-soft); - - --color-reference: #404040; - --color-reference-text: #ffffff; - --color-reference-background: #0b0e12; - - --modal-background: #1f1f1f; - - --navbar-background-mac: rgba(20, 20, 20, 0.55); - --navbar-background: #1f1f1f; - - --navbar-height: 40px; - --sidebar-width: 50px; - --status-bar-height: 40px; - --input-bar-height: 100px; - - --assistants-width: 275px; - --topic-list-width: 275px; - --settings-width: 250px; - - --chat-background: #111111; - --chat-background-user: #28b561; - --chat-background-assistant: #2c2c2c; - --chat-text-user: var(--color-black); - - --list-item-border-radius: 16px; -} - -body { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -body[theme-mode='light'] { - --color-white: #ffffff; - --color-white-soft: rgba(0, 0, 0, 0.04); - --color-white-mute: #eee; - - --color-black: #1b1b1f; - --color-black-soft: #262626; - --color-black-mute: #363636; - - --color-gray-1: #8e8e93; - --color-gray-2: #aeaeb2; - --color-gray-3: #c7c7cc; - - --color-text-1: rgba(0, 0, 0, 1); - --color-text-2: rgba(0, 0, 0, 0.6); - --color-text-3: rgba(0, 0, 0, 0.38); - - --color-background: var(--color-white); - --color-background-soft: var(--color-white-soft); - --color-background-mute: var(--color-white-mute); - --color-background-opacity: rgba(235, 235, 235, 0.7); - --inner-glow-opacity: 0.1; - - --color-primary: #00b96b; - --color-primary-soft: #00b96b99; - --color-primary-mute: #00b96b33; - - --color-text: var(--color-text-1); - --color-icon: #00000099; - --color-icon-white: #000000; - --color-border: #00000019; - --color-border-soft: #00000010; - --color-border-mute: #00000005; - --color-error: #f44336; - --color-link: #1677ff; - --color-code-background: #e3e3e3; - --color-hover: var(--color-white-mute); - --color-active: var(--color-white-soft); - --color-frame-border: #ddd; - --color-group-background: var(--color-white); - - --color-reference: #cfe1ff; - --color-reference-text: #000000; - --color-reference-background: #f1f7ff; - - --modal-background: var(--color-white); - - --navbar-background-mac: rgba(255, 255, 255, 0.55); - --navbar-background: rgba(244, 244, 244); - - --chat-background: #f3f3f3; - --chat-background-user: #95ec69; - --chat-background-assistant: #ffffff; - --chat-text-user: var(--color-text); -} - *, *::before, *::after { @@ -152,8 +24,18 @@ body[theme-mode='light'] { -webkit-tap-highlight-color: transparent; } -ul { - list-style: none; +html, +body, +#root { + height: 100%; + width: 100%; + margin: 0; +} + +#root { + display: flex; + flex-direction: row; + flex: 1; } body { @@ -163,13 +45,17 @@ body { font-size: 14px; line-height: 1.6; overflow: hidden; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family); text-rendering: optimizeLegibility; + transition: background-color 0.3s linear; + -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: background-color 0.3s linear; + + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } input, @@ -190,20 +76,8 @@ a { -webkit-user-drag: none; } -html, -body, -#root { - height: 100%; - width: 100%; - margin: 0; -} - -#root { - width: 100%; - height: 100%; - display: flex; - flex-direction: row; - flex: 1; +ul { + list-style: none; } .loader { @@ -291,3 +165,11 @@ body, .lucide { color: var(--color-icon); } + +span.highlight { + background-color: var(--color-background-highlight); + color: var(--color-highlight); +} +span.highlight.selected { + background-color: var(--color-background-highlight-accent); +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index e24569b0f2..40c0255468 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -20,10 +20,8 @@ h5, h6 { margin: 1em 0 1em 0; - font-weight: 800; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-weight: bold; + font-family: var(--font-family); } h1 { @@ -117,7 +115,7 @@ } code { - font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-family: var(--code-font-family); } pre { @@ -125,7 +123,9 @@ overflow-x: auto; font-family: 'Fira Code', 'Courier New', Courier, monospace; background-color: var(--color-background-mute); - &:has(> .mermaid) { + &:has(.mermaid), + &:has(.plantuml-preview), + &:has(.svg-preview) { background-color: transparent; } &:not(pre pre) { @@ -153,7 +153,7 @@ padding-left: 1em; color: var(--color-text-light); border-left: 4px solid var(--color-border); - font-family: Georgia, 'Times New Roman', Times, serif; + font-family: var(--font-family); } table { @@ -171,9 +171,7 @@ th { background-color: var(--color-background-mute); font-weight: bold; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family); } img { @@ -304,3 +302,26 @@ emoji-picker { mjx-container { overflow-x: auto; } + +/* CodeMirror 相关样式 */ +.cm-editor { + .cm-scroller { + font-family: var(--code-font-family); + padding: 1px; + border-radius: 5px; + + .cm-gutters { + line-height: 1.6; + } + + .cm-content { + line-height: 1.6; + padding-left: 0.25em; + } + + .cm-lineWrapping * { + word-wrap: break-word; + white-space: pre-wrap; + } + } +} diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx new file mode 100644 index 0000000000..965d6ad14b --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -0,0 +1,285 @@ +import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { uuid } from '@renderer/utils' +import { getReactStyleFromToken } from '@renderer/utils/shiki' +import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ThemedToken } from 'shiki/core' +import styled from 'styled-components' + +interface CodePreviewProps { + children: string + language: string +} + +/** + * Shiki 流式代码高亮组件 + * + * - 通过 shiki tokenizer 处理流式响应 + * - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过 + */ +const CodePreview = ({ children, language }: CodePreviewProps) => { + const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() + const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() + const [isExpanded, setIsExpanded] = useState(!codeCollapsible) + const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) + const [tokenLines, setTokenLines] = useState([]) + const codeContentRef = useRef(null) + const prevCodeLengthRef = useRef(0) + const safeCodeStringRef = useRef(children) + const highlightQueueRef = useRef>(Promise.resolve()) + const callerId = useRef(`${Date.now()}-${uuid()}`).current + const shikiThemeRef = useRef(activeShikiTheme) + + const { t } = useTranslation() + + const { registerTool, removeTool } = useCodeToolbar() + + // 展开/折叠工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.expand, + icon: isExpanded ? : , + tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), + visible: () => { + const scrollHeight = codeContentRef.current?.scrollHeight + return codeCollapsible && (scrollHeight ?? 0) > 350 + }, + onClick: () => setIsExpanded((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.expand.id) + }, [codeCollapsible, isExpanded, registerTool, removeTool, t]) + + // 自动换行工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.wrap, + icon: isUnwrapped ? : , + tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + visible: () => codeWrappable, + onClick: () => setIsUnwrapped((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.wrap.id) + }, [codeWrappable, isUnwrapped, registerTool, removeTool, t]) + + // 更新展开状态 + useEffect(() => { + setIsExpanded(!codeCollapsible) + }, [codeCollapsible]) + + // 更新换行状态 + useEffect(() => { + setIsUnwrapped(!codeWrappable) + }, [codeWrappable]) + + // 处理尾部空白字符 + const safeCodeString = useMemo(() => { + return typeof children === 'string' ? children.trimEnd() : '' + }, [children]) + + const highlightCode = useCallback(async () => { + if (!safeCodeString) return + + if (prevCodeLengthRef.current === safeCodeString.length) return + + // 捕获当前状态 + const startPos = prevCodeLengthRef.current + const endPos = safeCodeString.length + + // 添加到处理队列,确保按顺序处理 + highlightQueueRef.current = highlightQueueRef.current.then(async () => { + // FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮 + if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) { + cleanupTokenizers(callerId) + prevCodeLengthRef.current = 0 + safeCodeStringRef.current = '' + + const result = await highlightCodeChunk(safeCodeString, language, callerId) + setTokenLines(result.lines) + + prevCodeLengthRef.current = safeCodeString.length + safeCodeStringRef.current = safeCodeString + + return + } + + // 跳过 race condition,延迟到后续任务 + if (prevCodeLengthRef.current !== startPos) { + return + } + + const incrementalCode = safeCodeString.slice(startPos, endPos) + const result = await highlightCodeChunk(incrementalCode, language, callerId) + setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines]) + prevCodeLengthRef.current = endPos + safeCodeStringRef.current = safeCodeString + }) + }, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString]) + + // 主题变化时强制重新高亮 + useEffect(() => { + if (shikiThemeRef.current !== activeShikiTheme) { + prevCodeLengthRef.current++ + shikiThemeRef.current = activeShikiTheme + } + }, [activeShikiTheme]) + + // 组件卸载时清理资源 + useEffect(() => { + return () => cleanupTokenizers(callerId) + }, [callerId, cleanupTokenizers]) + + // 处理第二次开始的代码高亮 + useEffect(() => { + if (prevCodeLengthRef.current > 0) { + setTimeout(highlightCode, 0) + } + }, [highlightCode]) + + // 视口检测逻辑,只处理第一次代码高亮 + useEffect(() => { + const codeElement = codeContentRef.current + if (!codeElement || prevCodeLengthRef.current > 0) return + + let isMounted = true + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && isMounted) { + setTimeout(highlightCode, 0) + observer.disconnect() + } + }) + + observer.observe(codeElement) + + return () => { + isMounted = false + observer.disconnect() + } + }, [highlightCode]) + + return ( + + {tokenLines.length > 0 ? ( + + ) : ( +
{children}
+ )} +
+ ) +} + +/** + * 渲染 Shiki 高亮后的 tokens + * + * 独立出来,方便将来做 virtual list + */ +const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo( + ({ language, tokenLines }) => { + const { getShikiPreProperties } = useCodeStyle() + const rendererRef = useRef(null) + + // 设置 pre 标签属性 + useEffect(() => { + getShikiPreProperties(language).then((properties) => { + const pre = rendererRef.current + if (pre) { + pre.className = properties.class + pre.style.cssText = properties.style + pre.tabIndex = properties.tabindex + } + }) + }, [language, getShikiPreProperties]) + + return ( +
+        
+          {tokenLines.map((lineTokens, lineIndex) => (
+            
+              {lineTokens.map((token, tokenIndex) => (
+                
+                  {token.content}
+                
+              ))}
+            
+          ))}
+        
+      
+ ) + } +) + +const ContentContainer = styled.div<{ + $isShowLineNumbers: boolean + $isUnwrapped: boolean + $isCodeWrappable: boolean +}>` + position: relative; + border: 0.5px solid transparent; + border-radius: 5px; + margin-top: 0; + transition: opacity 0.3s ease; + + .shiki { + padding: 1em; + + code { + display: flex; + flex-direction: column; + width: 100%; + + .line { + display: block; + min-height: 1.3rem; + padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')}; + } + } + } + + ${(props) => + props.$isShowLineNumbers && + ` + code { + counter-reset: step; + counter-increment: step 0; + position: relative; + } + + code .line::before { + content: counter(step); + counter-increment: step; + width: 1rem; + position: absolute; + left: 0; + text-align: right; + opacity: 0.35; + } + `} + + ${(props) => + props.$isCodeWrappable && + !props.$isUnwrapped && + ` + code .line * { + word-wrap: break-word; + white-space: pre-wrap; + } + `} +` + +CodePreview.displayName = 'CodePreview' + +export default memo(CodePreview) diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx similarity index 84% rename from src/renderer/src/pages/home/Markdown/Artifacts.tsx rename to src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx index 746eb170c5..e979ea1541 100644 --- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx @@ -1,4 +1,4 @@ -import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons' +import { ExpandOutlined, LinkOutlined } from '@ant-design/icons' import { AppLogo } from '@renderer/config/env' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { extractTitle } from '@renderer/utils/formats' @@ -46,13 +46,6 @@ const Artifacts: FC = ({ html }) => { } } - /** - * 下载文件 - */ - const onDownload = () => { - window.api.file.save(`${title}.html`, html) - } - return ( - - ) } diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx new file mode 100644 index 0000000000..f02261c466 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -0,0 +1,99 @@ +import { nanoid } from '@reduxjs/toolkit' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { useMermaid } from '@renderer/hooks/useMermaid' +import { Flex } from 'antd' +import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +interface Props { + children: string +} + +const MermaidPreview: React.FC = ({ children }) => { + const { mermaid, isLoading, error: mermaidError } = useMermaid() + const mermaidRef = useRef(null) + const [error, setError] = useState(null) + const diagramId = useRef(`mermaid-${nanoid(6)}`).current + const errorTimeoutRef = useRef(null) + + // 使用通用图像工具 + const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, { + imgSelector: 'svg', + prefix: 'mermaid', + enableWheelZoom: true + }) + + // 使用工具栏 + usePreviewTools({ + handleZoom, + handleCopyImage, + handleDownload + }) + + const render = useCallback(async () => { + try { + if (!children) return + + // 验证语法,提前抛出异常 + await mermaid.parse(children) + + if (!mermaidRef.current) return + const { svg } = await mermaid.render(diagramId, children, mermaidRef.current) + + // 避免不可见时产生 undefined 和 NaN + const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)') + mermaidRef.current.innerHTML = fixedSvg + + // 没有语法错误时清除错误记录和定时器 + setError(null) + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current) + errorTimeoutRef.current = null + } + } catch (error) { + // 延迟显示错误 + if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current) + errorTimeoutRef.current = setTimeout(() => { + setError((error as Error).message) + }, 500) + } + }, [children, diagramId, mermaid]) + + // 渲染Mermaid图表 + useEffect(() => { + if (isLoading) return + + startTransition(render) + + // 清理定时器 + return () => { + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current) + errorTimeoutRef.current = null + } + } + }, [isLoading, render]) + + return ( + + {(mermaidError || error) && {mermaidError || error}} + + + ) +} + +const StyledMermaid = styled.div` + overflow: auto; +` + +const StyledError = styled.div` + overflow: auto; + padding: 16px; + color: #ff4d4f; + border: 1px solid #ff4d4f; + border-radius: 4px; + word-wrap: break-word; + white-space: pre-wrap; +` + +export default memo(MermaidPreview) diff --git a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx new file mode 100644 index 0000000000..9af10a5aa7 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx @@ -0,0 +1,193 @@ +import { LoadingOutlined } from '@ant-design/icons' +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { Spin } from 'antd' +import pako from 'pako' +import React, { memo, useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const PlantUMLServer = 'https://www.plantuml.com/plantuml' +function encode64(data: Uint8Array) { + let r = '' + for (let i = 0; i < data.length; i += 3) { + if (i + 2 === data.length) { + r += append3bytes(data[i], data[i + 1], 0) + } else if (i + 1 === data.length) { + r += append3bytes(data[i], 0, 0) + } else { + r += append3bytes(data[i], data[i + 1], data[i + 2]) + } + } + return r +} + +function encode6bit(b: number) { + if (b < 10) { + return String.fromCharCode(48 + b) + } + b -= 10 + if (b < 26) { + return String.fromCharCode(65 + b) + } + b -= 26 + if (b < 26) { + return String.fromCharCode(97 + b) + } + b -= 26 + if (b === 0) { + return '-' + } + if (b === 1) { + return '_' + } + return '?' +} + +function append3bytes(b1: number, b2: number, b3: number) { + const c1 = b1 >> 2 + const c2 = ((b1 & 0x3) << 4) | (b2 >> 4) + const c3 = ((b2 & 0xf) << 2) | (b3 >> 6) + const c4 = b3 & 0x3f + let r = '' + r += encode6bit(c1 & 0x3f) + r += encode6bit(c2 & 0x3f) + r += encode6bit(c3 & 0x3f) + r += encode6bit(c4 & 0x3f) + return r +} +/** + * https://plantuml.com/zh/code-javascript-synchronous + * To use PlantUML image generation, a text diagram description have to be : + 1. Encoded in UTF-8 + 2. Compressed using Deflate algorithm + 3. Reencoded in ASCII using a transformation _close_ to base64 + */ +function encodeDiagram(diagram: string): string { + const utf8text = new TextEncoder().encode(diagram) + const compressed = pako.deflateRaw(utf8text) + return encode64(compressed) +} + +async function downloadUrl(url: string, filename: string) { + const response = await fetch(url) + if (!response.ok) { + window.message.warning({ content: response.statusText, duration: 1.5 }) + return + } + const blob = await response.blob() + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) +} + +type PlantUMLServerImageProps = { + format: 'png' | 'svg' + diagram: string + onClick?: React.MouseEventHandler + className?: string +} + +function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) { + const encodedDiagram = encodeDiagram(diagram) + if (isDark) { + return `${PlantUMLServer}/d${format}/${encodedDiagram}` + } + return `${PlantUMLServer}/${format}/${encodedDiagram}` +} + +const PlantUMLServerImage: React.FC = ({ format, diagram, onClick, className }) => { + const [loading, setLoading] = useState(true) + // FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景 + const url = getPlantUMLImageUrl(format, diagram, false) + return ( + + + }> + { + setLoading(false) + }} + onError={(e) => { + setLoading(false) + const target = e.target as HTMLImageElement + target.style.opacity = '0.5' + target.style.filter = 'blur(2px)' + }} + /> + + + ) +} + +interface PlantUMLProps { + children: string +} + +const PlantUmlPreview: React.FC = ({ children }) => { + const { t } = useTranslation() + const containerRef = useRef(null) + + const encodedDiagram = encodeDiagram(children) + + // 自定义 PlantUML 下载方法 + const customDownload = useCallback( + (format: 'svg' | 'png') => { + const timestamp = Date.now() + const url = `${PlantUMLServer}/${format}/${encodedDiagram}` + const filename = `plantuml-diagram-${timestamp}.${format}` + downloadUrl(url, filename).catch(() => { + window.message.error(t('code_block.download.failed.network')) + }) + }, + [encodedDiagram, t] + ) + + // 使用通用图像工具,提供自定义下载方法 + const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, { + imgSelector: '.plantuml-preview img', + prefix: 'plantuml-diagram', + enableWheelZoom: true, + customDownloader: customDownload + }) + + // 使用工具栏 + usePreviewTools({ + handleZoom, + handleCopyImage, + handleDownload: customDownload + }) + + return ( +
+ +
+ ) +} + +const StyledPlantUML = styled.div` + max-height: calc(80vh - 100px); + text-align: left; + overflow-y: auto; + background-color: white; + img { + max-width: 100%; + height: auto; + min-height: 100px; + transition: transform 0.2s ease; + } +` + +export default memo(PlantUmlPreview) diff --git a/src/renderer/src/components/CodeBlockView/StatusBar.tsx b/src/renderer/src/components/CodeBlockView/StatusBar.tsx new file mode 100644 index 0000000000..7e4c5e9e04 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/StatusBar.tsx @@ -0,0 +1,22 @@ +import { FC, memo } from 'react' +import styled from 'styled-components' + +interface Props { + children: string +} + +const StatusBar: FC = ({ children }) => { + return {children} +} + +const Container = styled.div` + margin: 10px; + display: flex; + flex-direction: row; + gap: 8px; + padding-bottom: 10px; + overflow-y: auto; + text-wrap: wrap; +` + +export default memo(StatusBar) diff --git a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx new file mode 100644 index 0000000000..1e1f20b60e --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx @@ -0,0 +1,38 @@ +import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { memo, useRef } from 'react' +import styled from 'styled-components' + +interface Props { + children: string +} + +const SvgPreview: React.FC = ({ children }) => { + const svgContainerRef = useRef(null) + + // 使用通用图像工具 + const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, { + imgSelector: '.svg-preview svg', + prefix: 'svg-image' + }) + + // 使用工具栏 + usePreviewTools({ + handleCopyImage, + handleDownload + }) + + return ( + + ) +} + +const SvgPreviewContainer = styled.div` + padding: 1em; + background-color: white; + overflow: auto; + border: 0.5px solid var(--color-code-background); + border-top-left-radius: 0; + border-top-right-radius: 0; +` + +export default memo(SvgPreview) diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx new file mode 100644 index 0000000000..86a5f0d043 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -0,0 +1,324 @@ +import { LoadingOutlined } from '@ant-design/icons' +import CodeEditor from '@renderer/components/CodeEditor' +import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { useSettings } from '@renderer/hooks/useSettings' +import { pyodideService } from '@renderer/services/PyodideService' +import { extractTitle } from '@renderer/utils/formats' +import { isValidPlantUML } from '@renderer/utils/markdown' +import dayjs from 'dayjs' +import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react' +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import CodePreview from './CodePreview' +import HtmlArtifacts from './HtmlArtifacts' +import MermaidPreview from './MermaidPreview' +import PlantUmlPreview from './PlantUmlPreview' +import StatusBar from './StatusBar' +import SvgPreview from './SvgPreview' + +type ViewMode = 'source' | 'special' | 'split' + +interface Props { + children: string + language: string + onSave?: (newContent: string) => void +} + +/** + * 代码块视图 + * + * 视图类型: + * - preview: 预览视图,其中非源代码的是特殊视图 + * - edit: 编辑视图 + * + * 视图模式: + * - source: 源代码视图模式 + * - special: 特殊视图模式(Mermaid、PlantUML、SVG) + * - split: 分屏模式(源代码和特殊视图并排显示) + * + * 顶部 sticky 工具栏: + * - quick 工具 + * - core 工具 + */ +const CodeBlockView: React.FC = ({ children, language, onSave }) => { + const { t } = useTranslation() + const { codeEditor, codeExecution } = useSettings() + const [viewMode, setViewMode] = useState('special') + const [isRunning, setIsRunning] = useState(false) + const [output, setOutput] = useState('') + + const isExecutable = useMemo(() => { + return codeExecution.enabled && language === 'python' + }, [codeExecution.enabled, language]) + + const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language]) + + const isInSpecialView = useMemo(() => { + return hasSpecialView && viewMode === 'special' + }, [hasSpecialView, viewMode]) + + const { updateContext, registerTool, removeTool } = useCodeToolbar() + + useEffect(() => { + updateContext({ + code: children, + language + }) + }, [children, language, updateContext]) + + const handleCopySource = useCallback( + (ctx?: CodeToolContext) => { + if (!ctx) return + navigator.clipboard.writeText(ctx.code) + window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' }) + }, + [t] + ) + + const handleDownloadSource = useCallback((ctx?: CodeToolContext) => { + if (!ctx) return + + const { code, language } = ctx + let fileName = '' + + // 尝试提取标题 + if (language === 'html' && code.includes('')) { + const title = extractTitle(code) + if (title) { + fileName = `${title}.html` + } + } + + // 默认使用日期格式命名 + if (!fileName) { + fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` + } + + window.api.file.save(fileName, code) + }, []) + + const handleRunScript = useCallback( + (ctx?: CodeToolContext) => { + if (!ctx) return + + setIsRunning(true) + setOutput('') + + pyodideService + .runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000) + .then((formattedOutput) => { + setOutput(formattedOutput) + }) + .catch((error) => { + console.error('Unexpected error:', error) + setOutput(`Unexpected error: ${error.message || 'Unknown error'}`) + }) + .finally(() => { + setIsRunning(false) + }) + }, + [codeExecution.timeoutMinutes] + ) + + useEffect(() => { + // 复制按钮 + registerTool({ + ...TOOL_SPECS.copy, + icon: , + tooltip: t('code_block.copy.source'), + onClick: handleCopySource + }) + + // 下载按钮 + registerTool({ + ...TOOL_SPECS.download, + icon: , + tooltip: t('code_block.download.source'), + onClick: handleDownloadSource + }) + return () => { + removeTool(TOOL_SPECS.copy.id) + removeTool(TOOL_SPECS.download.id) + } + }, [handleCopySource, handleDownloadSource, registerTool, removeTool, t]) + + // 特殊视图的编辑按钮,在分屏模式下不可用 + useEffect(() => { + if (!hasSpecialView || viewMode === 'split') return + + const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source'] + + if (codeEditor.enabled) { + registerTool({ + ...viewSourceToolSpec, + icon: viewMode === 'source' ? : , + tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'), + onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source') + }) + } else { + registerTool({ + ...viewSourceToolSpec, + icon: viewMode === 'source' ? : , + tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.preview.source'), + onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source') + }) + } + + return () => removeTool(viewSourceToolSpec.id) + }, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t]) + + // 特殊视图的分屏按钮 + useEffect(() => { + if (!hasSpecialView) return + + registerTool({ + ...TOOL_SPECS['split-view'], + icon: viewMode === 'split' ? : , + tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split'), + onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split') + }) + + return () => removeTool(TOOL_SPECS['split-view'].id) + }, [hasSpecialView, viewMode, registerTool, removeTool, t]) + + // 运行按钮 + useEffect(() => { + if (!isExecutable) return + + registerTool({ + ...TOOL_SPECS.run, + icon: isRunning ? : , + tooltip: t('code_block.run'), + onClick: (ctx) => !isRunning && handleRunScript(ctx) + }) + + return () => isExecutable && removeTool(TOOL_SPECS.run.id) + }, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t]) + + // 源代码视图组件 + const sourceView = useMemo(() => { + const SourceView = codeEditor.enabled ? CodeEditor : CodePreview + return ( + + {children} + + ) + }, [children, codeEditor.enabled, language, onSave]) + + // 特殊视图组件映射 + const specialView = useMemo(() => { + if (language === 'mermaid') { + return {children} + } else if (language === 'plantuml' && isValidPlantUML(children)) { + return {children} + } else if (language === 'svg') { + return {children} + } + return null + }, [children, language]) + + const renderHeader = useMemo(() => { + const langTag = '<' + language.toUpperCase() + '>' + return {isInSpecialView ? '' : langTag} + }, [isInSpecialView, language]) + + // 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图 + const renderContent = useMemo(() => { + const showSpecialView = specialView && ['special', 'split'].includes(viewMode) + const showSourceView = !specialView || viewMode !== 'special' + + return ( + + {showSpecialView && specialView} + {showSourceView && sourceView} + + ) + }, [specialView, sourceView, viewMode]) + + const renderArtifacts = useMemo(() => { + if (language === 'html') { + return + } + return null + }, [children, language]) + + return ( + + {renderHeader} + + {renderContent} + {renderArtifacts} + {isExecutable && output && {output}} + + ) +} + +const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` + position: relative; + + .code-toolbar { + opacity: 0; + transition: opacity 0.2s ease; + transform: translateZ(0); + will-change: opacity; + &.show { + opacity: 1; + } + } + &:hover { + .code-toolbar { + opacity: 1; + } + } + + ${(props) => + props.$isInSpecialView && + css` + .code-toolbar { + margin-top: 20px; + } + `} + + ${(props) => + !props.$isInSpecialView && + css` + .code-toolbar { + background-color: var(--color-background-mute); + border-radius: 4px; + } + `} +` + +const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + color: var(--color-text); + font-size: 14px; + font-weight: bold; + height: 34px; + padding: 0 10px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + ${(props) => + props.$isInSpecialView && + css` + height: 16px; + `} +` + +const SplitViewWrapper = styled.div` + display: flex; + width: 100%; + + > * { + flex: 1 1 0; + min-width: 0; + overflow: auto; + } +` + +export default memo(CodeBlockView) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx new file mode 100644 index 0000000000..124912e277 --- /dev/null +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -0,0 +1,269 @@ +import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror' +import diff from 'fast-diff' +import { + ChevronsDownUp, + ChevronsUpDown, + Save as SaveIcon, + Text as UnWrapIcon, + WrapText as WrapIcon +} from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' + +// 标记非用户编辑的变更 +const External = Annotation.define() + +interface Props { + children: string + language: string + onSave?: (newContent: string) => void + onChange?: (newContent: string) => void + maxHeight?: string + /** 用于覆写编辑器的某些设置 */ + options?: { + collapsible?: boolean + wrappable?: boolean + keymap?: boolean + } & BasicSetupOptions + /** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */ + style?: React.CSSProperties +} + +/** + * 源代码编辑器,基于 CodeMirror + * + * 目前必须和 CodeToolbar 配合使用。 + */ +const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, style }: Props) => { + const { + fontSize, + codeShowLineNumbers: _lineNumbers, + codeCollapsible: _collapsible, + codeWrappable: _wrappable, + codeEditor + } = useSettings() + const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible]) + const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable]) + const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) + + // 合并 codeEditor 和 options 的 basicSetup,options 优先 + const customBasicSetup = useMemo(() => { + return { + lineNumbers: _lineNumbers, + ...(codeEditor as BasicSetupOptions), + ...(options as BasicSetupOptions) + } + }, [codeEditor, _lineNumbers, options]) + + const { activeCmTheme, languageMap } = useCodeStyle() + const [isExpanded, setIsExpanded] = useState(!collapsible) + const [isUnwrapped, setIsUnwrapped] = useState(!wrappable) + const initialContent = useRef(children?.trimEnd() ?? '') + const [langExtension, setLangExtension] = useState([]) + const [editorReady, setEditorReady] = useState(false) + const editorViewRef = useRef(null) + const { t } = useTranslation() + + const { registerTool, removeTool } = useCodeToolbar() + + // 加载语言 + useEffect(() => { + let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + + // 如果语言名包含 `-`,转换为驼峰命名法 + if (normalizedLang.includes('-')) { + normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + } + + import('@uiw/codemirror-extensions-langs') + .then(({ loadLanguage }) => { + const extension = loadLanguage(normalizedLang as any) + if (extension) { + setLangExtension([extension]) + } + }) + .catch((error) => { + console.debug(`Failed to load language: ${normalizedLang}`, error) + }) + }, [language, languageMap]) + + // 展开/折叠工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.expand, + icon: isExpanded ? : , + tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), + visible: () => { + const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight + return collapsible && (scrollHeight ?? 0) > 350 + }, + onClick: () => setIsExpanded((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.expand.id) + }, [collapsible, isExpanded, registerTool, removeTool, t, editorReady]) + + // 自动换行工具 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.wrap, + icon: isUnwrapped ? : , + tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + visible: () => wrappable, + onClick: () => setIsUnwrapped((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.wrap.id) + }, [wrappable, isUnwrapped, registerTool, removeTool, t]) + + const handleSave = useCallback(() => { + const currentDoc = editorViewRef.current?.state.doc.toString() ?? '' + onSave?.(currentDoc) + }, [onSave]) + + // 保存按钮 + useEffect(() => { + registerTool({ + ...TOOL_SPECS.save, + icon: , + tooltip: t('code_block.edit.save'), + onClick: handleSave + }) + + return () => removeTool(TOOL_SPECS.save.id) + }, [handleSave, registerTool, removeTool, t]) + + // 流式响应过程中计算 changes 来更新 EditorView + // 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理) + useEffect(() => { + if (!editorViewRef.current) return + + const newContent = children?.trimEnd() ?? '' + const currentDoc = editorViewRef.current.state.doc.toString() + + const changes = prepareCodeChanges(currentDoc, newContent) + + if (changes && changes.length > 0) { + editorViewRef.current.dispatch({ + changes, + annotations: [External.of(true)] + }) + } + }, [children]) + + useEffect(() => { + setIsExpanded(!collapsible) + }, [collapsible]) + + useEffect(() => { + setIsUnwrapped(!wrappable) + }, [wrappable]) + + // 保存功能的快捷键 + const saveKeymap = useMemo(() => { + return keymap.of([ + { + key: 'Mod-s', + run: () => { + handleSave() + return true + }, + preventDefault: true + } + ]) + }, [handleSave]) + + const enabledExtensions = useMemo(() => { + return [...langExtension, ...(isUnwrapped ? [] : [EditorView.lineWrapping]), ...(enableKeymap ? [saveKeymap] : [])] + }, [enableKeymap, langExtension, isUnwrapped, saveKeymap]) + + return ( + { + editorViewRef.current = view + setEditorReady(true) + }} + onChange={(value, viewUpdate) => { + if (onChange && viewUpdate.docChanged) onChange(value) + }} + basicSetup={{ + dropCursor: true, + allowMultipleSelections: true, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + rectangularSelection: true, + crosshairCursor: true, + highlightActiveLineGutter: false, + highlightSelectionMatches: true, + closeBracketsKeymap: enableKeymap, + searchKeymap: enableKeymap, + foldKeymap: enableKeymap, + completionKeymap: enableKeymap, + lintKeymap: enableKeymap, + ...customBasicSetup // override basicSetup + }} + style={{ + fontSize: `${fontSize - 1}px`, + overflow: collapsible && !isExpanded ? 'auto' : 'visible', + position: 'relative', + border: '0.5px solid transparent', + borderRadius: '5px', + marginTop: 0, + ...style + }} + /> + ) +} + +CodeEditor.displayName = 'CodeEditor' + +/** + * 使用 fast-diff 计算代码变更,再转换为 CodeMirror 的 changes。 + * 可以处理所有类型的变更,不过流式响应过程中多是插入操作。 + * @param oldCode 旧的代码内容 + * @param newCode 新的代码内容 + * @returns 用于 EditorView.dispatch 的 changes 数组 + */ +function prepareCodeChanges(oldCode: string, newCode: string) { + const diffResult = diff(oldCode, newCode) + + const changes: { from: number; to: number; insert: string }[] = [] + let offset = 0 + + // operation: 1=插入, -1=删除, 0=相等 + for (const [operation, text] of diffResult) { + if (operation === 1) { + changes.push({ + from: offset, + to: offset, + insert: text + }) + } else if (operation === -1) { + changes.push({ + from: offset, + to: offset + text.length, + insert: '' + }) + offset += text.length + } else { + offset += text.length + } + } + + return changes +} + +export default memo(CodeEditor) diff --git a/src/renderer/src/components/CodeToolbar/constants.ts b/src/renderer/src/components/CodeToolbar/constants.ts new file mode 100644 index 0000000000..00e7fa7958 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/constants.ts @@ -0,0 +1,76 @@ +import { CodeToolSpec } from './types' + +export const TOOL_SPECS: Record = { + // Core tools + copy: { + id: 'copy', + type: 'core', + order: 10 + }, + download: { + id: 'download', + type: 'core', + order: 11 + }, + edit: { + id: 'edit', + type: 'core', + order: 12 + }, + 'view-source': { + id: 'view-source', + type: 'core', + order: 12 + }, + save: { + id: 'save', + type: 'core', + order: 13 + }, + expand: { + id: 'expand', + type: 'core', + order: 20 + }, + // Quick tools + 'split-view': { + id: 'split-view', + type: 'quick', + order: 10 + }, + run: { + id: 'run', + type: 'quick', + order: 11 + }, + wrap: { + id: 'wrap', + type: 'quick', + order: 20 + }, + 'copy-image': { + id: 'copy-image', + type: 'quick', + order: 30 + }, + 'download-svg': { + id: 'download-svg', + type: 'quick', + order: 31 + }, + 'download-png': { + id: 'download-png', + type: 'quick', + order: 32 + }, + 'zoom-in': { + id: 'zoom-in', + type: 'quick', + order: 40 + }, + 'zoom-out': { + id: 'zoom-out', + type: 'quick', + order: 41 + } +} diff --git a/src/renderer/src/components/CodeToolbar/context.tsx b/src/renderer/src/components/CodeToolbar/context.tsx new file mode 100644 index 0000000000..32be179d85 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/context.tsx @@ -0,0 +1,71 @@ +import React, { createContext, use, useCallback, useMemo, useState } from 'react' + +import { CodeTool, CodeToolContext } from './types' + +// 定义上下文默认值 +const defaultContext: CodeToolContext = { + code: '', + language: '' +} + +export interface CodeToolbarContextType { + tools: CodeTool[] + context: CodeToolContext + registerTool: (tool: CodeTool) => void + removeTool: (id: string) => void + updateContext: (newContext: Partial) => void +} + +const defaultCodeToolbarContext: CodeToolbarContextType = { + tools: [], + context: defaultContext, + registerTool: () => {}, + removeTool: () => {}, + updateContext: () => {} +} + +const CodeToolbarContext = createContext(defaultCodeToolbarContext) + +export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [tools, setTools] = useState([]) + const [context, setContext] = useState(defaultContext) + + // 注册工具,如果已存在同ID工具则替换 + const registerTool = useCallback((tool: CodeTool) => { + setTools((prev) => { + const filtered = prev.filter((t) => t.id !== tool.id) + return [...filtered, tool].sort((a, b) => b.order - a.order) + }) + }, []) + + // 移除工具 + const removeTool = useCallback((id: string) => { + setTools((prev) => prev.filter((tool) => tool.id !== id)) + }, []) + + // 更新上下文 + const updateContext = useCallback((newContext: Partial) => { + setContext((prev) => ({ ...prev, ...newContext })) + }, []) + + const value: CodeToolbarContextType = useMemo( + () => ({ + tools, + context, + registerTool, + removeTool, + updateContext + }), + [tools, context, registerTool, removeTool, updateContext] + ) + + return {children} +} + +export const useCodeToolbar = () => { + const context = use(CodeToolbarContext) + if (!context) { + throw new Error('useCodeToolbar must be used within a CodeToolbarProvider') + } + return context +} diff --git a/src/renderer/src/components/CodeToolbar/index.ts b/src/renderer/src/components/CodeToolbar/index.ts new file mode 100644 index 0000000000..63d28e27f8 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/index.ts @@ -0,0 +1,5 @@ +export * from './constants' +export * from './context' +export * from './toolbar' +export * from './types' +export * from './usePreviewTools' diff --git a/src/renderer/src/components/CodeToolbar/toolbar.tsx b/src/renderer/src/components/CodeToolbar/toolbar.tsx new file mode 100644 index 0000000000..9a2f282bc3 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/toolbar.tsx @@ -0,0 +1,119 @@ +import { HStack } from '@renderer/components/Layout' +import { Tooltip } from 'antd' +import { EllipsisVertical } from 'lucide-react' +import React, { memo, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { useCodeToolbar } from './context' +import { CodeTool } from './types' + +interface CodeToolButtonProps { + tool: CodeTool +} + +const CodeToolButton: React.FC = memo(({ tool }) => { + const { context } = useCodeToolbar() + + return ( + + tool.onClick(context)}>{tool.icon} + + ) +}) + +export const CodeToolbar: React.FC = memo(() => { + const { tools, context } = useCodeToolbar() + const [showQuickTools, setShowQuickTools] = useState(false) + const { t } = useTranslation() + + // 根据条件显示工具 + const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context)) + + // 按类型分组 + const coreTools = visibleTools.filter((tool) => tool.type === 'core') + const quickTools = visibleTools.filter((tool) => tool.type === 'quick') + + // 点击了 more 按钮或者只有一个快捷工具时 + const quickToolButtons = useMemo(() => { + if (quickTools.length === 1 || (quickTools.length > 1 && showQuickTools)) { + return quickTools.map((tool) => ) + } + + return null + }, [quickTools, showQuickTools]) + + if (visibleTools.length === 0) { + return null + } + + return ( + + + {/* 有多个快捷工具时通过 more 按钮展示 */} + {quickToolButtons} + {quickTools.length > 1 && ( + + setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}> + + + + )} + + {/* 始终显示核心工具 */} + {coreTools.map((tool) => ( + + ))} + + + ) +}) + +const StickyWrapper = styled.div` + position: sticky; + top: 28px; + z-index: 10; +` + +const ToolbarWrapper = styled(HStack)` + position: absolute; + align-items: center; + bottom: 0.3rem; + right: 0.5rem; + height: 24px; + gap: 4px; +` + +const ToolWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + color: var(--color-text-3); + + &:hover { + background-color: var(--color-background-soft); + .icon { + color: var(--color-text-1); + } + } + + &.active { + color: var(--color-primary); + .icon { + color: var(--color-primary); + } + } + + /* For Lucide icons */ + .icon { + width: 14px; + height: 14px; + color: var(--color-text-3); + } +` diff --git a/src/renderer/src/components/CodeToolbar/types.ts b/src/renderer/src/components/CodeToolbar/types.ts new file mode 100644 index 0000000000..83db869371 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/types.ts @@ -0,0 +1,35 @@ +/** + * 代码块工具基本信息 + */ +export interface CodeToolSpec { + id: string + type: 'core' | 'quick' + order: number +} + +/** + * 代码块工具定义接口 + * @param id 唯一标识符 + * @param type 工具类型 + * @param icon 按钮图标 + * @param tooltip 提示文本 + * @param condition 显示条件 + * @param onClick 点击动作 + * @param order 显示顺序,越小越靠右 + */ +export interface CodeTool extends CodeToolSpec { + icon: React.ReactNode + tooltip: string + visible?: (ctx?: CodeToolContext) => boolean + onClick: (ctx?: CodeToolContext) => void +} + +/** + * 工具上下文接口 + * @param code 代码内容 + * @param language 语言类型 + */ +export interface CodeToolContext { + code: string + language: string +} diff --git a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx new file mode 100644 index 0000000000..7cd49f95da --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx @@ -0,0 +1,360 @@ +import { download } from '@renderer/utils/download' +import { FileImage, ZoomIn, ZoomOut } from 'lucide-react' +import { RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons' +import { TOOL_SPECS } from './constants' +import { useCodeToolbar } from './context' + +// 预编译正则表达式用于查询位置 +const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/ + +/** + * 使用图像处理工具的自定义Hook + * 提供图像缩放、复制和下载功能 + */ +export const usePreviewToolHandlers = ( + containerRef: RefObject, + options: { + prefix: string + imgSelector: string + enableWheelZoom?: boolean + customDownloader?: (format: 'svg' | 'png') => void + } +) => { + const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态 + const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态 + const { imgSelector, prefix, customDownloader, enableWheelZoom } = options + const { t } = useTranslation() + + // 创建选择器函数 + const getImgElement = useCallback(() => { + if (!containerRef.current) return null + return containerRef.current.querySelector(imgSelector) as SVGElement | null + }, [containerRef, imgSelector]) + + // 查询当前位置 + const getCurrentPosition = useCallback(() => { + const imgElement = getImgElement() + if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y } + + const transform = imgElement.style.transform + if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y } + + const match = transform.match(TRANSFORM_REGEX) + if (match && match.length >= 3) { + return { + x: parseFloat(match[1]), + y: parseFloat(match[2]) + } + } + + return { x: transformRef.current.x, y: transformRef.current.y } + }, [getImgElement]) + + // 平移缩放变换 + const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => { + if (!element) return + element.style.transformOrigin = 'top left' + element.style.transform = `translate(${x}px, ${y}px) scale(${scale})` + }, []) + + // 拖拽平移支持 + useEffect(() => { + const container = containerRef.current + if (!container) return + + let isDragging = false + const startPos = { x: 0, y: 0 } + const startOffset = { x: 0, y: 0 } + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return // 只响应左键 + + // 更新当前实际位置 + const position = getCurrentPosition() + transformRef.current.x = position.x + transformRef.current.y = position.y + + isDragging = true + startPos.x = e.clientX + startPos.y = e.clientY + startOffset.x = position.x + startOffset.y = position.y + + container.style.cursor = 'grabbing' + e.preventDefault() + } + + const onMouseMove = (e: MouseEvent) => { + if (!isDragging) return + + const dx = e.clientX - startPos.x + const dy = e.clientY - startPos.y + const newX = startOffset.x + dx + const newY = startOffset.y + dy + + const imgElement = getImgElement() + applyTransform(imgElement, newX, newY, transformRef.current.scale) + + e.preventDefault() + } + + const stopDrag = () => { + if (!isDragging) return + + // 更新位置但不立即触发状态变更 + const position = getCurrentPosition() + transformRef.current.x = position.x + transformRef.current.y = position.y + + // 只触发一次渲染以保持组件状态同步 + setRenderTrigger((prev) => prev + 1) + + isDragging = false + container.style.cursor = 'default' + } + + // 绑定到document以确保拖拽可以在鼠标离开容器后继续 + container.addEventListener('mousedown', onMouseDown) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', stopDrag) + + return () => { + container.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', stopDrag) + } + }, [containerRef, getCurrentPosition, getImgElement, applyTransform]) + + // 缩放处理函数 + const handleZoom = useCallback( + (delta: number) => { + const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta)) + transformRef.current.scale = newScale + + const imgElement = getImgElement() + applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale) + + // 触发重渲染以保持组件状态同步 + setRenderTrigger((prev) => prev + 1) + }, + [getImgElement, applyTransform] + ) + + // 滚轮缩放支持 + useEffect(() => { + if (!enableWheelZoom || !containerRef.current) return + + const container = containerRef.current + + const handleWheel = (e: WheelEvent) => { + if ((e.ctrlKey || e.metaKey) && e.target) { + // 确认事件发生在容器内部 + if (container.contains(e.target as Node)) { + const delta = e.deltaY < 0 ? 0.1 : -0.1 + handleZoom(delta) + } + } + } + + container.addEventListener('wheel', handleWheel, { passive: true }) + return () => container.removeEventListener('wheel', handleWheel) + }, [containerRef, handleZoom, enableWheelZoom]) + + // 复制图像处理函数 + const handleCopyImage = useCallback(async () => { + try { + const imgElement = getImgElement() + if (!imgElement) return + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.crossOrigin = 'anonymous' + + const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] + const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width + const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height + + const svgData = new XMLSerializer().serializeToString(imgElement) + const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` + + img.onload = async () => { + const scale = 3 + canvas.width = width * scale + canvas.height = height * scale + + if (ctx) { + ctx.scale(scale, scale) + ctx.drawImage(img, 0, 0, width, height) + const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')) + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + window.message.success(t('message.copy.success')) + } + } + img.src = svgBase64 + } catch (error) { + console.error('Copy failed:', error) + window.message.error(t('message.copy.failed')) + } + }, [getImgElement, t]) + + // 下载处理函数 + const handleDownload = useCallback( + (format: 'svg' | 'png') => { + // 如果有自定义下载器,使用自定义实现 + if (customDownloader) { + customDownloader(format) + return + } + + try { + const imgElement = getImgElement() + if (!imgElement) return + + const timestamp = Date.now() + + if (format === 'svg') { + const svgData = new XMLSerializer().serializeToString(imgElement) + const blob = new Blob([svgData], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + download(url, `${prefix}-${timestamp}.svg`) + URL.revokeObjectURL(url) + } else if (format === 'png') { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.crossOrigin = 'anonymous' + + const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] + const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width + const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height + + const svgData = new XMLSerializer().serializeToString(imgElement) + const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` + + img.onload = () => { + const scale = 3 + canvas.width = width * scale + canvas.height = height * scale + + if (ctx) { + ctx.scale(scale, scale) + ctx.drawImage(img, 0, 0, width, height) + } + + canvas.toBlob((blob) => { + if (blob) { + const pngUrl = URL.createObjectURL(blob) + download(pngUrl, `${prefix}-${timestamp}.png`) + URL.revokeObjectURL(pngUrl) + } + }, 'image/png') + } + img.src = svgBase64 + } + } catch (error) { + console.error('Download failed:', error) + } + }, + [getImgElement, prefix, customDownloader] + ) + + return { + scale: transformRef.current.scale, + handleZoom, + handleCopyImage, + handleDownload, + renderTrigger // 导出渲染触发器,万一要用 + } +} + +export interface PreviewToolsOptions { + handleZoom?: (delta: number) => void + handleCopyImage?: () => Promise + handleDownload?: (format: 'svg' | 'png') => void +} + +/** + * 提供预览组件通用工具栏功能的自定义Hook + */ +export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => { + const { t } = useTranslation() + const { registerTool, removeTool } = useCodeToolbar() + + const toolIds = useCallback(() => { + return { + zoomIn: 'preview-zoom-in', + zoomOut: 'preview-zoom-out', + copyImage: 'preview-copy-image', + downloadSvg: 'preview-download-svg', + downloadPng: 'preview-download-png' + } + }, []) + + useEffect(() => { + // 根据提供的功能有选择性地注册工具 + if (handleZoom) { + // 放大工具 + registerTool({ + ...TOOL_SPECS['zoom-in'], + icon: , + tooltip: t('code_block.preview.zoom_in'), + onClick: () => handleZoom(0.1) + }) + + // 缩小工具 + registerTool({ + ...TOOL_SPECS['zoom-out'], + icon: , + tooltip: t('code_block.preview.zoom_out'), + onClick: () => handleZoom(-0.1) + }) + } + + if (handleCopyImage) { + // 复制图片工具 + registerTool({ + ...TOOL_SPECS['copy-image'], + icon: , + tooltip: t('code_block.preview.copy.image'), + onClick: handleCopyImage + }) + } + + if (handleDownload) { + // 下载 SVG 工具 + registerTool({ + ...TOOL_SPECS['download-svg'], + icon: , + tooltip: t('code_block.download.svg'), + onClick: () => handleDownload('svg') + }) + + // 下载 PNG 工具 + registerTool({ + ...TOOL_SPECS['download-png'], + icon: , + tooltip: t('code_block.download.png'), + onClick: () => handleDownload('png') + }) + } + + // 清理函数 + return () => { + if (handleZoom) { + removeTool(TOOL_SPECS['zoom-in'].id) + removeTool(TOOL_SPECS['zoom-out'].id) + } + if (handleCopyImage) { + removeTool(TOOL_SPECS['copy-image'].id) + } + if (handleDownload) { + removeTool(TOOL_SPECS['download-svg'].id) + removeTool(TOOL_SPECS['download-png'].id) + } + } + }, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t, toolIds]) +} diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx new file mode 100644 index 0000000000..08f056a181 --- /dev/null +++ b/src/renderer/src/components/ContentSearch.tsx @@ -0,0 +1,710 @@ +import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar' +import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' +import { Tooltip } from 'antd' +import { debounce } from 'lodash' +import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' +import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const HIGHLIGHT_CLASS = 'highlight' +const HIGHLIGHT_SELECT_CLASS = 'selected' + +interface Props { + children?: React.ReactNode + searchTarget: React.RefObject | React.RefObject | HTMLElement + /** + * 过滤`node`,`node`只会是`Node.TEXT_NODE`类型的文本节点 + * + * 返回`true`表示该`node`会被搜索 + */ + filter: (node: Node) => boolean + includeUser?: boolean + onIncludeUserChange?: (value: boolean) => void +} + +enum SearchCompletedState { + NotSearched, + FirstSearched +} + +enum SearchTargetIndex { + Next, + Prev +} + +export interface ContentSearchRef { + disable(): void + enable(initialText?: string): void + // 搜索下一个并定位 + searchNext(): void + // 搜索上一个并定位 + searchPrev(): void + // 搜索并定位 + search(): void + // 搜索但不定位,或者说是更新 + silentSearch(): void + focus(): void +} + +interface MatchInfo { + index: number + length: number + text: string +} + +const escapeRegExp = (string: string): string => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => { + if (!elementList || elementList.length === 0) { + return null + } + let closestElementIndex: number | null = null + let minVerticalDistance = Infinity + const windowCenterY = window.innerHeight / 2 + for (let i = 0; i < elementList.length; i++) { + const element = elementList[i] + if (!(element instanceof HTMLElement)) { + continue + } + const rect = element.getBoundingClientRect() + if (rect.bottom < 0 || rect.top > window.innerHeight) { + continue + } + const elementCenterY = rect.top + rect.height / 2 + const verticalDistance = Math.abs(elementCenterY - windowCenterY) + if (verticalDistance < minVerticalDistance) { + minVerticalDistance = verticalDistance + closestElementIndex = i + } + } + return closestElementIndex +} + +const highlightText = ( + textNode: Node, + searchText: string, + highlightClass: string, + isCaseSensitive: boolean, + isWholeWord: boolean +): HTMLSpanElement[] | null => { + const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement + if (textNodeParentNode) { + if (textNodeParentNode.classList.contains(highlightClass)) { + return null + } + } + if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) { + return null + } + + const textContent = textNode.textContent + const escapedSearchText = escapeRegExp(searchText) + + // 检查搜索文本是否仅包含拉丁字母 + const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText) + + // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感 + const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi' + const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText + const regex = new RegExp(regexPattern, regexFlags) + + let match + const matches: MatchInfo[] = [] + while ((match = regex.exec(textContent)) !== null) { + if (typeof match.index === 'number' && typeof match[0] === 'string') { + matches.push({ index: match.index, length: match[0].length, text: match[0] }) + } else { + console.error('Unexpected match format:', match) + } + } + + if (matches.length === 0) { + return null + } + + const parentNode = textNode.parentNode + if (!parentNode) { + return null + } + + const fragment = document.createDocumentFragment() + let currentIndex = 0 + const highlightTextSet = new Set() + + matches.forEach(({ index, length, text }) => { + if (index > currentIndex) { + fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index))) + } + const highlightSpan = document.createElement('span') + highlightSpan.className = highlightClass + highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive + fragment.appendChild(highlightSpan) + highlightTextSet.add(highlightSpan) + currentIndex = index + length + }) + + if (currentIndex < textContent.length) { + fragment.appendChild(document.createTextNode(textContent.substring(currentIndex))) + } + + parentNode.replaceChild(fragment, textNode) + return [...highlightTextSet] +} + +const mergeAdjacentTextNodes = (node: HTMLElement) => { + const children = Array.from(node.childNodes) + const groups: Array = [] + let currentTextGroup: { text: string; nodes: Node[] } | null = null + + for (const child of children) { + if (child.nodeType === Node.TEXT_NODE) { + if (currentTextGroup === null) { + currentTextGroup = { + text: child.textContent ?? '', + nodes: [child] + } + } else { + currentTextGroup.text += child.textContent + currentTextGroup.nodes.push(child) + } + } else { + if (currentTextGroup !== null) { + groups.push(currentTextGroup!) + currentTextGroup = null + } + groups.push(child) + } + } + + if (currentTextGroup !== null) { + groups.push(currentTextGroup) + } + + const newChildren = groups.map((group) => { + if (group instanceof Node) { + return group + } else { + return document.createTextNode(group.text) + } + }) + + node.replaceChildren(...newChildren) +} + +// eslint-disable-next-line @eslint-react/no-forward-ref +export const ContentSearch = React.forwardRef( + ({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => { + const target: HTMLElement | null = (() => { + if (searchTarget instanceof HTMLElement) { + return searchTarget + } else { + return (searchTarget.current as HTMLElement) ?? null + } + })() + const containerRef = React.useRef(null) + const searchInputRef = React.useRef(null) + const [searchResultIndex, setSearchResultIndex] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [enableContentSearch, setEnableContentSearch] = useState(false) + const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched) + const [isCaseSensitive, setIsCaseSensitive] = useState(false) + const [isWholeWord, setIsWholeWord] = useState(false) + const [shouldScroll, setShouldScroll] = useState(false) + const highlightTextSet = useState(new Set())[0] + const prevSearchText = useRef('') + const { t } = useTranslation() + + const locateByIndex = (index: number, shouldScroll = true) => { + if (target) { + const highlightTextNodes = [...highlightTextSet] as HTMLElement[] + highlightTextNodes.sort((a, b) => { + const { top: aTop } = a.getBoundingClientRect() + const { top: bTop } = b.getBoundingClientRect() + return aTop - bTop + }) + for (const node of highlightTextNodes) { + node.classList.remove(HIGHLIGHT_SELECT_CLASS) + } + setSearchResultIndex(index) + if (highlightTextNodes.length > 0) { + const highlightTextNode = highlightTextNodes[index] ?? null + if (highlightTextNode) { + highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS) + if (shouldScroll) { + highlightTextNode.scrollIntoView({ + behavior: 'smooth', + block: 'center' + // inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码 + }) + } + } + } + } + } + + const restoreHighlight = () => { + const highlightTextParentNodeSet = new Set() + // Make a copy because the set might be modified during iteration indirectly + const nodesToRestore = [...highlightTextSet] + for (const highlightTextNode of nodesToRestore) { + if (highlightTextNode.textContent) { + const textNode = document.createTextNode(highlightTextNode.textContent) + const node = highlightTextNode as HTMLElement + if (node.parentNode) { + highlightTextParentNodeSet.add(node.parentNode as HTMLElement) + node.replaceWith(textNode) // This removes the node from the DOM + } + } + } + highlightTextSet.clear() // Clear the original set after processing + for (const parentNode of highlightTextParentNodeSet) { + mergeAdjacentTextNodes(parentNode) + } + // highlightTextSet.clear() // Already cleared + } + + const search = (searchTargetIndex?: SearchTargetIndex): number | null => { + const searchText = searchInputRef.current?.value.trim() ?? null + if (target && searchText !== null && searchText !== '') { + restoreHighlight() + const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT) + let textNode: Node | null + const textNodeSet: Set = new Set() + while ((textNode = iter.nextNode())) { + if (filter(textNode)) { + textNodeSet.add(textNode) + } + } + + const highlightTextSetTemp = new Set() + for (const node of textNodeSet) { + const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord) + if (list) { + list.forEach((node) => highlightTextSetTemp.add(node)) + } + } + const highlightTextList = [...highlightTextSetTemp] + setTotalCount(highlightTextList.length) + highlightTextSetTemp.forEach((node) => highlightTextSet.add(node)) + const changeIndex = () => { + let index: number + switch (searchTargetIndex) { + case SearchTargetIndex.Next: + { + index = (searchResultIndex + 1) % highlightTextList.length + } + break + case SearchTargetIndex.Prev: + { + index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length + } + break + default: { + index = searchResultIndex + } + } + return Math.max(index, 0) + } + + const targetIndex = (() => { + switch (searchCompleted) { + case SearchCompletedState.NotSearched: { + setSearchCompleted(SearchCompletedState.FirstSearched) + const index = findWindowVerticalCenterElementIndex(highlightTextList) + if (index !== null) { + setSearchResultIndex(index) + return index + } else { + setSearchResultIndex(0) + return 0 + } + } + case SearchCompletedState.FirstSearched: { + return changeIndex() + } + default: { + return null + } + } + })() + + if (targetIndex === null) { + return null + } else { + const totalCount = highlightTextSet.size + if (targetIndex >= totalCount) { + return totalCount - 1 + } else { + return targetIndex + } + } + } else { + return null + } + } + + const _searchHandlerDebounce = debounce(() => { + implementation.search() + }, 300) + const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce]) + const userInputHandler = (event: React.ChangeEvent) => { + const value = event.target.value.trim() + if (value.length === 0) { + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } else { + // 用户输入时允许滚动 + setShouldScroll(true) + searchHandler() + } + prevSearchText.current = value + } + + const keyDownHandler = (event: React.KeyboardEvent) => { + const { code, key, shiftKey } = event + if (key === 'Process') { + return + } + + switch (code) { + case 'Enter': + { + if (shiftKey) { + implementation.searchPrev() + } else { + implementation.searchNext() + } + event.preventDefault() + } + break + case 'Escape': + { + implementation.disable() + } + break + } + } + + const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus()) + + const userOutlinedButtonOnClick = () => { + if (onIncludeUserChange) { + onIncludeUserChange(!includeUser) + } + searchInputFocus() + } + + const implementation = { + disable() { + setEnableContentSearch(false) + restoreHighlight() + setShouldScroll(false) + }, + enable(initialText?: string) { + setEnableContentSearch(true) + setShouldScroll(false) // Default to false, search itself might set it to true + if (searchInputRef.current) { + const inputEl = searchInputRef.current + if (initialText && initialText.trim().length > 0) { + inputEl.value = initialText + // Trigger search after setting initial text + // Need to make sure search() uses the new value + // and also to focus and select + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + setShouldScroll(true) + const targetIndex = search() + if (targetIndex !== null) { + locateByIndex(targetIndex, true) // Ensure scrolling + } else { + // If search returns null (e.g., empty input or no matches with initial text), clear state + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } + }) + } else { + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + }) + // Only search if there's existing text and no new initialText + if (inputEl.value.trim()) { + const targetIndex = search() + if (targetIndex !== null) { + setSearchResultIndex(targetIndex) + // locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text + } + } + } + } + }, + searchNext() { + if (enableContentSearch) { + const targetIndex = search(SearchTargetIndex.Next) + if (targetIndex !== null) { + locateByIndex(targetIndex) + } + } + }, + searchPrev() { + if (enableContentSearch) { + const targetIndex = search(SearchTargetIndex.Prev) + if (targetIndex !== null) { + locateByIndex(targetIndex) + } + } + }, + resetSearchState() { + if (enableContentSearch) { + setSearchCompleted(SearchCompletedState.NotSearched) + // Maybe also reset index? Depends on desired behavior + // setSearchResultIndex(0); + } + }, + search() { + if (enableContentSearch) { + const targetIndex = search() + if (targetIndex !== null) { + locateByIndex(targetIndex, shouldScroll) + } else { + // If search returns null (e.g., empty input), clear state + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } + } + }, + silentSearch() { + if (enableContentSearch) { + const targetIndex = search() + if (targetIndex !== null) { + // 只更新索引,不触发滚动 + locateByIndex(targetIndex, false) + } + } + }, + focus() { + searchInputFocus() + } + } + + useImperativeHandle(ref, () => ({ + disable() { + implementation.disable() + }, + enable(initialText?: string) { + implementation.enable(initialText) + }, + searchNext() { + implementation.searchNext() + }, + searchPrev() { + implementation.searchPrev() + }, + search() { + implementation.search() + }, + silentSearch() { + implementation.silentSearch() + }, + focus() { + implementation.focus() + } + })) + + // Re-run search when options change and search is active + useEffect(() => { + if (enableContentSearch && searchInputRef.current?.value.trim()) { + implementation.search() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency + + const prevButtonOnClick = () => { + implementation.searchPrev() + searchInputFocus() + } + + const nextButtonOnClick = () => { + implementation.searchNext() + searchInputFocus() + } + + const closeButtonOnClick = () => { + implementation.disable() + } + + const caseSensitiveButtonOnClick = () => { + setIsCaseSensitive(!isCaseSensitive) + searchInputFocus() + } + + const wholeWordButtonOnClick = () => { + setIsWholeWord(!isWholeWord) + searchInputFocus() + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {searchCompleted !== SearchCompletedState.NotSearched ? ( + totalCount > 0 ? ( + <> + {searchResultIndex + 1} + / + {totalCount} + + ) : ( + {t('common.no_results')} + ) + ) : ( + 0/0 + )} + + + + + + + + + + + + + + + + + ) + } +) + +ContentSearch.displayName = 'ContentSearch' + +const Container = styled.div` + display: flex; + flex-direction: row; + z-index: 2; +` + +const SearchBarContainer = styled.div` + border: 1px solid var(--color-border); + border-radius: 10px; + transition: all 0.2s ease; + position: relative; + margin: 5px 20px; + margin-bottom: 0; + padding: 6px 15px 8px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background-opacity); + flex: 1 1 auto; /* Take up input's previous space */ +` + +const Placeholder = styled.div` + width: 5px; +` + +const InputWrapper = styled.div` + display: flex; + align-items: center; + flex: 1 1 auto; /* Take up input's previous space */ +` + +const Input = styled.input` + border: none; + color: var(--color-text); + background-color: transparent; + outline: none; + width: 100%; + padding: 0 5px; /* Adjust padding, wrapper will handle spacing */ + flex: 1; /* Allow input to grow */ + font-size: 14px; + font-family: Ubuntu; +` + +const ToolBar = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: tpx; +` + +const Separator = styled.div` + width: 1px; + height: 1.5em; + background-color: var(--color-border); + margin-left: 2px; + margin-right: 2px; + flex: 0 0 auto; +` + +const SearchResults = styled.div` + display: flex; + justify-content: center; + width: 80px; + margin: 0 2px; + flex: 0 0 auto; + color: var(--color-text-secondary); + font-size: 14px; + font-family: Ubuntu; +` + +const SearchResultsPlaceholder = styled.span` + color: var(--color-text-secondary); + opacity: 0.5; +` + +const NoResults = styled.span` + color: var(--color-text-secondary); +` + +const SearchResultCount = styled.span` + color: var(--color-text); +` + +const SearchResultSeparator = styled.span` + color: var(--color-text); + margin: 0 4px; +` + +const SearchResultTotalCount = styled.span` + color: var(--color-text); +` diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx new file mode 100644 index 0000000000..02b9b3eafd --- /dev/null +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -0,0 +1,91 @@ +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { Dropdown } from 'antd' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface ContextMenuProps { + children: React.ReactNode + onContextMenu?: (e: React.MouseEvent) => void +} + +const ContextMenu: React.FC = ({ children, onContextMenu }) => { + const { t } = useTranslation() + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) + const [selectedQuoteText, setSelectedQuoteText] = useState('') + const [selectedText, setSelectedText] = useState('') + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const _selectedText = window.getSelection()?.toString() + if (_selectedText) { + const quotedText = + _selectedText + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + '\n-------------' + setSelectedQuoteText(quotedText) + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setSelectedText(_selectedText) + } + onContextMenu?.(e) + }, + [onContextMenu] + ) + + useEffect(() => { + const handleClick = () => { + setContextMenuPosition(null) + } + document.addEventListener('click', handleClick) + return () => { + document.removeEventListener('click', handleClick) + } + }, []) + + // 获取右键菜单项 + const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [ + { + key: 'copy', + label: t('common.copy'), + onClick: () => { + if (selectedText) { + navigator.clipboard + .writeText(selectedText) + .then(() => { + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + }) + .catch(() => { + window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) + }) + } + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + if (selectedQuoteText) { + EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) + } + } + } + ] + + return ( +
+ {contextMenuPosition && ( + +
+ + )} + {children} +
+ ) +} + +export default ContextMenu diff --git a/src/renderer/src/components/Icons/DownloadIcons.tsx b/src/renderer/src/components/Icons/DownloadIcons.tsx new file mode 100644 index 0000000000..55c6f00f1a --- /dev/null +++ b/src/renderer/src/components/Icons/DownloadIcons.tsx @@ -0,0 +1,68 @@ +import { SVGProps } from 'react' + +// 基础下载图标 +export const DownloadIcon = (props: SVGProps) => ( + + + + + +) + +// 带有文件类型的下载图标基础组件 +const DownloadTypeIconBase = ({ type, ...props }: SVGProps & { type: string }) => ( + + + {type} + + + + + +) + +// JPG 文件下载图标 +export const DownloadJpgIcon = (props: SVGProps) => + +// PNG 文件下载图标 +export const DownloadPngIcon = (props: SVGProps) => + +// SVG 文件下载图标 +export const DownloadSvgIcon = (props: SVGProps) => diff --git a/src/renderer/src/components/Layout/index.ts b/src/renderer/src/components/Layout/index.ts index 2e5a0b608a..6ebc5788c5 100644 --- a/src/renderer/src/components/Layout/index.ts +++ b/src/renderer/src/components/Layout/index.ts @@ -149,15 +149,6 @@ export const BaseTypography = styled(Box)<{ text-align: ${(props) => props.textAlign || 'left'}; ` -export const TypographyNormal = styled(BaseTypography)` - font-family: 'Ubuntu'; -` - -export const TypographyBold = styled(BaseTypography)` - font-family: 'Ubuntu'; - font-weight: bold; -` - export const Container = styled.main` display: flex; flex-direction: column; diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index b3d4132845..b93f86b8f6 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -35,7 +35,6 @@ const ListItemContainer = styled.div` flex-direction: column; justify-content: space-between; position: relative; - font-family: Ubuntu; cursor: pointer; border: 1px solid transparent; diff --git a/src/renderer/src/components/ModelIdWithTags.tsx b/src/renderer/src/components/ModelIdWithTags.tsx index cfd109d0aa..4b8ca86123 100644 --- a/src/renderer/src/components/ModelIdWithTags.tsx +++ b/src/renderer/src/components/ModelIdWithTags.tsx @@ -57,7 +57,6 @@ const NameSpan = styled.span` text-overflow: ellipsis; white-space: nowrap; cursor: help; - font-family: 'Ubuntu'; line-height: 30px; ` diff --git a/src/renderer/src/components/Popups/BackupPopup.tsx b/src/renderer/src/components/Popups/BackupPopup.tsx index 41eb268a16..dd19e0010b 100644 --- a/src/renderer/src/components/Popups/BackupPopup.tsx +++ b/src/renderer/src/components/Popups/BackupPopup.tsx @@ -1,6 +1,8 @@ import { backup } from '@renderer/services/BackupService' +import store from '@renderer/store' import { IpcChannel } from '@shared/IpcChannel' import { Modal, Progress } from 'antd' +import Logger from 'electron-log' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,6 +22,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const [open, setOpen] = useState(true) const [progressData, setProgressData] = useState() const { t } = useTranslation() + const skipBackupFile = store.getState().settings.skipBackupFile useEffect(() => { const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => { @@ -32,7 +35,8 @@ const PopupContainer: React.FC = ({ resolve }) => { }, []) const onOk = async () => { - await backup() + Logger.log('[BackupManager] ', skipBackupFile) + await backup(skipBackupFile) setOpen(false) } diff --git a/src/renderer/src/components/Popups/FloatingSidebar.tsx b/src/renderer/src/components/Popups/FloatingSidebar.tsx new file mode 100644 index 0000000000..df77619d90 --- /dev/null +++ b/src/renderer/src/components/Popups/FloatingSidebar.tsx @@ -0,0 +1,90 @@ +import HomeTabs from '@renderer/pages/home/Tabs/index' +import { Assistant, Topic } from '@renderer/types' +import { Popover } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' +import styled from 'styled-components' + +import Scrollbar from '../Scrollbar' + +interface Props { + children: React.ReactNode + activeAssistant: Assistant + setActiveAssistant: (assistant: Assistant) => void + activeTopic: Topic + setActiveTopic: (topic: Topic) => void + position: 'left' | 'right' +} + +const FloatingSidebar: FC = ({ + children, + activeAssistant, + setActiveAssistant, + activeTopic, + setActiveTopic, + position = 'left' +}) => { + const [open, setOpen] = useState(false) + + useHotkeys('esc', () => { + setOpen(false) + }) + + const [maxHeight, setMaxHeight] = useState(Math.floor(window.innerHeight * 0.75)) + + useEffect(() => { + const handleResize = () => { + setMaxHeight(Math.floor(window.innerHeight * 0.75)) + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + const content = ( + + + + ) + + return ( + { + setOpen(visible) + }} + content={content} + trigger={['hover', 'click']} + placement="bottomRight" + arrow={false} + mouseEnterDelay={0.8} // 800ms delay before showing + mouseLeaveDelay={20} + styles={{ + body: { + padding: 0, + background: 'var(--color-background)', + border: '1px solid var(--color-border)', + borderRadius: '8px', + boxShadow: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12)' + } + }}> + {children} + + ) +} + +const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>` + max-height: ${(props) => props.maxHeight}px; + overflow-y: auto; +` + +export default FloatingSidebar diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 1602b6a4ac..ae05ac3466 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -647,7 +647,6 @@ const QuickPanelItem = styled.div` border-radius: 6px; cursor: pointer; transition: background-color 0.1s ease; - font-family: Ubuntu; &.selected { background-color: var(--selected-color); &.focused { diff --git a/src/renderer/src/components/Scrollbar/index.tsx b/src/renderer/src/components/Scrollbar/index.tsx index 670e37895a..857a8404e2 100644 --- a/src/renderer/src/components/Scrollbar/index.tsx +++ b/src/renderer/src/components/Scrollbar/index.tsx @@ -2,12 +2,13 @@ import { throttle } from 'lodash' import { FC, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' -interface Props extends React.HTMLAttributes { +interface Props extends Omit, 'onScroll'> { right?: boolean - ref?: any + ref?: React.RefObject + onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll } -const Scrollbar: FC = ({ ref, ...props }: Props & { ref?: React.RefObject }) => { +const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => { const [isScrolling, setIsScrolling] = useState(false) const timeoutRef = useRef(null) @@ -21,18 +22,31 @@ const Scrollbar: FC = ({ ref, ...props }: Props & { ref?: React.RefObject timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500) }, []) - const throttledHandleScroll = throttle(handleScroll, 200) + const throttledInternalScrollHandler = throttle(handleScroll, 200) + + // Combined scroll handler + const combinedOnScroll = useCallback(() => { + // Event is available if needed by internal handler + throttledInternalScrollHandler() // Call internal logic + if (externalOnScroll) { + externalOnScroll() // Call external logic (from useScrollPosition) + } + }, [throttledInternalScrollHandler, externalOnScroll]) useEffect(() => { return () => { timeoutRef.current && clearTimeout(timeoutRef.current) - throttledHandleScroll.cancel() + throttledInternalScrollHandler.cancel() } - }, [throttledHandleScroll]) + }, [throttledInternalScrollHandler]) return ( - - {props.children} + + {children} ) } diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 7e74464612..85a5265560 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -1,5 +1,6 @@ import { isLinux, isMac, isWindows } from '@renderer/config/constant' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' +import { useFullscreen } from '@renderer/hooks/useFullscreen' import type { FC, PropsWithChildren } from 'react' import type { HTMLAttributes } from 'react' import styled from 'styled-components' @@ -25,7 +26,12 @@ export const NavbarCenter: FC = ({ children, ...props }) => { } export const NavbarRight: FC = ({ children, ...props }) => { - return {children} + const isFullscreen = useFullscreen() + return ( + + {children} + + ) } const NavbarContainer = styled.div` @@ -58,11 +64,11 @@ const NavbarCenterContainer = styled.div` color: var(--color-text-1); ` -const NavbarRightContainer = styled.div` +const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>` min-width: var(--topic-list-width); display: flex; align-items: center; padding: 0 12px; - padding-right: ${isWindows ? '140px' : isLinux ? '120px' : '12px'}; + padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')}; justify-content: flex-end; ` diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index c319123d29..08f4c1fa22 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -3,6 +3,7 @@ import { isMac } from '@renderer/config/constant' import { AppLogo, UserAvatar } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useFullscreen } from '@renderer/hooks/useFullscreen' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' @@ -68,8 +69,13 @@ const Sidebar: FC = () => { }) } + const isFullscreen = useFullscreen() + return ( - + {isEmoji(avatar) ? ( {avatar} @@ -311,7 +317,7 @@ const PinnedApps: FC = () => { ) } -const Container = styled.div` +const Container = styled.div<{ $isFullscreen: boolean }>` display: flex; flex-direction: column; align-items: center; @@ -319,9 +325,9 @@ const Container = styled.div` padding-bottom: 12px; width: var(--sidebar-width); min-width: var(--sidebar-width); - height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'}; + height: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'calc(100vh - var(--navbar-height))' : '100vh')}; -webkit-app-region: drag !important; - margin-top: ${isMac ? 'var(--navbar-height)' : 0}; + margin-top: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'var(--navbar-height)' : 0)}; .sidebar-avatar { margin-bottom: ${isMac ? '12px' : '12px'}; diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index 5d2376d12f..db5f2c9174 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -3,8 +3,6 @@ export const DEFAULT_CONTEXTCOUNT = 5 export const DEFAULT_MAX_TOKENS = 4096 export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6 export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0 -export const FONT_FAMILY = - "Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif" export const platform = window.electron?.process?.platform export const isMac = platform === 'darwin' diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 4e688c8748..4dc80ad751 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -163,7 +163,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'minimax', name: '海螺', - url: 'https://hailuoai.com/', + url: 'https://chat.minimaxi.com/', logo: HailuoModelLogo }, { diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index ee44a99608..3e3656aacf 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -725,46 +725,34 @@ export const SYSTEM_MODELS: Record = { ], 'gitee-ai': [ { - id: 'DeepSeek-R1-Distill-Qwen-32B', - name: 'DeepSeek-R1-Distill-Qwen-32B', + id: 'Qwen3-30B-A3B', + name: 'Qwen3-30B-A3B', provider: 'gitee-ai', - group: 'DeepSeek' + group: 'Qwen' }, { - id: 'DeepSeek-R1-Distill-Qwen-1.5B', - name: 'DeepSeek-R1-Distill-Qwen-1.5B', + id: 'Qwen3-32B', + name: 'Qwen3-32B', provider: 'gitee-ai', - group: 'DeepSeek' + group: 'Qwen' }, { - id: 'DeepSeek-R1-Distill-Qwen-14B', - name: 'DeepSeek-R1-Distill-Qwen-14B', + id: 'Qwen3-8B', + name: 'Qwen3-8B', provider: 'gitee-ai', - group: 'DeepSeek' + group: 'Qwen' }, { - id: 'DeepSeek-R1-Distill-Qwen-7B', - name: 'DeepSeek-R1-Distill-Qwen-7B', + id: 'Qwen3-4B', + name: 'Qwen3-4B', provider: 'gitee-ai', - group: 'DeepSeek' + group: 'Qwen' }, { - id: 'DeepSeek-V3', - name: 'DeepSeek-V3', + id: 'Qwen3-0.6B', + name: 'Qwen3-0.6B', provider: 'gitee-ai', - group: 'DeepSeek' - }, - { - id: 'DeepSeek-R1', - name: 'DeepSeek-R1', - provider: 'gitee-ai', - group: 'DeepSeek' - }, - { - id: 'deepseek-coder-33B-instruct', - name: 'deepseek-coder-33B-instruct', - provider: 'gitee-ai', - group: 'DeepSeek' + group: 'Qwen' }, { id: 'Qwen2.5-72B-Instruct', @@ -803,11 +791,23 @@ export const SYSTEM_MODELS: Record = { group: 'Qwen' }, { - id: 'QwQ-32B-Preview', - name: 'QwQ-32B-Preview', + id: 'Qwen2.5-VL-32B-Instruct', + name: 'Qwen2.5-VL-32B-Instruct', provider: 'gitee-ai', group: 'Qwen' }, + { + id: 'QwQ-32B', + name: 'QwQ-32B', + provider: 'gitee-ai', + group: 'Qwen' + }, + { + id: 'Align-DS-V', + name: 'Align-DS-V', + provider: 'gitee-ai', + group: 'Align' + }, { id: 'Yi-34B-Chat', name: 'Yi-34B-Chat', @@ -820,6 +820,12 @@ export const SYSTEM_MODELS: Record = { provider: 'gitee-ai', group: 'THUDM' }, + { + id: 'deepseek-coder-33B-instruct', + name: 'deepseek-coder-33B-instruct', + provider: 'gitee-ai', + group: 'DeepSeek' + }, { id: 'codegeex4-all-9b', name: 'codegeex4-all-9b', @@ -844,6 +850,48 @@ export const SYSTEM_MODELS: Record = { provider: 'gitee-ai', group: 'OpenGVLab' }, + { + id: 'DeepSeek-R1-Distill-Qwen-32B', + name: 'DeepSeek-R1-Distill-Qwen-32B', + provider: 'gitee-ai', + group: 'DeepSeek' + }, + { + id: 'DeepSeek-R1-Distill-Qwen-1.5B', + name: 'DeepSeek-R1-Distill-Qwen-1.5B', + provider: 'gitee-ai', + group: 'DeepSeek' + }, + { + id: 'DeepSeek-R1-Distill-Qwen-14B', + name: 'DeepSeek-R1-Distill-Qwen-14B', + provider: 'gitee-ai', + group: 'DeepSeek' + }, + { + id: 'DeepSeek-R1-Distill-Qwen-7B', + name: 'DeepSeek-R1-Distill-Qwen-7B', + provider: 'gitee-ai', + group: 'DeepSeek' + }, + { + id: 'DeepSeek-V3', + name: 'DeepSeek-V3', + provider: 'gitee-ai', + group: 'DeepSeek' + }, + { + id: 'DeepSeek-R1', + name: 'DeepSeek-R1', + provider: 'gitee-ai', + group: 'DeepSeek' + }, + { + id: 'gemma-3-27b-it', + name: 'gemma-3-27b-it', + provider: 'gitee-ai', + group: 'Gemma' + }, { id: 'bge-large-zh-v1.5', name: 'bge-large-zh-v1.5', @@ -2627,7 +2675,7 @@ export const THINKING_TOKEN_MAP: Record = 'qwen-turbo-.*$': { min: 0, max: 38912 }, 'qwen3-0\\.6b$': { min: 0, max: 30720 }, 'qwen3-1\\.7b$': { min: 0, max: 30720 }, - 'qwen3-.*$': { min: 0, max: 38912 }, + 'qwen3-.*$': { min: 1024, max: 38912 }, // Claude models 'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 } diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 094144d8de..fdeed3d688 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -37,10 +37,14 @@ const AntdProvider: FC = ({ children }) => { }, Collapse: { headerBg: 'transparent' + }, + Tooltip: { + fontSize: 13 } }, token: { - colorPrimary: '#00b96b' + colorPrimary: '#00b96b', + fontFamily: 'var(--font-family)' } }}> {children} diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx new file mode 100644 index 0000000000..23e50f8deb --- /dev/null +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -0,0 +1,151 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import { useMermaid } from '@renderer/hooks/useMermaid' +import { useSettings } from '@renderer/hooks/useSettings' +import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService' +import { ThemeMode } from '@renderer/types' +import * as cmThemes from '@uiw/codemirror-themes-all' +import type React from 'react' +import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react' + +interface CodeStyleContextType { + highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise + cleanupTokenizers: (callerId: string) => void + getShikiPreProperties: (language: string) => Promise + themeNames: string[] + activeShikiTheme: string + activeCmTheme: any + languageMap: Record +} + +const defaultCodeStyleContext: CodeStyleContextType = { + highlightCodeChunk: async () => ({ lines: [], recall: 0 }), + cleanupTokenizers: () => {}, + getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }), + themeNames: ['auto'], + activeShikiTheme: 'auto', + activeCmTheme: null, + languageMap: {} +} + +const CodeStyleContext = createContext(defaultCodeStyleContext) + +export const CodeStyleProvider: React.FC = ({ children }) => { + const { codeEditor, codePreview } = useSettings() + const { theme } = useTheme() + const [shikiThemes, setShikiThemes] = useState({}) + useMermaid() + + useEffect(() => { + if (!codeEditor.enabled) { + import('shiki').then(({ bundledThemes }) => { + setShikiThemes(bundledThemes) + }) + } + }, [codeEditor.enabled]) + + // 获取支持的主题名称列表 + const themeNames = useMemo(() => { + // CodeMirror 主题 + // 更保险的做法可能是硬编码主题列表 + if (codeEditor.enabled) { + return ['auto', 'light', 'dark'] + .concat(Object.keys(cmThemes)) + .filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function') + .filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string)) + } + + // Shiki 主题 + return ['auto', ...Object.keys(shikiThemes)] + }, [codeEditor.enabled, shikiThemes]) + + // 获取当前使用的 Shiki 主题名称(只用于代码预览) + const activeShikiTheme = useMemo(() => { + const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark' + const codeStyle = codePreview[field] + if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) { + return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker' + } + return codeStyle + }, [theme, codePreview, themeNames]) + + // 获取当前使用的 CodeMirror 主题对象(只用于编辑器) + const activeCmTheme = useMemo(() => { + const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark' + let themeName = codeEditor[field] + if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) { + themeName = theme === ThemeMode.light ? 'materialLight' : 'dark' + } + return cmThemes[themeName as keyof typeof cmThemes] || themeName + }, [theme, codeEditor, themeNames]) + + // 一些语言的别名 + const languageMap = useMemo(() => { + return { + bash: 'shell', + 'objective-c++': 'objective-cpp', + svg: 'xml', + vab: 'vb' + } as Record + }, []) + + useEffect(() => { + // 在组件卸载时清理 Worker + return () => { + shikiStreamService.dispose() + } + }, []) + + // 流式代码高亮,返回已高亮的 token lines + const highlightCodeChunk = useCallback( + async (trunk: string, language: string, callerId: string) => { + const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + return shikiStreamService.highlightCodeChunk(trunk, normalizedLang, activeShikiTheme, callerId) + }, + [activeShikiTheme, languageMap] + ) + + // 清理代码高亮资源 + const cleanupTokenizers = useCallback((callerId: string) => { + shikiStreamService.cleanupTokenizers(callerId) + }, []) + + // 获取 Shiki pre 标签属性 + const getShikiPreProperties = useCallback( + async (language: string) => { + const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase() + return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme) + }, + [activeShikiTheme, languageMap] + ) + + const contextValue = useMemo( + () => ({ + highlightCodeChunk, + cleanupTokenizers, + getShikiPreProperties, + themeNames, + activeShikiTheme, + activeCmTheme, + languageMap + }), + [ + highlightCodeChunk, + cleanupTokenizers, + getShikiPreProperties, + themeNames, + activeShikiTheme, + activeCmTheme, + languageMap + ] + ) + + return {children} +} + +export const useCodeStyle = () => { + const context = use(CodeStyleContext) + if (!context) { + throw new Error('useCodeStyle must be used within a CodeStyleProvider') + } + return context +} diff --git a/src/renderer/src/context/MessageEditingContext.tsx b/src/renderer/src/context/MessageEditingContext.tsx new file mode 100644 index 0000000000..5c864908b6 --- /dev/null +++ b/src/renderer/src/context/MessageEditingContext.tsx @@ -0,0 +1,33 @@ +import { createContext, ReactNode, use, useState } from 'react' + +interface MessageEditingContextType { + editingMessageId: string | null + startEditing: (messageId: string) => void + stopEditing: () => void +} + +const MessageEditingContext = createContext(null) + +export function MessageEditingProvider({ children }: { children: ReactNode }) { + const [editingMessageId, setEditingMessageId] = useState(null) + + const startEditing = (messageId: string) => { + setEditingMessageId(messageId) + } + + const stopEditing = () => { + setEditingMessageId(null) + } + + return ( + {children} + ) +} + +export function useMessageEditing() { + const context = use(MessageEditingContext) + if (!context) { + throw new Error('useMessageEditing must be used within a MessageEditingProvider') + } + return context +} diff --git a/src/renderer/src/context/SyntaxHighlighterProvider.tsx b/src/renderer/src/context/SyntaxHighlighterProvider.tsx deleted file mode 100644 index ce35f2bba6..0000000000 --- a/src/renderer/src/context/SyntaxHighlighterProvider.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useTheme } from '@renderer/context/ThemeProvider' -import { useMermaid } from '@renderer/hooks/useMermaid' -import { useSettings } from '@renderer/hooks/useSettings' -import { CodeCacheService } from '@renderer/services/CodeCacheService' -import { type CodeStyleVarious, ThemeMode } from '@renderer/types' -import { getHighlighter, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/highlighter' -import type React from 'react' -import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react' -import { bundledThemes } from 'shiki' - -interface SyntaxHighlighterContextType { - codeToHtml: (code: string, language: string, enableCache: boolean) => Promise -} - -const SyntaxHighlighterContext = createContext(undefined) - -export const SyntaxHighlighterProvider: React.FC = ({ children }) => { - const { theme } = useTheme() - const { codeStyle } = useSettings() - useMermaid() - - const highlighterTheme = useMemo(() => { - if (!codeStyle || codeStyle === 'auto') { - return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker' - } - - return codeStyle - }, [theme, codeStyle]) - - const codeToHtml = useCallback( - async (_code: string, language: string, enableCache: boolean) => { - { - if (!_code) return '' - - const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme) - const cached = enableCache ? CodeCacheService.getCachedResult(key) : null - if (cached) return cached - - const languageMap: Record = { - vab: 'vb' - } - - const mappedLanguage = languageMap[language] || language - - const code = _code?.trimEnd() ?? '' - const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '<', '>': '>' })[char]!) - - try { - const highlighter = await getHighlighter() - - await loadThemeIfNeeded(highlighter, highlighterTheme) - await loadLanguageIfNeeded(highlighter, mappedLanguage) - - // 生成高亮HTML - const html = highlighter.codeToHtml(code, { - lang: mappedLanguage, - theme: highlighterTheme - }) - - // 设置缓存 - if (enableCache) { - CodeCacheService.setCachedResult(key, html, _code.length) - } - - return html - } catch (error) { - console.debug(`Error highlighting code for language '${mappedLanguage}':`, error) - return `
${escapedCode}
` - } - } - }, - [highlighterTheme] - ) - - return {children} -} - -export const useSyntaxHighlighter = () => { - const context = use(SyntaxHighlighterContext) - if (!context) { - throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider') - } - return context -} - -export const codeThemes = ['auto', ...Object.keys(bundledThemes)] as CodeStyleVarious[] diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 2ef23faf77..7674f15efd 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -19,7 +19,6 @@ declare global { message: MessageInstance modal: HookAPI keyv: KeyvStorage - mermaid: any store: any navigate: NavigateFunction } diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index 23b538b56a..dd2d47b89f 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -15,7 +15,7 @@ import { updateTopic, updateTopics } from '@renderer/store/assistants' -import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' +import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { useCallback, useMemo } from 'react' @@ -103,15 +103,17 @@ export function useDefaultAssistant() { } export function useDefaultModel() { - const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm) + const { defaultModel, topicNamingModel, translateModel, quickAssistantModel } = useAppSelector((state) => state.llm) const dispatch = useAppDispatch() return { defaultModel, topicNamingModel, translateModel, + quickAssistantModel, setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })), setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })), - setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })) + setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })), + setQuickAssistantModel: (model: Model) => dispatch(setQuickAssistantModel({ model })) } } diff --git a/src/renderer/src/hooks/useFullscreen.ts b/src/renderer/src/hooks/useFullscreen.ts new file mode 100644 index 0000000000..4a5820ed8e --- /dev/null +++ b/src/renderer/src/hooks/useFullscreen.ts @@ -0,0 +1,18 @@ +import { IpcChannel } from '@shared/IpcChannel' +import { useEffect, useState } from 'react' + +export function useFullscreen() { + const [isFullscreen, setIsFullscreen] = useState(false) + + useEffect(() => { + const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, fullscreen) => { + setIsFullscreen(fullscreen) + }) + + return () => { + cleanup() + } + }, []) + + return isFullscreen +} diff --git a/src/renderer/src/hooks/useMCPServers.ts b/src/renderer/src/hooks/useMCPServers.ts index 49fde29f60..bc95bb2ff6 100644 --- a/src/renderer/src/hooks/useMCPServers.ts +++ b/src/renderer/src/hooks/useMCPServers.ts @@ -4,13 +4,11 @@ import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@ import { MCPServer } from '@renderer/types' import { IpcChannel } from '@shared/IpcChannel' -const ipcRenderer = window.electron.ipcRenderer - // Listen for server changes from main process -ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => { +window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => { store.dispatch(setMCPServers(servers)) }) -ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => { +window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => { store.dispatch(addMCPServer(server)) }) diff --git a/src/renderer/src/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts index 3bc5de12c0..1ef9b43069 100644 --- a/src/renderer/src/hooks/useMermaid.ts +++ b/src/renderer/src/hooks/useMermaid.ts @@ -1,54 +1,76 @@ import { useTheme } from '@renderer/context/ThemeProvider' -import { EventEmitter } from '@renderer/services/EventService' import { ThemeMode } from '@renderer/types' -import { loadScript, runAsyncFunction } from '@renderer/utils' -import { useEffect, useRef } from 'react' +import { useEffect, useState } from 'react' + +// 跟踪 mermaid 模块状态,单例模式 +let mermaidModule: any = null +let mermaidLoading = false +let mermaidLoadPromise: Promise | null = null + +/** + * 导入 mermaid 库 + */ +const loadMermaidModule = async () => { + if (mermaidModule) return mermaidModule + if (mermaidLoading && mermaidLoadPromise) return mermaidLoadPromise + + mermaidLoading = true + mermaidLoadPromise = import('mermaid') + .then((module) => { + mermaidModule = module.default || module + mermaidLoading = false + return mermaidModule + }) + .catch((error) => { + mermaidLoading = false + throw error + }) + + return mermaidLoadPromise +} export const useMermaid = () => { const { theme } = useTheme() - const mermaidLoaded = useRef(false) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + // 初始化 mermaid 并监听主题变化 useEffect(() => { - runAsyncFunction(async () => { - if (!window.mermaid) { - await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js') - } + let mounted = true - if (!mermaidLoaded.current) { - await window.mermaid.initialize({ - startOnLoad: false, + const initialize = async () => { + try { + setIsLoading(true) + + const mermaid = await loadMermaidModule() + + if (!mounted) return + + mermaid.initialize({ + startOnLoad: false, // 禁用自动启动 theme: theme === ThemeMode.dark ? 'dark' : 'default' }) - mermaidLoaded.current = true - EventEmitter.emit('mermaid-loaded') - } - }) - }, [theme]) - useEffect(() => { - const handleWheel = (e: WheelEvent) => { - if (e.ctrlKey || e.metaKey) { - const mermaidElement = (e.target as HTMLElement).closest('.mermaid') - if (!mermaidElement) return - - const svg = mermaidElement.querySelector('svg') - if (!svg) return - - const currentScale = parseFloat(svg.style.transform?.match(/scale\((.*?)\)/)?.[1] || '1') - const delta = e.deltaY < 0 ? 0.1 : -0.1 - const newScale = Math.max(0.1, Math.min(3, currentScale + delta)) - - const container = svg.parentElement - if (container) { - container.style.overflow = 'auto' - container.style.position = 'relative' - svg.style.transformOrigin = 'top left' - svg.style.transform = `scale(${newScale})` + setError(null) + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid') + } finally { + if (mounted) { + setIsLoading(false) } } } - document.addEventListener('wheel', handleWheel, { passive: true }) - return () => document.removeEventListener('wheel', handleWheel) - }, []) + initialize() + + return () => { + mounted = false + } + }, [theme]) + + return { + mermaid: mermaidModule, + isLoading, + error + } } diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index c0c6112df3..5418893463 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -3,7 +3,7 @@ import Logger from '@renderer/config/logger' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { estimateUserPromptUsage } from '@renderer/services/TokenService' import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store' -import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock' +import { updateOneBlock } from '@renderer/store/messageBlock' import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage' import { appendAssistantResponseThunk, @@ -13,6 +13,7 @@ import { deleteSingleMessageThunk, initiateTranslationThunk, regenerateAssistantResponseThunk, + removeBlocksThunk, resendMessageThunk, resendUserMessageWithEditThunk, updateMessageAndBlocksThunk, @@ -22,21 +23,8 @@ import type { Assistant, Model, Topic } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { abortCompletion } from '@renderer/utils/abortController' -import { findFileBlocks } from '@renderer/utils/messageUtils/find' import { useCallback } from 'react' -const findMainTextBlockId = (message: Message): string | undefined => { - if (!message || !message.blocks) return undefined - const state = store.getState() - for (const blockId of message.blocks) { - const block = messageBlocksSelectors.selectById(state, String(blockId)) - if (block && block.type === MessageBlockType.MAIN_TEXT) { - return block.id - } - } - return undefined -} - const selectMessagesState = (state: RootState) => state.messages export const selectNewTopicLoading = createSelector( @@ -113,36 +101,6 @@ export function useMessageOperations(topic: Topic) { [dispatch, topic.id] ) - /** - * 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited. - * Dispatches resendUserMessageWithEditThunk. - */ - const resendUserMessageWithEdit = useCallback( - async (message: Message, editedContent: string, assistant: Assistant) => { - const mainTextBlockId = findMainTextBlockId(message) - if (!mainTextBlockId) { - console.error('Cannot resend edited message: Main text block not found.') - return - } - - const files = findFileBlocks(message).map((block) => block.file) - - const usage = await estimateUserPromptUsage({ content: editedContent, files }) - const messageUpdates: Partial & Pick = { - id: message.id, - updatedAt: new Date().toISOString(), - usage - } - - await dispatch( - newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates }) - ) - // 对于message的修改会在下面的thunk中保存 - await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant)) - }, - [dispatch, topic.id] - ) - /** * 清除当前或指定主题的所有消息。 / Clears all messages for the current or specified topic. * Dispatches clearTopicMessagesThunk. @@ -309,29 +267,127 @@ export function useMessageOperations(topic: Topic) { ) /** - * Updates properties of specific message blocks (e.g., content). - * Uses the generalized thunk for persistence. + * Updates message blocks by comparing original and edited blocks. + * Handles adding, updating, and removing blocks in a single operation. + * @param messageId The ID of the message to update + * @param editedBlocks The complete set of blocks after editing */ const editMessageBlocks = useCallback( - async (messageId: string, updates: Partial) => { + async (messageId: string, editedBlocks: MessageBlock[]) => { if (!topic?.id) { console.error('[editMessageBlocks] Topic prop is not valid.') return } - const blockUpdatesListProcessed = { - updatedAt: new Date().toISOString(), - ...updates - } + try { + // 1. Get the current state of the message and its blocks + const state = store.getState() + const message = state.messages.entities[messageId] + if (!message) { + console.error('[editMessageBlocks] Message not found:', messageId) + return + } - const messageUpdates: Partial & Pick = { - id: messageId, - updatedAt: new Date().toISOString() - } + // 2. Get all original blocks + const originalBlocks = message.blocks + ? (message.blocks + .map((blockId) => state.messageBlocks.entities[blockId]) + .filter((block) => block !== undefined) as MessageBlock[]) + : [] - await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed])) + // 3. Create sets for efficient comparison + const originalBlockIds = new Set(originalBlocks.map((block) => block.id)) + const editedBlockIds = new Set(editedBlocks.map((block) => block.id)) + + // 4. Identify blocks to remove, update, and add + const blockIdsToRemove = originalBlocks + .filter((block) => !editedBlockIds.has(block.id)) + .map((block) => block.id) + + const blocksToUpdate = editedBlocks + .filter((block) => originalBlockIds.has(block.id)) + .map((block) => ({ + ...block, + updatedAt: new Date().toISOString() + })) + + const blocksToAdd = editedBlocks + .filter((block) => !originalBlockIds.has(block.id)) + .map((block) => ({ + ...block, + updatedAt: new Date().toISOString() + })) + + // 5. Prepare message update with new block IDs + const updatedBlockIds = editedBlocks.map((block) => block.id) + const messageUpdates: Partial & Pick = { + id: messageId, + updatedAt: new Date().toISOString(), + blocks: updatedBlockIds + } + + // 6. Log operations for debugging + console.log('[editMessageBlocks] Operations:', { + blocksToRemove: blockIdsToRemove.length, + blocksToUpdate: blocksToUpdate.length, + blocksToAdd: blocksToAdd.length + }) + + // 7. Update Redux state and database + // First update message and add/update blocks + if (blocksToAdd.length > 0) { + await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToAdd)) + } + + if (blocksToUpdate.length > 0) { + await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToUpdate)) + } + + // Then remove blocks if needed + if (blockIdsToRemove.length > 0) { + await dispatch(removeBlocksThunk(topic.id, messageId, blockIdsToRemove)) + } + } catch (error) { + console.error('[editMessageBlocks] Failed to update message blocks:', error) + } }, - [dispatch, topic.id] + [dispatch, topic?.id] + ) + + /** + * 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited. + * Dispatches resendUserMessageWithEditThunk. + */ + const resendUserMessageWithEdit = useCallback( + async (message: Message, editedBlocks: MessageBlock[], assistant: Assistant) => { + await editMessageBlocks(message.id, editedBlocks) + + const mainTextBlock = editedBlocks.find((block) => block.type === MessageBlockType.MAIN_TEXT) + if (!mainTextBlock) { + console.error('[resendUserMessageWithEdit] Main text block not found in edited blocks') + return + } + + const fileBlocks = editedBlocks.filter( + (block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE + ) + + const files = fileBlocks.map((block) => block.file).filter((file) => file !== undefined) + + const usage = await estimateUserPromptUsage({ content: mainTextBlock.content, files }) + const messageUpdates: Partial & Pick = { + id: message.id, + updatedAt: new Date().toISOString(), + usage + } + + await dispatch( + newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates }) + ) + // 对于message的修改会在下面的thunk中保存 + await dispatch(resendUserMessageWithEditThunk(topic.id, message, assistant)) + }, + [dispatch, editMessageBlocks, topic.id] ) /** diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts index e5c5adf4f7..578246cc06 100644 --- a/src/renderer/src/hooks/useMinappPopup.ts +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -8,6 +8,7 @@ import { setOpenedOneOffMinapp } from '@renderer/store/runtime' import { MinAppType } from '@renderer/types' +import { useCallback } from 'react' /** * Usage: @@ -29,74 +30,86 @@ export const useMinappPopup = () => { const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值 /** Open a minapp (popup shows and minapp loaded) */ - const openMinapp = (app: MinAppType, keepAlive: boolean = false) => { - if (keepAlive) { - // 如果小程序已经打开,只切换显示 - if (openedKeepAliveMinapps.some((item) => item.id === app.id)) { + const openMinapp = useCallback( + (app: MinAppType, keepAlive: boolean = false) => { + if (keepAlive) { + // 如果小程序已经打开,只切换显示 + if (openedKeepAliveMinapps.some((item) => item.id === app.id)) { + dispatch(setCurrentMinappId(app.id)) + dispatch(setMinappShow(true)) + return + } + + // 如果缓存数量未达上限,添加到缓存列表 + if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) { + dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps])) + } else { + // 缓存数量达到上限,移除最后一个,添加新的 + dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)])) + } + + dispatch(setOpenedOneOffMinapp(null)) dispatch(setCurrentMinappId(app.id)) dispatch(setMinappShow(true)) return } - // 如果缓存数量未达上限,添加到缓存列表 - if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) { - dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps])) - } else { - // 缓存数量达到上限,移除最后一个,添加新的 - dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)])) - } - - dispatch(setOpenedOneOffMinapp(null)) + //if the minapp is not keep alive, open it as one-off minapp + dispatch(setOpenedOneOffMinapp(app)) dispatch(setCurrentMinappId(app.id)) dispatch(setMinappShow(true)) return - } - - //if the minapp is not keep alive, open it as one-off minapp - dispatch(setOpenedOneOffMinapp(app)) - dispatch(setCurrentMinappId(app.id)) - dispatch(setMinappShow(true)) - return - } + }, + [dispatch, maxKeepAliveMinapps, openedKeepAliveMinapps] + ) /** a wrapper of openMinapp(app, true) */ - const openMinappKeepAlive = (app: MinAppType) => { - openMinapp(app, true) - } + const openMinappKeepAlive = useCallback( + (app: MinAppType) => { + openMinapp(app, true) + }, + [openMinapp] + ) /** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */ - const openMinappById = (id: string, keepAlive: boolean = false) => { - import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => { - const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) - if (app) { - openMinapp(app, keepAlive) - } - }) - } + const openMinappById = useCallback( + (id: string, keepAlive: boolean = false) => { + import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => { + const app = DEFAULT_MIN_APPS.find((app) => app?.id === id) + if (app) { + openMinapp(app, keepAlive) + } + }) + }, + [openMinapp] + ) /** Close a minapp immediately (popup hides and minapp unloaded) */ - const closeMinapp = (appid: string) => { - if (openedKeepAliveMinapps.some((item) => item.id === appid)) { - dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid))) - } else if (openedOneOffMinapp?.id === appid) { - dispatch(setOpenedOneOffMinapp(null)) - } + const closeMinapp = useCallback( + (appid: string) => { + if (openedKeepAliveMinapps.some((item) => item.id === appid)) { + dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid))) + } else if (openedOneOffMinapp?.id === appid) { + dispatch(setOpenedOneOffMinapp(null)) + } - dispatch(setCurrentMinappId('')) - dispatch(setMinappShow(false)) - return - } + dispatch(setCurrentMinappId('')) + dispatch(setMinappShow(false)) + return + }, + [dispatch, openedKeepAliveMinapps, openedOneOffMinapp] + ) /** Close all minapps (popup hides and all minapps unloaded) */ - const closeAllMinapps = () => { + const closeAllMinapps = useCallback(() => { dispatch(setOpenedKeepAliveMinapps([])) dispatch(setOpenedOneOffMinapp(null)) dispatch(setCurrentMinappId('')) dispatch(setMinappShow(false)) - } + }, [dispatch]) /** Hide the minapp popup (only one-off minapp unloaded) */ - const hideMinappPopup = () => { + const hideMinappPopup = useCallback(() => { if (!minappShow) return if (openedOneOffMinapp) { @@ -104,7 +117,7 @@ export const useMinappPopup = () => { dispatch(setCurrentMinappId('')) } dispatch(setMinappShow(false)) - } + }, [dispatch, minappShow, openedOneOffMinapp]) return { openMinapp, diff --git a/src/renderer/src/hooks/useScrollPosition.ts b/src/renderer/src/hooks/useScrollPosition.ts index ddcd54028f..b3f4b5d512 100644 --- a/src/renderer/src/hooks/useScrollPosition.ts +++ b/src/renderer/src/hooks/useScrollPosition.ts @@ -7,7 +7,9 @@ export default function useScrollPosition(key: string) { const handleScroll = throttle(() => { const position = containerRef.current?.scrollTop ?? 0 - window.keyv.set(scrollKey, position) + window.requestAnimationFrame(() => { + window.keyv.set(scrollKey, position) + }) }, 100) useEffect(() => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d3ba296a58..0536ced621 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -131,7 +131,10 @@ "manage": "Manage", "select_model": "Select Model", "show.all": "Show All", - "update_available": "Update Available" + "update_available": "Update Available", + "includes_user_questions": "Include Your Questions", + "case_sensitive": "Case Sensitive", + "whole_word": "Whole Word" }, "chat": { "add.assistant.title": "Add Assistant", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "Estimated tokens", "input.expand": "Expand", "input.file_not_supported": "Model does not support this file type", + "input.file_error": "Error processing file", "input.generate_image": "Generate image", "input.generate_image_not_supported": "The model does not support generating images.", "input.knowledge_base": "Knowledge Base", @@ -199,6 +203,20 @@ }, "resend": "Resend", "save": "Save", + "settings.code.title": "Code Block Settings", + "settings.code_editor": { + "title": "Code Editor", + "highlight_active_line": "Highlight active line", + "fold_gutter": "Fold gutter", + "autocompletion": "Autocompletion", + "keymap": "Keymap" + }, + "settings.code_execution": { + "title": "Code Execution", + "tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!", + "timeout_minutes": "Timeout", + "timeout_minutes.tip": "The timeout time (minutes) of code execution" + }, "settings.code_collapsible": "Code block collapsible", "settings.code_wrappable": "Code block wrappable", "settings.code_cacheable": "Code block cache", @@ -304,9 +322,32 @@ }, "code_block": { "collapse": "Collapse", - "disable_wrap": "Unwrap", - "enable_wrap": "Wrap", - "expand": "Expand" + "copy.failed": "Copy failed", + "copy.source": "Copy Source Code", + "copy.success": "Copied", + "copy": "Copy", + "download.failed.network": "Download failed, please check the network", + "download.png": "Download PNG", + "download.source": "Download Source Code", + "download.svg": "Download SVG", + "download": "Download", + "edit.save.failed.message_not_found": "Save failed, message not found", + "edit.save.failed": "Save failed", + "edit.save.success": "Saved", + "edit.save": "Save Changes", + "edit": "Edit", + "expand": "Expand", + "more": "More", + "preview.copy.image": "Copy as image", + "preview.source": "View Source Code", + "preview.zoom_in": "Zoom In", + "preview.zoom_out": "Zoom Out", + "preview": "Preview", + "run": "Run", + "split.restore": "Restore Split View", + "split": "Split View", + "wrap.off": "Unwrap", + "wrap.on": "Wrap" }, "common": { "add": "Add", @@ -360,7 +401,8 @@ "pinyin": "Sort by Pinyin", "pinyin.asc": "Sort by Pinyin (A-Z)", "pinyin.desc": "Sort by Pinyin (Z-A)" - } + }, + "no_results": "No results" }, "docs": { "title": "Docs" @@ -527,21 +569,6 @@ "keep_alive_time.title": "Keep Alive Time", "title": "LM Studio" }, - "mermaid": { - "download": { - "png": "Download PNG", - "svg": "Download SVG" - }, - "resize": { - "zoom-in": "Zoom In", - "zoom-out": "Zoom Out" - }, - "tabs": { - "preview": "Preview", - "source": "Source" - }, - "title": "Mermaid Diagram" - }, "message": { "agents": { "imported": "Imported successfully", @@ -564,6 +591,7 @@ "copied": "Copied!", "copy.failed": "Copy failed", "copy.success": "Copied!", + "empty_url": "Failed to download image, possibly due to prompt containing sensitive content or prohibited words", "error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size", "error.dimension_too_large": "Content size is too large", "error.enter.api.host": "Please enter your API host first", @@ -775,6 +803,7 @@ "model": "Model Version", "aspect_ratio": "Aspect Ratio", "style_type": "Style", + "rendering_speed": "Rendering Speed", "learn_more": "Learn More", "prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap", "proxy_required": "Currently, you need to open a proxy to view the generated images, it will be supported in the future", @@ -782,6 +811,20 @@ "image_file_retry": "Please re-upload an image first", "image_placeholder": "No image available", "image_retry": "Retry", + "translating": "Translating...", + "style_types": { + "auto": "Auto", + "general": "General", + "realistic": "Realistic", + "design": "Design", + "3d": "3D", + "anime": "Anime" + }, + "rendering_speeds": { + "default": "Default", + "turbo": "Turbo", + "quality": "Quality" + }, "mode": { "generate": "Draw", "edit": "Edit", @@ -789,20 +832,22 @@ "upscale": "Upscale" }, "generate": { - "model_tip": "Model version: V2 is the latest model of the interface, V2A is the fast model, V_1 is the first-generation model, _TURBO is the acceleration version", + "model_tip": "Model version: V3 is the latest version, V2 is the previous model, V2A is the fast model, V_1 is the first-generation model, _TURBO is the acceleration version", "number_images_tip": "Number of images to generate", "seed_tip": "Controls image generation randomness for reproducible results", "negative_prompt_tip": "Describe unwanted elements, only for V_1, V_1_TURBO, V_2, and V_2_TURBO", "magic_prompt_option_tip": "Intelligently enhances prompts for better results", - "style_type_tip": "Image generation style for V_2 and above" + "style_type_tip": "Image generation style for V_2 and above", + "rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3" }, "edit": { "image_file": "Edited Image", - "model_tip": "Only supports V_2 and V_2_TURBO versions", + "model_tip": "V3 and V2 versions supported", "number_images_tip": "Number of edited results to generate", "style_type_tip": "Style for edited image, only for V_2 and above", "seed_tip": "Controls editing randomness", - "magic_prompt_option_tip": "Intelligently enhances editing prompts" + "magic_prompt_option_tip": "Intelligently enhances editing prompts", + "rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3" }, "remix": { "model_tip": "Select AI model version for remixing", @@ -813,7 +858,8 @@ "seed_tip": "Control the randomness of the mixed result", "style_type_tip": "Style for remixed image, only for V_2 and above", "negative_prompt_tip": "Describe unwanted elements in remix results", - "magic_prompt_option_tip": "Intelligently enhances remix prompts" + "magic_prompt_option_tip": "Intelligently enhances remix prompts", + "rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3" }, "upscale": { "image_file": "Image to upscale", @@ -826,18 +872,6 @@ "magic_prompt_option_tip": "Intelligently enhances upscaling prompts" } }, - "plantuml": { - "download": { - "failed": "Download failed, please check the network", - "png": "Download PNG", - "svg": "Download SVG" - }, - "tabs": { - "preview": "Preview", - "source": "Source" - }, - "title": "PlantUML Diagram" - }, "prompts": { "explanation": "Explain this concept to me", "summarize": "Summarize this text", @@ -946,6 +980,8 @@ "app_knowledge.remove_all_confirm": "Deleting knowledge base files will reduce the storage space occupied, but will not delete the knowledge base vector data, after deletion, the source file will no longer be able to be opened. Continue?", "app_knowledge.remove_all_success": "Files removed successfully", "app_logs": "App Logs", + "backup.skip_file_data_title": "Slim Backup", + "backup.skip_file_data_help": "Skip backing up data files such as pictures and knowledge bases during backup, and only back up chat records and settings. Reduce space occupancy and speed up the backup speed.", "clear_cache": { "button": "Clear Cache", "confirm": "Clearing the cache will delete application cache data, including minapp data. This action is irreversible, continue?", @@ -1436,6 +1472,8 @@ "models.translate_model_description": "Model used for translation service", "models.translate_model_prompt_message": "Please enter the translate model prompt", "models.translate_model_prompt_title": "Translate Model Prompt", + "models.quick_assistant_model": "Quick Assistant Model", + "models.quick_assistant_model_description": "Default model used by Quick Assistant", "moresetting": "More Settings", "moresetting.check.confirm": "Confirm Selection", "moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!", @@ -1545,6 +1583,7 @@ "reset_defaults_confirm": "Are you sure you want to reset all shortcuts?", "reset_to_default": "Reset to Default", "search_message": "Search Message", + "search_message_in_chat": "Search Message in Current Chat", "show_app": "Show/Hide App", "show_settings": "Open Settings", "title": "Keyboard Shortcuts", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9e20973ca6..3e16858f80 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -131,7 +131,10 @@ "manage": "管理", "select_model": "モデルを選択", "show.all": "すべて表示", - "update_available": "更新可能" + "update_available": "更新可能", + "includes_user_questions": "ユーザーからの質問を含む", + "case_sensitive": "大文字と小文字の区別", + "whole_word": "全語一致" }, "chat": { "add.assistant.title": "アシスタントを追加", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "推定トークン数", "input.expand": "展開", "input.file_not_supported": "モデルはこのファイルタイプをサポートしません", + "input.file_error": "ファイル処理エラー", "input.generate_image": "画像を生成する", "input.generate_image_not_supported": "モデルは画像の生成をサポートしていません。", "input.knowledge_base": "ナレッジベース", @@ -199,6 +203,20 @@ }, "resend": "再送信", "save": "保存", + "settings.code.title": "コード設定", + "settings.code_editor": { + "title": "コードエディター", + "highlight_active_line": "アクティブ行をハイライト", + "fold_gutter": "折りたたみガター", + "autocompletion": "自動補完", + "keymap": "キーマップ" + }, + "settings.code_execution": { + "title": "コード実行", + "tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!", + "timeout_minutes": "タイムアウト時間", + "timeout_minutes.tip": "コード実行のタイムアウト時間(分)" + }, "settings.code_collapsible": "コードブロック折り畳み", "settings.code_wrappable": "コードブロック折り返し", "settings.code_cacheable": "コードブロックキャッシュ", @@ -304,9 +322,32 @@ }, "code_block": { "collapse": "折りたたむ", - "disable_wrap": "改行解除", - "enable_wrap": "改行", - "expand": "展開する" + "copy.failed": "コピーに失敗しました", + "copy.source": "コピー源コード", + "copy.success": "コピーしました", + "copy": "コピー", + "download.failed.network": "ダウンロードに失敗しました。ネットワークを確認してください", + "download.png": "PNGとしてダウンロード", + "download.source": "ダウンロード源コード", + "download.svg": "SVGとしてダウンロード", + "download": "ダウンロード", + "edit.save.failed.message_not_found": "保存に失敗しました。対応するメッセージが見つかりませんでした", + "edit.save.failed": "保存に失敗しました", + "edit.save.success": "保存しました", + "edit.save": "保存する", + "edit": "編集", + "expand": "展開する", + "more": "もっと", + "preview.copy.image": "画像としてコピー", + "preview.source": "ソースコードを表示", + "preview.zoom_in": "拡大", + "preview.zoom_out": "縮小", + "preview": "プレビュー", + "run": "コードを実行", + "split.restore": "分割視圖を解除", + "split": "分割視圖", + "wrap.off": "改行解除", + "wrap.on": "改行" }, "common": { "add": "追加", @@ -360,7 +401,8 @@ "pinyin": "ピンインでソート", "pinyin.asc": "ピンインで昇順ソート", "pinyin.desc": "ピンインで降順ソート" - } + }, + "no_results": "検索結果なし" }, "docs": { "title": "ドキュメント" @@ -527,21 +569,6 @@ "keep_alive_time.title": "保持時間", "title": "LM Studio" }, - "mermaid": { - "download": { - "png": "PNGをダウンロード", - "svg": "SVGをダウンロード" - }, - "resize": { - "zoom-in": "拡大する", - "zoom-out": "ズームアウト" - }, - "tabs": { - "preview": "プレビュー", - "source": "ソース" - }, - "title": "Mermaid図" - }, "message": { "agents": { "imported": "インポートに成功しました", @@ -564,7 +591,8 @@ "copied": "コピーしました!", "copy.failed": "コピーに失敗しました", "copy.success": "コピーしました!", - "error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません", + "empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります", + "error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません", "error.dimension_too_large": "内容のサイズが大きすぎます", "error.enter.api.host": "APIホストを入力してください", "error.enter.api.key": "APIキーを入力してください", @@ -782,6 +810,19 @@ "image_file_retry": "画像を先にアップロードしてください", "image_placeholder": "画像がありません", "image_retry": "再試行", + "style_types": { + "auto": "自動", + "general": "一般", + "realistic": "リアル", + "design": "デザイン", + "3d": "3D", + "anime": "アニメ" + }, + "rendering_speeds": { + "default": "デフォルト", + "turbo": "高速", + "quality": "高品質" + }, "mode": { "generate": "画像生成", "edit": "部分編集", @@ -794,7 +835,8 @@ "seed_tip": "画像生成のランダム性を制御して、同じ生成結果を再現します", "negative_prompt_tip": "画像に含めたくない内容を説明します", "magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します", - "style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用" + "style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用", + "rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です" }, "edit": { "image_file": "編集画像", @@ -802,7 +844,8 @@ "number_images_tip": "生成される編集結果の数", "style_type_tip": "編集後の画像スタイル、V_2 以上のバージョンでのみ適用", "seed_tip": "編集結果のランダム性を制御します", - "magic_prompt_option_tip": "編集効果を向上させるための提示詞を最適化します" + "magic_prompt_option_tip": "編集効果を向上させるための提示詞を最適化します", + "rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です" }, "remix": { "model_tip": "リミックスに使用する AI モデルのバージョンを選択します", @@ -813,7 +856,8 @@ "seed_tip": "リミックス結果のランダム性を制御します", "style_type_tip": "リミックス後の画像スタイル、V_2 以上のバージョンでのみ適用", "negative_prompt_tip": "リミックス結果に含めたくない内容を説明します", - "magic_prompt_option_tip": "リミックス効果を向上させるための提示詞を最適化します" + "magic_prompt_option_tip": "リミックス効果を向上させるための提示詞を最適化します", + "rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です" }, "upscale": { "image_file": "拡大する画像", @@ -824,19 +868,9 @@ "number_images_tip": "生成される拡大結果の数", "seed_tip": "拡大結果のランダム性を制御します", "magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します" - } - }, - "plantuml": { - "download": { - "failed": "ダウンロードに失敗しました。ネットワークを確認してください", - "png": "PNG をダウンロード", - "svg": "SVG をダウンロード" }, - "tabs": { - "preview": "プレビュー", - "source": "ソースコード" - }, - "title": "PlantUML 図表" + "rendering_speed": "レンダリング速度", + "translating": "翻訳中..." }, "prompts": { "explanation": "この概念を説明してください", @@ -943,6 +977,8 @@ "app_knowledge.remove_all": "ナレッジベースファイルを削除", "app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?", "app_knowledge.remove_all_success": "ファイル削除成功", + "backup.skip_file_data_title": "精簡バックアップ", + "backup.skip_file_data_help": "バックアップ時に、画像や知識ベースなどのデータファイルをバックアップ対象から除外し、チャット履歴と設定のみをバックアップします。スペースの占有を減らし、バックアップ速度を向上させます。", "app_logs": "アプリログ", "clear_cache": { "button": "キャッシュをクリア", @@ -1432,6 +1468,8 @@ "models.translate_model_description": "翻訳サービスに使用されるモデル", "models.translate_model_prompt_message": "翻訳モデルのプロンプトを入力してください", "models.translate_model_prompt_title": "翻訳モデルのプロンプト", + "models.quick_assistant_model": "クイックアシスタントモデル", + "models.quick_assistant_model_description": "クイックアシスタントで使用されるデフォルトモデル", "moresetting": "詳細設定", "moresetting.check.confirm": "選択を確認", "moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!", @@ -1541,6 +1579,7 @@ "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?", "reset_to_default": "デフォルトにリセット", "search_message": "メッセージを検索", + "search_message_in_chat": "現在のチャットでメッセージを検索", "show_app": "アプリを表示/非表示", "show_settings": "設定を開く", "title": "ショートカット", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index f24f3510f7..cb182f71a9 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -131,7 +131,10 @@ "manage": "Редактировать", "select_model": "Выбрать модель", "show.all": "Показать все", - "update_available": "Доступно обновление" + "update_available": "Доступно обновление", + "includes_user_questions": "Включает вопросы пользователей", + "case_sensitive": "Чувствительность к регистру", + "whole_word": "Полное слово" }, "chat": { "add.assistant.title": "Добавить ассистента", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "Затраты токенов", "input.expand": "Развернуть", "input.file_not_supported": "Модель не поддерживает этот тип файла", + "input.file_error": "Ошибка обработки файла", "input.generate_image": "Сгенерировать изображение", "input.generate_image_not_supported": "Модель не поддерживает генерацию изображений.", "input.knowledge_base": "База знаний", @@ -199,6 +203,20 @@ }, "resend": "Переотправить", "save": "Сохранить", + "settings.code.title": "Настройки кода", + "settings.code_editor": { + "title": "Редактор кода", + "highlight_active_line": "Выделить активную строку", + "fold_gutter": "Свернуть", + "autocompletion": "Автодополнение", + "keymap": "Клавиатурные сокращения" + }, + "settings.code_execution": { + "title": "Выполнение кода", + "tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!", + "timeout_minutes": "Время выполнения", + "timeout_minutes.tip": "Время выполнения кода (минуты)" + }, "settings.code_collapsible": "Блок кода свернут", "settings.code_wrappable": "Блок кода можно переносить", "settings.code_cacheable": "Кэш блока кода", @@ -304,9 +322,32 @@ }, "code_block": { "collapse": "Свернуть", - "disable_wrap": "Отменить перенос строки", - "enable_wrap": "Перенос строки", - "expand": "Развернуть" + "copy.failed": "Не удалось скопировать", + "copy.source": "Копировать исходный код", + "copy.success": "Скопировано", + "copy": "Копировать", + "download.failed.network": "Не удалось скачать. Пожалуйста, проверьте ваше интернет-соединение", + "download.png": "Скачать PNG", + "download.source": "Скачать исходный код", + "download.svg": "Скачать SVG", + "download": "Скачать", + "edit.save.failed.message_not_found": "Не удалось сохранить изменения, не найдено сообщение", + "edit.save.failed": "Не удалось сохранить изменения", + "edit.save.success": "Изменения сохранены", + "edit.save": "Сохранить изменения", + "edit": "Редактировать", + "expand": "Развернуть", + "more": "Ещё", + "preview.copy.image": "Скопировать как изображение", + "preview.source": "Смотреть исходный код", + "preview.zoom_in": "Увеличить", + "preview.zoom_out": "Уменьшить", + "preview": "Предварительный просмотр", + "run": "Выполнить код", + "split.restore": "Вернуться к одному окну", + "split": "Разделить на два окна", + "wrap.off": "Отменить перенос строки", + "wrap.on": "Перенос строки" }, "common": { "add": "Добавить", @@ -360,7 +401,8 @@ "pinyin": "Сортировать по пиньинь", "pinyin.asc": "Сортировать по пиньинь (А-Я)", "pinyin.desc": "Сортировать по пиньинь (Я-А)" - } + }, + "no_results": "Результатов не найдено" }, "docs": { "title": "Документация" @@ -527,21 +569,6 @@ "keep_alive_time.title": "Время жизни модели", "title": "LM Studio" }, - "mermaid": { - "download": { - "png": "Скачать PNG", - "svg": "Скачать SVG" - }, - "resize": { - "zoom-in": "Yвеличить", - "zoom-out": "Yменьшить масштаб" - }, - "tabs": { - "preview": "Предпросмотр", - "source": "Исходный код" - }, - "title": "Диаграмма Mermaid" - }, "message": { "agents": { "imported": "Импорт успешно выполнен", @@ -564,7 +591,8 @@ "copied": "Скопировано!", "copy.failed": "Не удалось скопировать", "copy.success": "Скопировано!", - "error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента.", + "empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова", + "error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента", "error.dimension_too_large": "Размер содержимого слишком велик", "error.enter.api.host": "Пожалуйста, введите ваш API хост", "error.enter.api.key": "Пожалуйста, введите ваш API ключ", @@ -781,7 +809,21 @@ "image_file_required": "Пожалуйста, сначала загрузите изображение", "image_file_retry": "Пожалуйста, сначала загрузите изображение", "image_placeholder": "Изображение недоступно", - "image_retry": "Попробовать снова", + "image_retry": "Повторить", + "translating": "Перевод...", + "style_types": { + "auto": "Авто", + "general": "Общий", + "realistic": "Реалистичный", + "design": "Дизайн", + "3d": "3D", + "anime": "Аниме" + }, + "rendering_speeds": { + "default": "По умолчанию", + "turbo": "Быстро", + "quality": "Качественно" + }, "mode": { "generate": "Рисование", "edit": "Редактирование", @@ -789,31 +831,34 @@ "upscale": "Увеличение" }, "generate": { - "model_tip": "Версия модели: V2 — последняя модель интерфейса, V2A — быстрая модель, V_1 — первая модель, _TURBO — ускоренная версия", - "number_images_tip": "Количество изображений для генерации", - "seed_tip": "Контролирует случайный характер генерации изображений для воспроизводимых результатов", - "negative_prompt_tip": "Опишите элементы, которые вы не хотите включать в изображение, поддерживаются только версии V_1, V_1_TURBO, V_2 и V_2_TURBO", - "magic_prompt_option_tip": "Улучшает генерацию изображений с помощью интеллектуального оптимизирования промптов", - "style_type_tip": "Стиль генерации изображений, поддерживается только для версий V_2 и выше" + "model_tip": "Версия модели: V2 - новейшая API модель, V2A - быстрая модель, V_1 - первое поколение, _TURBO - ускоренная версия", + "number_images_tip": "Количество изображений для одновременной генерации", + "seed_tip": "Контролирует случайность генерации изображений для воспроизведения одинаковых результатов", + "negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении", + "magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации", + "style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше", + "rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3" }, "edit": { - "image_file": "Редактируемое изображение", + "image_file": "Изображение для редактирования", "model_tip": "Частичное редактирование поддерживается только версиями V_2 и V_2_TURBO", - "number_images_tip": "Количество редактированных результатов для генерации", - "style_type_tip": "Стиль редактированного изображения, поддерживается только для версий V_2 и выше", - "seed_tip": "Контролирует случайный характер редактирования изображений для воспроизводимых результатов", - "magic_prompt_option_tip": "Улучшает редактирование изображений с помощью интеллектуального оптимизирования промптов" + "number_images_tip": "Количество результатов редактирования для генерации", + "style_type_tip": "Стиль изображения после редактирования, доступен только для версий V_2 и выше", + "seed_tip": "Контролирует случайность результатов редактирования", + "magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта редактирования", + "rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3" }, "remix": { - "model_tip": "Выберите версию AI-модели для перемешивания", - "image_file": "Ссылка на изображение", - "image_weight": "Вес изображения", - "image_weight_tip": "Насколько сильно влияние изображения на результат", - "number_images_tip": "Количество перемешанных результатов для генерации", - "seed_tip": "Контролирует случайный характер перемешивания изображений для воспроизводимых результатов", - "style_type_tip": "Стиль перемешанного изображения, поддерживается только для версий V_2 и выше", - "negative_prompt_tip": "Опишите элементы, которые вы не хотите включать в изображение", - "magic_prompt_option_tip": "Улучшает перемешивание изображений с помощью интеллектуального оптимизирования промптов" + "model_tip": "Выберите версию AI модели для ремикса", + "image_file": "Референсное изображение", + "image_weight": "Вес референсного изображения", + "image_weight_tip": "Регулирует степень влияния референсного изображения", + "number_images_tip": "Количество результатов ремикса для генерации", + "seed_tip": "Контролирует случайность результатов ремикса", + "style_type_tip": "Стиль изображения после ремикса, доступен только для версий V_2 и выше", + "negative_prompt_tip": "Описывает, что вы не хотите видеть в результатах ремикса", + "magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта ремикса", + "rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3" }, "upscale": { "image_file": "Изображение для увеличения", @@ -826,18 +871,6 @@ "magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов" } }, - "plantuml": { - "download": { - "failed": "下载失败,请检查网络", - "png": "下载 PNG", - "svg": "下载 SVG" - }, - "tabs": { - "preview": "Предпросмотр", - "source": "Исходный код" - }, - "title": "PlantUML 图表" - }, "prompts": { "explanation": "Объясните мне этот концепт", "summarize": "Суммируйте этот текст", @@ -894,6 +927,7 @@ "restore": { "confirm": "Вы уверены, что хотите восстановить данные?", "confirm.button": "Выбрать файл резервной копии", + "content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.", "progress": { "completed": "Восстановление завершено", @@ -944,6 +978,8 @@ "app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?", "app_knowledge.remove_all_success": "Файлы удалены успешно", "app_logs": "Логи приложения", + "backup.skip_file_data_title": "Упрощенная резервная копия", + "backup.skip_file_data_help": "Пропустить при резервном копировании такие данные, как изображения, базы знаний и другие файлы данных, и сделать резервную копию только переписки и настроек. Это уменьшает использование места на диске и ускоряет процесс резервного копирования.", "clear_cache": { "button": "Очистка кэша", "confirm": "Очистка кэша удалит данные приложения. Это действие необратимо, продолжить?", @@ -1432,6 +1468,8 @@ "models.translate_model_description": "Модель, используемая для сервиса перевода", "models.translate_model_prompt_message": "Введите модель перевода", "models.translate_model_prompt_title": "Модель перевода", + "models.quick_assistant_model": "Модель быстрого помощника", + "models.quick_assistant_model_description": "Модель по умолчанию, используемая быстрым помощником", "moresetting": "Дополнительные настройки", "moresetting.check.confirm": "Подтвердить выбор", "moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!", @@ -1541,6 +1579,7 @@ "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", "reset_to_default": "Сбросить настройки по умолчанию", "search_message": "Поиск сообщения", + "search_message_in_chat": "Поиск сообщения в текущем диалоге", "show_app": "Показать/скрыть приложение", "show_settings": "Открыть настройки", "title": "Горячие клавиши", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ee836bea3b..1493d0d249 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -131,7 +131,10 @@ "manage": "管理", "select_model": "选择模型", "show.all": "显示全部", - "update_available": "有可用更新" + "update_available": "有可用更新", + "includes_user_questions": "包含用户提问", + "case_sensitive": "区分大小写", + "whole_word": "全字匹配" }, "chat": { "add.assistant.title": "添加助手", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "预估 token 数", "input.expand": "展开", "input.file_not_supported": "模型不支持此文件类型", + "input.file_error": "文件处理出错", "input.generate_image": "生成图片", "input.generate_image_not_supported": "模型不支持生成图片", "input.knowledge_base": "知识库", @@ -213,6 +217,20 @@ }, "resend": "重新发送", "save": "保存", + "settings.code.title": "代码块设置", + "settings.code_editor": { + "title": "代码编辑器", + "highlight_active_line": "高亮当前行", + "fold_gutter": "折叠控件", + "autocompletion": "自动补全", + "keymap": "快捷键" + }, + "settings.code_execution": { + "title": "代码执行", + "tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!", + "timeout_minutes": "超时时间", + "timeout_minutes.tip": "代码执行超时时间(分钟)" + }, "settings.code_collapsible": "代码块可折叠", "settings.code_wrappable": "代码块可换行", "settings.code_cacheable": "代码块缓存", @@ -304,9 +322,32 @@ }, "code_block": { "collapse": "收起", - "disable_wrap": "取消换行", - "enable_wrap": "换行", - "expand": "展开" + "copy.failed": "复制失败", + "copy.source": "复制源代码", + "copy.success": "复制成功", + "copy": "复制", + "download.failed.network": "下载失败,请检查网络", + "download.png": "下载 PNG", + "download.source": "下载源代码", + "download.svg": "下载 SVG", + "download": "下载", + "edit.save.failed.message_not_found": "保存失败,没有找到对应的消息", + "edit.save.failed": "保存失败", + "edit.save.success": "已保存", + "edit.save": "保存修改", + "edit": "编辑", + "expand": "展开", + "more": "更多", + "preview.copy.image": "复制为图片", + "preview.source": "查看源代码", + "preview.zoom_in": "放大", + "preview.zoom_out": "缩小", + "preview": "预览", + "run": "运行代码", + "split.restore": "取消分割视图", + "split": "分割视图", + "wrap.off": "取消换行", + "wrap.on": "换行" }, "common": { "add": "添加", @@ -360,7 +401,8 @@ "pinyin": "按拼音排序", "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" - } + }, + "no_results": "无结果" }, "docs": { "title": "帮助文档" @@ -527,21 +569,6 @@ "keep_alive_time.title": "保持活跃时间", "title": "LM Studio" }, - "mermaid": { - "download": { - "png": "下载 PNG", - "svg": "下载 SVG" - }, - "resize": { - "zoom-in": "放大", - "zoom-out": "缩小" - }, - "tabs": { - "preview": "预览", - "source": "源码" - }, - "title": "Mermaid 图表" - }, "message": { "agents": { "imported": "导入成功", @@ -564,6 +591,7 @@ "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", + "empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇", "error.chunk_overlap_too_large": "分段重叠不能大于分段大小", "error.dimension_too_large": "内容尺寸过大", "error.enter.api.host": "请输入您的 API 地址", @@ -775,6 +803,7 @@ "model": "版本", "aspect_ratio": "画幅比例", "style_type": "风格", + "rendering_speed": "渲染速度", "learn_more": "了解更多", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", "proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连", @@ -782,6 +811,20 @@ "image_file_retry": "请重新上传图片", "image_placeholder": "暂无图片", "image_retry": "重试", + "translating": "翻译中...", + "style_types": { + "auto": "自动", + "general": "通用", + "realistic": "写实", + "design": "设计", + "3d": "3D", + "anime": "动漫" + }, + "rendering_speeds": { + "default": "默认", + "turbo": "快速", + "quality": "高质量" + }, "mode": { "generate": "绘图", "edit": "编辑", @@ -789,20 +832,22 @@ "upscale": "放大" }, "generate": { - "model_tip": "模型版本:V2 为接口最新模型,V2A 为快速模型、V_1 为初代模型,_TURBO 为加速版本", + "model_tip": "模型版本:V3 为最新版本,V2 为之前版本,V2A 为快速模型、V_1 为初代模型,_TURBO 为加速版本", "number_images_tip": "单次出图数量", "seed_tip": "控制图像生成的随机性,用于复现相同的生成结果", "negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本", "magic_prompt_option_tip": "智能优化提示词以提升生成效果", - "style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本" + "style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本", + "rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本" }, "edit": { "image_file": "编辑的图像", - "model_tip": "局部编辑仅支持 V_2 和 V_2_TURBO 版本", + "model_tip": "支持 V3 和 V2 版本", "number_images_tip": "生成的编辑结果数量", "style_type_tip": "编辑后的图像风格,仅适用于 V_2 及以上版本", "seed_tip": "控制编辑结果的随机性", - "magic_prompt_option_tip": "智能优化编辑提示词" + "magic_prompt_option_tip": "智能优化编辑提示词", + "rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本" }, "remix": { "model_tip": "选择重混使用的 AI 模型版本", @@ -813,7 +858,8 @@ "seed_tip": "控制重混结果的随机性", "style_type_tip": "重混后的图像风格,仅适用于 V_2 及以上版本", "negative_prompt_tip": "描述不想在重混结果中出现的元素", - "magic_prompt_option_tip": "智能优化重混提示词" + "magic_prompt_option_tip": "智能优化重混提示词", + "rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于V_3版本" }, "upscale": { "image_file": "需要放大的图片", @@ -826,18 +872,6 @@ "magic_prompt_option_tip": "智能优化放大提示词" } }, - "plantuml": { - "download": { - "failed": "下载失败,请检查网络", - "png": "下载 PNG", - "svg": "下载 SVG" - }, - "tabs": { - "preview": "预览", - "source": "源码" - }, - "title": "PlantUML 图表" - }, "prompts": { "explanation": "帮我解释一下这个概念", "summarize": "帮我总结一下这段话", @@ -857,7 +891,7 @@ "doubao": "火山引擎", "fireworks": "Fireworks", "gemini": "Gemini", - "gitee-ai": "Gitee AI", + "gitee-ai": "模力方舟", "github": "GitHub Models", "gpustack": "GPUStack", "grok": "Grok", @@ -946,6 +980,8 @@ "app_knowledge.remove_all_confirm": "删除知识库文件可以减少存储空间占用,但不会删除知识库向量化数据,删除之后将无法打开源文件,是否删除?", "app_knowledge.remove_all_success": "文件删除成功", "app_logs": "应用日志", + "backup.skip_file_data_title": "精简备份", + "backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度。", "clear_cache": { "button": "清除缓存", "confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?", @@ -1436,6 +1472,8 @@ "models.translate_model_description": "翻译服务使用的模型", "models.translate_model_prompt_message": "请输入翻译模型提示词", "models.translate_model_prompt_title": "翻译模型提示词", + "models.quick_assistant_model": "快捷助手模型", + "models.quick_assistant_model_description": "快捷助手使用的默认模型", "moresetting": "更多设置", "moresetting.check.confirm": "确认勾选", "moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!", @@ -1545,6 +1583,7 @@ "reset_defaults_confirm": "确定要重置所有快捷键吗?", "reset_to_default": "重置为默认", "search_message": "搜索消息", + "search_message_in_chat": "在当前对话中搜索消息", "show_app": "显示/隐藏应用", "show_settings": "打开设置", "title": "快捷方式", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index eb70159620..f4e1adbb65 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -131,7 +131,10 @@ "manage": "管理", "select_model": "選擇模型", "show.all": "顯示全部", - "update_available": "有可用更新" + "update_available": "有可用更新", + "includes_user_questions": "包含使用者提問", + "case_sensitive": "區分大小寫", + "whole_word": "全字匹配" }, "chat": { "add.assistant.title": "新增助手", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "預估 Token 數", "input.expand": "展開", "input.file_not_supported": "模型不支援此檔案類型", + "input.file_error": "檔案處理錯誤", "input.generate_image": "生成圖片", "input.generate_image_not_supported": "模型不支援生成圖片", "input.knowledge_base": "知識庫", @@ -199,6 +203,20 @@ }, "resend": "重新傳送", "save": "儲存", + "settings.code.title": "程式碼區塊", + "settings.code_editor": { + "title": "程式碼編輯器", + "highlight_active_line": "高亮當前行", + "fold_gutter": "折疊控件", + "autocompletion": "自動補全", + "keymap": "快捷鍵" + }, + "settings.code_execution": { + "title": "程式碼執行", + "tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!", + "timeout_minutes": "超時時間", + "timeout_minutes.tip": "程式碼執行超時時間(分鐘)" + }, "settings.code_collapsible": "程式碼區塊可折疊", "settings.code_wrappable": "程式碼區塊可自動換行", "settings.code_cacheable": "程式碼區塊快取", @@ -304,9 +322,32 @@ }, "code_block": { "collapse": "折疊", - "disable_wrap": "停用自動換行", - "enable_wrap": "自動換行", - "expand": "展開" + "copy.failed": "複製失敗", + "copy.source": "複製源碼", + "copy.success": "已複製", + "copy": "複製", + "download.failed.network": "下載失敗,請檢查網路連線", + "download.png": "下載 PNG", + "download.source": "下載源碼", + "download.svg": "下載 SVG", + "download": "下載", + "edit.save.failed.message_not_found": "保存失敗,沒有找到對應的消息", + "edit.save.failed": "保存失敗", + "edit.save.success": "已保存", + "edit.save": "保存修改", + "edit": "編輯", + "expand": "展開", + "more": "更多", + "preview.copy.image": "複製為圖片", + "preview.source": "查看源碼", + "preview.zoom_in": "放大", + "preview.zoom_out": "縮小", + "preview": "預覽", + "run": "運行代碼", + "split.restore": "取消分割視圖", + "split": "分割視圖", + "wrap.off": "停用自動換行", + "wrap.on": "自動換行" }, "common": { "add": "新增", @@ -360,7 +401,8 @@ "pinyin": "按拼音排序", "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" - } + }, + "no_results": "沒有結果" }, "docs": { "title": "說明文件" @@ -527,21 +569,6 @@ "keep_alive_time.title": "保持活躍時間", "title": "LM Studio" }, - "mermaid": { - "download": { - "png": "下載 PNG", - "svg": "下載 SVG" - }, - "resize": { - "zoom-in": "放大", - "zoom-out": "縮小" - }, - "tabs": { - "preview": "預覽", - "source": "原始碼" - }, - "title": "Mermaid 圖表" - }, "message": { "agents": { "imported": "匯入成功", @@ -563,7 +590,8 @@ "citations": "引用內容", "copied": "已複製!", "copy.failed": "複製失敗", - "copy.success": "已複製!", + "copy.success": "複製成功", + "empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙", "error.chunk_overlap_too_large": "分段重疊不能大於分段大小", "error.dimension_too_large": "內容尺寸過大", "error.enter.api.host": "請先輸入您的 API 主機地址", @@ -782,6 +810,20 @@ "image_file_retry": "請重新上傳圖片", "image_placeholder": "無圖片", "image_retry": "重試", + "translating": "翻譯中...", + "style_types": { + "auto": "自動", + "general": "通用", + "realistic": "寫實", + "design": "設計", + "3d": "3D", + "anime": "動漫" + }, + "rendering_speeds": { + "default": "預設", + "turbo": "快速", + "quality": "高品質" + }, "mode": { "generate": "繪圖", "edit": "編輯", @@ -789,20 +831,22 @@ "upscale": "放大" }, "generate": { - "model_tip": "模型版本:V2 為接口最新模型,V2A 為快速模型、V_1 為初代模型,_TURBO 為加速版本", - "number_images_tip": "單次出圖數量", - "seed_tip": "控制圖像生成的隨機性,用於重現相同的生成結果", - "negative_prompt_tip": "描述不想在圖像中出現的元素,僅支援 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本", - "magic_prompt_option_tip": "智能優化提示詞以提升生成效果", - "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本" + "model_tip": "模型版本:V2 是最新 API 模型,V2A 是高速模型,V_1 是初代模型,_TURBO 是高速處理版", + "number_images_tip": "一次生成的圖片數量", + "seed_tip": "控制圖像生成的隨機性,以重現相同的生成結果", + "negative_prompt_tip": "描述不想在圖像中出現的內容", + "magic_prompt_option_tip": "智能優化生成效果的提示詞", + "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本", + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" }, "edit": { - "image_file": "編輯的圖像", - "model_tip": "局部編輯僅支援 V_2 和 V_2_TURBO 版本", + "image_file": "編輯圖像", + "model_tip": "部分編輯僅支持 V_2 和 V_2_TURBO 版本", "number_images_tip": "生成的編輯結果數量", "style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本", "seed_tip": "控制編輯結果的隨機性", - "magic_prompt_option_tip": "智能優化編輯提示詞" + "magic_prompt_option_tip": "智能優化編輯提示詞", + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" }, "remix": { "model_tip": "選擇重混使用的 AI 模型版本", @@ -813,7 +857,8 @@ "seed_tip": "控制重混結果的隨機性", "style_type_tip": "重混後的圖像風格,僅適用於 V_2 及以上版本", "negative_prompt_tip": "描述不想在重混結果中出現的元素", - "magic_prompt_option_tip": "智能優化重混提示詞" + "magic_prompt_option_tip": "智能優化重混提示詞", + "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本" }, "upscale": { "image_file": "需要放大的圖片", @@ -824,19 +869,8 @@ "number_images_tip": "生成的放大結果數量", "seed_tip": "控制放大結果的隨機性", "magic_prompt_option_tip": "智能優化放大提示詞" - } - }, - "plantuml": { - "download": { - "failed": "下載失敗,請檢查網路", - "png": "下載 PNG", - "svg": "下載 SVG" }, - "tabs": { - "preview": "預覽", - "source": "原始碼" - }, - "title": "PlantUML 圖表" + "rendering_speed": "渲染速度" }, "prompts": { "explanation": "幫我解釋一下這個概念", @@ -857,7 +891,7 @@ "doubao": "火山引擎", "fireworks": "Fireworks", "gemini": "Gemini", - "gitee-ai": "Gitee AI", + "gitee-ai": "模力方舟", "github": "GitHub Models", "gpustack": "GPUStack", "grok": "Grok", @@ -946,6 +980,8 @@ "app_knowledge.remove_all_confirm": "刪除知識庫文件可以減少儲存空間佔用,但不會刪除知識庫向量化資料,刪除之後將無法開啟原始檔,是否刪除?", "app_knowledge.remove_all_success": "檔案刪除成功", "app_logs": "應用程式日誌", + "backup.skip_file_data_title": "精簡備份", + "backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度。", "clear_cache": { "button": "清除快取", "confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?", @@ -1435,6 +1471,8 @@ "models.translate_model_description": "翻譯服務使用的模型", "models.translate_model_prompt_message": "請輸入翻譯模型提示詞", "models.translate_model_prompt_title": "翻譯模型提示詞", + "models.quick_assistant_model": "快捷助手模型", + "models.quick_assistant_model_description": "快捷助手使用的預設模型", "moresetting": "更多設定", "moresetting.check.confirm": "確認勾選", "moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!", @@ -1543,6 +1581,7 @@ "reset_defaults_confirm": "確定要重設所有快捷鍵嗎?", "reset_to_default": "重設為預設", "search_message": "搜尋訊息", + "search_message_in_chat": "在當前對話中搜尋訊息", "show_app": "顯示/隱藏應用程式", "show_settings": "開啟設定", "title": "快速方式", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 8f2cb2b295..394d271858 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -930,6 +930,8 @@ "app_knowledge.remove_all_confirm": "Η διαγραφή των αρχείων της βάσης γνώσεων μπορεί να μειώσει τη χρήση χώρου αποθήκευσης, αλλά δεν θα διαγράψει τα διανυσματωτικά δεδομένα της βάσης γνώσεων. Μετά τη διαγραφή, δεν θα μπορείτε να ανοίξετε τα αρχεία πηγή. Θέλετε να διαγράψετε;", "app_knowledge.remove_all_success": "Τα αρχεία διαγράφηκαν με επιτυχία", "app_logs": "Φάκελοι εφαρμογής", + "backup.skip_file_data_title": "Συμπυκνωμένο αντίγραφο ασφαλείας", + "backup.skip_file_data_help": "Κατά τη δημιουργία αντιγράφων ασφαλείας, παραλείψτε τις εικόνες, τις βάσεις γνώσεων και άλλα αρχεία δεδομένων. Δημιουργήστε αντίγραφα μόνο για το ιστορικό συνομιλιών και τις ρυθμίσεις. Αυτό θα μειώσει τη χρήση χώρου και θα επιταχύνει την ταχύτητα δημιουργίας αντιγράφων.", "clear_cache": { "button": "Καθαρισμός Μνήμης", "confirm": "Η διαγραφή της μνήμης θα διαγράψει τα στοιχεία καθαρισμού της εφαρμογής, συμπεριλαμβανομένων των στοιχείων πρόσθετων εφαρμογών. Αυτή η ενέργεια δεν είναι αναστρέψιμη. Θέλετε να συνεχίσετε;", @@ -1655,4 +1657,4 @@ "visualization": "προβολή" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 8dc1ad294a..f71f859ce2 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -107,6 +107,7 @@ "backup": { "confirm": "¿Está seguro de que desea realizar una copia de seguridad de los datos?", "confirm.button": "Seleccionar ubicación de copia de seguridad", + "confirm.file_checkbox": "El tamaño del archivo es {{size}}, ¿desea elegir el archivo de copia de seguridad?", "content": "Realizar una copia de seguridad de todos los datos, incluyendo registros de chat, configuraciones, bases de conocimiento y todos los demás datos. Tenga en cuenta que el proceso de copia de seguridad puede llevar algún tiempo, gracias por su paciencia.", "progress": { "completed": "Copia de seguridad completada", @@ -1655,4 +1656,4 @@ "visualization": "Visualización" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d80f63c02b..725f89717c 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -930,6 +930,8 @@ "app_knowledge.remove_all_confirm": "La suppression des fichiers de la base de connaissances libérera de l'espace de stockage, mais ne supprimera pas les données vectorisées de la base de connaissances. Après la suppression, vous ne pourrez plus ouvrir les fichiers sources. Souhaitez-vous continuer ?", "app_knowledge.remove_all_success": "Fichiers supprimés avec succès", "app_logs": "Journaux de l'application", + "backup.skip_file_data_title": "Sauvegarde réduite", + "backup.skip_file_data_help": "Passer outre les fichiers de données tels que les images et les bases de connaissances lors de la sauvegarde, et ne sauvegarder que les conversations et les paramètres. Cela réduit l'occupation d'espace et accélère la vitesse de sauvegarde.", "clear_cache": { "button": "Effacer le cache", "confirm": "L'effacement du cache supprimera les données du cache de l'application, y compris les données des mini-programmes. Cette action ne peut pas être annulée, voulez-vous continuer ?", @@ -1655,4 +1657,4 @@ "visualization": "Visualisation" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 52ae447027..d4b5362404 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -107,6 +107,7 @@ "backup": { "confirm": "Tem certeza de que deseja fazer backup dos dados?", "confirm.button": "Escolher local de backup", + "confirm.file_checkbox": "Pule a cópia de segurança de arquivos de dados como imagens e banco de conhecimento e copie apenas as conversas e as configurações.", "content": "Fazer backup de todos os dados, incluindo registros de chat, configurações, base de conhecimento e todos os outros dados. Por favor, note que o processo de backup pode levar algum tempo. Agradecemos sua paciência.", "progress": { "completed": "Backup concluído", @@ -931,6 +932,8 @@ "app_knowledge.remove_all_confirm": "A exclusão dos arquivos da base de conhecimento reduzirá o uso do espaço de armazenamento, mas não excluirá os dados vetoriais da base de conhecimento. Após a exclusão, os arquivos originais não poderão ser abertos. Deseja excluir?", "app_knowledge.remove_all_success": "Arquivo excluído com sucesso", "app_logs": "Logs do aplicativo", + "backup.skip_file_data_title": "Backup simplificado", + "backup.skip_file_data_help": "Pule arquivos de dados como imagens e bancos de conhecimento durante o backup e realize apenas o backup das conversas e configurações. Diminua o consumo de espaço e aumente a velocidade do backup.", "clear_cache": { "button": "Limpar cache", "confirm": "Limpar cache removerá os dados armazenados em cache do aplicativo, incluindo dados de aplicativos minúsculos. Esta ação não pode ser desfeita, deseja continuar?", @@ -1656,4 +1659,4 @@ "visualization": "Visualização" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/middlewares/extractReasoningMiddleware.ts b/src/renderer/src/middlewares/extractReasoningMiddleware.ts index 4c39925aaf..a466822d8c 100644 --- a/src/renderer/src/middlewares/extractReasoningMiddleware.ts +++ b/src/renderer/src/middlewares/extractReasoningMiddleware.ts @@ -14,12 +14,12 @@ function escapeRegExp(str: string) { } // 支持泛型 T,默认 T = { type: string; textDelta: string } -export function extractReasoningMiddleware({ - openingTag, - closingTag, - separator = '\n', - enableReasoning -}: ExtractReasoningMiddlewareOptions) { +export function extractReasoningMiddleware< + T extends { type: string } & ( + | { type: 'text-delta' | 'reasoning'; textDelta: string } + | { type: string } // 其他类型 + ) = { type: string; textDelta: string } +>({ openingTag, closingTag, separator = '\n', enableReasoning }: ExtractReasoningMiddlewareOptions) { const openingTagEscaped = escapeRegExp(openingTag) const closingTagEscaped = escapeRegExp(closingTag) @@ -71,8 +71,8 @@ export function extractReasoningMiddleware 0) { const prefix = afterSwitch && (isReasoning ? !isFirstReasoning : !isFirstText) ? separator : '' @@ -80,7 +80,7 @@ export function extractReasoningMiddleware { {isEmpty(filteredApps) ? (
- +
) : ( diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 07d89dedcb..8516692309 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,10 +1,14 @@ +import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch' import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' +import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { Assistant, Topic } from '@renderer/types' import { Flex } from 'antd' -import { FC } from 'react' +import { debounce } from 'lodash' +import React, { FC, useMemo, useState } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' import styled from 'styled-components' import Inputbar from './Inputbar/Inputbar' @@ -20,18 +24,103 @@ interface Props { const Chat: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) - const { topicPosition, messageStyle } = useSettings() + const { topicPosition, messageStyle, showAssistants } = useSettings() const { showTopics } = useShowTopics() + const mainRef = React.useRef(null) + const contentSearchRef = React.useRef(null) + const [filterIncludeUser, setFilterIncludeUser] = useState(false) + + const maxWidth = useMemo(() => { + const showRightTopics = showTopics && topicPosition === 'right' + const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' + const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' + return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)` + }, [showAssistants, showTopics, topicPosition]) + + useHotkeys('esc', () => { + contentSearchRef.current?.disable() + }) + + useShortcut('search_message_in_chat', () => { + try { + const selectedText = window.getSelection()?.toString().trim() + contentSearchRef.current?.enable(selectedText) + } catch (error) { + console.error('Error enabling content search:', error) + } + }) + + const contentSearchFilter = (node: Node): boolean => { + if (node.parentNode) { + let parentNode: HTMLElement | null = node.parentNode as HTMLElement + while (parentNode?.parentNode) { + if (parentNode.classList.contains('MessageFooter')) { + return false + } + + if (filterIncludeUser) { + if (parentNode?.classList.contains('message-content-container')) { + return true + } + } else { + if (parentNode?.classList.contains('message-content-container-assistant')) { + return true + } + } + parentNode = parentNode.parentNode as HTMLElement + } + return false + } else { + return false + } + } + + const userOutlinedItemClickHandler = () => { + setFilterIncludeUser(!filterIncludeUser) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(() => { + contentSearchRef.current?.search() + contentSearchRef.current?.focus() + }, 0) + }) + }) + } + + let firstUpdateCompleted = false + const firstUpdateOrNoFirstUpdateHandler = debounce(() => { + contentSearchRef.current?.silentSearch() + }, 10) + const messagesComponentUpdateHandler = () => { + if (firstUpdateCompleted) { + firstUpdateOrNoFirstUpdateHandler() + } + } + const messagesComponentFirstUpdateHandler = () => { + setTimeout(() => (firstUpdateCompleted = true), 300) + firstUpdateOrNoFirstUpdateHandler() + } return ( -
- + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} /> + + + @@ -49,18 +138,25 @@ const Chat: FC = (props) => { ) } +const MessagesContainer = styled.div` + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; +` + const Container = styled.div` display: flex; flex-direction: row; height: 100%; flex: 1; - justify-content: space-between; ` const Main = styled(Flex)` height: calc(100vh - var(--navbar-height)); // 设置为containing block,方便子元素fixed定位 transform: translateZ(0); + position: relative; ` export default Chat diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index d233cdce4a..3bc47468fd 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -1,6 +1,7 @@ import { useAssistants } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { useActiveTopic } from '@renderer/hooks/useTopic' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import NavigationService from '@renderer/services/NavigationService' import { Assistant } from '@renderer/types' import { FC, useEffect, useState } from 'react' @@ -36,6 +37,19 @@ const HomePage: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state]) + useEffect(() => { + const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => { + const newAssistant = assistants.find((a) => a.id === assistantId) + if (newAssistant) { + setActiveAssistant(newAssistant) + } + }) + + return () => { + unsubscribe() + } + }, [assistants, setActiveAssistant]) + useEffect(() => { const canMinimize = topicPosition == 'left' ? !showAssistants : !showAssistants && !showTopics window.api.window.setMinimumSize(canMinimize ? 520 : 1080, 600) @@ -47,7 +61,13 @@ const HomePage: FC = () => { return ( - + {showAssistants && ( = ({ file }) => { +export const getFileIcon = (type?: string) => { + if (!type) return + + const ext = type.toLowerCase() + + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + return + } + + if (['.doc', '.docx'].includes(ext)) { + return + } + if (['.xls', '.xlsx'].includes(ext)) { + return + } + if (['.ppt', '.pptx'].includes(ext)) { + return + } + if (ext === '.pdf') { + return + } + if (['.md', '.markdown'].includes(ext)) { + return + } + + if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { + return + } + + if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { + return + } + + if (['.url'].includes(ext)) { + return + } + + if (['.sitemap'].includes(ext)) { + return + } + + if (['.folder'].includes(ext)) { + return + } + + return +} + +export const FileNameRender: FC<{ file: FileType }> = ({ file }) => { const [visible, setVisible] = useState(false) const isImage = (ext: string) => { return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext) @@ -85,54 +133,6 @@ const FileNameRender: FC<{ file: FileType }> = ({ file }) => { } const AttachmentPreview: FC = ({ files, setFiles }) => { - const getFileIcon = (type?: string) => { - if (!type) return - - const ext = type.toLowerCase() - - if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { - return - } - - if (['.doc', '.docx'].includes(ext)) { - return - } - if (['.xls', '.xlsx'].includes(ext)) { - return - } - if (['.ppt', '.pptx'].includes(ext)) { - return - } - if (ext === '.pdf') { - return - } - if (['.md', '.markdown'].includes(ext)) { - return - } - - if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { - return - } - - if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { - return - } - - if (['.url'].includes(ext)) { - return - } - - if (['.sitemap'].includes(ext)) { - return - } - - if (['.folder'].includes(ext)) { - return - } - - return - } - if (isEmpty(files)) { return null } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 2458e35a49..f68f5fbbbf 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -117,6 +117,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]) const [mentionModels, setMentionModels] = useState([]) const [isDragging, setIsDragging] = useState(false) + const [isFileDragging, setIsFileDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) const startHeight = useRef(0) @@ -604,27 +605,33 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (event.clipboardData?.files && event.clipboardData.files.length > 0) { event.preventDefault() for (const file of event.clipboardData.files) { - if (file.path === '') { - // 图像生成也支持图像编辑 - if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { - const tempFilePath = await window.api.file.create(file.name) - const arrayBuffer = await file.arrayBuffer() - const uint8Array = new Uint8Array(arrayBuffer) - await window.api.file.write(tempFilePath, uint8Array) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - break - } else { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') - }) - } - } + try { + // 使用新的API获取文件路径 + const filePath = window.api.file.getPathForFile(file) - if (file.path) { - if (supportExts.includes(getFileExtension(file.path))) { - const selectedFile = await window.api.file.get(file.path) + // 如果没有路径,可能是剪贴板中的图像数据 + if (!filePath) { + // 图像生成也支持图像编辑 + if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { + const tempFilePath = await window.api.file.create(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + break + } else { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } + continue + } + + // 有路径的情况 + if (supportExts.includes(getFileExtension(filePath))) { + const selectedFile = await window.api.file.get(filePath) selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) } else { window.message.info({ @@ -632,6 +639,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = content: t('chat.input.file_not_supported') }) } + } catch (error) { + Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] onPaste:', error) + window.message.error(t('chat.input.file_error')) } } return @@ -644,11 +654,25 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + setIsFileDragging(true) + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsFileDragging(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsFileDragging(false) } const handleDrop = async (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + setIsFileDragging(false) const files = await getFilesFromDropEvent(e).catch((err) => { Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err) @@ -656,11 +680,22 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) if (files) { + let supportedFiles = 0 + files.forEach((file) => { if (supportExts.includes(getFileExtension(file.path))) { setFiles((prevFiles) => [...prevFiles, file]) + supportedFiles++ } }) + + // 如果有文件,但都不支持 + if (files.length > 0 && supportedFiles === 0) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } } } @@ -876,12 +911,17 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model) return ( - + {files.length > 0 && } {selectedKnowledgeBases.length > 0 && ( @@ -1062,6 +1102,23 @@ const InputBarContainer = styled.div` border-radius: 15px; padding-top: 6px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); + + &.file-dragging { + border: 2px dashed #2ecc71; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(46, 204, 113, 0.03); + border-radius: 14px; + z-index: 5; + pointer-events: none; + } + } ` const TextareaStyle: CSSProperties = { @@ -1074,7 +1131,6 @@ const Textarea = styled(TextArea)` border-radius: 0; display: flex; flex: 1; - font-family: Ubuntu; resize: none !important; overflow: auto; width: 100%; @@ -1101,7 +1157,7 @@ const ToolbarMenu = styled.div` gap: 6px; ` -const ToolbarButton = styled(Button)` +export const ToolbarButton = styled(Button)` width: 30px; height: 30px; font-size: 16px; diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index b7ca3b351b..d73d18b82c 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -62,7 +62,6 @@ const Container = styled.div` z-index: 10; padding: 3px 10px; user-select: none; - font-family: Ubuntu; border: 0.5px solid var(--color-text-3); border-radius: 20px; display: flex; diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 2772a2dfe3..2de19fe6c3 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -1,179 +1,34 @@ -import { CheckOutlined, DownloadOutlined, DownOutlined, RightOutlined } from '@ant-design/icons' -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 { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider' -import { useSettings } from '@renderer/hooks/useSettings' -import { Tooltip } from 'antd' -import dayjs from 'dayjs' -import React, { memo, useCallback, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' +import CodeBlockView from '@renderer/components/CodeBlockView' +import { CodeToolbarProvider } from '@renderer/components/CodeToolbar' +import React, { memo, useCallback } from 'react' -import Artifacts from './Artifacts' -import Mermaid from './Mermaid' -import { isValidPlantUML, PlantUML } from './PlantUML' -import SvgPreview from './SvgPreview' - -interface CodeBlockProps { +interface Props { children: string className?: string + id?: string + onSave?: (id: string, newContent: string) => void [key: string]: any } -const CodeBlock: React.FC = ({ children, className }) => { - const match = /language-(\w+)/.exec(className || '') || children?.includes('\n') - const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() +const CodeBlock: React.FC = ({ children, className, id, onSave }) => { + const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n') const language = match?.[1] ?? 'text' - // const [html, setHtml] = useState('') - const { codeToHtml } = useSyntaxHighlighter() - const [isExpanded, setIsExpanded] = useState(!codeCollapsible) - const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) - const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) - const codeContentRef = useRef(null) - const childrenLengthRef = useRef(0) - const isStreamingRef = useRef(false) - const showFooterCopyButton = children && children.length > 500 && !codeCollapsible - - const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language) - - const shouldShowExpandButtonRef = useRef(false) - - const shouldHighlight = useCallback((lang: string) => { - const NON_HIGHLIGHT_LANGS = ['mermaid', 'plantuml', 'svg'] - return !NON_HIGHLIGHT_LANGS.includes(lang) - }, []) - - const highlightCode = useCallback(async () => { - if (!codeContentRef.current) return - const codeElement = codeContentRef.current - - // 只在非流式输出状态才尝试启用cache - const highlightedHtml = await codeToHtml(children, language, !isStreamingRef.current) - - codeElement.innerHTML = highlightedHtml - codeElement.style.opacity = '1' - - const isShowExpandButton = codeElement.scrollHeight > 350 - if (shouldShowExpandButtonRef.current === isShowExpandButton) return - shouldShowExpandButtonRef.current = isShowExpandButton - setShouldShowExpandButton(shouldShowExpandButtonRef.current) - }, [language, codeToHtml, children]) - - useEffect(() => { - // 跳过非文本代码块 - if (!codeContentRef.current || !shouldHighlight(language)) return - - let isMounted = true - const codeElement = codeContentRef.current - - if (childrenLengthRef.current > 0 && childrenLengthRef.current !== children?.length) { - isStreamingRef.current = true - } else { - isStreamingRef.current = false - codeElement.style.opacity = '0.1' - } - - if (childrenLengthRef.current === 0) { - // 挂载时显示原始代码 - codeElement.textContent = children - } - - const observer = new IntersectionObserver(async (entries) => { - if (entries[0].isIntersecting && isMounted) { - setTimeout(highlightCode, 0) - observer.disconnect() + const handleSave = useCallback( + (newContent: string) => { + if (id !== undefined) { + onSave?.(id, newContent) } - }) - - observer.observe(codeElement) - - return () => { - childrenLengthRef.current = children?.length - isMounted = false - observer.disconnect() - } - }, [children, highlightCode, language, shouldHighlight]) - - useEffect(() => { - setIsExpanded(!codeCollapsible) - setShouldShowExpandButton(codeCollapsible && (codeContentRef.current?.scrollHeight ?? 0) > 350) - }, [codeCollapsible]) - - useEffect(() => { - setIsUnwrapped(!codeWrappable) - }, [codeWrappable]) - - if (language === 'mermaid') { - return - } - - if (language === 'plantuml' && isValidPlantUML(children)) { - return - } - - if (language === 'svg') { - return ( - - - {''} - - - {children} - - ) - } + }, + [id, onSave] + ) return match ? ( - - - {'<' + language.toUpperCase() + '>'} - - - - {showDownloadButton && } - {codeWrappable && setIsUnwrapped(!isUnwrapped)} />} - {codeCollapsible && shouldShowExpandButton && ( - setIsExpanded(!isExpanded)} /> - )} - - - - - {codeCollapsible && ( - setIsExpanded(!isExpanded)} - showButton={shouldShowExpandButton} - /> - )} - {showFooterCopyButton && ( - - - - )} - {language === 'html' && children?.includes('') && } - + + + {children} + + ) : ( {children} @@ -181,268 +36,4 @@ const CodeBlock: React.FC = ({ children, className }) => { ) } -const CollapseIcon: React.FC<{ expanded: boolean; onClick: () => void }> = ({ expanded, onClick }) => { - const { t } = useTranslation() - const [tooltipVisible, setTooltipVisible] = useState(false) - - const handleClick = () => { - setTooltipVisible(false) - onClick() - } - - return ( - - - {expanded ? : } - - - ) -} - -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 CodeContent = styled.div<{ $isShowLineNumbers: boolean; $isUnwrapped: boolean; $isCodeWrappable: boolean }>` - transition: opacity 0.3s ease; - .shiki { - padding: 1em; - - code { - display: flex; - flex-direction: column; - width: 100%; - - .line { - display: block; - min-height: 1.3rem; - padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')}; - } - } - } - - ${(props) => - props.$isShowLineNumbers && - ` - code { - counter-reset: step; - counter-increment: step 0; - position: relative; - } - - code .line::before { - content: counter(step); - counter-increment: step; - width: 1rem; - position: absolute; - left: 0; - text-align: right; - opacity: 0.35; - } - `} - - ${(props) => - props.$isCodeWrappable && - !props.$isUnwrapped && - ` - code .line * { - word-wrap: break-word; - white-space: pre-wrap; - } - `} -` -const CodeHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - color: var(--color-text); - font-size: 14px; - font-weight: bold; - height: 34px; - padding: 0 10px; - border-top-left-radius: 8px; - border-top-right-radius: 8px; -` - -const CodeLanguage = styled.div` - font-weight: bold; -` - -const CodeFooter = styled.div` - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: center; - position: relative; - .copy { - cursor: pointer; - color: var(--color-text-3); - transition: color 0.3s; - } - .copy:hover { - color: var(--color-text-1); - } -` -const CopyButtonWrapper = styled.div` - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--color-text-3); - transition: color 0.3s; - font-size: 16px; - - &:hover { - color: var(--color-text-1); - } -` -const ExpandButtonWrapper = styled.div` - position: relative; - cursor: pointer; - height: 25px; - margin-top: -25px; - - .button-text { - position: absolute; - bottom: 0; - left: 0; - right: 0; - text-align: center; - padding: 8px; - color: var(--color-text-3); - z-index: 1; - transition: color 0.2s; - font-size: 12px; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; - } - - &:hover .button-text { - color: var(--color-text-1); - } -` - -const CollapseIconWrapper = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 4px; - cursor: pointer; - color: var(--color-text-3); - transition: all 0.2s ease; - - &:hover { - color: var(--color-text-1); - } -` - -const UnwrapButtonWrapper = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 4px; - cursor: pointer; - color: var(--color-text-3); - transition: all 0.2s ease; - - &:hover { - background-color: var(--color-background-soft); - color: var(--color-text-1); - } -` - -const DownloadWrapper = styled.div` - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: var(--color-text-3); - transition: color 0.3s; - font-size: 16px; - - &:hover { - color: var(--color-text-1); - } -` - -const StickyWrapper = styled.div` - position: sticky; - top: 28px; - z-index: 10; -` - export default memo(CodeBlock) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 34c156a3a2..8e2d64177a 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -4,12 +4,13 @@ import 'katex/dist/contrib/mhchem' import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer' import { useSettings } from '@renderer/hooks/useSettings' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' import { parseJSON } from '@renderer/utils' import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats' -import { findCitationInChildren } from '@renderer/utils/markdown' +import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown' import { isEmpty } from 'lodash' -import { type FC, useMemo } from 'react' +import { type FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeKatex from 'rehype-katex' @@ -65,14 +66,27 @@ const Markdown: FC = ({ block }) => { return plugins }, [mathEngine, messageContent]) + const onSaveCodeBlock = useCallback( + (id: string, newContent: string) => { + EventEmitter.emit(EVENT_NAMES.EDIT_CODE_BLOCK, { + msgBlockId: block.id, + codeBlockId: id, + newContent + }) + }, + [block.id] + ) + const components = useMemo(() => { return { a: (props: any) => , - code: CodeBlock, + code: (props: any) => ( + + ), img: ImagePreview, pre: (props: any) =>
     } as Partial
-  }, [])
+  }, [onSaveCodeBlock])
 
   // if (role === 'user' && !renderInputMessageAsMarkdown) {
   //   return 

{messageContent}

@@ -99,4 +113,4 @@ const Markdown: FC = ({ block }) => { ) } -export default Markdown +export default memo(Markdown) diff --git a/src/renderer/src/pages/home/Markdown/Mermaid.tsx b/src/renderer/src/pages/home/Markdown/Mermaid.tsx deleted file mode 100644 index f15595724e..0000000000 --- a/src/renderer/src/pages/home/Markdown/Mermaid.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useTheme } from '@renderer/context/ThemeProvider' -import { EventEmitter } from '@renderer/services/EventService' -import { ThemeMode } from '@renderer/types' -import { debounce, isEmpty } from 'lodash' -import React, { useCallback, useEffect, useRef } from 'react' - -import MermaidPopup from './MermaidPopup' - -interface Props { - chart: string -} - -const Mermaid: React.FC = ({ chart }) => { - const { theme } = useTheme() - const mermaidRef = useRef(null) - - const renderMermaidBase = useCallback(async () => { - if (!mermaidRef.current || !window.mermaid || isEmpty(chart)) return - - try { - mermaidRef.current.innerHTML = chart - mermaidRef.current.removeAttribute('data-processed') - - await window.mermaid.initialize({ - startOnLoad: true, - theme: theme === ThemeMode.dark ? 'dark' : 'default' - }) - - await window.mermaid.run({ nodes: [mermaidRef.current] }) - } catch (error) { - console.error('Failed to render mermaid chart:', error) - } - }, [chart, theme]) - - // eslint-disable-next-line react-hooks/exhaustive-deps - const renderMermaid = useCallback(debounce(renderMermaidBase, 1000), [renderMermaidBase]) - - useEffect(() => { - renderMermaid() - // Make sure to cancel any pending debounced calls when unmounting - return () => renderMermaid.cancel() - }, [renderMermaid]) - - useEffect(() => { - setTimeout(renderMermaidBase, 0) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - const removeListener = EventEmitter.on('mermaid-loaded', renderMermaid) - return () => { - removeListener() - renderMermaid.cancel() - } - }, [renderMermaid]) - - const onPreview = () => { - MermaidPopup.show({ chart }) - } - - return ( -
- {chart} -
- ) -} - -export default Mermaid diff --git a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx b/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx deleted file mode 100644 index 1975f132cb..0000000000 --- a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { TopView } from '@renderer/components/TopView' -import { useTheme } from '@renderer/context/ThemeProvider' -import { ThemeMode } from '@renderer/types' -import { runAsyncFunction } from '@renderer/utils' -import { download } from '@renderer/utils/download' -import { Button, Modal, Space, Tabs } from 'antd' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface ShowParams { - chart: string -} - -interface Props extends ShowParams { - resolve: (data: any) => void -} - -const PopupContainer: React.FC = ({ resolve, chart }) => { - const [open, setOpen] = useState(true) - const { t } = useTranslation() - const { theme } = useTheme() - const mermaidId = `mermaid-popup-${Date.now()}` - const [activeTab, setActiveTab] = useState('preview') - const [scale, setScale] = useState(1) - - const onOk = () => { - setOpen(false) - } - - const onCancel = () => { - setOpen(false) - } - - const onClose = () => { - resolve({}) - } - - const handleZoom = (delta: number) => { - const newScale = Math.max(0.1, Math.min(3, scale + delta)) - setScale(newScale) - - const element = document.getElementById(mermaidId) - if (!element) return - - const svg = element.querySelector('svg') - if (!svg) return - - const container = svg.parentElement - if (container) { - container.style.overflow = 'auto' - container.style.position = 'relative' - svg.style.transformOrigin = 'top left' - svg.style.transform = `scale(${newScale})` - } - } - - const handleCopyImage = async () => { - try { - const element = document.getElementById(mermaidId) - if (!element) return - - const svgElement = element.querySelector('svg') - if (!svgElement) return - - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - const img = new Image() - img.crossOrigin = 'anonymous' - - const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] - const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width - const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height - - const svgData = new XMLSerializer().serializeToString(svgElement) - const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` - - img.onload = async () => { - const scale = 3 - canvas.width = width * scale - canvas.height = height * scale - - if (ctx) { - ctx.scale(scale, scale) - ctx.drawImage(img, 0, 0, width, height) - const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')) - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) - window.message.success(t('message.copy.success')) - } - } - img.src = svgBase64 - } catch (error) { - console.error('Copy failed:', error) - window.message.error(t('message.copy.failed')) - } - } - - const handleDownload = async (format: 'svg' | 'png') => { - try { - const element = document.getElementById(mermaidId) - if (!element) return - - const timestamp = Date.now() - const backgroundColor = theme === ThemeMode.dark ? '#1F1F1F' : '#fff' - const svgElement = element.querySelector('svg') - - if (!svgElement) return - - if (format === 'svg') { - // Add background color to SVG - svgElement.style.backgroundColor = backgroundColor - - const svgData = new XMLSerializer().serializeToString(svgElement) - const blob = new Blob([svgData], { type: 'image/svg+xml' }) - const url = URL.createObjectURL(blob) - download(url, `mermaid-diagram-${timestamp}.svg`) - URL.revokeObjectURL(url) - } else if (format === 'png') { - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - const img = new Image() - img.crossOrigin = 'anonymous' - - const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] - const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width - const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height - - // Add background color to SVG before converting to image - svgElement.style.backgroundColor = backgroundColor - - const svgData = new XMLSerializer().serializeToString(svgElement) - const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` - - img.onload = () => { - const scale = 3 - canvas.width = width * scale - canvas.height = height * scale - - if (ctx) { - ctx.scale(scale, scale) - // Fill background - ctx.fillStyle = backgroundColor - ctx.fillRect(0, 0, width, height) - ctx.drawImage(img, 0, 0, width, height) - } - - canvas.toBlob((blob) => { - if (blob) { - const pngUrl = URL.createObjectURL(blob) - download(pngUrl, `mermaid-diagram-${timestamp}.png`) - URL.revokeObjectURL(pngUrl) - } - }, 'image/png') - } - img.src = svgBase64 - } - svgElement.style.backgroundColor = 'transparent' - } catch (error) { - console.error('Download failed:', error) - } - } - - const handleCopy = () => { - navigator.clipboard.writeText(chart) - window.message.success(t('message.copy.success')) - } - - useEffect(() => { - runAsyncFunction(async () => { - if (!window.mermaid) return - - try { - const element = document.getElementById(mermaidId) - if (!element) return - - // Clear previous content - element.innerHTML = chart - element.removeAttribute('data-processed') - - await window.mermaid.initialize({ - startOnLoad: false, - theme: theme === ThemeMode.dark ? 'dark' : 'default' - }) - - await window.mermaid.run({ - nodes: [element] - }) - } catch (error) { - console.error('Failed to render mermaid chart in popup:', error) - } - }) - }, [activeTab, theme, mermaidId, chart]) - - return ( - - {activeTab === 'source' && } - {activeTab === 'preview' && ( - <> - - - - - - - )} - - ]}> - setActiveTab(key)} - items={[ - { - key: 'preview', - label: t('mermaid.tabs.preview'), - children: ( - - {chart} - - ) - }, - { - key: 'source', - label: t('mermaid.tabs.source'), - children: ( -
-                {chart}
-              
- ) - } - ]} - /> -
- ) -} - -export default class MermaidPopup { - static topviewId = 0 - static hide() { - TopView.hide('MermaidPopup') - } - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - />, - 'MermaidPopup' - ) - }) - } -} - -const StyledMermaid = styled.div` - max-height: calc(80vh - 200px); - text-align: center; - overflow-y: auto; -` diff --git a/src/renderer/src/pages/home/Markdown/PlantUML.tsx b/src/renderer/src/pages/home/Markdown/PlantUML.tsx deleted file mode 100644 index 8a0995fdcb..0000000000 --- a/src/renderer/src/pages/home/Markdown/PlantUML.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { CopyOutlined, LoadingOutlined } from '@ant-design/icons' -import { TopView } from '@renderer/components/TopView' -import { useTheme } from '@renderer/context/ThemeProvider' -import { Button, Modal, Space, Spin, Tabs } from 'antd' -import pako from 'pako' -import React, { useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface PlantUMLPopupProps { - resolve: (data: any) => void - diagram: string -} -export function isValidPlantUML(diagram: string | null): boolean { - if (!diagram || !diagram.trim().startsWith('@start')) { - return false - } - const diagramType = diagram.match(/@start(\w+)/)?.[1] - - return diagramType !== undefined && diagram.search(`@end${diagramType}`) !== -1 -} - -const PlantUMLServer = 'https://www.plantuml.com/plantuml' -function encode64(data: Uint8Array) { - let r = '' - for (let i = 0; i < data.length; i += 3) { - if (i + 2 === data.length) { - r += append3bytes(data[i], data[i + 1], 0) - } else if (i + 1 === data.length) { - r += append3bytes(data[i], 0, 0) - } else { - r += append3bytes(data[i], data[i + 1], data[i + 2]) - } - } - return r -} - -function encode6bit(b: number) { - if (b < 10) { - return String.fromCharCode(48 + b) - } - b -= 10 - if (b < 26) { - return String.fromCharCode(65 + b) - } - b -= 26 - if (b < 26) { - return String.fromCharCode(97 + b) - } - b -= 26 - if (b === 0) { - return '-' - } - if (b === 1) { - return '_' - } - return '?' -} - -function append3bytes(b1: number, b2: number, b3: number) { - const c1 = b1 >> 2 - const c2 = ((b1 & 0x3) << 4) | (b2 >> 4) - const c3 = ((b2 & 0xf) << 2) | (b3 >> 6) - const c4 = b3 & 0x3f - let r = '' - r += encode6bit(c1 & 0x3f) - r += encode6bit(c2 & 0x3f) - r += encode6bit(c3 & 0x3f) - r += encode6bit(c4 & 0x3f) - return r -} -/** - * https://plantuml.com/zh/code-javascript-synchronous - * To use PlantUML image generation, a text diagram description have to be : - 1. Encoded in UTF-8 - 2. Compressed using Deflate algorithm - 3. Reencoded in ASCII using a transformation _close_ to base64 - */ -function encodeDiagram(diagram: string): string { - const utf8text = new TextEncoder().encode(diagram) - const compressed = pako.deflateRaw(utf8text) - return encode64(compressed) -} - -type PlantUMLServerImageProps = { - format: 'png' | 'svg' - diagram: string - onClick?: React.MouseEventHandler - className?: string -} - -function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) { - const encodedDiagram = encodeDiagram(diagram) - if (isDark) { - return `${PlantUMLServer}/d${format}/${encodedDiagram}` - } - return `${PlantUMLServer}/${format}/${encodedDiagram}` -} - -const PlantUMLServerImage: React.FC = ({ format, diagram, onClick, className }) => { - const [loading, setLoading] = useState(true) - const { theme } = useTheme() - const isDark = theme === 'dark' - const url = getPlantUMLImageUrl(format, diagram, isDark) - return ( - - - }> - { - setLoading(false) - }} - onError={(e) => { - setLoading(false) - const target = e.target as HTMLImageElement - target.style.opacity = '0.5' - target.style.filter = 'blur(2px)' - }} - /> - - - ) -} - -const PlantUMLPopupCantaier: React.FC = ({ resolve, diagram }) => { - const [open, setOpen] = useState(true) - const [downloading, setDownloading] = useState({ - png: false, - svg: false - }) - const [scale, setScale] = useState(1) - const [activeTab, setActiveTab] = useState('preview') - const { t } = useTranslation() - - const encodedDiagram = encodeDiagram(diagram) - const onOk = () => { - setOpen(false) - } - - const onCancel = () => { - setOpen(false) - } - const onClose = () => { - resolve({}) - } - - const handleZoom = (delta: number) => { - const newScale = Math.max(0.1, Math.min(3, scale + delta)) - setScale(newScale) - - const container = document.querySelector('.plantuml-image-container') - if (container) { - const img = container.querySelector('img') - if (img) { - img.style.transformOrigin = 'top left' - img.style.transform = `scale(${newScale})` - } - } - } - - const handleCopyImage = async () => { - try { - const imageElement = document.querySelector('.plantuml-image-container img') - if (!imageElement) return - - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - const img = imageElement as HTMLImageElement - - if (!img.complete) { - await new Promise((resolve) => { - img.onload = resolve - }) - } - - canvas.width = img.naturalWidth - canvas.height = img.naturalHeight - - if (ctx) { - ctx.drawImage(img, 0, 0) - const blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png')) - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) - window.message.success(t('message.copy.success')) - } - } catch (error) { - console.error('Copy failed:', error) - window.message.error(t('message.copy.failed')) - } - } - - const handleDownload = (format: 'svg' | 'png') => { - const timestamp = Date.now() - const url = `${PlantUMLServer}/${format}/${encodedDiagram}` - setDownloading((prev) => ({ ...prev, [format]: true })) - const filename = `plantuml-diagram-${timestamp}.${format}` - downloadUrl(url, filename) - .catch(() => { - window.message.error(t('plantuml.download.failed')) - }) - .finally(() => { - setDownloading((prev) => ({ ...prev, [format]: false })) - }) - } - - function handleCopy() { - navigator.clipboard.writeText(diagram) - window.message.success(t('message.copy.success')) - } - - return ( - - {activeTab === 'source' && ( - - )} - {activeTab === 'preview' && ( - <> - - - - - - - )} - - ]}> - setActiveTab(key)} - items={[ - { - key: 'preview', - label: t('plantuml.tabs.preview'), - children: - }, - { - key: 'source', - label: t('plantuml.tabs.source'), - children: ( -
-                {diagram}
-              
- ) - } - ]} - /> -
- ) -} - -class PlantUMLPopupTopView { - static topviewId = 0 - static hide() { - TopView.hide('PlantUMLPopup') - } - static show(diagram: string) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - diagram={diagram} - />, - 'PlantUMLPopup' - ) - }) - } -} -interface PlantUMLProps { - diagram: string -} -export const PlantUML: React.FC = ({ diagram }) => { - // const { t } = useTranslation() - const onPreview = () => { - PlantUMLPopupTopView.show(diagram) - } - return -} - -const StyledPlantUML = styled.div` - max-height: calc(80vh - 100px); - text-align: center; - overflow-y: auto; - img { - max-width: 100%; - height: auto; - min-height: 100px; - background: var(--color-code-background); - cursor: pointer; - transition: transform 0.2s ease; - } -` -async function downloadUrl(url: string, filename: string) { - const response = await fetch(url) - if (!response.ok) { - window.message.warning({ content: response.statusText, duration: 1.5 }) - return - } - const blob = await response.blob() - const link = document.createElement('a') - link.href = URL.createObjectURL(blob) - link.download = filename - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(link.href) -} diff --git a/src/renderer/src/pages/home/Markdown/SvgPreview.tsx b/src/renderer/src/pages/home/Markdown/SvgPreview.tsx deleted file mode 100644 index 27685a4ade..0000000000 --- a/src/renderer/src/pages/home/Markdown/SvgPreview.tsx +++ /dev/null @@ -1,16 +0,0 @@ -const SvgPreview = ({ children }: { children: string }) => { - return ( -
- ) -} - -export default SvgPreview diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 476c67e57e..74d16a80f0 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -23,12 +23,6 @@ const ThinkingBlock: React.FC = ({ block }) => { const isThinking = useMemo(() => block.status === MessageBlockStatus.STREAMING, [block.status]) - const fontFamily = useMemo(() => { - return messageFont === 'serif' - ? 'serif' - : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif' - }, [messageFont]) - useEffect(() => { if (!isThinking && thoughtAutoCollapse) { setActiveKey('') @@ -98,7 +92,11 @@ const ThinkingBlock: React.FC = ({ block }) => { ), children: ( // FIXME: 临时兼容 -
+
) diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 230767a3a8..ea2ad062aa 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,3 +1,4 @@ +import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' @@ -136,36 +137,44 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { return ( - - {citation.showFavicon && citation.url && ( - + + + {citation.showFavicon && citation.url && ( + + )} + handleLinkClick(citation.url, e)}> + {citation.title || {citation.hostname}} + + {fetchedContent && } + + {isLoading ? ( + + ) : ( + {fetchedContent} )} - handleLinkClick(citation.url, e)}> - {citation.title || {citation.hostname}} - - {fetchedContent && } - - {isLoading ? ( - - ) : ( - {fetchedContent} - )} + ) } -const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => ( - - - {citation.showFavicon && } - handleLinkClick(citation.url, e)}> - {citation.title} - - {citation.content && } - - {citation.content && truncateText(citation.content, 100)} - -) +const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => { + return ( + + + + {citation.showFavicon && } + handleLinkClick(citation.url, e)}> + {citation.title} + + {citation.content && } + + + {citation.content && truncateText(citation.content, 100)} + + + + ) +} const OpenButton = styled(Button)` display: flex; @@ -237,6 +246,7 @@ const WebSearchCard = styled.div` border-radius: var(--list-item-border-radius); background-color: var(--color-background); transition: all 0.3s ease; + position: relative; ` const WebSearchCardHeader = styled.div` @@ -252,6 +262,15 @@ const WebSearchCardContent = styled.div` font-size: 13px; line-height: 1.6; color: var(--color-text-2); + user-select: text; + cursor: text; + + &.selectable-text { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + } ` export default CitationsList diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 6e8334d50a..b1995a4c05 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -1,19 +1,22 @@ -import { FONT_FAMILY } from '@renderer/config/constant' +import ContextMenu from '@renderer/components/ContextMenu' +import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useModel } from '@renderer/hooks/useModel' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageModelId } from '@renderer/services/MessagesService' import { getModelUniqId } from '@renderer/services/ModelService' import { Assistant, Topic } from '@renderer/types' -import type { Message } from '@renderer/types/newMessage' +import type { Message, MessageBlock } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' -import { Divider, Dropdown } from 'antd' -import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Divider } from 'antd' +import React, { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import MessageContent from './MessageContent' +import MessageEditor from './MessageEditor' import MessageErrorBoundary from './MessageErrorBoundary' import MessageHeader from './MessageHeader' import MessageMenubar from './MessageMenubar' @@ -47,48 +50,58 @@ const MessageItem: FC = ({ const model = useModel(getMessageModelId(message), message.model?.provider) || message.model const { isBubbleStyle } = useMessageStyle() const { showMessageDivider, messageFont, fontSize } = useSettings() + const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic) const messageContainerRef = useRef(null) + const { editingMessageId, stopEditing } = useMessageEditing() + const isEditing = editingMessageId === message.id - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [selectedQuoteText, setSelectedQuoteText] = useState('') - const [selectedText, setSelectedText] = useState('') + useEffect(() => { + if (isEditing && messageContainerRef.current) { + messageContainerRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + } + }, [isEditing]) + + const handleEditSave = useCallback( + async (blocks: MessageBlock[]) => { + try { + console.log('after save blocks', blocks) + await editMessageBlocks(message.id, blocks) + stopEditing() + } catch (error) { + console.error('Failed to save message blocks:', error) + } + }, + [message, editMessageBlocks, stopEditing] + ) + + const handleEditResend = useCallback( + async (blocks: MessageBlock[]) => { + try { + // 编辑后重新发送消息 + console.log('after resend blocks', blocks) + await resendUserMessageWithEdit(message, blocks, assistant) + stopEditing() + } catch (error) { + console.error('Failed to resend message:', error) + } + }, + [message, resendUserMessageWithEdit, assistant, stopEditing] + ) + + const handleEditCancel = useCallback(() => { + stopEditing() + }, [stopEditing]) const isLastMessage = index === 0 const isAssistantMessage = message.role === 'assistant' - const showMenubar = !isStreaming && !message.status.includes('ing') - - const fontFamily = useMemo(() => { - return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY - }, [messageFont]) + const showMenubar = !isStreaming && !message.status.includes('ing') && !isEditing const messageBorder = showMessageDivider ? undefined : 'none' const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault() - const _selectedText = window.getSelection()?.toString() - if (_selectedText) { - const quotedText = - _selectedText - .split('\n') - .map((line) => `> ${line}`) - .join('\n') + '\n-------------' - setSelectedQuoteText(quotedText) - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setSelectedText(_selectedText) - } - }, []) - - useEffect(() => { - const handleClick = () => { - setContextMenuPosition(null) - } - document.addEventListener('click', handleClick) - return () => { - document.removeEventListener('click', handleClick) - } - }, []) - const messageHighlightHandler = useCallback((highlight: boolean = true) => { if (messageContainerRef.current) { messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) @@ -130,46 +143,59 @@ const MessageItem: FC = ({ 'message-user': !isAssistantMessage })} ref={messageContainerRef} - onContextMenu={handleContextMenu} style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}> - {contextMenuPosition && ( - -
- - )} - - - - - - {showMenubar && ( - - - + + + {isEditing ? ( + } - setModel={setModel} + onSave={handleEditSave} + onResend={handleEditResend} + onCancel={handleEditCancel} /> - - )} - + ) : ( + + + + )} + {showMenubar && ( + + + } + setModel={setModel} + /> + + )} + + ) } @@ -182,24 +208,6 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea : undefined } -const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [ - { - key: 'copy', - label: t('common.copy'), - onClick: () => { - navigator.clipboard.writeText(selectedText) - window.message.success({ content: t('message.copied'), key: 'copy-message' }) - } - }, - { - key: 'quote', - label: t('chat.message.quote'), - onClick: () => { - EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) - } - } -] - const MessageContainer = styled.div` display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index b01feb2be9..14be41e64d 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -20,17 +20,6 @@ const MessageContent: React.FC = ({ message }) => { ) } -// const SearchingContainer = styled.div` -// display: flex; -// flex-direction: row; -// align-items: center; -// background-color: var(--color-background-mute); -// padding: 10px; -// border-radius: 10px; -// margin-bottom: 10px; -// gap: 10px; -// ` - const MentionTag = styled.span` color: var(--color-link); ` diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx new file mode 100644 index 0000000000..8b4cd32685 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -0,0 +1,367 @@ +import CustomTag from '@renderer/components/CustomTag' +import TranslateButton from '@renderer/components/TranslateButton' +import { isGenerateImageModel, isVisionModel } from '@renderer/config/models' +import { useAssistant } from '@renderer/hooks/useAssistant' +import { useSettings } from '@renderer/hooks/useSettings' +import FileManager from '@renderer/services/FileManager' +import { FileType, FileTypes } from '@renderer/types' +import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { classNames, getFileExtension } from '@renderer/utils' +import { getFilesFromDropEvent } from '@renderer/utils/input' +import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create' +import { findAllBlocks } from '@renderer/utils/messageUtils/find' +import { documentExts, imageExts, textExts } from '@shared/config/constant' +import { Tooltip } from 'antd' +import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' +import { Save, Send, X } from 'lucide-react' +import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import AttachmentButton, { AttachmentButtonRef } from '../Inputbar/AttachmentButton' +import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview' +import { ToolbarButton } from '../Inputbar/Inputbar' + +interface Props { + message: Message + onSave: (blocks: MessageBlock[]) => void + onResend: (blocks: MessageBlock[]) => void + onCancel: () => void +} + +const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel }) => { + const allBlocks = findAllBlocks(message) + const [editedBlocks, setEditedBlocks] = useState(allBlocks) + const [files, setFiles] = useState([]) + const [isProcessing, setIsProcessing] = useState(false) + const [isFileDragging, setIsFileDragging] = useState(false) + const { assistant } = useAssistant(message.assistantId) + const model = assistant.model || assistant.defaultModel + const isVision = useMemo(() => isVisionModel(model), [model]) + const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) + const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize } = useSettings() + const { t } = useTranslation() + const textareaRef = useRef(null) + const attachmentButtonRef = useRef(null) + + useEffect(() => { + setTimeout(() => { + resizeTextArea() + if (textareaRef.current) { + textareaRef.current.focus({ cursor: 'end' }) + } + }, 0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const resizeTextArea = useCallback(() => { + const textArea = textareaRef.current?.resizableTextArea?.textArea + if (textArea) { + textArea.style.height = 'auto' + textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px` + } + }, []) + + const handleTextChange = (blockId: string, content: string) => { + setEditedBlocks((prev) => prev.map((block) => (block.id === blockId ? { ...block, content } : block))) + } + + const onTranslated = (translatedText: string) => { + const mainTextBlock = editedBlocks.find((b) => b.type === MessageBlockType.MAIN_TEXT) + if (mainTextBlock) { + handleTextChange(mainTextBlock.id, translatedText) + } + setTimeout(() => resizeTextArea(), 0) + } + + // 处理文件删除 + const handleFileRemove = async (blockId: string) => { + setEditedBlocks((prev) => prev.filter((block) => block.id !== blockId)) + } + + // 处理拖拽上传 + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsFileDragging(false) + + const files = await getFilesFromDropEvent(e).catch((err) => { + console.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err) + return null + }) + if (files) { + let supportedFiles = 0 + files.forEach((file) => { + if (supportExts.includes(getFileExtension(file.path))) { + setFiles((prevFiles) => [...prevFiles, file]) + supportedFiles++ + } + }) + + // 如果有文件,但都不支持 + if (files.length > 0 && supportedFiles === 0) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } + } + } + + const handleClick = async (withResend?: boolean) => { + if (isProcessing) return + setIsProcessing(true) + const updatedBlocks = [...editedBlocks] + if (files && files.length) { + const uploadedFiles = await FileManager.uploadFiles(files) + uploadedFiles.forEach((file) => { + if (file.type === FileTypes.IMAGE) { + const imgBlock = createImageBlock(message.id, { file, status: MessageBlockStatus.SUCCESS }) + updatedBlocks.push(imgBlock) + } else { + const fileBlock = createFileBlock(message.id, file, { status: MessageBlockStatus.SUCCESS }) + updatedBlocks.push(fileBlock) + } + }) + } + if (withResend) { + onResend(updatedBlocks) + } else { + onSave(updatedBlocks) + } + } + + const onPaste = useCallback( + async (event: ClipboardEvent) => { + // 1. 文本粘贴 + const clipboardText = event.clipboardData?.getData('text') + if (clipboardText) { + if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) { + // 长文本直接转文件,阻止默认粘贴 + event.preventDefault() + + const tempFilePath = await window.api.file.create('pasted_text.txt') + await window.api.file.write(tempFilePath, clipboardText) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + setTimeout(() => resizeTextArea(), 50) + return + } + // 短文本走默认粘贴行为,直接返回 + return + } + + // 2. 文件/图片粘贴 + if (event.clipboardData?.files && event.clipboardData.files.length > 0) { + event.preventDefault() + for (const file of event.clipboardData.files) { + const filePath = window.api.file.getPathForFile(file) + if (!filePath) { + // 图像生成也支持图像编辑 + if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { + const tempFilePath = await window.api.file.create(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + break + } else { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } + } + + if (supportExts.includes(getFileExtension(filePath))) { + const selectedFile = await window.api.file.get(filePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + } else { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } + } + return + } + + // 短文本走默认粘贴行为 + }, + [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t] + ) + + const autoResizeTextArea = (e: React.ChangeEvent) => { + const textarea = e.target + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + } + + return ( + <> + e.preventDefault()} onDrop={handleDrop}> + {editedBlocks + .filter((block) => block.type === MessageBlockType.MAIN_TEXT) + .map((block) => ( + + ))} + {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || + files.length > 0) && ( + + {editedBlocks + .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) + .map( + (block) => + block.file && ( + handleFileRemove(block.id)}> + + + ) + )} + + {files.map((file) => ( + setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}> + + + ))} + + )} + + + + + + + + + + + + + + handleClick()}> + + + + + handleClick(true)}> + + + + + + + + ) +} + +const FileBlocksContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 15px; + margin: 8px 0; + background: transplant; + border-radius: 4px; +` + +const EditorContainer = styled.div` + padding: 8px 0; + border: 1px solid var(--color-border); + transition: all 0.2s ease; + border-radius: 15px; + margin-top: 0; + background-color: var(--color-background-opacity); + + &.file-dragging { + border: 2px dashed #2ecc71; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(46, 204, 113, 0.03); + border-radius: 14px; + z-index: 5; + pointer-events: none; + } + } +` + +const Textarea = styled(TextArea)` + padding: 0; + border-radius: 0; + display: flex; + flex: 1; + font-family: Ubuntu; + resize: none !important; + overflow: auto; + width: 100%; + box-sizing: border-box; + &.ant-input { + line-height: 1.4; + } +` + +const ActionBar = styled.div` + display: flex; + padding: 0 8px; + justify-content: space-between; + margin-top: 8px; +` + +const ActionBarLeft = styled.div` + display: flex; + align-items: center; +` + +const ActionBarMiddle = styled.div` + flex: 1; +` + +const ActionBarRight = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +export default memo(MessageBlockEditor) diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index b1c1a5d577..3d8c1de1fd 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -1,4 +1,5 @@ import Scrollbar from '@renderer/components/Scrollbar' +import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -213,34 +214,36 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { ) return ( - - + - {messages.map(renderMessage)} - - {isGrouped && ( - { - setMultiModelMessageStyle(style) - messages.forEach((message) => { - editMessage(message.id, { multiModelMessageStyle: style }) - }) - }} - messages={messages} - selectMessageId={selectedMessageId} - setSelectedMessage={setSelectedMessage} - topic={topic} - /> - )} - + className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}> + + {messages.map(renderMessage)} + + {isGrouped && ( + { + setMultiModelMessageStyle(style) + messages.forEach((message) => { + editMessage(message.id, { multiModelMessageStyle: style }) + }) + }} + messages={messages} + selectMessageId={selectedMessageId} + setSelectedMessage={setSelectedMessage} + topic={topic} + /> + )} + + ) } diff --git a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx index 328e25159c..8fe085aa2f 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx @@ -1,5 +1,6 @@ import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' +import { HStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' @@ -7,7 +8,7 @@ import { setFoldDisplayMode } from '@renderer/store/settings' import type { Model } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd' -import { FC } from 'react' +import { FC, memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -25,39 +26,54 @@ const MessageGroupModelList: FC = ({ messages, selec const { foldDisplayMode } = useSettings() const isCompact = foldDisplayMode === 'compact' + const renderLabel = useCallback( + (message: Message) => { + const modelTip = message.model?.name + + if (isCompact) { + return ( + + { + setSelectedMessage(message) + }}> + + + + ) + } + return ( + + + {message.model?.name} + + ) + }, + [isCompact, selectMessageId, setSelectedMessage] + ) + return ( - + dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}> - {foldDisplayMode === 'compact' ? : } + {isCompact ? : } - {foldDisplayMode === 'compact' ? ( + {isCompact ? ( /* Compact style display */ - - {messages.map((message, index) => ( - - { - setSelectedMessage(message) - }}> - - - - ))} - + {messages.map((message) => renderLabel(message))} ) : ( /* Expanded style display */ = ({ messages, selec setSelectedMessage(message) }} options={messages.map((message) => ({ - label: ( - - - {message.model?.name} - - ), + label: renderLabel(message), value: message.id }))} size="small" /> )} - + ) } -const ModelsWrapper = styled.div` - position: relative; - display: flex; +const Container = styled(HStack)` flex: 1; overflow: hidden; + align-items: center; + margin-left: 4px; ` const DisplayModeToggle = styled.div<{ displayMode: DisplayMode }>` - position: absolute; - left: 4px; /* Add more space on the left */ - top: 50%; - transform: translateY(-50%); - z-index: 5; - width: 28px; /* Increase width */ - height: 28px; /* Add height */ display: flex; - justify-content: center; - align-items: center; cursor: pointer; + padding: 2px 6px 3px 6px; border-radius: 4px; - padding: 2px; + width: 26px; + height: 26px; - /* Add hover effect */ &:hover { background-color: var(--color-hover); } @@ -119,9 +122,7 @@ const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>` overflow-x: auto; flex: 1; padding: 0 8px; - margin-left: 24px; /* Space for toggle button */ - /* Hide scrollbar to match original code */ &::-webkit-scrollbar { display: none; } @@ -131,27 +132,23 @@ const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>` display: flex; align-items: center; flex-wrap: nowrap; - position: relative; padding: 6px 4px; /* Base style - default overlapping effect */ & > * { margin-left: -6px !important; - /* Separate transition properties to avoid conflicts */ transition: transform 0.18s ease-out, margin 0.18s ease-out !important; position: relative; - /* Only use will-change for transform to reduce rendering overhead */ will-change: transform; } - /* First element has no left margin */ & > *:first-child { margin-left: 0 !important; } - /* Using :has() selector to handle the element before the hovered one */ + /* Element before the hovered one */ & > *:has(+ *:hover) { margin-right: 2px !important; /* Use transform instead of margin to reduce layout recalculations */ @@ -171,52 +168,24 @@ const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>` } ` -const AvatarWrapper = styled.div<{ isSelected: boolean }>` +const AvatarWrapper = styled.div<{ $isSelected: boolean }>` cursor: pointer; display: inline-flex; border-radius: 50%; - /* Keep z-index separate from transitions to avoid rendering issues */ - z-index: ${(props) => (props.isSelected ? 2 : 0)}; background: var(--color-background); - /* Simplify transitions to reduce jittering */ transition: transform 0.18s ease-out, margin 0.18s ease-out, - box-shadow 0.18s ease-out, filter 0.18s ease-out; - box-shadow: 0 0 0 1px var(--color-background); - - /* Use CSS variables to define animation parameters for easy adjustment */ - --hover-scale: 1.15; - --hover-x-offset: 6px; - --hover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + z-index: ${(props) => (props.$isSelected ? 1 : 0)}; + border: ${(props) => (props.$isSelected ? '2px solid var(--color-primary)' : 'none')}; &:hover { - /* z-index is applied immediately, not part of the transition */ - z-index: 10; - transform: translateX(var(--hover-x-offset)) scale(var(--hover-scale)); - box-shadow: var(--hover-shadow); + transform: translateX(6px) scale(1.15); filter: brightness(1.02); margin-left: 8px !important; margin-right: 4px !important; } - - ${(props) => - props.isSelected && - ` - border: 2px solid var(--color-primary); - z-index: 2; - - &:hover { - /* z-index is applied immediately, not part of the transition */ - z-index: 10; - border: 2px solid var(--color-primary); - filter: brightness(1.02); - transform: translateX(var(--hover-x-offset)) scale(var(--hover-scale)); - margin-left: 8px !important; - margin-right: 4px !important; - } - `} ` const Segmented = styled(AntdSegmented)` @@ -224,21 +193,15 @@ const Segmented = styled(AntdSegmented)` background-color: transparent !important; .ant-segmented-item { - background-color: transparent !important; - transition: none !important; border-radius: var(--list-item-border-radius) !important; - box-shadow: none !important; &:hover { background: transparent !important; } } .ant-segmented-thumb, .ant-segmented-item-selected { - background-color: transparent !important; border: 0.5px solid var(--color-border); - transition: none !important; border-radius: var(--list-item-border-radius) !important; - box-shadow: none !important; } ` @@ -254,4 +217,4 @@ const ModelName = styled.span` font-size: 12px; ` -export default MessageGroupModelList +export default memo(MessageGroupModelList) diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index eaed4b08a5..ff765403d2 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -143,7 +143,6 @@ const UserName = styled.div<{ isBubbleStyle?: boolean; theme?: string }>` const MessageTime = styled.div` font-size: 10px; color: var(--color-text-3); - font-family: 'Ubuntu'; ` export default MessageHeader diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 7a3ca793ff..eac6e4c9d2 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -1,8 +1,8 @@ import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' -import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' +import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' @@ -23,13 +23,8 @@ import { } from '@renderer/utils/export' // import { withMessageThought } from '@renderer/utils/formats' import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' -import { - findImageBlocks, - findMainTextBlocks, - findTranslationBlocks, - getMainTextContent -} from '@renderer/utils/messageUtils/find' -import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' +import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' import { FilePenLine } from 'lucide-react' @@ -65,10 +60,8 @@ const MessageMenubar: FC = (props) => { deleteMessage, resendMessage, regenerateAssistantMessage, - resendUserMessageWithEdit, getTranslationUpdater, appendAssistantResponse, - editMessageBlocks, removeMessageBlock } = useMessageOperations(topic) const loading = useTopicLoading(topic) @@ -119,92 +112,11 @@ const MessageMenubar: FC = (props) => { [assistant, loading, message, resendMessage] ) + const { startEditing } = useMessageEditing() + const onEdit = useCallback(async () => { - // 禁用了助手消息的编辑,现在都是用户消息的编辑 - let resendMessage = false - - let textToEdit = '' - - const imageBlocks = findImageBlocks(message) - // 如果是包含图片的消息,添加图片的 markdown 格式 - if (imageBlocks.length > 0) { - const imageMarkdown = imageBlocks - .map((image, index) => `![image-${index}](file://${image?.file?.path})`) - .join('\n') - textToEdit = `${textToEdit}\n\n${imageMarkdown}` - } - textToEdit += mainTextContent - // if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) { - // // const processedMessage = withMessageThought(clone(message)) - // // textToEdit = getMainTextContent(processedMessage) - // textToEdit = mainTextContent - // } - - const editedText = await TextEditPopup.show({ - text: textToEdit, - children: (props) => { - const onPress = () => { - props.onOk?.() - resendMessage = true - } - return message.role === 'user' ? ( - } - onClick={onPress}> - {t('chat.resend')} - - ) : null - } - }) - - if (editedText && editedText !== textToEdit) { - // 解析编辑后的文本,提取图片 URL - // const imageRegex = /!\[image-\d+\]\((.*?)\)/g - // const imageUrls: string[] = [] - // let match - // let content = editedText - // TODO 按理说图片应该走上传,不应该在这改 - // while ((match = imageRegex.exec(editedText)) !== null) { - // imageUrls.push(match[1]) - // content = content.replace(match[0], '') - // } - if (resendMessage) { - resendUserMessageWithEdit(message, editedText, assistant) - } else { - editMessageBlocks(message.id, { id: findMainTextBlocks(message)[0].id, content: editedText }) - } - // // 更新消息内容,保留图片信息 - // await editMessage(message.id, { - // content: content.trim(), - // metadata: { - // ...message.metadata, - // generateImage: - // imageUrls.length > 0 - // ? { - // type: 'url', - // images: imageUrls - // } - // : undefined - // } - // }) - - // resendMessage && - // handleResendUserMessage({ - // ...message, - // content: content.trim(), - // metadata: { - // ...message.metadata, - // generateImage: - // imageUrls.length > 0 - // ? { - // type: 'url', - // images: imageUrls - // } - // : undefined - // } - // }) - } - }, [resendUserMessageWithEdit, editMessageBlocks, assistant, mainTextContent, message, t]) + startEditing(message.id) + }, [message.id, startEditing]) const handleTranslate = useCallback( async (language: string) => { @@ -584,10 +496,10 @@ const ActionButton = styled.div` } ` -const ReSendButton = styled(Button)` - position: absolute; - top: 10px; - left: 0; -` +// const ReSendButton = styled(Button)` +// position: absolute; +// top: 10px; +// left: 0; +// ` export default memo(MessageMenubar) diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 94460abe12..3bea823906 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -17,11 +17,6 @@ const MessageTools: FC = ({ blocks }) => { const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null) const { t } = useTranslation() const { messageFont, fontSize } = useSettings() - const fontFamily = useMemo(() => { - return messageFont === 'serif' - ? 'serif' - : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif' - }, [messageFont]) const toolResponse = blocks.metadata?.rawMcpToolResponse @@ -119,7 +114,11 @@ const MessageTools: FC = ({ blocks }) => { ), children: isDone && result && ( - + ) @@ -168,7 +167,11 @@ const MessageTools: FC = ({ blocks }) => { transitionName="animation-move-down" styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}> {expandedResponse && ( - + void + onComponentUpdate?(): void + onFirstUpdate?(): void } -const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { +const Messages: React.FC = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => { + const { containerRef: scrollContainerRef, handleScroll: handleScrollPosition } = useScrollPosition( + `topic-${topic.id}` + ) const { t } = useTranslation() const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() const { updateTopic, addTopic } = useAssistant(assistant.id) const dispatch = useAppDispatch() - const containerRef = useRef(null) const [displayMessages, setDisplayMessages] = useState([]) const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) @@ -72,16 +80,16 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) }, [showAssistants, showTopics, topicPosition]) const scrollToBottom = useCallback(() => { - if (containerRef.current) { + if (scrollContainerRef.current) { requestAnimationFrame(() => { - if (containerRef.current) { - containerRef.current.scrollTo({ - top: containerRef.current.scrollHeight + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight }) } }) } - }, []) + }, [scrollContainerRef]) const clearTopic = useCallback( async (data: Topic) => { @@ -115,14 +123,14 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) }) }), EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => { - await captureScrollableDivAsBlob(containerRef, async (blob) => { + await captureScrollableDivAsBlob(scrollContainerRef, async (blob) => { if (blob) { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) } }) }), EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => { - const imageData = await captureScrollableDivAsDataURL(containerRef) + const imageData = await captureScrollableDivAsDataURL(scrollContainerRef) if (imageData) { window.api.file.saveImage(removeSpecialCharactersForFileName(topic.name), imageData) } @@ -183,7 +191,32 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) console.error(`[NEW_BRANCH] Failed to create topic branch for topic ${newTopic.id}`) window.message.error(t('message.branch.error')) // Example error message } - }) + }), + EventEmitter.on( + EVENT_NAMES.EDIT_CODE_BLOCK, + async (data: { msgBlockId: string; codeBlockId: string; newContent: string }) => { + const { msgBlockId, codeBlockId, newContent } = data + + const msgBlock = messageBlocksSelectors.selectById(store.getState(), msgBlockId) + + // FIXME: 目前 error block 没有 content + if (msgBlock && isTextLikeBlock(msgBlock) && msgBlock.type !== MessageBlockType.ERROR) { + try { + const updatedRaw = updateCodeBlock(msgBlock.content, codeBlockId, newContent) + dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } })) + window.message.success({ content: t('code_block.edit.save.success'), key: 'save-code' }) + } catch (error) { + console.error(`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`, error) + window.message.error({ content: t('code_block.edit.save.failed'), key: 'save-code-failed' }) + } + } else { + console.error( + `Failed to save code block ${codeBlockId} content to message block ${msgBlockId}: no such message block or the block doesn't have a content field` + ) + window.message.error({ content: t('code_block.edit.save.failed'), key: 'save-code-failed' }) + } + } + ) ] return () => unsubscribes.forEach((unsub) => unsub()) @@ -196,8 +229,8 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) tokensCount: await estimateHistoryTokens(assistant, messages), contextCount: getContextCount(assistant, messages) }) - }) - }, [assistant, messages]) + }).then(() => onFirstUpdate?.()) + }, [assistant, messages, onFirstUpdate]) const loadMoreMessages = useCallback(() => { if (!hasMore || isLoadingMore) return @@ -221,13 +254,18 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) } }) + useEffect(() => { + requestAnimationFrame(() => onComponentUpdate?.()) + }, []) + const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages]) return ( void + setActiveAssistant: (assistant: Assistant) => void + position: 'left' | 'right' } -const HeaderNavbar: FC = ({ activeAssistant }) => { +const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => { const { assistant } = useAssistant(activeAssistant.id) const { showAssistants, toggleShowAssistants } = useShowAssistants() const { topicPosition, sidebarIcons, narrowMode } = useSettings() const { showTopics, toggleShowTopics } = useShowTopics() const dispatch = useAppDispatch() + const [sidebarHideCooldown, setSidebarHideCooldown] = useState(false) - useShortcut('toggle_show_assistants', () => { - toggleShowAssistants() - }) + // Function to toggle assistants with cooldown + const handleToggleShowAssistants = useCallback(() => { + if (showAssistants) { + // When hiding sidebar, set cooldown + toggleShowAssistants() + setSidebarHideCooldown(true) + // setTimeout(() => { + // setSidebarHideCooldown(false) + // }, 10000) // 10 seconds cooldown + } else { + // When showing sidebar, no cooldown needed + toggleShowAssistants() + } + }, [showAssistants, toggleShowAssistants]) + const handleToggleShowTopics = useCallback(() => { + if (showTopics) { + // When hiding sidebar, set cooldown + toggleShowTopics() + setSidebarHideCooldown(true) + // setTimeout(() => { + // setSidebarHideCooldown(false) + // }, 10000) // 10 seconds cooldown + } else { + // When showing sidebar, no cooldown needed + toggleShowTopics() + } + }, [showTopics, toggleShowTopics]) + + useShortcut('toggle_show_assistants', handleToggleShowAssistants) useShortcut('toggle_show_topics', () => { if (topicPosition === 'right') { @@ -60,7 +90,7 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { {showAssistants && ( - + @@ -73,11 +103,28 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { )} - {!showAssistants && ( + {!showAssistants && !sidebarHideCooldown && ( + + + toggleShowAssistants()} + style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}> + + + + + )} + {!showAssistants && sidebarHideCooldown && ( toggleShowAssistants()} - style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}> + style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }} + onMouseOut={() => setSidebarHideCooldown(false)}> @@ -105,10 +152,33 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { )} - {topicPosition === 'right' && ( - - {showTopics ? : } - + {topicPosition === 'right' && !showTopics && !sidebarHideCooldown && ( + + + toggleShowTopics()}> + + + + + )} + {topicPosition === 'right' && !showTopics && sidebarHideCooldown && ( + + toggleShowTopics()} onMouseOut={() => setSidebarHideCooldown(false)}> + + + + )} + {topicPosition === 'right' && showTopics && ( + + handleToggleShowTopics()}> + + + )} diff --git a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx index a2ed2715ed..ac8e355d5f 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx @@ -241,7 +241,6 @@ const Container = styled.div` padding: 0 10px; height: 37px; position: relative; - font-family: Ubuntu; border-radius: var(--list-item-border-radius); border: 0.5px solid transparent; width: calc(var(--assistants-width) - 20px); diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 52e84445c1..b2f291a837 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -89,7 +89,6 @@ const AssistantAddItem = styled.div` padding: 7px 12px; position: relative; padding-right: 35px; - font-family: Ubuntu; border-radius: var(--list-item-border-radius); border: 0.5px solid transparent; cursor: pointer; diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 7a72a43147..6754ccdb43 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -13,24 +13,23 @@ import { isSupportedFlexServiceTier, isSupportedReasoningEffortOpenAIModel } from '@renderer/config/models' -import { codeThemes } from '@renderer/context/SyntaxHighlighterProvider' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useAssistant } from '@renderer/hooks/useAssistant' import { useProvider } from '@renderer/hooks/useProvider' import { useSettings } from '@renderer/hooks/useSettings' -import { SettingDivider, SettingRow, SettingRowTitle, SettingSubtitle } from '@renderer/pages/settings' +import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings' import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' +import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' import { getDefaultModel } from '@renderer/services/AssistantService' import { useAppDispatch } from '@renderer/store' import { SendMessageShortcut, setAutoTranslateWithSpace, - setCodeCacheable, - setCodeCacheMaxSize, - setCodeCacheThreshold, - setCodeCacheTTL, setCodeCollapsible, + setCodeEditor, + setCodeExecution, + setCodePreview, setCodeShowLineNumbers, - setCodeStyle, setCodeWrappable, setEnableBackspaceDeleteModel, setEnableQuickPanelTriggers, @@ -60,7 +59,7 @@ import { import { modalConfirm } from '@renderer/utils' import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' import { CircleHelp, RotateCcw, Settings2 } from 'lucide-react' -import { FC, useEffect, useState } from 'react' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -73,7 +72,9 @@ interface Props { const SettingsTab: FC = (props) => { const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id) const { provider } = useProvider(assistant.model.provider) - const { messageStyle, codeStyle, fontSize, language } = useSettings() + + const { messageStyle, fontSize, language, theme } = useSettings() + const { themeNames } = useCodeStyle() const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE) const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT) @@ -99,10 +100,9 @@ const SettingsTab: FC = (props) => { codeShowLineNumbers, codeCollapsible, codeWrappable, - codeCacheable, - codeCacheMaxSize, - codeCacheTTL, - codeCacheThreshold, + codeEditor, + codePreview, + codeExecution, mathEngine, autoTranslateWithSpace, pasteLongTextThreshold, @@ -154,6 +154,32 @@ const SettingsTab: FC = (props) => { }) } + const codeStyle = useMemo(() => { + return codeEditor.enabled + ? theme === ThemeMode.light + ? codeEditor.themeLight + : codeEditor.themeDark + : theme === ThemeMode.light + ? codePreview.themeLight + : codePreview.themeDark + }, [ + codeEditor.enabled, + codeEditor.themeLight, + codeEditor.themeDark, + theme, + codePreview.themeLight, + codePreview.themeDark + ]) + + const onCodeStyleChange = useCallback( + (value: CodeStyleVarious) => { + const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark' + const action = codeEditor.enabled ? setCodeEditor : setCodePreview + dispatch(action({ [field]: value })) + }, + [dispatch, theme, codeEditor.enabled] + ) + useEffect(() => { setTemperature(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE) setContextCount(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT) @@ -176,461 +202,494 @@ const SettingsTab: FC = (props) => { return ( - - + - {t('assistants.settings.title')}{' '} +