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【新源力】贡献挑战赛
+
+
+
+
+
+
+
# 📖 使用教程
https://docs.cherry-ai.com
@@ -147,4 +155,4 @@ yinsenho@cherry-ai.com
# ⭐️ Star 记录
-[](https://star-history.com/#kangfenmao/cherry-studio&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 (
} onClick={handleOpenInApp}>
@@ -62,10 +55,6 @@ const Artifacts: FC = ({ html }) => {
} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
-
- } onClick={onDownload}>
- {t('chat.artifacts.button.download')}
-
)
}
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('