From e4fd3096a83a214ee08199c55ce49df0f340dbde Mon Sep 17 00:00:00 2001 From: "tiankuo.zhou" <296260444@qq.com> Date: Fri, 16 May 2025 10:13:53 +0800 Subject: [PATCH 01/44] fix: update mcp sdk version to solve the bug-preserve custom paths in SSE endpoint URLs (#6021) * fix: update mcp sdk to slove the bug - preserve custom paths in SSE endpoint URLs * add signoff Signed-off-by: tiankuo.zhou * add signoff Signed-off-by: tiankuo.zhou --------- Signed-off-by: tiankuo.zhou Co-authored-by: tiankuo.zhou --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 03ffae7b06..97c9086480 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@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", diff --git a/yarn.lock b/yarn.lock index 1dcaa49011..f64f659852 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2673,13 +2673,13 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.10.2": - version: 1.10.2 - resolution: "@modelcontextprotocol/sdk@npm:1.10.2" +"@modelcontextprotocol/sdk@npm:^1.11.3": + version: 1.11.3 + resolution: "@modelcontextprotocol/sdk@npm:1.11.3" dependencies: content-type: "npm:^1.0.5" cors: "npm:^2.8.5" - cross-spawn: "npm:^7.0.3" + cross-spawn: "npm:^7.0.5" eventsource: "npm:^3.0.2" express: "npm:^5.0.1" express-rate-limit: "npm:^7.5.0" @@ -2687,7 +2687,7 @@ __metadata: raw-body: "npm:^3.0.0" zod: "npm:^3.23.8" zod-to-json-schema: "npm:^3.24.1" - checksum: 10c0/a2a146dec37d13c8108b4c42912d65f1f5b0e8f3adda4c300336369519f3caa52e996afb65d6a6c03ae3b6fc1e2425cad4af1e619206b6ee3e15327b4ee01d4c + checksum: 10c0/5bcaf6fb97d886e1a262ba9b44ede91c209bb474a2e5193c9f7d9e2c82829d83775e50f5c37977bc156376e0eca84837da531fe0dfafdc5ad8921db4bf5039c0 languageName: node linkType: hard @@ -4370,7 +4370,7 @@ __metadata: "@iconify-json/svg-spinners": "npm:^1.2.2" "@kangfenmao/keyv-storage": "npm:^0.1.0" "@langchain/community": "npm:^0.3.36" - "@modelcontextprotocol/sdk": "npm:^1.10.2" + "@modelcontextprotocol/sdk": "npm:^1.11.3" "@mozilla/readability": "npm:^0.6.0" "@notionhq/client": "npm:^2.2.15" "@reduxjs/toolkit": "npm:^2.2.5" From da98f07838dd3447a69e85b0eef02bf29de735b7 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 16 May 2025 12:56:52 +0800 Subject: [PATCH 02/44] feat: upgrade electron to 35.2.2 (#5151) * upgrade electron to 35.2.0 * update electron to 32.3.3 * udpate electron-vite to 3.0.0 * upgrade to 35.2.2 * patch https://github.com/electron-userland/electron-builder/pull/9046 * add minimumSystemVersion config * update electron vite --------- Co-authored-by: beyondkmp --- ...p-builder-lib-npm-26.0.15-360e5b0476.patch | 159 ++++++ electron-builder.yml | 1 + package.json | 7 +- yarn.lock | 473 ++++++++++-------- 4 files changed, 429 insertions(+), 211 deletions(-) create mode 100644 .yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch 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/electron-builder.yml b/electron-builder.yml index 2a07e48542..8164bb2fd9 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/package.json b/package.json index 97c9086480..819042befe 100644 --- a/package.json +++ b/package.json @@ -155,11 +155,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", @@ -220,7 +220,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/yarn.lock b/yarn.lock index f64f659852..7734845e01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -222,7 +222,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.24.7": +"@babel/core@npm:^7.26.10": version: 7.26.10 resolution: "@babel/core@npm:7.26.10" dependencies: @@ -363,7 +363,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-arrow-functions@npm:^7.24.7": +"@babel/plugin-transform-arrow-functions@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-transform-arrow-functions@npm:7.25.9" dependencies: @@ -942,13 +942,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/aix-ppc64@npm:0.21.5" - conditions: os=aix & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/aix-ppc64@npm:0.25.2": version: 0.25.2 resolution: "@esbuild/aix-ppc64@npm:0.25.2" @@ -956,10 +949,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/android-arm64@npm:0.21.5" - conditions: os=android & cpu=arm64 +"@esbuild/aix-ppc64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/aix-ppc64@npm:0.25.3" + conditions: os=aix & cpu=ppc64 languageName: node linkType: hard @@ -970,10 +963,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/android-arm@npm:0.21.5" - conditions: os=android & cpu=arm +"@esbuild/android-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/android-arm64@npm:0.25.3" + conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -984,10 +977,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/android-x64@npm:0.21.5" - conditions: os=android & cpu=x64 +"@esbuild/android-arm@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/android-arm@npm:0.25.3" + conditions: os=android & cpu=arm languageName: node linkType: hard @@ -998,10 +991,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/darwin-arm64@npm:0.21.5" - conditions: os=darwin & cpu=arm64 +"@esbuild/android-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/android-x64@npm:0.25.3" + conditions: os=android & cpu=x64 languageName: node linkType: hard @@ -1012,10 +1005,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/darwin-x64@npm:0.21.5" - conditions: os=darwin & cpu=x64 +"@esbuild/darwin-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/darwin-arm64@npm:0.25.3" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -1026,10 +1019,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/freebsd-arm64@npm:0.21.5" - conditions: os=freebsd & cpu=arm64 +"@esbuild/darwin-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/darwin-x64@npm:0.25.3" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -1040,10 +1033,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/freebsd-x64@npm:0.21.5" - conditions: os=freebsd & cpu=x64 +"@esbuild/freebsd-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/freebsd-arm64@npm:0.25.3" + conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -1054,10 +1047,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-arm64@npm:0.21.5" - conditions: os=linux & cpu=arm64 +"@esbuild/freebsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/freebsd-x64@npm:0.25.3" + conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -1068,10 +1061,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-arm@npm:0.21.5" - conditions: os=linux & cpu=arm +"@esbuild/linux-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-arm64@npm:0.25.3" + conditions: os=linux & cpu=arm64 languageName: node linkType: hard @@ -1082,10 +1075,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-ia32@npm:0.21.5" - conditions: os=linux & cpu=ia32 +"@esbuild/linux-arm@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-arm@npm:0.25.3" + conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -1096,10 +1089,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-loong64@npm:0.21.5" - conditions: os=linux & cpu=loong64 +"@esbuild/linux-ia32@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-ia32@npm:0.25.3" + conditions: os=linux & cpu=ia32 languageName: node linkType: hard @@ -1110,10 +1103,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-mips64el@npm:0.21.5" - conditions: os=linux & cpu=mips64el +"@esbuild/linux-loong64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-loong64@npm:0.25.3" + conditions: os=linux & cpu=loong64 languageName: node linkType: hard @@ -1124,10 +1117,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-ppc64@npm:0.21.5" - conditions: os=linux & cpu=ppc64 +"@esbuild/linux-mips64el@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-mips64el@npm:0.25.3" + conditions: os=linux & cpu=mips64el languageName: node linkType: hard @@ -1138,10 +1131,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-riscv64@npm:0.21.5" - conditions: os=linux & cpu=riscv64 +"@esbuild/linux-ppc64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-ppc64@npm:0.25.3" + conditions: os=linux & cpu=ppc64 languageName: node linkType: hard @@ -1152,10 +1145,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-s390x@npm:0.21.5" - conditions: os=linux & cpu=s390x +"@esbuild/linux-riscv64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-riscv64@npm:0.25.3" + conditions: os=linux & cpu=riscv64 languageName: node linkType: hard @@ -1166,10 +1159,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/linux-x64@npm:0.21.5" - conditions: os=linux & cpu=x64 +"@esbuild/linux-s390x@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-s390x@npm:0.25.3" + conditions: os=linux & cpu=s390x languageName: node linkType: hard @@ -1180,6 +1173,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/linux-x64@npm:0.25.3" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.2": version: 0.25.2 resolution: "@esbuild/netbsd-arm64@npm:0.25.2" @@ -1187,10 +1187,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/netbsd-x64@npm:0.21.5" - conditions: os=netbsd & cpu=x64 +"@esbuild/netbsd-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/netbsd-arm64@npm:0.25.3" + conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard @@ -1201,6 +1201,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/netbsd-x64@npm:0.25.3" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.2": version: 0.25.2 resolution: "@esbuild/openbsd-arm64@npm:0.25.2" @@ -1208,10 +1215,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/openbsd-x64@npm:0.21.5" - conditions: os=openbsd & cpu=x64 +"@esbuild/openbsd-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/openbsd-arm64@npm:0.25.3" + conditions: os=openbsd & cpu=arm64 languageName: node linkType: hard @@ -1222,10 +1229,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/sunos-x64@npm:0.21.5" - conditions: os=sunos & cpu=x64 +"@esbuild/openbsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/openbsd-x64@npm:0.25.3" + conditions: os=openbsd & cpu=x64 languageName: node linkType: hard @@ -1236,10 +1243,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/win32-arm64@npm:0.21.5" - conditions: os=win32 & cpu=arm64 +"@esbuild/sunos-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/sunos-x64@npm:0.25.3" + conditions: os=sunos & cpu=x64 languageName: node linkType: hard @@ -1250,10 +1257,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/win32-ia32@npm:0.21.5" - conditions: os=win32 & cpu=ia32 +"@esbuild/win32-arm64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/win32-arm64@npm:0.25.3" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -1264,10 +1271,10 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.21.5": - version: 0.21.5 - resolution: "@esbuild/win32-x64@npm:0.21.5" - conditions: os=win32 & cpu=x64 +"@esbuild/win32-ia32@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/win32-ia32@npm:0.25.3" + conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -1278,6 +1285,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/win32-x64@npm:0.25.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.6.0 resolution: "@eslint-community/eslint-utils@npm:4.6.0" @@ -3871,12 +3885,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.9.0": - version: 20.17.30 - resolution: "@types/node@npm:20.17.30" +"@types/node@npm:^22.7.7": + version: 22.15.3 + resolution: "@types/node@npm:22.15.3" dependencies: - undici-types: "npm:~6.19.2" - checksum: 10c0/649782c7822367d751472d70c948bcc50cded1a4744610f706f81cd54e1fc015523567d7e3e17f6b19e3e2797f6f23b653e898bdb4a2f21f8759ceba49976310 + undici-types: "npm:~6.21.0" + checksum: 10c0/2879f012d1aeba0bfdb5fed80d165f4f2cb3d1f2e1f98a24b18d4a211b4ace7d64bf2622784c78355982ffc1081ba79d0934efc2fb8353913e5871a63609661f languageName: node linkType: hard @@ -4412,14 +4426,14 @@ __metadata: diff: "npm:^7.0.0" docx: "npm:^9.0.2" dotenv-cli: "npm:^7.4.2" - electron: "npm:31.7.6" + electron: "npm:35.2.2" electron-builder: "npm:26.0.15" electron-devtools-installer: "npm:^3.2.0" electron-icon-builder: "npm:^2.0.1" electron-log: "npm:^5.1.5" electron-store: "npm:^8.2.0" electron-updater: "npm:6.6.4" - electron-vite: "npm:^2.3.0" + electron-vite: "npm:^3.1.0" electron-window-state: "npm:^5.0.3" emittery: "npm:^1.0.3" emoji-picker-element: "npm:^1.22.1" @@ -4812,6 +4826,50 @@ __metadata: languageName: node linkType: hard +"app-builder-lib@patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch": + version: 26.0.15 + resolution: "app-builder-lib@patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch::version=26.0.15&hash=b02ae9" + dependencies: + "@develar/schema-utils": "npm:~2.6.5" + "@electron/asar": "npm:3.4.1" + "@electron/fuses": "npm:^1.8.0" + "@electron/notarize": "npm:2.5.0" + "@electron/osx-sign": "npm:1.3.3" + "@electron/rebuild": "npm:3.7.2" + "@electron/universal": "npm:2.0.3" + "@malept/flatpak-bundler": "npm:^0.4.0" + "@types/fs-extra": "npm:9.0.13" + async-exit-hook: "npm:^2.0.1" + builder-util: "npm:26.0.13" + builder-util-runtime: "npm:9.3.2" + chromium-pickle-js: "npm:^0.2.0" + config-file-ts: "npm:0.2.8-rc1" + debug: "npm:^4.3.4" + dotenv: "npm:^16.4.5" + dotenv-expand: "npm:^11.0.6" + ejs: "npm:^3.1.8" + electron-publish: "npm:26.0.13" + fs-extra: "npm:^10.1.0" + hosted-git-info: "npm:^4.1.0" + is-ci: "npm:^3.0.0" + isbinaryfile: "npm:^5.0.0" + js-yaml: "npm:^4.1.0" + json5: "npm:^2.2.3" + lazy-val: "npm:^1.0.5" + minimatch: "npm:^10.0.0" + plist: "npm:3.1.0" + resedit: "npm:^1.7.0" + semver: "npm:^7.3.8" + tar: "npm:^6.1.12" + temp-file: "npm:^3.4.0" + tiny-async-pool: "npm:1.3.0" + peerDependencies: + dmg-builder: 26.0.15 + electron-builder-squirrel-windows: 26.0.15 + checksum: 10c0/616072842c01f9f65283c95bf5642106c32bc3c6679672955f57b48bae9c28de10e18f2005d0e6e46cb2cb560dda3869ebf1412d3db50b7872c5f660581ad6db + languageName: node + linkType: hard + "applescript@npm:^1.0.0": version: 1.0.0 resolution: "applescript@npm:1.0.0" @@ -7031,25 +7089,25 @@ __metadata: languageName: node linkType: hard -"electron-vite@npm:^2.3.0": - version: 2.3.0 - resolution: "electron-vite@npm:2.3.0" +"electron-vite@npm:^3.1.0": + version: 3.1.0 + resolution: "electron-vite@npm:3.1.0" dependencies: - "@babel/core": "npm:^7.24.7" - "@babel/plugin-transform-arrow-functions": "npm:^7.24.7" + "@babel/core": "npm:^7.26.10" + "@babel/plugin-transform-arrow-functions": "npm:^7.25.9" cac: "npm:^6.7.14" - esbuild: "npm:^0.21.5" - magic-string: "npm:^0.30.10" - picocolors: "npm:^1.0.1" + esbuild: "npm:^0.25.1" + magic-string: "npm:^0.30.17" + picocolors: "npm:^1.1.1" peerDependencies: "@swc/core": ^1.0.0 - vite: ^4.0.0 || ^5.0.0 + vite: ^4.0.0 || ^5.0.0 || ^6.0.0 peerDependenciesMeta: "@swc/core": optional: true bin: electron-vite: bin/electron-vite.js - checksum: 10c0/7a8e4358a9b2053bd90c530f001b28837044ced7b8579bedb6002eb2be94d206d986d7f177da9ff93d805facf60e78d1e487ed88b097e4a6afab06f33afef6ac + checksum: 10c0/c5efacf83c869a933d7da390b3312beb47c145339e630f9d3ebbedbe3301ec2b070e4d05668dad28088284bad25c8044736b2339a341b1d89242a4489b0807c8 languageName: node linkType: hard @@ -7063,16 +7121,16 @@ __metadata: languageName: node linkType: hard -"electron@npm:31.7.6": - version: 31.7.6 - resolution: "electron@npm:31.7.6" +"electron@npm:35.2.2": + version: 35.2.2 + resolution: "electron@npm:35.2.2" dependencies: "@electron/get": "npm:^2.0.0" - "@types/node": "npm:^20.9.0" + "@types/node": "npm:^22.7.7" extract-zip: "npm:^2.0.1" bin: electron: cli.js - checksum: 10c0/4b7ee31894eb3606d6a6047cd7af22d3b82331dacb96869c483bfd32ffc8581ef638ccfa027938d83d5242e7bf8b7856cad29a09fb80942a25ef3de0c888fb48 + checksum: 10c0/54f9dac6b7fe6ed3da6aeb72249ec7e2164ae05170cf1e97f5b196d966dc4eb18de012dfec28d18157f6406db33ba446c11b3a9d158d8b20148698241ae7a2da languageName: node linkType: hard @@ -7263,86 +7321,6 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.21.5": - version: 0.21.5 - resolution: "esbuild@npm:0.21.5" - dependencies: - "@esbuild/aix-ppc64": "npm:0.21.5" - "@esbuild/android-arm": "npm:0.21.5" - "@esbuild/android-arm64": "npm:0.21.5" - "@esbuild/android-x64": "npm:0.21.5" - "@esbuild/darwin-arm64": "npm:0.21.5" - "@esbuild/darwin-x64": "npm:0.21.5" - "@esbuild/freebsd-arm64": "npm:0.21.5" - "@esbuild/freebsd-x64": "npm:0.21.5" - "@esbuild/linux-arm": "npm:0.21.5" - "@esbuild/linux-arm64": "npm:0.21.5" - "@esbuild/linux-ia32": "npm:0.21.5" - "@esbuild/linux-loong64": "npm:0.21.5" - "@esbuild/linux-mips64el": "npm:0.21.5" - "@esbuild/linux-ppc64": "npm:0.21.5" - "@esbuild/linux-riscv64": "npm:0.21.5" - "@esbuild/linux-s390x": "npm:0.21.5" - "@esbuild/linux-x64": "npm:0.21.5" - "@esbuild/netbsd-x64": "npm:0.21.5" - "@esbuild/openbsd-x64": "npm:0.21.5" - "@esbuild/sunos-x64": "npm:0.21.5" - "@esbuild/win32-arm64": "npm:0.21.5" - "@esbuild/win32-ia32": "npm:0.21.5" - "@esbuild/win32-x64": "npm:0.21.5" - dependenciesMeta: - "@esbuild/aix-ppc64": - optional: true - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10c0/fa08508adf683c3f399e8a014a6382a6b65542213431e26206c0720e536b31c09b50798747c2a105a4bbba1d9767b8d3615a74c2f7bf1ddf6d836cd11eb672de - languageName: node - linkType: hard - "esbuild@npm:^0.25.0": version: 0.25.2 resolution: "esbuild@npm:0.25.2" @@ -7429,6 +7407,92 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.25.1": + version: 0.25.3 + resolution: "esbuild@npm:0.25.3" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.3" + "@esbuild/android-arm": "npm:0.25.3" + "@esbuild/android-arm64": "npm:0.25.3" + "@esbuild/android-x64": "npm:0.25.3" + "@esbuild/darwin-arm64": "npm:0.25.3" + "@esbuild/darwin-x64": "npm:0.25.3" + "@esbuild/freebsd-arm64": "npm:0.25.3" + "@esbuild/freebsd-x64": "npm:0.25.3" + "@esbuild/linux-arm": "npm:0.25.3" + "@esbuild/linux-arm64": "npm:0.25.3" + "@esbuild/linux-ia32": "npm:0.25.3" + "@esbuild/linux-loong64": "npm:0.25.3" + "@esbuild/linux-mips64el": "npm:0.25.3" + "@esbuild/linux-ppc64": "npm:0.25.3" + "@esbuild/linux-riscv64": "npm:0.25.3" + "@esbuild/linux-s390x": "npm:0.25.3" + "@esbuild/linux-x64": "npm:0.25.3" + "@esbuild/netbsd-arm64": "npm:0.25.3" + "@esbuild/netbsd-x64": "npm:0.25.3" + "@esbuild/openbsd-arm64": "npm:0.25.3" + "@esbuild/openbsd-x64": "npm:0.25.3" + "@esbuild/sunos-x64": "npm:0.25.3" + "@esbuild/win32-arm64": "npm:0.25.3" + "@esbuild/win32-ia32": "npm:0.25.3" + "@esbuild/win32-x64": "npm:0.25.3" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/127aff654310ede4e2eb232a7b1d8823f5b5d69222caf17aa7f172574a5b6b75f71ce78c6d8a40030421d7c75b784dc640de0fb1b87b7ea77ab2a1c832fa8df8 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -10988,7 +11052,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.10, magic-string@npm:^0.30.17": +"magic-string@npm:^0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17" dependencies: @@ -13493,7 +13557,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.1": +"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -16874,13 +16938,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.19.2": - version: 6.19.8 - resolution: "undici-types@npm:6.19.8" - checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 - languageName: node - linkType: hard - "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" From 1d8bf38bfac4ef8dc385293be6b4044c4678e596 Mon Sep 17 00:00:00 2001 From: one Date: Fri, 16 May 2025 13:43:47 +0800 Subject: [PATCH 03/44] feat: code tools, editor, executor (#4632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: code tools, editor, executor CodeEditor & Preview - CodeEditor: CodeMirror 6 - Switch to CodeEditor in the settings - Support edit&save with a accurate diff&lookup strategy - Use CodeEditor for editing MCP json configuration - CodePreview: Original Shiki syntax highlighting - Implemented using a custom Shiki stream tokenizer - Remov code caching as it is incompatible with the current streaming code highlighting - Add a webworker for shiki - Other preview components - Merge MermaidPopup and Mermaid to MermaidPreview, use local mermaidjs - Show mermaid syntax error message on demand - Rename PlantUML to PlantUmlPreview - Rename SyntaxHighlighterProvider to CodeStyleProvider for clarity - Both light and dark themes are preserved for convenience CodeToolbar - Top sticky toolbar provides quick tools (left) and core tools (right) - Quick tools are hidden under the `More` button to avoid clutter, while core tools are always visible - View&edit mode - Allow switching between preview and edit modes - Add a split view Code execution - Pyodide for executing Python scripts - Add a webworker for Pyodide * fix: migrate version and lint error * refactor: use constants for defining tool specs * refactor: add user-select, fix tool specs * refactor: simplify some state changing * fix: make sure editor tools registered after the editor is ready --------- Co-authored-by: 自由的世界人 <3196812536@qq.com> --- electron.vite.config.ts | 15 +- package.json | 6 + src/renderer/src/App.tsx | 6 +- src/renderer/src/assets/styles/markdown.scss | 27 +- .../components/CodeBlockView/CodePreview.tsx | 285 +++ .../CodeBlockView/HtmlArtifacts.tsx} | 13 +- .../CodeBlockView/MermaidPreview.tsx | 99 + .../CodeBlockView/PlantUmlPreview.tsx | 193 ++ .../components/CodeBlockView/StatusBar.tsx | 22 + .../components/CodeBlockView/SvgPreview.tsx | 38 + .../src/components/CodeBlockView/index.tsx | 324 +++ .../src/components/CodeEditor/index.tsx | 251 ++ .../src/components/CodeToolbar/constants.ts | 76 + .../src/components/CodeToolbar/context.tsx | 71 + .../src/components/CodeToolbar/index.ts | 5 + .../src/components/CodeToolbar/toolbar.tsx | 119 + .../src/components/CodeToolbar/types.ts | 35 + .../CodeToolbar/usePreviewTools.tsx | 360 +++ .../src/components/Icons/DownloadIcons.tsx | 68 + .../src/context/CodeStyleProvider.tsx | 149 ++ .../src/context/SyntaxHighlighterProvider.tsx | 109 - src/renderer/src/env.d.ts | 1 - src/renderer/src/hooks/useMermaid.ts | 98 +- src/renderer/src/hooks/useMinappPopup.ts | 105 +- src/renderer/src/i18n/locales/en-us.json | 70 +- src/renderer/src/i18n/locales/ja-jp.json | 70 +- src/renderer/src/i18n/locales/ru-ru.json | 70 +- src/renderer/src/i18n/locales/zh-cn.json | 70 +- src/renderer/src/i18n/locales/zh-tw.json | 70 +- .../middlewares/extractReasoningMiddleware.ts | 18 +- .../src/pages/home/Markdown/CodeBlock.tsx | 449 +--- .../src/pages/home/Markdown/Markdown.tsx | 24 +- .../src/pages/home/Markdown/Mermaid.tsx | 68 - .../src/pages/home/Markdown/MermaidPopup.tsx | 276 -- .../src/pages/home/Markdown/PlantUML.tsx | 338 --- .../src/pages/home/Markdown/SvgPreview.tsx | 16 - .../src/pages/home/Messages/Messages.tsx | 34 +- .../src/pages/home/Tabs/SettingsTab.tsx | 282 +- .../settings/MCPSettings/EditMcpJsonPopup.tsx | 29 +- src/renderer/src/services/CodeCacheService.ts | 219 -- src/renderer/src/services/EventService.ts | 3 +- src/renderer/src/services/PyodideService.ts | 231 ++ .../src/services/ShikiStreamService.ts | 497 ++++ .../src/services/ShikiStreamTokenizer.ts | 111 + .../__tests__/ShikiStreamService.test.ts | 293 +++ .../__tests__/ShikiStreamTokenizer.test.ts | 200 ++ .../helpers/ShikiStreamTokenizer.helper.ts | 95 + src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 34 + src/renderer/src/store/settings.ts | 119 +- src/renderer/src/types/index.ts | 3 +- .../src/utils/__tests__/markdown.test.ts | 193 +- .../src/utils/__tests__/shiki.test.ts | 198 ++ src/renderer/src/utils/markdown.ts | 53 + src/renderer/src/utils/shiki.ts | 31 + .../src/windows/mini/MiniWindowApp.tsx | 6 +- src/renderer/src/workers/pyodide.worker.ts | 155 ++ .../src/workers/shiki-stream.worker.ts | 236 ++ tsconfig.web.json | 1 + vitest.config.ts | 4 +- yarn.lock | 2264 ++++++++++++++++- 61 files changed, 7389 insertions(+), 1918 deletions(-) create mode 100644 src/renderer/src/components/CodeBlockView/CodePreview.tsx rename src/renderer/src/{pages/home/Markdown/Artifacts.tsx => components/CodeBlockView/HtmlArtifacts.tsx} (84%) create mode 100644 src/renderer/src/components/CodeBlockView/MermaidPreview.tsx create mode 100644 src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx create mode 100644 src/renderer/src/components/CodeBlockView/StatusBar.tsx create mode 100644 src/renderer/src/components/CodeBlockView/SvgPreview.tsx create mode 100644 src/renderer/src/components/CodeBlockView/index.tsx create mode 100644 src/renderer/src/components/CodeEditor/index.tsx create mode 100644 src/renderer/src/components/CodeToolbar/constants.ts create mode 100644 src/renderer/src/components/CodeToolbar/context.tsx create mode 100644 src/renderer/src/components/CodeToolbar/index.ts create mode 100644 src/renderer/src/components/CodeToolbar/toolbar.tsx create mode 100644 src/renderer/src/components/CodeToolbar/types.ts create mode 100644 src/renderer/src/components/CodeToolbar/usePreviewTools.tsx create mode 100644 src/renderer/src/components/Icons/DownloadIcons.tsx create mode 100644 src/renderer/src/context/CodeStyleProvider.tsx delete mode 100644 src/renderer/src/context/SyntaxHighlighterProvider.tsx delete mode 100644 src/renderer/src/pages/home/Markdown/Mermaid.tsx delete mode 100644 src/renderer/src/pages/home/Markdown/MermaidPopup.tsx delete mode 100644 src/renderer/src/pages/home/Markdown/PlantUML.tsx delete mode 100644 src/renderer/src/pages/home/Markdown/SvgPreview.tsx delete mode 100644 src/renderer/src/services/CodeCacheService.ts create mode 100644 src/renderer/src/services/PyodideService.ts create mode 100644 src/renderer/src/services/ShikiStreamService.ts create mode 100644 src/renderer/src/services/ShikiStreamTokenizer.ts create mode 100644 src/renderer/src/services/__tests__/ShikiStreamService.test.ts create mode 100644 src/renderer/src/services/__tests__/ShikiStreamTokenizer.test.ts create mode 100644 src/renderer/src/services/__tests__/helpers/ShikiStreamTokenizer.helper.ts create mode 100644 src/renderer/src/utils/__tests__/shiki.test.ts create mode 100644 src/renderer/src/workers/pyodide.worker.ts create mode 100644 src/renderer/src/workers/shiki-stream.worker.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9a70fac51d..0a435c5493 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -73,13 +73,26 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: [] + exclude: ['pyodide'] + }, + worker: { + format: 'es' }, build: { rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html') + }, + output: { + manualChunks: (id) => { + // 检测所有 worker 文件,提取 worker 名称作为 chunk 名 + if (id.includes('.worker') && id.endsWith('?worker')) { + const workerName = id.split('/').pop()?.split('.')[0] || 'worker' + return `workers/${workerName}` + } + return undefined + } } } } diff --git a/package.json b/package.json index 819042befe..3459bdb54d 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,9 @@ "@strongtz/win32-arm64-msvc": "^0.4.7", "@tanstack/react-query": "^5.27.0", "@types/react-infinite-scroll-component": "^5.0.0", + "@uiw/codemirror-extensions-langs": "^4.23.12", + "@uiw/codemirror-themes-all": "^4.23.12", + "@uiw/react-codemirror": "^4.23.12", "archiver": "^7.0.1", "async-mutex": "^0.5.0", "color": "^5.0.0", @@ -84,12 +87,14 @@ "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", "got-scraping": "^4.1.1", "jsdom": "^26.0.0", "markdown-it": "^14.1.0", + "mermaid": "^11.6.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.1.1", "os-proxy-config": "^1.1.2", @@ -145,6 +150,7 @@ "@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", 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/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index e24569b0f2..c96e5b2f58 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -125,7 +125,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) { @@ -304,3 +306,26 @@ emoji-picker { mjx-container { overflow-x: auto; } + +/* CodeMirror 相关样式 */ +.cm-editor { + .cm-scroller { + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + 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..f63f3cecba --- /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 var(--color-code-background); + 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..744abf7f2a --- /dev/null +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -0,0 +1,251 @@ +import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import CodeMirror, { Annotation, 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 + // options used to override the default behaviour + options?: { + maxHeight?: string + } +} + +/** + * 源代码编辑器,基于 CodeMirror + * + * 目前必须和 CodeToolbar 配合使用。 + */ +const CodeEditor = ({ children, language, onSave, onChange, options }: Props) => { + const { fontSize, codeShowLineNumbers, codeCollapsible, codeWrappable, codeEditor } = useSettings() + const { activeCmTheme, languageMap } = useCodeStyle() + const [isExpanded, setIsExpanded] = useState(!codeCollapsible) + const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) + 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 codeCollapsible && (scrollHeight ?? 0) > 350 + }, + onClick: () => setIsExpanded((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.expand.id) + }, [codeCollapsible, 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: () => codeWrappable, + onClick: () => setIsUnwrapped((prev) => !prev) + }) + + return () => removeTool(TOOL_SPECS.wrap.id) + }, [codeWrappable, 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(!codeCollapsible) + }, [codeCollapsible]) + + useEffect(() => { + setIsUnwrapped(!codeWrappable) + }, [codeWrappable]) + + // 保存功能的快捷键 + const saveKeymap = useMemo(() => { + return keymap.of([ + { + key: 'Mod-s', + run: () => { + handleSave() + return true + }, + preventDefault: true + } + ]) + }, [handleSave]) + + const enabledExtensions = useMemo(() => { + return [ + ...langExtension, + ...(isUnwrapped ? [] : [EditorView.lineWrapping]), + ...(codeEditor.keymap ? [saveKeymap] : []) + ] + }, [codeEditor.keymap, langExtension, isUnwrapped, saveKeymap]) + + return ( + { + editorViewRef.current = view + setEditorReady(true) + }} + onChange={(value, viewUpdate) => { + if (onChange && viewUpdate.docChanged) onChange(value) + }} + basicSetup={{ + lineNumbers: codeShowLineNumbers, + highlightActiveLineGutter: codeEditor.highlightActiveLine, + foldGutter: codeEditor.foldGutter, + dropCursor: true, + allowMultipleSelections: true, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + autocompletion: codeEditor.autocompletion, + rectangularSelection: true, + crosshairCursor: true, + highlightActiveLine: codeEditor.highlightActiveLine, + highlightSelectionMatches: true, + closeBracketsKeymap: codeEditor.keymap, + searchKeymap: codeEditor.keymap, + foldKeymap: codeEditor.keymap, + completionKeymap: codeEditor.keymap, + lintKeymap: codeEditor.keymap + }} + style={{ + fontSize: `${fontSize - 1}px`, + overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible', + position: 'relative', + border: '0.5px solid var(--color-code-background)', + borderRadius: '5px', + marginTop: 0 + }} + /> + ) +} + +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/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/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx new file mode 100644 index 0000000000..050f225615 --- /dev/null +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -0,0 +1,149 @@ +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, theme } = useSettings() + 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/SyntaxHighlighterProvider.tsx b/src/renderer/src/context/SyntaxHighlighterProvider.tsx deleted file mode 100644 index 9e94665dbb..0000000000 --- a/src/renderer/src/context/SyntaxHighlighterProvider.tsx +++ /dev/null @@ -1,109 +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 type React from 'react' -import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react' -import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki' - -let highlighterPromise: Promise | null = null - -async function getHighlighter() { - if (!highlighterPromise) { - highlighterPromise = createHighlighter({ - langs: ['javascript', 'typescript', 'python', 'java', 'markdown'], - themes: ['one-light', 'material-theme-darker'] - }) - } - - return await highlighterPromise -} - -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() - - if (!highlighter.getLoadedThemes().includes(highlighterTheme)) { - const themeImportFn = bundledThemes[highlighterTheme] - if (themeImportFn) { - await highlighter.loadTheme(await themeImportFn()) - } - } - - if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) { - const languageImportFn = bundledLanguages[mappedLanguage] - if (languageImportFn) { - await highlighter.loadLanguage(await languageImportFn()) - } - } - - // 生成高亮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/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/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/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 12dcef76ce..94ee6c07a9 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -198,6 +198,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", @@ -303,9 +317,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", @@ -526,21 +563,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", @@ -825,18 +847,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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 4c743a4e34..fef52b7d58 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -198,6 +198,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": "コードブロックキャッシュ", @@ -303,9 +317,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": "追加", @@ -526,21 +563,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": "インポートに成功しました", @@ -825,18 +847,6 @@ "magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します" } }, - "plantuml": { - "download": { - "failed": "ダウンロードに失敗しました。ネットワークを確認してください", - "png": "PNG をダウンロード", - "svg": "SVG をダウンロード" - }, - "tabs": { - "preview": "プレビュー", - "source": "ソースコード" - }, - "title": "PlantUML 図表" - }, "prompts": { "explanation": "この概念を説明してください", "summarize": "このテキストを要約してください", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 30332b88a3..bda4157737 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -198,6 +198,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": "Кэш блока кода", @@ -303,9 +317,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": "Добавить", @@ -526,21 +563,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": "Импорт успешно выполнен", @@ -825,18 +847,6 @@ "magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов" } }, - "plantuml": { - "download": { - "failed": "下载失败,请检查网络", - "png": "下载 PNG", - "svg": "下载 SVG" - }, - "tabs": { - "preview": "Предпросмотр", - "source": "Исходный код" - }, - "title": "PlantUML 图表" - }, "prompts": { "explanation": "Объясните мне этот концепт", "summarize": "Суммируйте этот текст", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index df40aacdbe..852a3ed89e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -212,6 +212,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": "代码块缓存", @@ -303,9 +317,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": "添加", @@ -526,21 +563,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": "导入成功", @@ -825,18 +847,6 @@ "magic_prompt_option_tip": "智能优化放大提示词" } }, - "plantuml": { - "download": { - "failed": "下载失败,请检查网络", - "png": "下载 PNG", - "svg": "下载 SVG" - }, - "tabs": { - "preview": "预览", - "source": "源码" - }, - "title": "PlantUML 图表" - }, "prompts": { "explanation": "帮我解释一下这个概念", "summarize": "帮我总结一下这段话", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index de3c0a9593..980b946f45 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -198,6 +198,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": "程式碼區塊快取", @@ -303,9 +317,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": "新增", @@ -526,21 +563,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": "匯入成功", @@ -825,18 +847,6 @@ "magic_prompt_option_tip": "智能優化放大提示詞" } }, - "plantuml": { - "download": { - "failed": "下載失敗,請檢查網路", - "png": "下載 PNG", - "svg": "下載 SVG" - }, - "tabs": { - "preview": "預覽", - "source": "原始碼" - }, - "title": "PlantUML 圖表" - }, "prompts": { "explanation": "幫我解釋一下這個概念", "summarize": "幫我總結一下這段話", 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 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/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index f69dc678aa..3d95dc609b 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -10,18 +10,21 @@ import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getContextCount, getGroupedMessages, getUserMessage } from '@renderer/services/MessagesService' import { estimateHistoryTokens } from '@renderer/services/TokenService' -import { useAppDispatch } from '@renderer/store' +import store, { useAppDispatch } from '@renderer/store' +import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock' import { newMessagesActions } from '@renderer/store/newMessage' import { saveMessageAndBlocksToDB } from '@renderer/store/thunk/messageThunk' import type { Assistant, Topic } from '@renderer/types' -import type { Message } from '@renderer/types/newMessage' +import { type Message, MessageBlockType } from '@renderer/types/newMessage' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeSpecialCharactersForFileName, runAsyncFunction } from '@renderer/utils' +import { updateCodeBlock } from '@renderer/utils/markdown' import { getMainTextContent } from '@renderer/utils/messageUtils/find' +import { isTextLikeBlock } from '@renderer/utils/messageUtils/is' import { last } from 'lodash' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -183,7 +186,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()) diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 06bd5e335b..43a439a1ae 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -8,7 +8,7 @@ import { isMac, isWindows } from '@renderer/config/constant' -import { codeThemes } from '@renderer/context/SyntaxHighlighterProvider' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { SettingDivider, SettingRow, SettingRowTitle, SettingSubtitle } from '@renderer/pages/settings' @@ -17,13 +17,11 @@ import { useAppDispatch } from '@renderer/store' import { SendMessageShortcut, setAutoTranslateWithSpace, - setCodeCacheable, - setCodeCacheMaxSize, - setCodeCacheThreshold, - setCodeCacheTTL, setCodeCollapsible, + setCodeEditor, + setCodeExecution, + setCodePreview, setCodeShowLineNumbers, - setCodeStyle, setCodeWrappable, setEnableBackspaceDeleteModel, setEnableQuickPanelTriggers, @@ -53,7 +51,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' @@ -63,7 +61,8 @@ interface Props { const SettingsTab: FC = (props) => { const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id) - 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) @@ -89,10 +88,9 @@ const SettingsTab: FC = (props) => { codeShowLineNumbers, codeCollapsible, codeWrappable, - codeCacheable, - codeCacheMaxSize, - codeCacheTTL, - codeCacheThreshold, + codeEditor, + codePreview, + codeExecution, mathEngine, autoTranslateWithSpace, pasteLongTextThreshold, @@ -144,6 +142,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) @@ -291,97 +315,6 @@ const SettingsTab: FC = (props) => { /> - - {t('chat.settings.show_line_numbers')} - dispatch(setCodeShowLineNumbers(checked))} - /> - - - - {t('chat.settings.code_collapsible')} - dispatch(setCodeCollapsible(checked))} - /> - - - - {t('chat.settings.code_wrappable')} - dispatch(setCodeWrappable(checked))} /> - - - - - {t('chat.settings.code_cacheable')}{' '} - - - - - dispatch(setCodeCacheable(checked))} /> - - {codeCacheable && ( - <> - - - - {t('chat.settings.code_cache_max_size')} - - - - - dispatch(setCodeCacheMaxSize(value ?? 1000))} - style={{ width: 80 }} - /> - - - - - {t('chat.settings.code_cache_ttl')} - - - - - dispatch(setCodeCacheTTL(value ?? 15))} - style={{ width: 80 }} - /> - - - - - {t('chat.settings.code_cache_threshold')} - - - - - dispatch(setCodeCacheThreshold(value ?? 2))} - style={{ width: 80 }} - /> - - - )} - {t('chat.settings.thought_auto_collapse')} @@ -437,21 +370,6 @@ const SettingsTab: FC = (props) => { - - {t('message.message.code_style')} - dispatch(setCodeStyle(value as CodeStyleVarious))} - style={{ width: 135 }} - size="small"> - {codeThemes.map((theme) => ( - - {theme} - - ))} - - - {t('settings.messages.math_engine')} = (props) => { - {t('settings.messages.input.title')} + {t('chat.settings.code.title')} + + + {t('message.message.code_style')} + onCodeStyleChange(value as CodeStyleVarious)} + style={{ width: 135 }} + size="small"> + {themeNames.map((theme) => ( + + {theme} + + ))} + + + + + + {t('chat.settings.code_execution.title')} + + + + + dispatch(setCodeExecution({ enabled: checked }))} + /> + + {codeExecution.enabled && ( + <> + + + + {t('chat.settings.code_execution.timeout_minutes')} + + + + + dispatch(setCodeExecution({ timeoutMinutes: value ?? 1 }))} + style={{ width: 80 }} + /> + + + )} + + + {t('chat.settings.code_editor.title')} + dispatch(setCodeEditor({ enabled: checked }))} + /> + + {codeEditor.enabled && ( + <> + + + {t('chat.settings.code_editor.highlight_active_line')} + dispatch(setCodeEditor({ highlightActiveLine: checked }))} + /> + + + + {t('chat.settings.code_editor.fold_gutter')} + dispatch(setCodeEditor({ foldGutter: checked }))} + /> + + + + {t('chat.settings.code_editor.autocompletion')} + dispatch(setCodeEditor({ autocompletion: checked }))} + /> + + + + {t('chat.settings.code_editor.keymap')} + dispatch(setCodeEditor({ keymap: checked }))} + /> + + + )} + + + {t('chat.settings.show_line_numbers')} + dispatch(setCodeShowLineNumbers(checked))} + /> + + + + {t('chat.settings.code_collapsible')} + dispatch(setCodeCollapsible(checked))} + /> + + + + {t('chat.settings.code_wrappable')} + dispatch(setCodeWrappable(checked))} /> + + + + {t('settings.messages.input.title')} {t('settings.messages.input.show_estimated_tokens')} diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index fc4324801a..894a6d0948 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -1,10 +1,11 @@ +import CodeEditor from '@renderer/components/CodeEditor' +import { CodeToolbarProvider } from '@renderer/components/CodeToolbar' import { TopView } from '@renderer/components/TopView' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setMCPServers } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' import { Modal, Typography } from 'antd' -import TextArea from 'antd/es/input/TextArea' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' interface Props { @@ -99,6 +100,10 @@ const PopupContainer: React.FC = ({ resolve }) => { resolve({}) } + const handleChange = useCallback((newContent: string) => { + setJsonConfig(newContent) + }, []) + EditMcpJsonPopup.hide = onCancel return ( @@ -118,17 +123,15 @@ const PopupContainer: React.FC = ({ resolve }) => { {jsonError ? {jsonError} : ''}
- + ))} + {(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/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/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 78f2876212..099bc005da 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -971,22 +971,7 @@ export const resendMessageThunk = * of its associated assistant responses using resendMessageThunk. */ export const resendUserMessageWithEditThunk = - ( - topicId: Topic['id'], - originalMessage: Message, - mainTextBlockId: string, - editedContent: string, - assistant: Assistant - ) => - async (dispatch: AppDispatch) => { - const blockChanges = { - content: editedContent, - updatedAt: new Date().toISOString() - } - // Update block in Redux and DB - dispatch(updateOneBlock({ id: mainTextBlockId, changes: blockChanges })) - await db.message_blocks.update(mainTextBlockId, blockChanges) - + (topicId: Topic['id'], originalMessage: Message, assistant: Assistant) => async (dispatch: AppDispatch) => { // Trigger the regeneration logic for associated assistant messages dispatch(resendMessageThunk(topicId, originalMessage, assistant)) } @@ -1411,14 +1396,14 @@ export const updateMessageAndBlocksThunk = topicId: string, // Allow messageUpdates to be optional or just contain the ID if only blocks are updated messageUpdates: (Partial & Pick) | null, // ID is always required for context - blockUpdatesList: Partial[] // Block updates remain required for this thunk's purpose + blockUpdatesList: MessageBlock[] // Block updates remain required for this thunk's purpose ) => - async (dispatch: AppDispatch): Promise => { + async (dispatch: AppDispatch): Promise => { const messageId = messageUpdates?.id if (messageUpdates && !messageId) { - console.error('[updateMessageAndBlocksThunk] Message ID is required.') - return false + console.error('[updateMessageAndUpdateBlocksThunk] Message ID is required.') + return } try { @@ -1434,14 +1419,7 @@ export const updateMessageAndBlocksThunk = } if (blockUpdatesList.length > 0) { - blockUpdatesList.forEach((blockUpdate) => { - const { id: blockId, ...blockChanges } = blockUpdate - if (blockId && Object.keys(blockChanges).length > 0) { - dispatch(updateOneBlock({ id: blockId, changes: blockChanges })) - } else if (!blockId) { - console.warn('[updateMessageAndBlocksThunk] Skipping block update due to missing block ID:', blockUpdate) - } - }) + dispatch(upsertManyBlocks(blockUpdatesList)) } // 2. 更新数据库 (在事务中) @@ -1468,27 +1446,57 @@ export const updateMessageAndBlocksThunk = } } - // Always process block updates if the list is provided and not empty if (blockUpdatesList.length > 0) { - const validBlockUpdatesForDb = blockUpdatesList - .map((bu) => { - const { id, ...changes } = bu - if (id && Object.keys(changes).length > 0) { - return { key: id, changes: changes } - } - return null - }) - .filter((bu) => bu !== null) as { key: string; changes: Partial }[] + await db.message_blocks.bulkPut(blockUpdatesList) + } + }) + } catch (error) { + console.error(`[updateMessageAndBlocksThunk] Failed to process updates for message ${messageId}:`, error) + } + } - if (validBlockUpdatesForDb.length > 0) { - await db.message_blocks.bulkUpdate(validBlockUpdatesForDb) - } +export const removeBlocksThunk = + (topicId: string, messageId: string, blockIdsToRemove: string[]) => + async (dispatch: AppDispatch, getState: () => RootState): Promise => { + if (!blockIdsToRemove.length) { + console.warn('[removeBlocksFromMessageThunk] No block IDs provided to remove.') + return + } + + try { + const state = getState() + const message = state.messages.entities[messageId] + + if (!message) { + console.error(`[removeBlocksFromMessageThunk] Message ${messageId} not found in state.`) + return + } + const blockIdsToRemoveSet = new Set(blockIdsToRemove) + + const updatedBlockIds = (message.blocks || []).filter((id) => !blockIdsToRemoveSet.has(id)) + + // 1. Update Redux state + dispatch(newMessagesActions.updateMessage({ topicId, messageId, updates: { blocks: updatedBlockIds } })) + + if (blockIdsToRemove.length > 0) { + dispatch(removeManyBlocks(blockIdsToRemove)) + } + + const finalMessagesToSave = selectMessagesForTopic(getState(), topicId) + + // 2. Update database (in a transaction) + await db.transaction('rw', db.topics, db.message_blocks, async () => { + // Update the message in the topic + await db.topics.update(topicId, { messages: finalMessagesToSave }) + // Delete the blocks from the database + if (blockIdsToRemove.length > 0) { + await db.message_blocks.bulkDelete(blockIdsToRemove) } }) - return true + return } catch (error) { - console.error(`[updateMessageAndBlocksThunk] Failed to process updates for message ${messageId}:`, error) - return false + console.error(`[removeBlocksFromMessageThunk] Failed to remove blocks from message ${messageId}:`, error) + throw error } } diff --git a/src/renderer/src/utils/messageUtils/find.ts b/src/renderer/src/utils/messageUtils/find.ts index 64e8beba05..dd3ae7f92b 100644 --- a/src/renderer/src/utils/messageUtils/find.ts +++ b/src/renderer/src/utils/messageUtils/find.ts @@ -1,16 +1,33 @@ import store from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { FileType } from '@renderer/types' import type { CitationMessageBlock, FileMessageBlock, ImageMessageBlock, MainTextMessageBlock, Message, + MessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' +export const findAllBlocks = (message: Message): MessageBlock[] => { + if (!message || !message.blocks || message.blocks.length === 0) { + return [] + } + const state = store.getState() + const allBlocks: MessageBlock[] = [] + for (const blockId of message.blocks) { + const block = messageBlocksSelectors.selectById(state, blockId) + if (block) { + allBlocks.push(block) + } + } + return allBlocks +} + /** * Finds all MainTextMessageBlocks associated with a given message, in order. * @param message - The message object. @@ -122,6 +139,28 @@ export const getKnowledgeBaseIds = (message: Message): string[] | undefined => { return firstTextBlock?.flatMap((block) => block.knowledgeBaseIds).filter((id): id is string => Boolean(id)) } +/** + * Gets the file content from all FileMessageBlocks and ImageMessageBlocks of a message. + * @param message - The message object. + * @returns The file content or an empty string if no file blocks are found. + */ +export const getFileContent = (message: Message): FileType[] => { + const files: FileType[] = [] + const fileBlocks = findFileBlocks(message) + for (const block of fileBlocks) { + if (block.file) { + files.push(block.file) + } + } + const imageBlocks = findImageBlocks(message) + for (const block of imageBlocks) { + if (block.file) { + files.push(block.file) + } + } + return files +} + /** * Finds all CitationBlocks associated with a given message. * @param message - The message object. From 8c9186a0ee05ea696c832ea5cad845f404cd1101 Mon Sep 17 00:00:00 2001 From: Morax <100508620+fzlzjerry@users.noreply.github.com> Date: Mon, 19 May 2025 16:30:07 +0800 Subject: [PATCH 41/44] feat: Add-aihubmix-ideogram-v3 (#5958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update Yarn version to 4.9.1 and add rendering speed option for V3 model * Discard changes to .yarnrc.yml * Discard changes to .yarn/releases/yarn-4.6.0.cjs * Update package.json * Delete .yarn/releases/yarn-4.9.1.cjs * Discard changes to .yarn/releases/yarn-4.6.0.cjs * docs: Update README.zh.md add GitCode✖️Cherry Studio【新源力】贡献挑战赛 GitCode✖️Cherry Studio【新源力】贡献挑战赛 * fix: Update the file permissions for yarn-4.6.0.cjs, modify image links and formatting in README.zh.md * clean * refactor: improve switch case blocks and update config parameters in AihubmixPage * feat: add error handling for empty image URLs and update localization messages * refactor: replace modal error handling with warning messages for empty image URLs * feat: update localization for rendering speed and translating messages in Japanese, Russian, and Traditional Chinese * feat: add style types and rendering speed options to localization files --------- Co-authored-by: 亢奋猫 Co-authored-by: suyao --- docs/README.zh.md | 2 +- src/renderer/src/i18n/locales/en-us.json | 29 +- src/renderer/src/i18n/locales/ja-jp.json | 29 +- src/renderer/src/i18n/locales/ru-ru.json | 62 +- src/renderer/src/i18n/locales/zh-cn.json | 29 +- src/renderer/src/i18n/locales/zh-tw.json | 43 +- .../src/pages/paintings/AihubmixPage.tsx | 563 +++++++++++++++--- .../src/pages/paintings/PaintingsPage.tsx | 17 + .../pages/paintings/config/aihubmixConfig.tsx | 80 ++- .../src/pages/paintings/config/constants.ts | 44 +- src/renderer/src/types/index.ts | 4 + 11 files changed, 761 insertions(+), 141 deletions(-) diff --git a/docs/README.zh.md b/docs/README.zh.md index 670a8420f2..1e4876a820 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -155,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/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 7d9dfcd1f1..17f2823664 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -591,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", @@ -802,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", @@ -809,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", @@ -816,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", @@ -840,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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 966245c786..badd3a8fb5 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -591,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キーを入力してください", @@ -809,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": "部分編集", @@ -821,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": "編集画像", @@ -829,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 モデルのバージョンを選択します", @@ -840,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": "拡大する画像", @@ -851,7 +868,9 @@ "number_images_tip": "生成される拡大結果の数", "seed_tip": "拡大結果のランダム性を制御します", "magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します" - } + }, + "rendering_speed": "レンダリング速度", + "translating": "翻訳中..." }, "prompts": { "explanation": "この概念を説明してください", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index d8e703b9b9..ad15202b9d 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -591,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 ключ", @@ -808,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": "Редактирование", @@ -816,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": "Изображение для увеличения", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 3dd926865f..e5a95672e7 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -591,6 +591,7 @@ "copied": "已复制", "copy.failed": "复制失败", "copy.success": "复制成功", + "empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇", "error.chunk_overlap_too_large": "分段重叠不能大于分段大小", "error.dimension_too_large": "内容尺寸过大", "error.enter.api.host": "请输入您的 API 地址", @@ -802,6 +803,7 @@ "model": "版本", "aspect_ratio": "画幅比例", "style_type": "风格", + "rendering_speed": "渲染速度", "learn_more": "了解更多", "prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹", "proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连", @@ -809,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": "编辑", @@ -816,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 模型版本", @@ -840,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": "需要放大的图片", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 87b79acd32..6081da329e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -590,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 主機地址", @@ -809,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": "編輯", @@ -816,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 模型版本", @@ -840,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": "需要放大的圖片", @@ -851,7 +869,8 @@ "number_images_tip": "生成的放大結果數量", "seed_tip": "控制放大結果的隨機性", "magic_prompt_option_tip": "智能優化放大提示詞" - } + }, + "rendering_speed": "渲染速度" }, "prompts": { "explanation": "幫我解釋一下這個概念", diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index 6d1a7eaa18..f8b69e1466 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -136,21 +136,151 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { // 不使用 AiProvider 的通用规则,而是直接调用自定义接口 try { if (mode === 'generate') { - const requestData = { - image_request: { - prompt, - model: painting.model, - aspect_ratio: painting.aspectRatio, - num_images: painting.numImages, - style_type: painting.styleType, - seed: painting.seed ? +painting.seed : undefined, - negative_prompt: painting.negativePrompt || undefined, - magic_prompt_option: painting.magicPromptOption ? 'ON' : 'OFF' + if (painting.model === 'V_3') { + // V3 API uses different endpoint and parameters format + const formData = new FormData() + formData.append('prompt', prompt) + + // 确保渲染速度参数正确传递 + const renderSpeed = painting.renderingSpeed || 'DEFAULT' + console.log('使用渲染速度:', renderSpeed) + formData.append('rendering_speed', renderSpeed) + + formData.append('num_images', String(painting.numImages || 1)) + + // Convert aspect ratio format from ASPECT_1_1 to 1x1 for V3 API + if (painting.aspectRatio) { + const aspectRatioValue = painting.aspectRatio.replace('ASPECT_', '').replace('_', 'x').toLowerCase() + console.log('转换后的宽高比:', aspectRatioValue) + formData.append('aspect_ratio', aspectRatioValue) } + + if (painting.styleType && painting.styleType !== 'AUTO') { + // 确保样式类型与API文档一致,保持大写形式 + // V3 API支持的样式类型: AUTO, GENERAL, REALISTIC, DESIGN + const styleType = painting.styleType + console.log('使用样式类型:', styleType) + formData.append('style_type', styleType) + } else { + // 确保明确设置默认样式类型 + console.log('使用默认样式类型: AUTO') + formData.append('style_type', 'AUTO') + } + + if (painting.seed) { + console.log('使用随机种子:', painting.seed) + formData.append('seed', painting.seed) + } + + if (painting.negativePrompt) { + console.log('使用负面提示词:', painting.negativePrompt) + formData.append('negative_prompt', painting.negativePrompt) + } + + if (painting.magicPromptOption !== undefined) { + const magicPrompt = painting.magicPromptOption ? 'ON' : 'OFF' + console.log('使用魔法提示词:', magicPrompt) + formData.append('magic_prompt', magicPrompt) + } + + // 打印所有FormData内容 + console.log('FormData内容:') + for (const pair of formData.entries()) { + console.log(pair[0] + ': ' + pair[1]) + } + + body = formData + // For V3 endpoints - 使用模板字符串而不是字符串连接 + console.log('API 端点:', `${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/generate`) + + // 调整请求头,可能需要指定multipart/form-data + // 注意:FormData会自动设置Content-Type,不应手动设置 + const apiHeaders = { 'Api-Key': aihubmixProvider.apiKey } + + try { + const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/generate`, { + method: 'POST', + headers: apiHeaders, + body + }) + + if (!response.ok) { + const errorData = await response.json() + console.error('V3 API错误:', errorData) + throw new Error(errorData.error?.message || '生成图像失败') + } + + const data = await response.json() + console.log('V3 API响应:', data) + const urls = data.data.map((item) => item.url) + + // Rest of the code for handling image downloads is the same + if (urls.length > 0) { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + // 检查URL是否为空 + if (!url || url.trim() === '') { + console.error('图像URL为空,可能是提示词违禁') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + console.error('下载图像失败:', error) + // 检查是否是URL解析错误 + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + return + } catch (error: unknown) { + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: getErrorMessage(error), + centered: true + }) + } + } finally { + setIsLoading(false) + dispatch(setGenerating(false)) + setAbortController(null) + } + } else { + // Existing V1/V2 API + const requestData = { + image_request: { + prompt, + model: painting.model, + aspect_ratio: painting.aspectRatio, + num_images: painting.numImages, + style_type: painting.styleType, + seed: painting.seed ? +painting.seed : undefined, + negative_prompt: painting.negativePrompt || undefined, + magic_prompt_option: painting.magicPromptOption ? 'ON' : 'OFF' + } + } + body = JSON.stringify(requestData) + headers['Content-Type'] = 'application/json' } - body = JSON.stringify(requestData) - headers['Content-Type'] = 'application/json' - } else { + } else if (mode === 'remix') { if (!painting.imageFile) { window.modal.error({ content: t('paintings.image_file_required'), @@ -165,67 +295,311 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { }) return } - const form = new FormData() - let imageRequest: Record = { - prompt, - num_images: painting.numImages, - seed: painting.seed ? +painting.seed : undefined, - magic_prompt_option: painting.magicPromptOption ? 'ON' : 'OFF' - } - if (mode === 'remix') { - imageRequest = { - ...imageRequest, + + if (painting.model === 'V_3') { + // V3 Remix API + const formData = new FormData() + formData.append('prompt', prompt) + formData.append('rendering_speed', painting.renderingSpeed || 'DEFAULT') + formData.append('num_images', String(painting.numImages || 1)) + + // Convert aspect ratio format for V3 API + if (painting.aspectRatio) { + const aspectRatioValue = painting.aspectRatio.replace('ASPECT_', '').replace('_', 'x').toLowerCase() + formData.append('aspect_ratio', aspectRatioValue) + } + + if (painting.styleType) { + formData.append('style_type', painting.styleType) + } + + if (painting.seed) { + formData.append('seed', painting.seed) + } + + if (painting.negativePrompt) { + formData.append('negative_prompt', painting.negativePrompt) + } + + if (painting.magicPromptOption !== undefined) { + formData.append('magic_prompt', painting.magicPromptOption ? 'ON' : 'OFF') + } + + if (painting.imageWeight) { + formData.append('image_weight', String(painting.imageWeight)) + } + + // Add the image file + formData.append('image', fileMap[painting.imageFile] as unknown as Blob) + + body = formData + // For V3 Remix endpoint + const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/remix`, { + method: 'POST', + headers: { 'Api-Key': aihubmixProvider.apiKey }, + body + }) + + if (!response.ok) { + const errorData = await response.json() + console.error('V3 Remix API错误:', errorData) + throw new Error(errorData.error?.message || '图像混合失败') + } + + const data = await response.json() + console.log('V3 Remix API响应:', data) + const urls = data.data.map((item) => item.url) + + // Handle the downloaded images + if (urls.length > 0) { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + // 检查URL是否为空 + if (!url || url.trim() === '') { + console.error('图像URL为空,可能是提示词违禁') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + console.error('下载图像失败:', error) + // 检查是否是URL解析错误 + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + return + } else { + // Existing V1/V2 API for remix + const form = new FormData() + const imageRequest: Record = { + prompt, model: painting.model, aspect_ratio: painting.aspectRatio, image_weight: painting.imageWeight, - style_type: painting.styleType + style_type: painting.styleType, + num_images: painting.numImages, + seed: painting.seed ? +painting.seed : undefined, + negative_prompt: painting.negativePrompt || undefined, + magic_prompt_option: painting.magicPromptOption ? 'ON' : 'OFF' } - } else if (mode === 'upscale') { - imageRequest = { - ...imageRequest, - resemblance: painting.resemblance, - detail: painting.detail + form.append('image_request', JSON.stringify(imageRequest)) + form.append('image_file', fileMap[painting.imageFile] as unknown as Blob) + body = form + } + } else if (mode === 'edit') { + if (!painting.imageFile) { + window.modal.error({ + content: t('paintings.image_file_required'), + centered: true + }) + return + } + if (!fileMap[painting.imageFile]) { + window.modal.error({ + content: t('paintings.image_file_retry'), + centered: true + }) + return + } + + if (painting.model === 'V_3') { + // V3 Edit API + const formData = new FormData() + formData.append('prompt', prompt) + formData.append('rendering_speed', painting.renderingSpeed || 'DEFAULT') + formData.append('num_images', String(painting.numImages || 1)) + + if (painting.styleType) { + formData.append('style_type', painting.styleType) } - } else if (mode === 'edit') { - imageRequest = { - ...imageRequest, + + if (painting.seed) { + formData.append('seed', painting.seed) + } + + if (painting.magicPromptOption !== undefined) { + formData.append('magic_prompt', painting.magicPromptOption ? 'ON' : 'OFF') + } + + // Add the image file + formData.append('image', fileMap[painting.imageFile] as unknown as Blob) + + // Add the mask if available + if (painting.mask) { + formData.append('mask', painting.mask as unknown as Blob) + } + + body = formData + // For V3 Edit endpoint + const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/v1/ideogram-v3/edit`, { + method: 'POST', + headers: { 'Api-Key': aihubmixProvider.apiKey }, + body + }) + + if (!response.ok) { + const errorData = await response.json() + console.error('V3 Edit API错误:', errorData) + throw new Error(errorData.error?.message || '图像编辑失败') + } + + const data = await response.json() + console.log('V3 Edit API响应:', data) + const urls = data.data.map((item) => item.url) + + // Handle the downloaded images + if (urls.length > 0) { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + // 检查URL是否为空 + if (!url || url.trim() === '') { + console.error('图像URL为空,可能是提示词违禁') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + console.error('下载图像失败:', error) + // 检查是否是URL解析错误 + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + return + } else { + // Existing V1/V2 API for edit + const form = new FormData() + const imageRequest: Record = { + prompt, model: painting.model, - style_type: painting.styleType + style_type: painting.styleType, + num_images: painting.numImages, + seed: painting.seed ? +painting.seed : undefined, + magic_prompt_option: painting.magicPromptOption ? 'ON' : 'OFF' } + form.append('image_request', JSON.stringify(imageRequest)) + form.append('image_file', fileMap[painting.imageFile] as unknown as Blob) + body = form + } + } else if (mode === 'upscale') { + if (!painting.imageFile) { + window.modal.error({ + content: t('paintings.image_file_required'), + centered: true + }) + return + } + if (!fileMap[painting.imageFile]) { + window.modal.error({ + content: t('paintings.image_file_retry'), + centered: true + }) + return + } + + const form = new FormData() + const imageRequest: Record = { + prompt, + resemblance: painting.resemblance, + detail: painting.detail, + num_images: painting.numImages, + seed: painting.seed ? +painting.seed : undefined, + magic_prompt_option: painting.magicPromptOption ? 'AUTO' : 'OFF' } form.append('image_request', JSON.stringify(imageRequest)) form.append('image_file', fileMap[painting.imageFile] as unknown as Blob) body = form } - // 直接调用自定义接口 - const response = await fetch(aihubmixProvider.apiHost + `/ideogram/` + mode, { method: 'POST', headers, body }) + // 只针对非V3模型使用通用接口 + if (!painting.model?.includes('V_3')) { + // 直接调用自定义接口 + const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/${mode}`, { method: 'POST', headers, body }) - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error?.message || '生成图像失败') - } + if (!response.ok) { + const errorData = await response.json() + console.error('通用API错误:', errorData) + throw new Error(errorData.error?.message || '生成图像失败') + } - const data = await response.json() - const urls = data.data.map((item: any) => item.url) + const data = await response.json() + console.log('通用API响应:', data) + const urls = data.data.map((item) => item.url) - if (urls.length > 0) { - const downloadedFiles = await Promise.all( - urls.map(async (url) => { - try { - return await window.api.file.download(url) - } catch (error) { - console.error('下载图像失败:', error) - return null - } - }) - ) + if (urls.length > 0) { + const downloadedFiles = await Promise.all( + urls.map(async (url) => { + try { + // 检查URL是否为空 + if (!url || url.trim() === '') { + console.error('图像URL为空,可能是提示词违禁') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + console.error('下载图像失败:', error) + // 检查是否是URL解析错误 + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) - const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) - await FileManager.addFiles(validFiles) + await FileManager.addFiles(validFiles) - updatePaintingState({ files: validFiles, urls }) + updatePaintingState({ files: validFiles, urls }) + } } } catch (error: unknown) { if (error instanceof Error && error.name !== 'AbortError') { @@ -246,9 +620,28 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { const downloadedFiles = await Promise.all( painting.urls.map(async (url) => { try { + // 检查URL是否为空 + if (!url || url.trim() === '') { + console.error('图像URL为空,可能是提示词违禁') + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } return await window.api.file.download(url) } catch (error) { console.error('下载图像失败:', error) + // 检查是否是URL解析错误 + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } setIsLoading(false) return null } @@ -363,7 +756,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { // 渲染配置项的函数 const renderConfigItem = (item: ConfigItem, index: number) => { switch (item.type) { - case 'title': + case 'title': { return ( {t(item.title!)} @@ -374,30 +767,60 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { )} ) - case 'select': + } + case 'select': { + // 处理函数类型的disabled属性 + const isDisabled = typeof item.disabled === 'function' ? item.disabled(item, painting) : item.disabled + + // 处理函数类型的options属性 + const selectOptions = + typeof item.options === 'function' + ? item.options(item, painting).map((option) => ({ + ...option, + label: option.label.startsWith('paintings.') ? t(option.label) : option.label + })) + : item.options?.map((option) => ({ + ...option, + label: option.label.startsWith('paintings.') ? t(option.label) : option.label + })) + return (