mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
Merge branch 'develop'
This commit is contained in:
commit
1469675a20
159
.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch
vendored
Normal file
159
.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch
vendored
Normal file
@ -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": [
|
||||
{
|
||||
@ -18,6 +18,14 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
❤️ 喜欢 Cherry Studio? 点亮小星星 🌟 或 [赞助开发者](sponsor.md)! ❤️
|
||||
|
||||
# GitCode✖️Cherry Studio【新源力】贡献挑战赛
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitcode.com/CherryHQ/cherry-studio/discussion/2">
|
||||
<img src="https://raw.gitcode.com/user-images/assets/5007375/8d8d7559-1141-4691-b90f-d154558c6896/cherry-studio-gitcode.jpg" width="100%" alt="banner" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 📖 使用教程
|
||||
|
||||
https://docs.cherry-ai.com
|
||||
@ -147,4 +155,4 @@ yinsenho@cherry-ai.com
|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
[](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
|
||||
@ -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.
|
||||
|
||||
@ -73,7 +73,10 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: []
|
||||
exclude: ['pyodide']
|
||||
},
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@ -82,11 +85,18 @@ export default defineConfig({
|
||||
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
|
||||
},
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
manualChunks: (id: string) => {
|
||||
// 检测所有 worker 文件,提取 worker 名称作为 chunk 名
|
||||
if (id.includes('.worker') && id.endsWith('?worker')) {
|
||||
const workerName = id.split('/').pop()?.split('.')[0] || 'worker'
|
||||
return `workers/${workerName}`
|
||||
}
|
||||
|
||||
// All node_modules are in the vendor chunk
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor'
|
||||
}
|
||||
|
||||
// Other modules use default chunk splitting strategy
|
||||
return undefined
|
||||
}
|
||||
|
||||
30
package.json
30
package.json
@ -84,6 +84,7 @@
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
@ -94,8 +95,6 @@
|
||||
"officeparser": "^4.1.1",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react-window": "^1.8.11",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@ -118,17 +117,14 @@
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "^0.13.0",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@iconify-json/svg-spinners": "^1.2.2",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"@modelcontextprotocol/sdk": "^1.11.3",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.2.2",
|
||||
"@swc/plugin-styled-components": "^7.1.3",
|
||||
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/adm-zip": "^0",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/lodash": "^4.17.5",
|
||||
@ -142,24 +138,25 @@
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/ws": "^8",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"@vitest/web-worker": "^3.1.3",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "^5.22.5",
|
||||
"applescript": "^1.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "31.7.6",
|
||||
"electron": "35.2.2",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^2.3.0",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"eslint": "^9.22.0",
|
||||
@ -173,12 +170,14 @@
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"p-queue": "^8.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rc-virtual-list": "^3.18.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
@ -189,6 +188,7 @@
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
"react-spinners": "^0.14.1",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
@ -198,12 +198,11 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.77.2",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.2.2",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"tokenx": "^0.4.1",
|
||||
"typescript": "^5.6.2",
|
||||
"uuid": "^10.0.0",
|
||||
@ -220,7 +219,8 @@
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"shiki": "3.2.2",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch"
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"lint-staged": {
|
||||
|
||||
@ -77,7 +77,8 @@ class BackupManager {
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
data: string,
|
||||
destinationPath: string = this.backupDir
|
||||
destinationPath: string = this.backupDir,
|
||||
skipBackupFile: boolean = false
|
||||
): Promise<string> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
@ -104,23 +105,30 @@ class BackupManager {
|
||||
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
Logger.log('[BackupManager IPC] ', skipBackupFile)
|
||||
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
if (!skipBackupFile) {
|
||||
// 复制 Data 目录到临时目录
|
||||
const sourcePath = path.join(app.getPath('userData'), 'Data')
|
||||
const tempDataDir = path.join(this.tempDir, 'Data')
|
||||
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
// 获取源目录总大小
|
||||
const totalSize = await this.getDirSize(sourcePath)
|
||||
let copiedSize = 0
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
// 使用流式复制
|
||||
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
|
||||
copiedSize += size
|
||||
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
|
||||
onProgress({ stage: 'copying_files', progress, total: 100 })
|
||||
})
|
||||
|
||||
await this.setWritableRecursive(tempDataDir)
|
||||
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
|
||||
} else {
|
||||
Logger.log('[BackupManager] Skip the backup of the file')
|
||||
await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败
|
||||
}
|
||||
|
||||
// 创建输出文件流
|
||||
const backupedFilePath = path.join(destinationPath, fileName)
|
||||
@ -279,7 +287,7 @@ class BackupManager {
|
||||
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data)
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
try {
|
||||
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
|
||||
@ -4,6 +4,7 @@ import path from 'node:path'
|
||||
|
||||
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists } from '@main/utils'
|
||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
@ -404,7 +405,7 @@ class McpService {
|
||||
tools.map((tool: any) => {
|
||||
const serverTool: MCPTool = {
|
||||
...tool,
|
||||
id: `f${nanoid()}`,
|
||||
id: buildFunctionCallToolName(server.name, tool.name),
|
||||
serverId: server.id,
|
||||
serverName: server.name
|
||||
}
|
||||
|
||||
@ -76,6 +76,7 @@ export class WindowService {
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
zoomFactor: configManager.getZoomFactor(),
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
@ -185,6 +186,12 @@ export class WindowService {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
// set the zoom factor again when the window is going to restore
|
||||
// minimize and restore will cause zoom reset
|
||||
mainWindow.on('restore', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
// ARCH: as `will-resize` is only for Win & Mac,
|
||||
// linux has the same problem, use `resize` listener instead
|
||||
// but `resize` will fliker the ui
|
||||
|
||||
34
src/main/utils/mcp.ts
Normal file
34
src/main/utils/mcp.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export function buildFunctionCallToolName(serverName: string, toolName: string) {
|
||||
const sanitizedServer = serverName.trim().replace(/-/g, '_')
|
||||
const sanitizedTool = toolName.trim().replace(/-/g, '_')
|
||||
|
||||
// Combine server name and tool name
|
||||
let name = sanitizedTool
|
||||
if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) {
|
||||
name = `${sanitizedServer.slice(0, 7) || ''}-${sanitizedTool || ''}`
|
||||
}
|
||||
|
||||
// Replace invalid characters with underscores or dashes
|
||||
// Keep a-z, A-Z, 0-9, underscores and dashes
|
||||
name = name.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
|
||||
// Ensure name starts with a letter or underscore (for valid JavaScript identifier)
|
||||
if (!/^[a-zA-Z]/.test(name)) {
|
||||
name = `tool-${name}`
|
||||
}
|
||||
|
||||
// Remove consecutive underscores/dashes (optional improvement)
|
||||
name = name.replace(/[_-]{2,}/g, '_')
|
||||
|
||||
// Truncate to 63 characters maximum
|
||||
if (name.length > 63) {
|
||||
name = name.slice(0, 63)
|
||||
}
|
||||
|
||||
// Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges
|
||||
if (name.endsWith('_') || name.endsWith('-')) {
|
||||
name = name.slice(0, -1)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
import { isDev, isWin } from '../constant'
|
||||
|
||||
function isTilingWindowManager() {
|
||||
if (process.platform === 'darwin') {
|
||||
return false
|
||||
@ -15,31 +17,59 @@ function isTilingWindowManager() {
|
||||
return tilingSystems.some((system) => desktopEnv?.includes(system))
|
||||
}
|
||||
|
||||
//see: https://github.com/electron/electron/issues/42055#issuecomment-2449365647
|
||||
export const replaceDevtoolsFont = (browserWindow: BrowserWindow) => {
|
||||
if (process.platform === 'win32') {
|
||||
//only for windows and dev, don't do this in production to avoid performance issues
|
||||
if (isWin && isDev) {
|
||||
browserWindow.webContents.on('devtools-opened', () => {
|
||||
const css = `
|
||||
:root {
|
||||
--sys-color-base: var(--ref-palette-neutral100);
|
||||
--source-code-font-family: consolas;
|
||||
--source-code-font-family: consolas !important;
|
||||
--source-code-font-size: 12px;
|
||||
--monospace-font-family: consolas;
|
||||
--monospace-font-family: consolas !important;
|
||||
--monospace-font-size: 12px;
|
||||
--default-font-family: system-ui, sans-serif;
|
||||
--default-font-size: 12px;
|
||||
--ref-palette-neutral99: #ffffffff;
|
||||
}
|
||||
.-theme-with-dark-background {
|
||||
.theme-with-dark-background {
|
||||
--sys-color-base: var(--ref-palette-secondary25);
|
||||
}
|
||||
body {
|
||||
--default-font-family: system-ui,sans-serif;
|
||||
}`
|
||||
|
||||
--default-font-family: system-ui, sans-serif;
|
||||
}
|
||||
`
|
||||
browserWindow.webContents.devToolsWebContents?.executeJavaScript(`
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.innerHTML = '${css.replaceAll('\n', ' ')}';
|
||||
document.body.append(overriddenStyle);
|
||||
document.body.classList.remove('platform-windows');`)
|
||||
document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows'));
|
||||
addStyleToAutoComplete();
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (let i = 0; i < mutation.addedNodes.length; i++) {
|
||||
const item = mutation.addedNodes[i];
|
||||
if (item.classList.contains('editor-tooltip-host')) {
|
||||
addStyleToAutoComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {childList: true});
|
||||
function addStyleToAutoComplete() {
|
||||
document.querySelectorAll('.editor-tooltip-host').forEach(element => {
|
||||
if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) {
|
||||
const overriddenStyle = document.createElement('style');
|
||||
overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font');
|
||||
overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}';
|
||||
element.shadowRoot.append(overriddenStyle);
|
||||
}
|
||||
});
|
||||
}
|
||||
`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
// Custom APIs for renderer
|
||||
@ -37,8 +37,8 @@ const api = {
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath),
|
||||
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||
@ -73,7 +73,8 @@ const api = {
|
||||
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId)
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
},
|
||||
fs: {
|
||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
||||
|
||||
@ -18,3 +18,32 @@ vi.mock('electron-log/renderer', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electron: {
|
||||
ipcRenderer: {
|
||||
on: vi.fn(), // Mocking ipcRenderer.on
|
||||
send: vi.fn() // Mocking ipcRenderer.send
|
||||
}
|
||||
},
|
||||
api: {
|
||||
file: {
|
||||
read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
|
||||
writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
|
||||
post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
|
||||
// You can add other axios methods like put, delete etc. as needed
|
||||
}
|
||||
}))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
...global.window, // Copy other global properties
|
||||
addEventListener: vi.fn(), // Mock addEventListener
|
||||
removeEventListener: vi.fn() // You can also mock removeEventListener if needed
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<SyntaxHighlighterProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
@ -46,7 +46,7 @@ function App(): React.ReactElement {
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</SyntaxHighlighterProvider>
|
||||
</CodeStyleProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
|
||||
130
src/renderer/src/assets/styles/color.scss
Normal file
130
src/renderer/src/assets/styles/color.scss
Normal file
@ -0,0 +1,130 @@
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(255, 255, 255, 0.8);
|
||||
--color-white-mute: rgba(255, 255, 255, 0.94);
|
||||
|
||||
--color-black: #181818;
|
||||
--color-black-soft: #222222;
|
||||
--color-black-mute: #333333;
|
||||
|
||||
--color-gray-1: #515c67;
|
||||
--color-gray-2: #414853;
|
||||
--color-gray-3: #32363f;
|
||||
|
||||
--color-text-1: rgba(255, 255, 245, 0.9);
|
||||
--color-text-2: rgba(235, 235, 245, 0.6);
|
||||
--color-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
--color-background: var(--color-black);
|
||||
--color-background-soft: var(--color-black-soft);
|
||||
--color-background-mute: var(--color-black-mute);
|
||||
--color-background-opacity: rgba(34, 34, 34, 0.7);
|
||||
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
--color-link: #338cff;
|
||||
--color-code-background: #323232;
|
||||
--color-hover: rgba(40, 40, 40, 1);
|
||||
--color-active: rgba(55, 55, 55, 1);
|
||||
--color-frame-border: #333;
|
||||
--color-group-background: var(--color-background-soft);
|
||||
|
||||
--color-reference: #404040;
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--color-highlight: rgba(0, 0, 0, 1);
|
||||
--color-background-highlight: rgba(255, 255, 0, 0.9);
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
|
||||
--chat-background: #111111;
|
||||
--chat-background-user: #28b561;
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(0, 0, 0, 0.04);
|
||||
--color-white-mute: #eee;
|
||||
|
||||
--color-black: #1b1b1f;
|
||||
--color-black-soft: #262626;
|
||||
--color-black-mute: #363636;
|
||||
|
||||
--color-gray-1: #8e8e93;
|
||||
--color-gray-2: #aeaeb2;
|
||||
--color-gray-3: #c7c7cc;
|
||||
|
||||
--color-text-1: rgba(0, 0, 0, 1);
|
||||
--color-text-2: rgba(0, 0, 0, 0.6);
|
||||
--color-text-3: rgba(0, 0, 0, 0.38);
|
||||
|
||||
--color-background: var(--color-white);
|
||||
--color-background-soft: var(--color-white-soft);
|
||||
--color-background-mute: var(--color-white-mute);
|
||||
--color-background-opacity: rgba(235, 235, 235, 0.7);
|
||||
--inner-glow-opacity: 0.1;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
--color-border-soft: #00000010;
|
||||
--color-border-mute: #00000005;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #e3e3e3;
|
||||
--color-hover: var(--color-white-mute);
|
||||
--color-active: var(--color-white-soft);
|
||||
--color-frame-border: #ddd;
|
||||
--color-group-background: var(--color-white);
|
||||
|
||||
--color-reference: #cfe1ff;
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--color-highlight: initial;
|
||||
--color-background-highlight: rgba(255, 255, 0, 0.5);
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
12
src/renderer/src/assets/styles/font.scss
Normal file
12
src/renderer/src/assets/styles/font.scss
Normal file
@ -0,0 +1,12 @@
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
@use './color.scss';
|
||||
@use './font.scss';
|
||||
@use './markdown.scss';
|
||||
@use './ant.scss';
|
||||
@use './scrollbar.scss';
|
||||
@ -6,136 +8,6 @@
|
||||
@import '../fonts/icon-fonts/iconfont.css';
|
||||
@import '../fonts/ubuntu/ubuntu.css';
|
||||
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(255, 255, 255, 0.8);
|
||||
--color-white-mute: rgba(255, 255, 255, 0.94);
|
||||
|
||||
--color-black: #181818;
|
||||
--color-black-soft: #222222;
|
||||
--color-black-mute: #333333;
|
||||
|
||||
--color-gray-1: #515c67;
|
||||
--color-gray-2: #414853;
|
||||
--color-gray-3: #32363f;
|
||||
|
||||
--color-text-1: rgba(255, 255, 245, 0.9);
|
||||
--color-text-2: rgba(235, 235, 245, 0.6);
|
||||
--color-text-3: rgba(235, 235, 245, 0.38);
|
||||
|
||||
--color-background: var(--color-black);
|
||||
--color-background-soft: var(--color-black-soft);
|
||||
--color-background-mute: var(--color-black-mute);
|
||||
--color-background-opacity: rgba(34, 34, 34, 0.7);
|
||||
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
--color-link: #338cff;
|
||||
--color-code-background: #323232;
|
||||
--color-hover: rgba(40, 40, 40, 1);
|
||||
--color-active: rgba(55, 55, 55, 1);
|
||||
--color-frame-border: #333;
|
||||
--color-group-background: var(--color-background-soft);
|
||||
|
||||
--color-reference: #404040;
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--modal-background: #1f1f1f;
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
|
||||
--chat-background: #111111;
|
||||
--chat-background-user: #28b561;
|
||||
--chat-background-assistant: #2c2c2c;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-white: #ffffff;
|
||||
--color-white-soft: rgba(0, 0, 0, 0.04);
|
||||
--color-white-mute: #eee;
|
||||
|
||||
--color-black: #1b1b1f;
|
||||
--color-black-soft: #262626;
|
||||
--color-black-mute: #363636;
|
||||
|
||||
--color-gray-1: #8e8e93;
|
||||
--color-gray-2: #aeaeb2;
|
||||
--color-gray-3: #c7c7cc;
|
||||
|
||||
--color-text-1: rgba(0, 0, 0, 1);
|
||||
--color-text-2: rgba(0, 0, 0, 0.6);
|
||||
--color-text-3: rgba(0, 0, 0, 0.38);
|
||||
|
||||
--color-background: var(--color-white);
|
||||
--color-background-soft: var(--color-white-soft);
|
||||
--color-background-mute: var(--color-white-mute);
|
||||
--color-background-opacity: rgba(235, 235, 235, 0.7);
|
||||
--inner-glow-opacity: 0.1;
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
--color-text: var(--color-text-1);
|
||||
--color-icon: #00000099;
|
||||
--color-icon-white: #000000;
|
||||
--color-border: #00000019;
|
||||
--color-border-soft: #00000010;
|
||||
--color-border-mute: #00000005;
|
||||
--color-error: #f44336;
|
||||
--color-link: #1677ff;
|
||||
--color-code-background: #e3e3e3;
|
||||
--color-hover: var(--color-white-mute);
|
||||
--color-active: var(--color-white-soft);
|
||||
--color-frame-border: #ddd;
|
||||
--color-group-background: var(--color-white);
|
||||
|
||||
--color-reference: #cfe1ff;
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
--chat-background-assistant: #ffffff;
|
||||
--chat-text-user: var(--color-text);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@ -152,8 +24,18 @@ body[theme-mode='light'] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -163,13 +45,17 @@ body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: var(--font-family);
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background-color 0.3s linear;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s linear;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
@ -190,20 +76,8 @@ a {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.loader {
|
||||
@ -291,3 +165,11 @@ body,
|
||||
.lucide {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
@ -20,10 +20,8 @@
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
font-weight: 800;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@ -117,7 +115,7 @@
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
font-family: var(--code-font-family);
|
||||
}
|
||||
|
||||
pre {
|
||||
@ -125,7 +123,9 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(> .mermaid) {
|
||||
&:has(.mermaid),
|
||||
&:has(.plantuml-preview),
|
||||
&:has(.svg-preview) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
@ -153,7 +153,7 @@
|
||||
padding-left: 1em;
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
table {
|
||||
@ -171,9 +171,7 @@
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
img {
|
||||
@ -304,3 +302,26 @@ emoji-picker {
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
.cm-scroller {
|
||||
font-family: var(--code-font-family);
|
||||
padding: 1px;
|
||||
border-radius: 5px;
|
||||
|
||||
.cm-gutters {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
line-height: 1.6;
|
||||
padding-left: 0.25em;
|
||||
}
|
||||
|
||||
.cm-lineWrapping * {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
285
src/renderer/src/components/CodeBlockView/CodePreview.tsx
Normal file
285
src/renderer/src/components/CodeBlockView/CodePreview.tsx
Normal file
@ -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<ThemedToken[][]>([])
|
||||
const codeContentRef = useRef<HTMLDivElement>(null)
|
||||
const prevCodeLengthRef = useRef(0)
|
||||
const safeCodeStringRef = useRef(children)
|
||||
const highlightQueueRef = useRef<Promise<void>>(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 ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
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 ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
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 (
|
||||
<ContentContainer
|
||||
ref={codeContentRef}
|
||||
$isShowLineNumbers={codeShowLineNumbers}
|
||||
$isUnwrapped={isUnwrapped}
|
||||
$isCodeWrappable={codeWrappable}
|
||||
style={{
|
||||
fontSize: fontSize - 1,
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
|
||||
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible'
|
||||
}}>
|
||||
{tokenLines.length > 0 ? (
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
|
||||
) : (
|
||||
<div style={{ opacity: 0.1 }}>{children}</div>
|
||||
)}
|
||||
</ContentContainer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Shiki 高亮后的 tokens
|
||||
*
|
||||
* 独立出来,方便将来做 virtual list
|
||||
*/
|
||||
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo(
|
||||
({ language, tokenLines }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(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 (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
{lineTokens.map((token, tokenIndex) => (
|
||||
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const ContentContainer = styled.div<{
|
||||
$isShowLineNumbers: boolean
|
||||
$isUnwrapped: boolean
|
||||
$isCodeWrappable: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
border: 0.5px solid transparent;
|
||||
border-radius: 5px;
|
||||
margin-top: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
|
||||
code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.line {
|
||||
display: block;
|
||||
min-height: 1.3rem;
|
||||
padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$isShowLineNumbers &&
|
||||
`
|
||||
code {
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
code .line::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
width: 1rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.$isCodeWrappable &&
|
||||
!props.$isUnwrapped &&
|
||||
`
|
||||
code .line * {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
CodePreview.displayName = 'CodePreview'
|
||||
|
||||
export default memo(CodePreview)
|
||||
@ -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<Props> = ({ html }) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
const onDownload = () => {
|
||||
window.api.file.save(`${title}.html`, html)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
@ -62,10 +55,6 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<DownloadOutlined />} onClick={onDownload}>
|
||||
{t('chat.artifacts.button.download')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
99
src/renderer/src/components/CodeBlockView/MermaidPreview.tsx
Normal file
99
src/renderer/src/components/CodeBlockView/MermaidPreview.tsx
Normal file
@ -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<Props> = ({ children }) => {
|
||||
const { mermaid, isLoading, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<Flex vertical>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
193
src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx
Normal file
193
src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx
Normal file
@ -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<HTMLDivElement>
|
||||
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<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
// FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景
|
||||
const url = getPlantUMLImageUrl(format, diagram, false)
|
||||
return (
|
||||
<StyledPlantUML onClick={onClick} className={className}>
|
||||
<Spin
|
||||
spinning={loading}
|
||||
indicator={
|
||||
<LoadingOutlined
|
||||
spin
|
||||
style={{
|
||||
fontSize: 32
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<img
|
||||
src={url}
|
||||
onLoad={() => {
|
||||
setLoading(false)
|
||||
}}
|
||||
onError={(e) => {
|
||||
setLoading(false)
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.opacity = '0.5'
|
||||
target.style.filter = 'blur(2px)'
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</StyledPlantUML>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
22
src/renderer/src/components/CodeBlockView/StatusBar.tsx
Normal file
22
src/renderer/src/components/CodeBlockView/StatusBar.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { FC, memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
}
|
||||
|
||||
const StatusBar: FC<Props> = ({ children }) => {
|
||||
return <Container>{children}</Container>
|
||||
}
|
||||
|
||||
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)
|
||||
38
src/renderer/src/components/CodeBlockView/SvgPreview.tsx
Normal file
38
src/renderer/src/components/CodeBlockView/SvgPreview.tsx
Normal file
@ -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<Props> = ({ children }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: '.svg-preview svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
324
src/renderer/src/components/CodeBlockView/index.tsx
Normal file
324
src/renderer/src/components/CodeBlockView/index.tsx
Normal file
@ -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<Props> = ({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('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('</html>')) {
|
||||
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: <Copy className="icon" />,
|
||||
tooltip: t('code_block.copy.source'),
|
||||
onClick: handleCopySource
|
||||
})
|
||||
|
||||
// 下载按钮
|
||||
registerTool({
|
||||
...TOOL_SPECS.download,
|
||||
icon: <Download className="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' ? <Eye className="icon" /> : <SquarePen className="icon" />,
|
||||
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'),
|
||||
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
|
||||
})
|
||||
} else {
|
||||
registerTool({
|
||||
...viewSourceToolSpec,
|
||||
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
|
||||
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' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
|
||||
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 ? <LoadingOutlined /> : <CirclePlay className="icon" />,
|
||||
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 (
|
||||
<SourceView language={language} onSave={onSave}>
|
||||
{children}
|
||||
</SourceView>
|
||||
)
|
||||
}, [children, codeEditor.enabled, language, onSave])
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview>{children}</SvgPreview>
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
const langTag = '<' + language.toUpperCase() + '>'
|
||||
return <CodeHeader $isInSpecialView={isInSpecialView}>{isInSpecialView ? '' : langTag}</CodeHeader>
|
||||
}, [isInSpecialView, language])
|
||||
|
||||
// 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图
|
||||
const renderContent = useMemo(() => {
|
||||
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
|
||||
const showSourceView = !specialView || viewMode !== 'special'
|
||||
|
||||
return (
|
||||
<SplitViewWrapper className="split-view-wrapper">
|
||||
{showSpecialView && specialView}
|
||||
{showSourceView && sourceView}
|
||||
</SplitViewWrapper>
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
const renderArtifacts = useMemo(() => {
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifacts html={children} />
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
269
src/renderer/src/components/CodeEditor/index.tsx
Normal file
269
src/renderer/src/components/CodeEditor/index.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
|
||||
import diff from 'fast-diff'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Save as SaveIcon,
|
||||
Text as UnWrapIcon,
|
||||
WrapText as WrapIcon
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// 标记非用户编辑的变更
|
||||
const External = Annotation.define<boolean>()
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
maxHeight?: string
|
||||
/** 用于覆写编辑器的某些设置 */
|
||||
options?: {
|
||||
collapsible?: boolean
|
||||
wrappable?: boolean
|
||||
keymap?: boolean
|
||||
} & BasicSetupOptions
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 源代码编辑器,基于 CodeMirror
|
||||
*
|
||||
* 目前必须和 CodeToolbar 配合使用。
|
||||
*/
|
||||
const CodeEditor = ({ children, language, onSave, onChange, maxHeight, options, style }: Props) => {
|
||||
const {
|
||||
fontSize,
|
||||
codeShowLineNumbers: _lineNumbers,
|
||||
codeCollapsible: _collapsible,
|
||||
codeWrappable: _wrappable,
|
||||
codeEditor
|
||||
} = useSettings()
|
||||
const collapsible = useMemo(() => options?.collapsible ?? _collapsible, [options?.collapsible, _collapsible])
|
||||
const wrappable = useMemo(() => options?.wrappable ?? _wrappable, [options?.wrappable, _wrappable])
|
||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||
|
||||
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
||||
const customBasicSetup = useMemo(() => {
|
||||
return {
|
||||
lineNumbers: _lineNumbers,
|
||||
...(codeEditor as BasicSetupOptions),
|
||||
...(options as BasicSetupOptions)
|
||||
}
|
||||
}, [codeEditor, _lineNumbers, options])
|
||||
|
||||
const { activeCmTheme, languageMap } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!wrappable)
|
||||
const initialContent = useRef(children?.trimEnd() ?? '')
|
||||
const [langExtension, setLangExtension] = useState<Extension[]>([])
|
||||
const [editorReady, setEditorReady] = useState(false)
|
||||
const editorViewRef = useRef<EditorView | null>(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 ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight
|
||||
return collapsible && (scrollHeight ?? 0) > 350
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [collapsible, isExpanded, registerTool, removeTool, t, editorReady])
|
||||
|
||||
// 自动换行工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => wrappable,
|
||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [wrappable, isUnwrapped, registerTool, removeTool, t])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
|
||||
onSave?.(currentDoc)
|
||||
}, [onSave])
|
||||
|
||||
// 保存按钮
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.save,
|
||||
icon: <SaveIcon className="icon" />,
|
||||
tooltip: t('code_block.edit.save'),
|
||||
onClick: handleSave
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.save.id)
|
||||
}, [handleSave, registerTool, removeTool, t])
|
||||
|
||||
// 流式响应过程中计算 changes 来更新 EditorView
|
||||
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
|
||||
useEffect(() => {
|
||||
if (!editorViewRef.current) return
|
||||
|
||||
const newContent = children?.trimEnd() ?? ''
|
||||
const currentDoc = editorViewRef.current.state.doc.toString()
|
||||
|
||||
const changes = prepareCodeChanges(currentDoc, newContent)
|
||||
|
||||
if (changes && changes.length > 0) {
|
||||
editorViewRef.current.dispatch({
|
||||
changes,
|
||||
annotations: [External.of(true)]
|
||||
})
|
||||
}
|
||||
}, [children])
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(!collapsible)
|
||||
}, [collapsible])
|
||||
|
||||
useEffect(() => {
|
||||
setIsUnwrapped(!wrappable)
|
||||
}, [wrappable])
|
||||
|
||||
// 保存功能的快捷键
|
||||
const saveKeymap = useMemo(() => {
|
||||
return keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
handleSave()
|
||||
return true
|
||||
},
|
||||
preventDefault: true
|
||||
}
|
||||
])
|
||||
}, [handleSave])
|
||||
|
||||
const enabledExtensions = useMemo(() => {
|
||||
return [...langExtension, ...(isUnwrapped ? [] : [EditorView.lineWrapping]), ...(enableKeymap ? [saveKeymap] : [])]
|
||||
}, [enableKeymap, langExtension, isUnwrapped, saveKeymap])
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
// 维持一个稳定值,避免触发 CodeMirror 重置
|
||||
value={initialContent.current}
|
||||
width="100%"
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={enabledExtensions}
|
||||
onCreateEditor={(view: EditorView) => {
|
||||
editorViewRef.current = view
|
||||
setEditorReady(true)
|
||||
}}
|
||||
onChange={(value, viewUpdate) => {
|
||||
if (onChange && viewUpdate.docChanged) onChange(value)
|
||||
}}
|
||||
basicSetup={{
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: true,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: enableKeymap,
|
||||
searchKeymap: enableKeymap,
|
||||
foldKeymap: enableKeymap,
|
||||
completionKeymap: enableKeymap,
|
||||
lintKeymap: enableKeymap,
|
||||
...customBasicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
overflow: collapsible && !isExpanded ? 'auto' : 'visible',
|
||||
position: 'relative',
|
||||
border: '0.5px solid transparent',
|
||||
borderRadius: '5px',
|
||||
marginTop: 0,
|
||||
...style
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
CodeEditor.displayName = 'CodeEditor'
|
||||
|
||||
/**
|
||||
* 使用 fast-diff 计算代码变更,再转换为 CodeMirror 的 changes。
|
||||
* 可以处理所有类型的变更,不过流式响应过程中多是插入操作。
|
||||
* @param oldCode 旧的代码内容
|
||||
* @param newCode 新的代码内容
|
||||
* @returns 用于 EditorView.dispatch 的 changes 数组
|
||||
*/
|
||||
function prepareCodeChanges(oldCode: string, newCode: string) {
|
||||
const diffResult = diff(oldCode, newCode)
|
||||
|
||||
const changes: { from: number; to: number; insert: string }[] = []
|
||||
let offset = 0
|
||||
|
||||
// operation: 1=插入, -1=删除, 0=相等
|
||||
for (const [operation, text] of diffResult) {
|
||||
if (operation === 1) {
|
||||
changes.push({
|
||||
from: offset,
|
||||
to: offset,
|
||||
insert: text
|
||||
})
|
||||
} else if (operation === -1) {
|
||||
changes.push({
|
||||
from: offset,
|
||||
to: offset + text.length,
|
||||
insert: ''
|
||||
})
|
||||
offset += text.length
|
||||
} else {
|
||||
offset += text.length
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
export default memo(CodeEditor)
|
||||
76
src/renderer/src/components/CodeToolbar/constants.ts
Normal file
76
src/renderer/src/components/CodeToolbar/constants.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { CodeToolSpec } from './types'
|
||||
|
||||
export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
71
src/renderer/src/components/CodeToolbar/context.tsx
Normal file
71
src/renderer/src/components/CodeToolbar/context.tsx
Normal file
@ -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<CodeToolContext>) => void
|
||||
}
|
||||
|
||||
const defaultCodeToolbarContext: CodeToolbarContextType = {
|
||||
tools: [],
|
||||
context: defaultContext,
|
||||
registerTool: () => {},
|
||||
removeTool: () => {},
|
||||
updateContext: () => {}
|
||||
}
|
||||
|
||||
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
|
||||
|
||||
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [tools, setTools] = useState<CodeTool[]>([])
|
||||
const [context, setContext] = useState<CodeToolContext>(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<CodeToolContext>) => {
|
||||
setContext((prev) => ({ ...prev, ...newContext }))
|
||||
}, [])
|
||||
|
||||
const value: CodeToolbarContextType = useMemo(
|
||||
() => ({
|
||||
tools,
|
||||
context,
|
||||
registerTool,
|
||||
removeTool,
|
||||
updateContext
|
||||
}),
|
||||
[tools, context, registerTool, removeTool, updateContext]
|
||||
)
|
||||
|
||||
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
|
||||
}
|
||||
|
||||
export const useCodeToolbar = () => {
|
||||
const context = use(CodeToolbarContext)
|
||||
if (!context) {
|
||||
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
5
src/renderer/src/components/CodeToolbar/index.ts
Normal file
5
src/renderer/src/components/CodeToolbar/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './constants'
|
||||
export * from './context'
|
||||
export * from './toolbar'
|
||||
export * from './types'
|
||||
export * from './usePreviewTools'
|
||||
119
src/renderer/src/components/CodeToolbar/toolbar.tsx
Normal file
119
src/renderer/src/components/CodeToolbar/toolbar.tsx
Normal file
@ -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<CodeToolButtonProps> = memo(({ tool }) => {
|
||||
const { context } = useCodeToolbar()
|
||||
|
||||
return (
|
||||
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
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) => <CodeToolButton key={tool.id} tool={tool} />)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [quickTools, showQuickTools])
|
||||
|
||||
if (visibleTools.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StickyWrapper>
|
||||
<ToolbarWrapper className="code-toolbar">
|
||||
{/* 有多个快捷工具时通过 more 按钮展示 */}
|
||||
{quickToolButtons}
|
||||
{quickTools.length > 1 && (
|
||||
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
|
||||
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
|
||||
<EllipsisVertical className="icon" />
|
||||
</ToolWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 始终显示核心工具 */}
|
||||
{coreTools.map((tool) => (
|
||||
<CodeToolButton key={tool.id} tool={tool} />
|
||||
))}
|
||||
</ToolbarWrapper>
|
||||
</StickyWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
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);
|
||||
}
|
||||
`
|
||||
35
src/renderer/src/components/CodeToolbar/types.ts
Normal file
35
src/renderer/src/components/CodeToolbar/types.ts
Normal file
@ -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
|
||||
}
|
||||
360
src/renderer/src/components/CodeToolbar/usePreviewTools.tsx
Normal file
360
src/renderer/src/components/CodeToolbar/usePreviewTools.tsx
Normal file
@ -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<HTMLDivElement | null>,
|
||||
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<Blob>((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<void>
|
||||
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: <ZoomIn className="icon" />,
|
||||
tooltip: t('code_block.preview.zoom_in'),
|
||||
onClick: () => handleZoom(0.1)
|
||||
})
|
||||
|
||||
// 缩小工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['zoom-out'],
|
||||
icon: <ZoomOut className="icon" />,
|
||||
tooltip: t('code_block.preview.zoom_out'),
|
||||
onClick: () => handleZoom(-0.1)
|
||||
})
|
||||
}
|
||||
|
||||
if (handleCopyImage) {
|
||||
// 复制图片工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['copy-image'],
|
||||
icon: <FileImage className="icon" />,
|
||||
tooltip: t('code_block.preview.copy.image'),
|
||||
onClick: handleCopyImage
|
||||
})
|
||||
}
|
||||
|
||||
if (handleDownload) {
|
||||
// 下载 SVG 工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['download-svg'],
|
||||
icon: <DownloadSvgIcon />,
|
||||
tooltip: t('code_block.download.svg'),
|
||||
onClick: () => handleDownload('svg')
|
||||
})
|
||||
|
||||
// 下载 PNG 工具
|
||||
registerTool({
|
||||
...TOOL_SPECS['download-png'],
|
||||
icon: <DownloadPngIcon />,
|
||||
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])
|
||||
}
|
||||
710
src/renderer/src/components/ContentSearch.tsx
Normal file
710
src/renderer/src/components/ContentSearch.tsx
Normal file
@ -0,0 +1,710 @@
|
||||
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
|
||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const HIGHLIGHT_CLASS = 'highlight'
|
||||
const HIGHLIGHT_SELECT_CLASS = 'selected'
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode
|
||||
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
|
||||
/**
|
||||
* 过滤`node`,`node`只会是`Node.TEXT_NODE`类型的文本节点
|
||||
*
|
||||
* 返回`true`表示该`node`会被搜索
|
||||
*/
|
||||
filter: (node: Node) => boolean
|
||||
includeUser?: boolean
|
||||
onIncludeUserChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
enum SearchCompletedState {
|
||||
NotSearched,
|
||||
FirstSearched
|
||||
}
|
||||
|
||||
enum SearchTargetIndex {
|
||||
Next,
|
||||
Prev
|
||||
}
|
||||
|
||||
export interface ContentSearchRef {
|
||||
disable(): void
|
||||
enable(initialText?: string): void
|
||||
// 搜索下一个并定位
|
||||
searchNext(): void
|
||||
// 搜索上一个并定位
|
||||
searchPrev(): void
|
||||
// 搜索并定位
|
||||
search(): void
|
||||
// 搜索但不定位,或者说是更新
|
||||
silentSearch(): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
interface MatchInfo {
|
||||
index: number
|
||||
length: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const escapeRegExp = (string: string): string => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||
}
|
||||
|
||||
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
|
||||
if (!elementList || elementList.length === 0) {
|
||||
return null
|
||||
}
|
||||
let closestElementIndex: number | null = null
|
||||
let minVerticalDistance = Infinity
|
||||
const windowCenterY = window.innerHeight / 2
|
||||
for (let i = 0; i < elementList.length; i++) {
|
||||
const element = elementList[i]
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||
continue
|
||||
}
|
||||
const elementCenterY = rect.top + rect.height / 2
|
||||
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
|
||||
if (verticalDistance < minVerticalDistance) {
|
||||
minVerticalDistance = verticalDistance
|
||||
closestElementIndex = i
|
||||
}
|
||||
}
|
||||
return closestElementIndex
|
||||
}
|
||||
|
||||
const highlightText = (
|
||||
textNode: Node,
|
||||
searchText: string,
|
||||
highlightClass: string,
|
||||
isCaseSensitive: boolean,
|
||||
isWholeWord: boolean
|
||||
): HTMLSpanElement[] | null => {
|
||||
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
|
||||
if (textNodeParentNode) {
|
||||
if (textNodeParentNode.classList.contains(highlightClass)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const textContent = textNode.textContent
|
||||
const escapedSearchText = escapeRegExp(searchText)
|
||||
|
||||
// 检查搜索文本是否仅包含拉丁字母
|
||||
const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText)
|
||||
|
||||
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
|
||||
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
|
||||
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
|
||||
const regex = new RegExp(regexPattern, regexFlags)
|
||||
|
||||
let match
|
||||
const matches: MatchInfo[] = []
|
||||
while ((match = regex.exec(textContent)) !== null) {
|
||||
if (typeof match.index === 'number' && typeof match[0] === 'string') {
|
||||
matches.push({ index: match.index, length: match[0].length, text: match[0] })
|
||||
} else {
|
||||
console.error('Unexpected match format:', match)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parentNode = textNode.parentNode
|
||||
if (!parentNode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
let currentIndex = 0
|
||||
const highlightTextSet = new Set<HTMLSpanElement>()
|
||||
|
||||
matches.forEach(({ index, length, text }) => {
|
||||
if (index > currentIndex) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
|
||||
}
|
||||
const highlightSpan = document.createElement('span')
|
||||
highlightSpan.className = highlightClass
|
||||
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
|
||||
fragment.appendChild(highlightSpan)
|
||||
highlightTextSet.add(highlightSpan)
|
||||
currentIndex = index + length
|
||||
})
|
||||
|
||||
if (currentIndex < textContent.length) {
|
||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
|
||||
}
|
||||
|
||||
parentNode.replaceChild(fragment, textNode)
|
||||
return [...highlightTextSet]
|
||||
}
|
||||
|
||||
const mergeAdjacentTextNodes = (node: HTMLElement) => {
|
||||
const children = Array.from(node.childNodes)
|
||||
const groups: Array<Node | { text: string; nodes: Node[] }> = []
|
||||
let currentTextGroup: { text: string; nodes: Node[] } | null = null
|
||||
|
||||
for (const child of children) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
if (currentTextGroup === null) {
|
||||
currentTextGroup = {
|
||||
text: child.textContent ?? '',
|
||||
nodes: [child]
|
||||
}
|
||||
} else {
|
||||
currentTextGroup.text += child.textContent
|
||||
currentTextGroup.nodes.push(child)
|
||||
}
|
||||
} else {
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup!)
|
||||
currentTextGroup = null
|
||||
}
|
||||
groups.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextGroup !== null) {
|
||||
groups.push(currentTextGroup)
|
||||
}
|
||||
|
||||
const newChildren = groups.map((group) => {
|
||||
if (group instanceof Node) {
|
||||
return group
|
||||
} else {
|
||||
return document.createTextNode(group.text)
|
||||
}
|
||||
})
|
||||
|
||||
node.replaceChildren(...newChildren)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @eslint-react/no-forward-ref
|
||||
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
|
||||
const target: HTMLElement | null = (() => {
|
||||
if (searchTarget instanceof HTMLElement) {
|
||||
return searchTarget
|
||||
} else {
|
||||
return (searchTarget.current as HTMLElement) ?? null
|
||||
}
|
||||
})()
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [searchResultIndex, setSearchResultIndex] = useState(0)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [enableContentSearch, setEnableContentSearch] = useState(false)
|
||||
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [shouldScroll, setShouldScroll] = useState(false)
|
||||
const highlightTextSet = useState(new Set<Node>())[0]
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
const locateByIndex = (index: number, shouldScroll = true) => {
|
||||
if (target) {
|
||||
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
|
||||
highlightTextNodes.sort((a, b) => {
|
||||
const { top: aTop } = a.getBoundingClientRect()
|
||||
const { top: bTop } = b.getBoundingClientRect()
|
||||
return aTop - bTop
|
||||
})
|
||||
for (const node of highlightTextNodes) {
|
||||
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
|
||||
}
|
||||
setSearchResultIndex(index)
|
||||
if (highlightTextNodes.length > 0) {
|
||||
const highlightTextNode = highlightTextNodes[index] ?? null
|
||||
if (highlightTextNode) {
|
||||
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
|
||||
if (shouldScroll) {
|
||||
highlightTextNode.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restoreHighlight = () => {
|
||||
const highlightTextParentNodeSet = new Set<HTMLElement>()
|
||||
// Make a copy because the set might be modified during iteration indirectly
|
||||
const nodesToRestore = [...highlightTextSet]
|
||||
for (const highlightTextNode of nodesToRestore) {
|
||||
if (highlightTextNode.textContent) {
|
||||
const textNode = document.createTextNode(highlightTextNode.textContent)
|
||||
const node = highlightTextNode as HTMLElement
|
||||
if (node.parentNode) {
|
||||
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
|
||||
node.replaceWith(textNode) // This removes the node from the DOM
|
||||
}
|
||||
}
|
||||
}
|
||||
highlightTextSet.clear() // Clear the original set after processing
|
||||
for (const parentNode of highlightTextParentNodeSet) {
|
||||
mergeAdjacentTextNodes(parentNode)
|
||||
}
|
||||
// highlightTextSet.clear() // Already cleared
|
||||
}
|
||||
|
||||
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
restoreHighlight()
|
||||
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
|
||||
let textNode: Node | null
|
||||
const textNodeSet: Set<Node> = new Set()
|
||||
while ((textNode = iter.nextNode())) {
|
||||
if (filter(textNode)) {
|
||||
textNodeSet.add(textNode)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightTextSetTemp = new Set<HTMLSpanElement>()
|
||||
for (const node of textNodeSet) {
|
||||
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
|
||||
if (list) {
|
||||
list.forEach((node) => highlightTextSetTemp.add(node))
|
||||
}
|
||||
}
|
||||
const highlightTextList = [...highlightTextSetTemp]
|
||||
setTotalCount(highlightTextList.length)
|
||||
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
|
||||
const changeIndex = () => {
|
||||
let index: number
|
||||
switch (searchTargetIndex) {
|
||||
case SearchTargetIndex.Next:
|
||||
{
|
||||
index = (searchResultIndex + 1) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
case SearchTargetIndex.Prev:
|
||||
{
|
||||
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
|
||||
}
|
||||
break
|
||||
default: {
|
||||
index = searchResultIndex
|
||||
}
|
||||
}
|
||||
return Math.max(index, 0)
|
||||
}
|
||||
|
||||
const targetIndex = (() => {
|
||||
switch (searchCompleted) {
|
||||
case SearchCompletedState.NotSearched: {
|
||||
setSearchCompleted(SearchCompletedState.FirstSearched)
|
||||
const index = findWindowVerticalCenterElementIndex(highlightTextList)
|
||||
if (index !== null) {
|
||||
setSearchResultIndex(index)
|
||||
return index
|
||||
} else {
|
||||
setSearchResultIndex(0)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
case SearchCompletedState.FirstSearched: {
|
||||
return changeIndex()
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
if (targetIndex === null) {
|
||||
return null
|
||||
} else {
|
||||
const totalCount = highlightTextSet.size
|
||||
if (targetIndex >= totalCount) {
|
||||
return totalCount - 1
|
||||
} else {
|
||||
return targetIndex
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const _searchHandlerDebounce = debounce(() => {
|
||||
implementation.search()
|
||||
}, 300)
|
||||
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
|
||||
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim()
|
||||
if (value.length === 0) {
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
} else {
|
||||
// 用户输入时允许滚动
|
||||
setShouldScroll(true)
|
||||
searchHandler()
|
||||
}
|
||||
prevSearchText.current = value
|
||||
}
|
||||
|
||||
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { code, key, shiftKey } = event
|
||||
if (key === 'Process') {
|
||||
return
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'Enter':
|
||||
{
|
||||
if (shiftKey) {
|
||||
implementation.searchPrev()
|
||||
} else {
|
||||
implementation.searchNext()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
implementation.disable()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||
|
||||
const userOutlinedButtonOnClick = () => {
|
||||
if (onIncludeUserChange) {
|
||||
onIncludeUserChange(!includeUser)
|
||||
}
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const implementation = {
|
||||
disable() {
|
||||
setEnableContentSearch(false)
|
||||
restoreHighlight()
|
||||
setShouldScroll(false)
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
setEnableContentSearch(true)
|
||||
setShouldScroll(false) // Default to false, search itself might set it to true
|
||||
if (searchInputRef.current) {
|
||||
const inputEl = searchInputRef.current
|
||||
if (initialText && initialText.trim().length > 0) {
|
||||
inputEl.value = initialText
|
||||
// Trigger search after setting initial text
|
||||
// Need to make sure search() uses the new value
|
||||
// and also to focus and select
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
setShouldScroll(true)
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, true) // Ensure scrolling
|
||||
} else {
|
||||
// If search returns null (e.g., empty input or no matches with initial text), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
})
|
||||
// Only search if there's existing text and no new initialText
|
||||
if (inputEl.value.trim()) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
setSearchResultIndex(targetIndex)
|
||||
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
searchNext() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Next)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
searchPrev() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search(SearchTargetIndex.Prev)
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
resetSearchState() {
|
||||
if (enableContentSearch) {
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
// Maybe also reset index? Depends on desired behavior
|
||||
// setSearchResultIndex(0);
|
||||
}
|
||||
},
|
||||
search() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
locateByIndex(targetIndex, shouldScroll)
|
||||
} else {
|
||||
// If search returns null (e.g., empty input), clear state
|
||||
restoreHighlight()
|
||||
setTotalCount(0)
|
||||
setSearchResultIndex(0)
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
}
|
||||
}
|
||||
},
|
||||
silentSearch() {
|
||||
if (enableContentSearch) {
|
||||
const targetIndex = search()
|
||||
if (targetIndex !== null) {
|
||||
// 只更新索引,不触发滚动
|
||||
locateByIndex(targetIndex, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
focus() {
|
||||
searchInputFocus()
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disable() {
|
||||
implementation.disable()
|
||||
},
|
||||
enable(initialText?: string) {
|
||||
implementation.enable(initialText)
|
||||
},
|
||||
searchNext() {
|
||||
implementation.searchNext()
|
||||
},
|
||||
searchPrev() {
|
||||
implementation.searchPrev()
|
||||
},
|
||||
search() {
|
||||
implementation.search()
|
||||
},
|
||||
silentSearch() {
|
||||
implementation.silentSearch()
|
||||
},
|
||||
focus() {
|
||||
implementation.focus()
|
||||
}
|
||||
}))
|
||||
|
||||
// Re-run search when options change and search is active
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
implementation.search()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency
|
||||
|
||||
const prevButtonOnClick = () => {
|
||||
implementation.searchPrev()
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const nextButtonOnClick = () => {
|
||||
implementation.searchNext()
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const closeButtonOnClick = () => {
|
||||
implementation.disable()
|
||||
}
|
||||
|
||||
const caseSensitiveButtonOnClick = () => {
|
||||
setIsCaseSensitive(!isCaseSensitive)
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
const wholeWordButtonOnClick = () => {
|
||||
setIsWholeWord(!isWholeWord)
|
||||
searchInputFocus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<SearchBarContainer>
|
||||
<InputWrapper>
|
||||
<Input ref={searchInputRef} onInput={userInputHandler} onKeyDown={keyDownHandler} />
|
||||
<ToolBar>
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
size={18}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
|
||||
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ToolBar>
|
||||
</InputWrapper>
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
totalCount > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
)
|
||||
) : (
|
||||
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
|
||||
)}
|
||||
</SearchResults>
|
||||
<ToolBar>
|
||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
|
||||
<ChevronUp size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
|
||||
<ChevronDown size={18} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||
<X size={18} />
|
||||
</ToolbarButton>
|
||||
</ToolBar>
|
||||
</SearchBarContainer>
|
||||
</NarrowLayout>
|
||||
<Placeholder />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ContentSearch.displayName = 'ContentSearch'
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
margin: 5px 20px;
|
||||
margin-bottom: 0;
|
||||
padding: 6px 15px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-opacity);
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
`
|
||||
|
||||
const Placeholder = styled.div`
|
||||
width: 5px;
|
||||
`
|
||||
|
||||
const InputWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
`
|
||||
|
||||
const Input = styled.input`
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0 5px; /* Adjust padding, wrapper will handle spacing */
|
||||
flex: 1; /* Allow input to grow */
|
||||
font-size: 14px;
|
||||
font-family: Ubuntu;
|
||||
`
|
||||
|
||||
const ToolBar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: tpx;
|
||||
`
|
||||
|
||||
const Separator = styled.div`
|
||||
width: 1px;
|
||||
height: 1.5em;
|
||||
background-color: var(--color-border);
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
flex: 0 0 auto;
|
||||
`
|
||||
|
||||
const SearchResults = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
margin: 0 2px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
font-family: Ubuntu;
|
||||
`
|
||||
|
||||
const SearchResultsPlaceholder = styled.span`
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const NoResults = styled.span`
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const SearchResultCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const SearchResultSeparator = styled.span`
|
||||
color: var(--color-text);
|
||||
margin: 0 4px;
|
||||
`
|
||||
|
||||
const SearchResultTotalCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
91
src/renderer/src/components/ContextMenu/index.tsx
Normal file
91
src/renderer/src/components/ContextMenu/index.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Dropdown } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
onContextMenu?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) => {
|
||||
const { t } = useTranslation()
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
onContextMenu?.(e)
|
||||
},
|
||||
[onContextMenu]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取右键菜单项
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => {
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' })
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
if (selectedQuoteText) {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu} style={{ width: '100%' }}>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextMenu
|
||||
68
src/renderer/src/components/Icons/DownloadIcons.tsx
Normal file
68
src/renderer/src/components/Icons/DownloadIcons.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
// 基础下载图标
|
||||
export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.1em"
|
||||
height="1.1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<path d="M12 15V3" />
|
||||
<polygon points="12,15 9,11 15,11" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// 带有文件类型的下载图标基础组件
|
||||
const DownloadTypeIconBase = ({ type, ...props }: SVGProps<SVGSVGElement> & { type: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.1em"
|
||||
height="1.1em"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}>
|
||||
<text
|
||||
x="12"
|
||||
y="7"
|
||||
fontSize="8"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.3"
|
||||
letterSpacing="1"
|
||||
fontFamily="Arial Black, sans-serif"
|
||||
style={{
|
||||
paintOrder: 'stroke',
|
||||
fontStretch: 'expanded',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}>
|
||||
{type}
|
||||
</text>
|
||||
<path d="M21 16v3a2 2 0 01-2 2H5a2 2 0 01-2-2v-3" />
|
||||
<path d="M12 17V10" />
|
||||
<polygon points="12,17 9.5,14 14.5,14" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// JPG 文件下载图标
|
||||
export const DownloadJpgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="JPG" {...props} />
|
||||
|
||||
// PNG 文件下载图标
|
||||
export const DownloadPngIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="PNG" {...props} />
|
||||
|
||||
// SVG 文件下载图标
|
||||
export const DownloadSvgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="SVG" {...props} />
|
||||
@ -149,15 +149,6 @@ export const BaseTypography = styled(Box)<{
|
||||
text-align: ${(props) => props.textAlign || 'left'};
|
||||
`
|
||||
|
||||
export const TypographyNormal = styled(BaseTypography)`
|
||||
font-family: 'Ubuntu';
|
||||
`
|
||||
|
||||
export const TypographyBold = styled(BaseTypography)`
|
||||
font-family: 'Ubuntu';
|
||||
font-weight: bold;
|
||||
`
|
||||
|
||||
export const Container = styled.main<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -35,7 +35,6 @@ const ListItemContainer = styled.div`
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
font-family: Ubuntu;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
|
||||
|
||||
@ -57,7 +57,6 @@ const NameSpan = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
font-family: 'Ubuntu';
|
||||
line-height: 30px;
|
||||
`
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import store from '@renderer/store'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
import Logger from 'electron-log'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -20,6 +22,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [progressData, setProgressData] = useState<ProgressData>()
|
||||
const { t } = useTranslation()
|
||||
const skipBackupFile = store.getState().settings.skipBackupFile
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => {
|
||||
@ -32,7 +35,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
}, [])
|
||||
|
||||
const onOk = async () => {
|
||||
await backup()
|
||||
Logger.log('[BackupManager] ', skipBackupFile)
|
||||
await backup(skipBackupFile)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
|
||||
90
src/renderer/src/components/Popups/FloatingSidebar.tsx
Normal file
90
src/renderer/src/components/Popups/FloatingSidebar.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import HomeTabs from '@renderer/pages/home/Tabs/index'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Popover } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
activeAssistant: Assistant
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
activeTopic: Topic
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
position: 'left' | 'right'
|
||||
}
|
||||
|
||||
const FloatingSidebar: FC<Props> = ({
|
||||
children,
|
||||
activeAssistant,
|
||||
setActiveAssistant,
|
||||
activeTopic,
|
||||
setActiveTopic,
|
||||
position = 'left'
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(Math.floor(window.innerHeight * 0.75))
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMaxHeight(Math.floor(window.innerHeight * 0.75))
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const content = (
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position={position}
|
||||
forceToSeeAllTab={true}></HomeTabs>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(visible) => {
|
||||
setOpen(visible)
|
||||
}}
|
||||
content={content}
|
||||
trigger={['hover', 'click']}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
mouseEnterDelay={0.8} // 800ms delay before showing
|
||||
mouseLeaveDelay={20}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
background: 'var(--color-background)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12)'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
export default FloatingSidebar
|
||||
@ -647,7 +647,6 @@ const QuickPanelItem = styled.div`
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
font-family: Ubuntu;
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
|
||||
@ -2,12 +2,13 @@ import { throttle } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
right?: boolean
|
||||
ref?: any
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@ -21,18 +22,31 @@ const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject
|
||||
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
|
||||
}, [])
|
||||
|
||||
const throttledHandleScroll = throttle(handleScroll, 200)
|
||||
const throttledInternalScrollHandler = throttle(handleScroll, 200)
|
||||
|
||||
// Combined scroll handler
|
||||
const combinedOnScroll = useCallback(() => {
|
||||
// Event is available if needed by internal handler
|
||||
throttledInternalScrollHandler() // Call internal logic
|
||||
if (externalOnScroll) {
|
||||
externalOnScroll() // Call external logic (from useScrollPosition)
|
||||
}
|
||||
}, [throttledInternalScrollHandler, externalOnScroll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
throttledHandleScroll.cancel()
|
||||
throttledInternalScrollHandler.cancel()
|
||||
}
|
||||
}, [throttledHandleScroll])
|
||||
}, [throttledInternalScrollHandler])
|
||||
|
||||
return (
|
||||
<Container {...props} isScrolling={isScrolling} onScroll={throttledHandleScroll} ref={ref}>
|
||||
{props.children}
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
isScrolling={isScrolling}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@ -25,7 +26,12 @@ export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
|
||||
}
|
||||
|
||||
export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
return <NavbarRightContainer {...props}>{children}</NavbarRightContainer>
|
||||
const isFullscreen = useFullscreen()
|
||||
return (
|
||||
<NavbarRightContainer {...props} $isFullscreen={isFullscreen}>
|
||||
{children}
|
||||
</NavbarRightContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const NavbarContainer = styled.div`
|
||||
@ -58,11 +64,11 @@ const NavbarCenterContainer = styled.div`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div`
|
||||
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
min-width: var(--topic-list-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
padding-right: ${isWindows ? '140px' : isLinux ? '120px' : '12px'};
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')};
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
@ -3,6 +3,7 @@ import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
@ -68,8 +69,13 @@ const Sidebar: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
return (
|
||||
<Container id="app-sidebar" style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
|
||||
<Container
|
||||
$isFullscreen={isFullscreen}
|
||||
id="app-sidebar"
|
||||
style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={31} fontSize={18}>
|
||||
{avatar}
|
||||
@ -311,7 +317,7 @@ const PinnedApps: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
const Container = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@ -319,9 +325,9 @@ const Container = styled.div`
|
||||
padding-bottom: 12px;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'};
|
||||
height: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'calc(100vh - var(--navbar-height))' : '100vh')};
|
||||
-webkit-app-region: drag !important;
|
||||
margin-top: ${isMac ? 'var(--navbar-height)' : 0};
|
||||
margin-top: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'var(--navbar-height)' : 0)};
|
||||
|
||||
.sidebar-avatar {
|
||||
margin-bottom: ${isMac ? '12px' : '12px'};
|
||||
|
||||
@ -3,8 +3,6 @@ export const DEFAULT_CONTEXTCOUNT = 5
|
||||
export const DEFAULT_MAX_TOKENS = 4096
|
||||
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
|
||||
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
|
||||
export const FONT_FAMILY =
|
||||
"Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
|
||||
|
||||
export const platform = window.electron?.process?.platform
|
||||
export const isMac = platform === 'darwin'
|
||||
|
||||
@ -163,7 +163,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'minimax',
|
||||
name: '海螺',
|
||||
url: 'https://hailuoai.com/',
|
||||
url: 'https://chat.minimaxi.com/',
|
||||
logo: HailuoModelLogo
|
||||
},
|
||||
{
|
||||
|
||||
@ -725,46 +725,34 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
],
|
||||
'gitee-ai': [
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
id: 'Qwen3-30B-A3B',
|
||||
name: 'Qwen3-30B-A3B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
id: 'Qwen3-32B',
|
||||
name: 'Qwen3-32B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
id: 'Qwen3-8B',
|
||||
name: 'Qwen3-8B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
id: 'Qwen3-4B',
|
||||
name: 'Qwen3-4B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-V3',
|
||||
name: 'DeepSeek-V3',
|
||||
id: 'Qwen3-0.6B',
|
||||
name: 'Qwen3-0.6B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1',
|
||||
name: 'DeepSeek-R1',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-coder-33B-instruct',
|
||||
name: 'deepseek-coder-33B-instruct',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'Qwen2.5-72B-Instruct',
|
||||
@ -803,11 +791,23 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'QwQ-32B-Preview',
|
||||
name: 'QwQ-32B-Preview',
|
||||
id: 'Qwen2.5-VL-32B-Instruct',
|
||||
name: 'Qwen2.5-VL-32B-Instruct',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'QwQ-32B',
|
||||
name: 'QwQ-32B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'Align-DS-V',
|
||||
name: 'Align-DS-V',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Align'
|
||||
},
|
||||
{
|
||||
id: 'Yi-34B-Chat',
|
||||
name: 'Yi-34B-Chat',
|
||||
@ -820,6 +820,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'gitee-ai',
|
||||
group: 'THUDM'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-coder-33B-instruct',
|
||||
name: 'deepseek-coder-33B-instruct',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'codegeex4-all-9b',
|
||||
name: 'codegeex4-all-9b',
|
||||
@ -844,6 +850,48 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'gitee-ai',
|
||||
group: 'OpenGVLab'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-14B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
name: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-V3',
|
||||
name: 'DeepSeek-V3',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1',
|
||||
name: 'DeepSeek-R1',
|
||||
provider: 'gitee-ai',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'gemma-3-27b-it',
|
||||
name: 'gemma-3-27b-it',
|
||||
provider: 'gitee-ai',
|
||||
group: 'Gemma'
|
||||
},
|
||||
{
|
||||
id: 'bge-large-zh-v1.5',
|
||||
name: 'bge-large-zh-v1.5',
|
||||
@ -2627,7 +2675,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
'qwen-turbo-.*$': { min: 0, max: 38912 },
|
||||
'qwen3-0\\.6b$': { min: 0, max: 30720 },
|
||||
'qwen3-1\\.7b$': { min: 0, max: 30720 },
|
||||
'qwen3-.*$': { min: 0, max: 38912 },
|
||||
'qwen3-.*$': { min: 1024, max: 38912 },
|
||||
|
||||
// Claude models
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 }
|
||||
|
||||
@ -37,10 +37,14 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
},
|
||||
Collapse: {
|
||||
headerBg: 'transparent'
|
||||
},
|
||||
Tooltip: {
|
||||
fontSize: 13
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: '#00b96b'
|
||||
colorPrimary: '#00b96b',
|
||||
fontFamily: 'var(--font-family)'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
|
||||
151
src/renderer/src/context/CodeStyleProvider.tsx
Normal file
151
src/renderer/src/context/CodeStyleProvider.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import * as cmThemes from '@uiw/codemirror-themes-all'
|
||||
import type React from 'react'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
interface CodeStyleContextType {
|
||||
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
|
||||
cleanupTokenizers: (callerId: string) => void
|
||||
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
|
||||
themeNames: string[]
|
||||
activeShikiTheme: string
|
||||
activeCmTheme: any
|
||||
languageMap: Record<string, string>
|
||||
}
|
||||
|
||||
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<CodeStyleContextType>(defaultCodeStyleContext)
|
||||
|
||||
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { codeEditor, codePreview } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const [shikiThemes, setShikiThemes] = useState({})
|
||||
useMermaid()
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeEditor.enabled) {
|
||||
import('shiki').then(({ bundledThemes }) => {
|
||||
setShikiThemes(bundledThemes)
|
||||
})
|
||||
}
|
||||
}, [codeEditor.enabled])
|
||||
|
||||
// 获取支持的主题名称列表
|
||||
const themeNames = useMemo(() => {
|
||||
// CodeMirror 主题
|
||||
// 更保险的做法可能是硬编码主题列表
|
||||
if (codeEditor.enabled) {
|
||||
return ['auto', 'light', 'dark']
|
||||
.concat(Object.keys(cmThemes))
|
||||
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
|
||||
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
|
||||
}
|
||||
|
||||
// Shiki 主题
|
||||
return ['auto', ...Object.keys(shikiThemes)]
|
||||
}, [codeEditor.enabled, shikiThemes])
|
||||
|
||||
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
|
||||
const activeShikiTheme = useMemo(() => {
|
||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||
const codeStyle = codePreview[field]
|
||||
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
|
||||
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
|
||||
}
|
||||
return codeStyle
|
||||
}, [theme, codePreview, themeNames])
|
||||
|
||||
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
|
||||
const activeCmTheme = useMemo(() => {
|
||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||
let themeName = codeEditor[field]
|
||||
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
|
||||
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
|
||||
}
|
||||
return cmThemes[themeName as keyof typeof cmThemes] || themeName
|
||||
}, [theme, codeEditor, themeNames])
|
||||
|
||||
// 一些语言的别名
|
||||
const languageMap = useMemo(() => {
|
||||
return {
|
||||
bash: 'shell',
|
||||
'objective-c++': 'objective-cpp',
|
||||
svg: 'xml',
|
||||
vab: 'vb'
|
||||
} as Record<string, string>
|
||||
}, [])
|
||||
|
||||
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 <CodeStyleContext value={contextValue}>{children}</CodeStyleContext>
|
||||
}
|
||||
|
||||
export const useCodeStyle = () => {
|
||||
const context = use(CodeStyleContext)
|
||||
if (!context) {
|
||||
throw new Error('useCodeStyle must be used within a CodeStyleProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
33
src/renderer/src/context/MessageEditingContext.tsx
Normal file
33
src/renderer/src/context/MessageEditingContext.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { createContext, ReactNode, use, useState } from 'react'
|
||||
|
||||
interface MessageEditingContextType {
|
||||
editingMessageId: string | null
|
||||
startEditing: (messageId: string) => void
|
||||
stopEditing: () => void
|
||||
}
|
||||
|
||||
const MessageEditingContext = createContext<MessageEditingContextType | null>(null)
|
||||
|
||||
export function MessageEditingProvider({ children }: { children: ReactNode }) {
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
|
||||
const startEditing = (messageId: string) => {
|
||||
setEditingMessageId(messageId)
|
||||
}
|
||||
|
||||
const stopEditing = () => {
|
||||
setEditingMessageId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageEditingContext value={{ editingMessageId, startEditing, stopEditing }}>{children}</MessageEditingContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMessageEditing() {
|
||||
const context = use(MessageEditingContext)
|
||||
if (!context) {
|
||||
throw new Error('useMessageEditing must be used within a MessageEditingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { CodeCacheService } from '@renderer/services/CodeCacheService'
|
||||
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
|
||||
import { getHighlighter, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/highlighter'
|
||||
import type React from 'react'
|
||||
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
|
||||
import { bundledThemes } from 'shiki'
|
||||
|
||||
interface SyntaxHighlighterContextType {
|
||||
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
|
||||
}
|
||||
|
||||
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
|
||||
|
||||
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ 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<string, string> = {
|
||||
vab: 'vb'
|
||||
}
|
||||
|
||||
const mappedLanguage = languageMap[language] || language
|
||||
|
||||
const code = _code?.trimEnd() ?? ''
|
||||
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '<', '>': '>' })[char]!)
|
||||
|
||||
try {
|
||||
const highlighter = await getHighlighter()
|
||||
|
||||
await loadThemeIfNeeded(highlighter, highlighterTheme)
|
||||
await loadLanguageIfNeeded(highlighter, mappedLanguage)
|
||||
|
||||
// 生成高亮HTML
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang: mappedLanguage,
|
||||
theme: highlighterTheme
|
||||
})
|
||||
|
||||
// 设置缓存
|
||||
if (enableCache) {
|
||||
CodeCacheService.setCachedResult(key, html, _code.length)
|
||||
}
|
||||
|
||||
return html
|
||||
} catch (error) {
|
||||
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
|
||||
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
|
||||
}
|
||||
}
|
||||
},
|
||||
[highlighterTheme]
|
||||
)
|
||||
|
||||
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>
|
||||
}
|
||||
|
||||
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[]
|
||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@ -19,7 +19,6 @@ declare global {
|
||||
message: MessageInstance
|
||||
modal: HookAPI
|
||||
keyv: KeyvStorage
|
||||
mermaid: any
|
||||
store: any
|
||||
navigate: NavigateFunction
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
updateTopic,
|
||||
updateTopics
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { setDefaultModel, setQuickAssistantModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
@ -103,15 +103,17 @@ export function useDefaultAssistant() {
|
||||
}
|
||||
|
||||
export function useDefaultModel() {
|
||||
const { defaultModel, topicNamingModel, translateModel } = useAppSelector((state) => state.llm)
|
||||
const { defaultModel, topicNamingModel, translateModel, quickAssistantModel } = useAppSelector((state) => state.llm)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
defaultModel,
|
||||
topicNamingModel,
|
||||
translateModel,
|
||||
quickAssistantModel,
|
||||
setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })),
|
||||
setTopicNamingModel: (model: Model) => dispatch(setTopicNamingModel({ model })),
|
||||
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model }))
|
||||
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model })),
|
||||
setQuickAssistantModel: (model: Model) => dispatch(setQuickAssistantModel({ model }))
|
||||
}
|
||||
}
|
||||
|
||||
18
src/renderer/src/hooks/useFullscreen.ts
Normal file
18
src/renderer/src/hooks/useFullscreen.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useFullscreen() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, fullscreen) => {
|
||||
setIsFullscreen(fullscreen)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isFullscreen
|
||||
}
|
||||
@ -4,13 +4,11 @@ import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
|
||||
const ipcRenderer = window.electron.ipcRenderer
|
||||
|
||||
// Listen for server changes from main process
|
||||
ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
|
||||
store.dispatch(setMCPServers(servers))
|
||||
})
|
||||
ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
|
||||
store.dispatch(addMCPServer(server))
|
||||
})
|
||||
|
||||
|
||||
@ -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<any> | 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<string | null>(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
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import Logger from '@renderer/config/logger'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
|
||||
import { updateOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import {
|
||||
appendAssistantResponseThunk,
|
||||
@ -13,6 +13,7 @@ import {
|
||||
deleteSingleMessageThunk,
|
||||
initiateTranslationThunk,
|
||||
regenerateAssistantResponseThunk,
|
||||
removeBlocksThunk,
|
||||
resendMessageThunk,
|
||||
resendUserMessageWithEditThunk,
|
||||
updateMessageAndBlocksThunk,
|
||||
@ -22,21 +23,8 @@ import type { Assistant, Model, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const findMainTextBlockId = (message: Message): string | undefined => {
|
||||
if (!message || !message.blocks) return undefined
|
||||
const state = store.getState()
|
||||
for (const blockId of message.blocks) {
|
||||
const block = messageBlocksSelectors.selectById(state, String(blockId))
|
||||
if (block && block.type === MessageBlockType.MAIN_TEXT) {
|
||||
return block.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const selectMessagesState = (state: RootState) => state.messages
|
||||
|
||||
export const selectNewTopicLoading = createSelector(
|
||||
@ -113,36 +101,6 @@ export function useMessageOperations(topic: Topic) {
|
||||
[dispatch, topic.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
|
||||
* Dispatches resendUserMessageWithEditThunk.
|
||||
*/
|
||||
const resendUserMessageWithEdit = useCallback(
|
||||
async (message: Message, editedContent: string, assistant: Assistant) => {
|
||||
const mainTextBlockId = findMainTextBlockId(message)
|
||||
if (!mainTextBlockId) {
|
||||
console.error('Cannot resend edited message: Main text block not found.')
|
||||
return
|
||||
}
|
||||
|
||||
const files = findFileBlocks(message).map((block) => block.file)
|
||||
|
||||
const usage = await estimateUserPromptUsage({ content: editedContent, files })
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: message.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
usage
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
|
||||
)
|
||||
// 对于message的修改会在下面的thunk中保存
|
||||
await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant))
|
||||
},
|
||||
[dispatch, topic.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* 清除当前或指定主题的所有消息。 / Clears all messages for the current or specified topic.
|
||||
* Dispatches clearTopicMessagesThunk.
|
||||
@ -309,29 +267,127 @@ export function useMessageOperations(topic: Topic) {
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates properties of specific message blocks (e.g., content).
|
||||
* Uses the generalized thunk for persistence.
|
||||
* Updates message blocks by comparing original and edited blocks.
|
||||
* Handles adding, updating, and removing blocks in a single operation.
|
||||
* @param messageId The ID of the message to update
|
||||
* @param editedBlocks The complete set of blocks after editing
|
||||
*/
|
||||
const editMessageBlocks = useCallback(
|
||||
async (messageId: string, updates: Partial<MessageBlock>) => {
|
||||
async (messageId: string, editedBlocks: MessageBlock[]) => {
|
||||
if (!topic?.id) {
|
||||
console.error('[editMessageBlocks] Topic prop is not valid.')
|
||||
return
|
||||
}
|
||||
|
||||
const blockUpdatesListProcessed = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
...updates
|
||||
}
|
||||
try {
|
||||
// 1. Get the current state of the message and its blocks
|
||||
const state = store.getState()
|
||||
const message = state.messages.entities[messageId]
|
||||
if (!message) {
|
||||
console.error('[editMessageBlocks] Message not found:', messageId)
|
||||
return
|
||||
}
|
||||
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: messageId,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
// 2. Get all original blocks
|
||||
const originalBlocks = message.blocks
|
||||
? (message.blocks
|
||||
.map((blockId) => state.messageBlocks.entities[blockId])
|
||||
.filter((block) => block !== undefined) as MessageBlock[])
|
||||
: []
|
||||
|
||||
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed]))
|
||||
// 3. Create sets for efficient comparison
|
||||
const originalBlockIds = new Set(originalBlocks.map((block) => block.id))
|
||||
const editedBlockIds = new Set(editedBlocks.map((block) => block.id))
|
||||
|
||||
// 4. Identify blocks to remove, update, and add
|
||||
const blockIdsToRemove = originalBlocks
|
||||
.filter((block) => !editedBlockIds.has(block.id))
|
||||
.map((block) => block.id)
|
||||
|
||||
const blocksToUpdate = editedBlocks
|
||||
.filter((block) => originalBlockIds.has(block.id))
|
||||
.map((block) => ({
|
||||
...block,
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
const blocksToAdd = editedBlocks
|
||||
.filter((block) => !originalBlockIds.has(block.id))
|
||||
.map((block) => ({
|
||||
...block,
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
// 5. Prepare message update with new block IDs
|
||||
const updatedBlockIds = editedBlocks.map((block) => block.id)
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: messageId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
blocks: updatedBlockIds
|
||||
}
|
||||
|
||||
// 6. Log operations for debugging
|
||||
console.log('[editMessageBlocks] Operations:', {
|
||||
blocksToRemove: blockIdsToRemove.length,
|
||||
blocksToUpdate: blocksToUpdate.length,
|
||||
blocksToAdd: blocksToAdd.length
|
||||
})
|
||||
|
||||
// 7. Update Redux state and database
|
||||
// First update message and add/update blocks
|
||||
if (blocksToAdd.length > 0) {
|
||||
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToAdd))
|
||||
}
|
||||
|
||||
if (blocksToUpdate.length > 0) {
|
||||
await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToUpdate))
|
||||
}
|
||||
|
||||
// Then remove blocks if needed
|
||||
if (blockIdsToRemove.length > 0) {
|
||||
await dispatch(removeBlocksThunk(topic.id, messageId, blockIdsToRemove))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[editMessageBlocks] Failed to update message blocks:', error)
|
||||
}
|
||||
},
|
||||
[dispatch, topic.id]
|
||||
[dispatch, topic?.id]
|
||||
)
|
||||
|
||||
/**
|
||||
* 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
|
||||
* Dispatches resendUserMessageWithEditThunk.
|
||||
*/
|
||||
const resendUserMessageWithEdit = useCallback(
|
||||
async (message: Message, editedBlocks: MessageBlock[], assistant: Assistant) => {
|
||||
await editMessageBlocks(message.id, editedBlocks)
|
||||
|
||||
const mainTextBlock = editedBlocks.find((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
if (!mainTextBlock) {
|
||||
console.error('[resendUserMessageWithEdit] Main text block not found in edited blocks')
|
||||
return
|
||||
}
|
||||
|
||||
const fileBlocks = editedBlocks.filter(
|
||||
(block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE
|
||||
)
|
||||
|
||||
const files = fileBlocks.map((block) => block.file).filter((file) => file !== undefined)
|
||||
|
||||
const usage = await estimateUserPromptUsage({ content: mainTextBlock.content, files })
|
||||
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
|
||||
id: message.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
usage
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
|
||||
)
|
||||
// 对于message的修改会在下面的thunk中保存
|
||||
await dispatch(resendUserMessageWithEditThunk(topic.id, message, assistant))
|
||||
},
|
||||
[dispatch, editMessageBlocks, topic.id]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -7,7 +7,9 @@ export default function useScrollPosition(key: string) {
|
||||
|
||||
const handleScroll = throttle(() => {
|
||||
const position = containerRef.current?.scrollTop ?? 0
|
||||
window.keyv.set(scrollKey, position)
|
||||
window.requestAnimationFrame(() => {
|
||||
window.keyv.set(scrollKey, position)
|
||||
})
|
||||
}, 100)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -131,7 +131,10 @@
|
||||
"manage": "Manage",
|
||||
"select_model": "Select Model",
|
||||
"show.all": "Show All",
|
||||
"update_available": "Update Available"
|
||||
"update_available": "Update Available",
|
||||
"includes_user_questions": "Include Your Questions",
|
||||
"case_sensitive": "Case Sensitive",
|
||||
"whole_word": "Whole Word"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "Add Assistant",
|
||||
@ -163,6 +166,7 @@
|
||||
"input.estimated_tokens.tip": "Estimated tokens",
|
||||
"input.expand": "Expand",
|
||||
"input.file_not_supported": "Model does not support this file type",
|
||||
"input.file_error": "Error processing file",
|
||||
"input.generate_image": "Generate image",
|
||||
"input.generate_image_not_supported": "The model does not support generating images.",
|
||||
"input.knowledge_base": "Knowledge Base",
|
||||
@ -199,6 +203,20 @@
|
||||
},
|
||||
"resend": "Resend",
|
||||
"save": "Save",
|
||||
"settings.code.title": "Code Block Settings",
|
||||
"settings.code_editor": {
|
||||
"title": "Code Editor",
|
||||
"highlight_active_line": "Highlight active line",
|
||||
"fold_gutter": "Fold gutter",
|
||||
"autocompletion": "Autocompletion",
|
||||
"keymap": "Keymap"
|
||||
},
|
||||
"settings.code_execution": {
|
||||
"title": "Code Execution",
|
||||
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
|
||||
"timeout_minutes": "Timeout",
|
||||
"timeout_minutes.tip": "The timeout time (minutes) of code execution"
|
||||
},
|
||||
"settings.code_collapsible": "Code block collapsible",
|
||||
"settings.code_wrappable": "Code block wrappable",
|
||||
"settings.code_cacheable": "Code block cache",
|
||||
@ -304,9 +322,32 @@
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
"disable_wrap": "Unwrap",
|
||||
"enable_wrap": "Wrap",
|
||||
"expand": "Expand"
|
||||
"copy.failed": "Copy failed",
|
||||
"copy.source": "Copy Source Code",
|
||||
"copy.success": "Copied",
|
||||
"copy": "Copy",
|
||||
"download.failed.network": "Download failed, please check the network",
|
||||
"download.png": "Download PNG",
|
||||
"download.source": "Download Source Code",
|
||||
"download.svg": "Download SVG",
|
||||
"download": "Download",
|
||||
"edit.save.failed.message_not_found": "Save failed, message not found",
|
||||
"edit.save.failed": "Save failed",
|
||||
"edit.save.success": "Saved",
|
||||
"edit.save": "Save Changes",
|
||||
"edit": "Edit",
|
||||
"expand": "Expand",
|
||||
"more": "More",
|
||||
"preview.copy.image": "Copy as image",
|
||||
"preview.source": "View Source Code",
|
||||
"preview.zoom_in": "Zoom In",
|
||||
"preview.zoom_out": "Zoom Out",
|
||||
"preview": "Preview",
|
||||
"run": "Run",
|
||||
"split.restore": "Restore Split View",
|
||||
"split": "Split View",
|
||||
"wrap.off": "Unwrap",
|
||||
"wrap.on": "Wrap"
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
@ -360,7 +401,8 @@
|
||||
"pinyin": "Sort by Pinyin",
|
||||
"pinyin.asc": "Sort by Pinyin (A-Z)",
|
||||
"pinyin.desc": "Sort by Pinyin (Z-A)"
|
||||
}
|
||||
},
|
||||
"no_results": "No results"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Docs"
|
||||
@ -527,21 +569,6 @@
|
||||
"keep_alive_time.title": "Keep Alive Time",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
"png": "Download PNG",
|
||||
"svg": "Download SVG"
|
||||
},
|
||||
"resize": {
|
||||
"zoom-in": "Zoom In",
|
||||
"zoom-out": "Zoom Out"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "Preview",
|
||||
"source": "Source"
|
||||
},
|
||||
"title": "Mermaid Diagram"
|
||||
},
|
||||
"message": {
|
||||
"agents": {
|
||||
"imported": "Imported successfully",
|
||||
@ -564,6 +591,7 @@
|
||||
"copied": "Copied!",
|
||||
"copy.failed": "Copy failed",
|
||||
"copy.success": "Copied!",
|
||||
"empty_url": "Failed to download image, possibly due to prompt containing sensitive content or prohibited words",
|
||||
"error.chunk_overlap_too_large": "Chunk overlap cannot be greater than chunk size",
|
||||
"error.dimension_too_large": "Content size is too large",
|
||||
"error.enter.api.host": "Please enter your API host first",
|
||||
@ -775,6 +803,7 @@
|
||||
"model": "Model Version",
|
||||
"aspect_ratio": "Aspect Ratio",
|
||||
"style_type": "Style",
|
||||
"rendering_speed": "Rendering Speed",
|
||||
"learn_more": "Learn More",
|
||||
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
|
||||
"proxy_required": "Currently, you need to open a proxy to view the generated images, it will be supported in the future",
|
||||
@ -782,6 +811,20 @@
|
||||
"image_file_retry": "Please re-upload an image first",
|
||||
"image_placeholder": "No image available",
|
||||
"image_retry": "Retry",
|
||||
"translating": "Translating...",
|
||||
"style_types": {
|
||||
"auto": "Auto",
|
||||
"general": "General",
|
||||
"realistic": "Realistic",
|
||||
"design": "Design",
|
||||
"3d": "3D",
|
||||
"anime": "Anime"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "Default",
|
||||
"turbo": "Turbo",
|
||||
"quality": "Quality"
|
||||
},
|
||||
"mode": {
|
||||
"generate": "Draw",
|
||||
"edit": "Edit",
|
||||
@ -789,20 +832,22 @@
|
||||
"upscale": "Upscale"
|
||||
},
|
||||
"generate": {
|
||||
"model_tip": "Model version: V2 is the latest model of the interface, V2A is the fast model, V_1 is the first-generation model, _TURBO is the acceleration version",
|
||||
"model_tip": "Model version: V3 is the latest version, V2 is the previous model, V2A is the fast model, V_1 is the first-generation model, _TURBO is the acceleration version",
|
||||
"number_images_tip": "Number of images to generate",
|
||||
"seed_tip": "Controls image generation randomness for reproducible results",
|
||||
"negative_prompt_tip": "Describe unwanted elements, only for V_1, V_1_TURBO, V_2, and V_2_TURBO",
|
||||
"magic_prompt_option_tip": "Intelligently enhances prompts for better results",
|
||||
"style_type_tip": "Image generation style for V_2 and above"
|
||||
"style_type_tip": "Image generation style for V_2 and above",
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "Edited Image",
|
||||
"model_tip": "Only supports V_2 and V_2_TURBO versions",
|
||||
"model_tip": "V3 and V2 versions supported",
|
||||
"number_images_tip": "Number of edited results to generate",
|
||||
"style_type_tip": "Style for edited image, only for V_2 and above",
|
||||
"seed_tip": "Controls editing randomness",
|
||||
"magic_prompt_option_tip": "Intelligently enhances editing prompts"
|
||||
"magic_prompt_option_tip": "Intelligently enhances editing prompts",
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3"
|
||||
},
|
||||
"remix": {
|
||||
"model_tip": "Select AI model version for remixing",
|
||||
@ -813,7 +858,8 @@
|
||||
"seed_tip": "Control the randomness of the mixed result",
|
||||
"style_type_tip": "Style for remixed image, only for V_2 and above",
|
||||
"negative_prompt_tip": "Describe unwanted elements in remix results",
|
||||
"magic_prompt_option_tip": "Intelligently enhances remix prompts"
|
||||
"magic_prompt_option_tip": "Intelligently enhances remix prompts",
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3"
|
||||
},
|
||||
"upscale": {
|
||||
"image_file": "Image to upscale",
|
||||
@ -826,18 +872,6 @@
|
||||
"magic_prompt_option_tip": "Intelligently enhances upscaling prompts"
|
||||
}
|
||||
},
|
||||
"plantuml": {
|
||||
"download": {
|
||||
"failed": "Download failed, please check the network",
|
||||
"png": "Download PNG",
|
||||
"svg": "Download SVG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "Preview",
|
||||
"source": "Source"
|
||||
},
|
||||
"title": "PlantUML Diagram"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Explain this concept to me",
|
||||
"summarize": "Summarize this text",
|
||||
@ -946,6 +980,8 @@
|
||||
"app_knowledge.remove_all_confirm": "Deleting knowledge base files will reduce the storage space occupied, but will not delete the knowledge base vector data, after deletion, the source file will no longer be able to be opened. Continue?",
|
||||
"app_knowledge.remove_all_success": "Files removed successfully",
|
||||
"app_logs": "App Logs",
|
||||
"backup.skip_file_data_title": "Slim Backup",
|
||||
"backup.skip_file_data_help": "Skip backing up data files such as pictures and knowledge bases during backup, and only back up chat records and settings. Reduce space occupancy and speed up the backup speed.",
|
||||
"clear_cache": {
|
||||
"button": "Clear Cache",
|
||||
"confirm": "Clearing the cache will delete application cache data, including minapp data. This action is irreversible, continue?",
|
||||
@ -1436,6 +1472,8 @@
|
||||
"models.translate_model_description": "Model used for translation service",
|
||||
"models.translate_model_prompt_message": "Please enter the translate model prompt",
|
||||
"models.translate_model_prompt_title": "Translate Model Prompt",
|
||||
"models.quick_assistant_model": "Quick Assistant Model",
|
||||
"models.quick_assistant_model_description": "Default model used by Quick Assistant",
|
||||
"moresetting": "More Settings",
|
||||
"moresetting.check.confirm": "Confirm Selection",
|
||||
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",
|
||||
@ -1545,6 +1583,7 @@
|
||||
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
|
||||
"reset_to_default": "Reset to Default",
|
||||
"search_message": "Search Message",
|
||||
"search_message_in_chat": "Search Message in Current Chat",
|
||||
"show_app": "Show/Hide App",
|
||||
"show_settings": "Open Settings",
|
||||
"title": "Keyboard Shortcuts",
|
||||
|
||||
@ -131,7 +131,10 @@
|
||||
"manage": "管理",
|
||||
"select_model": "モデルを選択",
|
||||
"show.all": "すべて表示",
|
||||
"update_available": "更新可能"
|
||||
"update_available": "更新可能",
|
||||
"includes_user_questions": "ユーザーからの質問を含む",
|
||||
"case_sensitive": "大文字と小文字の区別",
|
||||
"whole_word": "全語一致"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "アシスタントを追加",
|
||||
@ -163,6 +166,7 @@
|
||||
"input.estimated_tokens.tip": "推定トークン数",
|
||||
"input.expand": "展開",
|
||||
"input.file_not_supported": "モデルはこのファイルタイプをサポートしません",
|
||||
"input.file_error": "ファイル処理エラー",
|
||||
"input.generate_image": "画像を生成する",
|
||||
"input.generate_image_not_supported": "モデルは画像の生成をサポートしていません。",
|
||||
"input.knowledge_base": "ナレッジベース",
|
||||
@ -199,6 +203,20 @@
|
||||
},
|
||||
"resend": "再送信",
|
||||
"save": "保存",
|
||||
"settings.code.title": "コード設定",
|
||||
"settings.code_editor": {
|
||||
"title": "コードエディター",
|
||||
"highlight_active_line": "アクティブ行をハイライト",
|
||||
"fold_gutter": "折りたたみガター",
|
||||
"autocompletion": "自動補完",
|
||||
"keymap": "キーマップ"
|
||||
},
|
||||
"settings.code_execution": {
|
||||
"title": "コード実行",
|
||||
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
|
||||
"timeout_minutes": "タイムアウト時間",
|
||||
"timeout_minutes.tip": "コード実行のタイムアウト時間(分)"
|
||||
},
|
||||
"settings.code_collapsible": "コードブロック折り畳み",
|
||||
"settings.code_wrappable": "コードブロック折り返し",
|
||||
"settings.code_cacheable": "コードブロックキャッシュ",
|
||||
@ -304,9 +322,32 @@
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折りたたむ",
|
||||
"disable_wrap": "改行解除",
|
||||
"enable_wrap": "改行",
|
||||
"expand": "展開する"
|
||||
"copy.failed": "コピーに失敗しました",
|
||||
"copy.source": "コピー源コード",
|
||||
"copy.success": "コピーしました",
|
||||
"copy": "コピー",
|
||||
"download.failed.network": "ダウンロードに失敗しました。ネットワークを確認してください",
|
||||
"download.png": "PNGとしてダウンロード",
|
||||
"download.source": "ダウンロード源コード",
|
||||
"download.svg": "SVGとしてダウンロード",
|
||||
"download": "ダウンロード",
|
||||
"edit.save.failed.message_not_found": "保存に失敗しました。対応するメッセージが見つかりませんでした",
|
||||
"edit.save.failed": "保存に失敗しました",
|
||||
"edit.save.success": "保存しました",
|
||||
"edit.save": "保存する",
|
||||
"edit": "編集",
|
||||
"expand": "展開する",
|
||||
"more": "もっと",
|
||||
"preview.copy.image": "画像としてコピー",
|
||||
"preview.source": "ソースコードを表示",
|
||||
"preview.zoom_in": "拡大",
|
||||
"preview.zoom_out": "縮小",
|
||||
"preview": "プレビュー",
|
||||
"run": "コードを実行",
|
||||
"split.restore": "分割視圖を解除",
|
||||
"split": "分割視圖",
|
||||
"wrap.off": "改行解除",
|
||||
"wrap.on": "改行"
|
||||
},
|
||||
"common": {
|
||||
"add": "追加",
|
||||
@ -360,7 +401,8 @@
|
||||
"pinyin": "ピンインでソート",
|
||||
"pinyin.asc": "ピンインで昇順ソート",
|
||||
"pinyin.desc": "ピンインで降順ソート"
|
||||
}
|
||||
},
|
||||
"no_results": "検索結果なし"
|
||||
},
|
||||
"docs": {
|
||||
"title": "ドキュメント"
|
||||
@ -527,21 +569,6 @@
|
||||
"keep_alive_time.title": "保持時間",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
"png": "PNGをダウンロード",
|
||||
"svg": "SVGをダウンロード"
|
||||
},
|
||||
"resize": {
|
||||
"zoom-in": "拡大する",
|
||||
"zoom-out": "ズームアウト"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "プレビュー",
|
||||
"source": "ソース"
|
||||
},
|
||||
"title": "Mermaid図"
|
||||
},
|
||||
"message": {
|
||||
"agents": {
|
||||
"imported": "インポートに成功しました",
|
||||
@ -564,7 +591,8 @@
|
||||
"copied": "コピーしました!",
|
||||
"copy.failed": "コピーに失敗しました",
|
||||
"copy.success": "コピーしました!",
|
||||
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
|
||||
"empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります",
|
||||
"error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません",
|
||||
"error.dimension_too_large": "内容のサイズが大きすぎます",
|
||||
"error.enter.api.host": "APIホストを入力してください",
|
||||
"error.enter.api.key": "APIキーを入力してください",
|
||||
@ -782,6 +810,19 @@
|
||||
"image_file_retry": "画像を先にアップロードしてください",
|
||||
"image_placeholder": "画像がありません",
|
||||
"image_retry": "再試行",
|
||||
"style_types": {
|
||||
"auto": "自動",
|
||||
"general": "一般",
|
||||
"realistic": "リアル",
|
||||
"design": "デザイン",
|
||||
"3d": "3D",
|
||||
"anime": "アニメ"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "デフォルト",
|
||||
"turbo": "高速",
|
||||
"quality": "高品質"
|
||||
},
|
||||
"mode": {
|
||||
"generate": "画像生成",
|
||||
"edit": "部分編集",
|
||||
@ -794,7 +835,8 @@
|
||||
"seed_tip": "画像生成のランダム性を制御して、同じ生成結果を再現します",
|
||||
"negative_prompt_tip": "画像に含めたくない内容を説明します",
|
||||
"magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します",
|
||||
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用"
|
||||
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用",
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "編集画像",
|
||||
@ -802,7 +844,8 @@
|
||||
"number_images_tip": "生成される編集結果の数",
|
||||
"style_type_tip": "編集後の画像スタイル、V_2 以上のバージョンでのみ適用",
|
||||
"seed_tip": "編集結果のランダム性を制御します",
|
||||
"magic_prompt_option_tip": "編集効果を向上させるための提示詞を最適化します"
|
||||
"magic_prompt_option_tip": "編集効果を向上させるための提示詞を最適化します",
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です"
|
||||
},
|
||||
"remix": {
|
||||
"model_tip": "リミックスに使用する AI モデルのバージョンを選択します",
|
||||
@ -813,7 +856,8 @@
|
||||
"seed_tip": "リミックス結果のランダム性を制御します",
|
||||
"style_type_tip": "リミックス後の画像スタイル、V_2 以上のバージョンでのみ適用",
|
||||
"negative_prompt_tip": "リミックス結果に含めたくない内容を説明します",
|
||||
"magic_prompt_option_tip": "リミックス効果を向上させるための提示詞を最適化します"
|
||||
"magic_prompt_option_tip": "リミックス効果を向上させるための提示詞を最適化します",
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です"
|
||||
},
|
||||
"upscale": {
|
||||
"image_file": "拡大する画像",
|
||||
@ -824,19 +868,9 @@
|
||||
"number_images_tip": "生成される拡大結果の数",
|
||||
"seed_tip": "拡大結果のランダム性を制御します",
|
||||
"magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します"
|
||||
}
|
||||
},
|
||||
"plantuml": {
|
||||
"download": {
|
||||
"failed": "ダウンロードに失敗しました。ネットワークを確認してください",
|
||||
"png": "PNG をダウンロード",
|
||||
"svg": "SVG をダウンロード"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "プレビュー",
|
||||
"source": "ソースコード"
|
||||
},
|
||||
"title": "PlantUML 図表"
|
||||
"rendering_speed": "レンダリング速度",
|
||||
"translating": "翻訳中..."
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "この概念を説明してください",
|
||||
@ -943,6 +977,8 @@
|
||||
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
|
||||
"app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?",
|
||||
"app_knowledge.remove_all_success": "ファイル削除成功",
|
||||
"backup.skip_file_data_title": "精簡バックアップ",
|
||||
"backup.skip_file_data_help": "バックアップ時に、画像や知識ベースなどのデータファイルをバックアップ対象から除外し、チャット履歴と設定のみをバックアップします。スペースの占有を減らし、バックアップ速度を向上させます。",
|
||||
"app_logs": "アプリログ",
|
||||
"clear_cache": {
|
||||
"button": "キャッシュをクリア",
|
||||
@ -1432,6 +1468,8 @@
|
||||
"models.translate_model_description": "翻訳サービスに使用されるモデル",
|
||||
"models.translate_model_prompt_message": "翻訳モデルのプロンプトを入力してください",
|
||||
"models.translate_model_prompt_title": "翻訳モデルのプロンプト",
|
||||
"models.quick_assistant_model": "クイックアシスタントモデル",
|
||||
"models.quick_assistant_model_description": "クイックアシスタントで使用されるデフォルトモデル",
|
||||
"moresetting": "詳細設定",
|
||||
"moresetting.check.confirm": "選択を確認",
|
||||
"moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
|
||||
@ -1541,6 +1579,7 @@
|
||||
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
|
||||
"reset_to_default": "デフォルトにリセット",
|
||||
"search_message": "メッセージを検索",
|
||||
"search_message_in_chat": "現在のチャットでメッセージを検索",
|
||||
"show_app": "アプリを表示/非表示",
|
||||
"show_settings": "設定を開く",
|
||||
"title": "ショートカット",
|
||||
|
||||
@ -131,7 +131,10 @@
|
||||
"manage": "Редактировать",
|
||||
"select_model": "Выбрать модель",
|
||||
"show.all": "Показать все",
|
||||
"update_available": "Доступно обновление"
|
||||
"update_available": "Доступно обновление",
|
||||
"includes_user_questions": "Включает вопросы пользователей",
|
||||
"case_sensitive": "Чувствительность к регистру",
|
||||
"whole_word": "Полное слово"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "Добавить ассистента",
|
||||
@ -163,6 +166,7 @@
|
||||
"input.estimated_tokens.tip": "Затраты токенов",
|
||||
"input.expand": "Развернуть",
|
||||
"input.file_not_supported": "Модель не поддерживает этот тип файла",
|
||||
"input.file_error": "Ошибка обработки файла",
|
||||
"input.generate_image": "Сгенерировать изображение",
|
||||
"input.generate_image_not_supported": "Модель не поддерживает генерацию изображений.",
|
||||
"input.knowledge_base": "База знаний",
|
||||
@ -199,6 +203,20 @@
|
||||
},
|
||||
"resend": "Переотправить",
|
||||
"save": "Сохранить",
|
||||
"settings.code.title": "Настройки кода",
|
||||
"settings.code_editor": {
|
||||
"title": "Редактор кода",
|
||||
"highlight_active_line": "Выделить активную строку",
|
||||
"fold_gutter": "Свернуть",
|
||||
"autocompletion": "Автодополнение",
|
||||
"keymap": "Клавиатурные сокращения"
|
||||
},
|
||||
"settings.code_execution": {
|
||||
"title": "Выполнение кода",
|
||||
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
|
||||
"timeout_minutes": "Время выполнения",
|
||||
"timeout_minutes.tip": "Время выполнения кода (минуты)"
|
||||
},
|
||||
"settings.code_collapsible": "Блок кода свернут",
|
||||
"settings.code_wrappable": "Блок кода можно переносить",
|
||||
"settings.code_cacheable": "Кэш блока кода",
|
||||
@ -304,9 +322,32 @@
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Свернуть",
|
||||
"disable_wrap": "Отменить перенос строки",
|
||||
"enable_wrap": "Перенос строки",
|
||||
"expand": "Развернуть"
|
||||
"copy.failed": "Не удалось скопировать",
|
||||
"copy.source": "Копировать исходный код",
|
||||
"copy.success": "Скопировано",
|
||||
"copy": "Копировать",
|
||||
"download.failed.network": "Не удалось скачать. Пожалуйста, проверьте ваше интернет-соединение",
|
||||
"download.png": "Скачать PNG",
|
||||
"download.source": "Скачать исходный код",
|
||||
"download.svg": "Скачать SVG",
|
||||
"download": "Скачать",
|
||||
"edit.save.failed.message_not_found": "Не удалось сохранить изменения, не найдено сообщение",
|
||||
"edit.save.failed": "Не удалось сохранить изменения",
|
||||
"edit.save.success": "Изменения сохранены",
|
||||
"edit.save": "Сохранить изменения",
|
||||
"edit": "Редактировать",
|
||||
"expand": "Развернуть",
|
||||
"more": "Ещё",
|
||||
"preview.copy.image": "Скопировать как изображение",
|
||||
"preview.source": "Смотреть исходный код",
|
||||
"preview.zoom_in": "Увеличить",
|
||||
"preview.zoom_out": "Уменьшить",
|
||||
"preview": "Предварительный просмотр",
|
||||
"run": "Выполнить код",
|
||||
"split.restore": "Вернуться к одному окну",
|
||||
"split": "Разделить на два окна",
|
||||
"wrap.off": "Отменить перенос строки",
|
||||
"wrap.on": "Перенос строки"
|
||||
},
|
||||
"common": {
|
||||
"add": "Добавить",
|
||||
@ -360,7 +401,8 @@
|
||||
"pinyin": "Сортировать по пиньинь",
|
||||
"pinyin.asc": "Сортировать по пиньинь (А-Я)",
|
||||
"pinyin.desc": "Сортировать по пиньинь (Я-А)"
|
||||
}
|
||||
},
|
||||
"no_results": "Результатов не найдено"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Документация"
|
||||
@ -527,21 +569,6 @@
|
||||
"keep_alive_time.title": "Время жизни модели",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
"png": "Скачать PNG",
|
||||
"svg": "Скачать SVG"
|
||||
},
|
||||
"resize": {
|
||||
"zoom-in": "Yвеличить",
|
||||
"zoom-out": "Yменьшить масштаб"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "Предпросмотр",
|
||||
"source": "Исходный код"
|
||||
},
|
||||
"title": "Диаграмма Mermaid"
|
||||
},
|
||||
"message": {
|
||||
"agents": {
|
||||
"imported": "Импорт успешно выполнен",
|
||||
@ -564,7 +591,8 @@
|
||||
"copied": "Скопировано!",
|
||||
"copy.failed": "Не удалось скопировать",
|
||||
"copy.success": "Скопировано!",
|
||||
"error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента.",
|
||||
"empty_url": "Не удалось загрузить изображение, возможно, запрос содержит конфиденциальный контент или запрещенные слова",
|
||||
"error.chunk_overlap_too_large": "Перекрытие фрагментов не может быть больше размера фрагмента",
|
||||
"error.dimension_too_large": "Размер содержимого слишком велик",
|
||||
"error.enter.api.host": "Пожалуйста, введите ваш API хост",
|
||||
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
|
||||
@ -781,7 +809,21 @@
|
||||
"image_file_required": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_file_retry": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_placeholder": "Изображение недоступно",
|
||||
"image_retry": "Попробовать снова",
|
||||
"image_retry": "Повторить",
|
||||
"translating": "Перевод...",
|
||||
"style_types": {
|
||||
"auto": "Авто",
|
||||
"general": "Общий",
|
||||
"realistic": "Реалистичный",
|
||||
"design": "Дизайн",
|
||||
"3d": "3D",
|
||||
"anime": "Аниме"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "По умолчанию",
|
||||
"turbo": "Быстро",
|
||||
"quality": "Качественно"
|
||||
},
|
||||
"mode": {
|
||||
"generate": "Рисование",
|
||||
"edit": "Редактирование",
|
||||
@ -789,31 +831,34 @@
|
||||
"upscale": "Увеличение"
|
||||
},
|
||||
"generate": {
|
||||
"model_tip": "Версия модели: V2 — последняя модель интерфейса, V2A — быстрая модель, V_1 — первая модель, _TURBO — ускоренная версия",
|
||||
"number_images_tip": "Количество изображений для генерации",
|
||||
"seed_tip": "Контролирует случайный характер генерации изображений для воспроизводимых результатов",
|
||||
"negative_prompt_tip": "Опишите элементы, которые вы не хотите включать в изображение, поддерживаются только версии V_1, V_1_TURBO, V_2 и V_2_TURBO",
|
||||
"magic_prompt_option_tip": "Улучшает генерацию изображений с помощью интеллектуального оптимизирования промптов",
|
||||
"style_type_tip": "Стиль генерации изображений, поддерживается только для версий V_2 и выше"
|
||||
"model_tip": "Версия модели: V2 - новейшая API модель, V2A - быстрая модель, V_1 - первое поколение, _TURBO - ускоренная версия",
|
||||
"number_images_tip": "Количество изображений для одновременной генерации",
|
||||
"seed_tip": "Контролирует случайность генерации изображений для воспроизведения одинаковых результатов",
|
||||
"negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении",
|
||||
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации",
|
||||
"style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше",
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "Редактируемое изображение",
|
||||
"image_file": "Изображение для редактирования",
|
||||
"model_tip": "Частичное редактирование поддерживается только версиями V_2 и V_2_TURBO",
|
||||
"number_images_tip": "Количество редактированных результатов для генерации",
|
||||
"style_type_tip": "Стиль редактированного изображения, поддерживается только для версий V_2 и выше",
|
||||
"seed_tip": "Контролирует случайный характер редактирования изображений для воспроизводимых результатов",
|
||||
"magic_prompt_option_tip": "Улучшает редактирование изображений с помощью интеллектуального оптимизирования промптов"
|
||||
"number_images_tip": "Количество результатов редактирования для генерации",
|
||||
"style_type_tip": "Стиль изображения после редактирования, доступен только для версий V_2 и выше",
|
||||
"seed_tip": "Контролирует случайность результатов редактирования",
|
||||
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта редактирования",
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3"
|
||||
},
|
||||
"remix": {
|
||||
"model_tip": "Выберите версию AI-модели для перемешивания",
|
||||
"image_file": "Ссылка на изображение",
|
||||
"image_weight": "Вес изображения",
|
||||
"image_weight_tip": "Насколько сильно влияние изображения на результат",
|
||||
"number_images_tip": "Количество перемешанных результатов для генерации",
|
||||
"seed_tip": "Контролирует случайный характер перемешивания изображений для воспроизводимых результатов",
|
||||
"style_type_tip": "Стиль перемешанного изображения, поддерживается только для версий V_2 и выше",
|
||||
"negative_prompt_tip": "Опишите элементы, которые вы не хотите включать в изображение",
|
||||
"magic_prompt_option_tip": "Улучшает перемешивание изображений с помощью интеллектуального оптимизирования промптов"
|
||||
"model_tip": "Выберите версию AI модели для ремикса",
|
||||
"image_file": "Референсное изображение",
|
||||
"image_weight": "Вес референсного изображения",
|
||||
"image_weight_tip": "Регулирует степень влияния референсного изображения",
|
||||
"number_images_tip": "Количество результатов ремикса для генерации",
|
||||
"seed_tip": "Контролирует случайность результатов ремикса",
|
||||
"style_type_tip": "Стиль изображения после ремикса, доступен только для версий V_2 и выше",
|
||||
"negative_prompt_tip": "Описывает, что вы не хотите видеть в результатах ремикса",
|
||||
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта ремикса",
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3"
|
||||
},
|
||||
"upscale": {
|
||||
"image_file": "Изображение для увеличения",
|
||||
@ -826,18 +871,6 @@
|
||||
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
|
||||
}
|
||||
},
|
||||
"plantuml": {
|
||||
"download": {
|
||||
"failed": "下载失败,请检查网络",
|
||||
"png": "下载 PNG",
|
||||
"svg": "下载 SVG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "Предпросмотр",
|
||||
"source": "Исходный код"
|
||||
},
|
||||
"title": "PlantUML 图表"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
"summarize": "Суммируйте этот текст",
|
||||
@ -894,6 +927,7 @@
|
||||
"restore": {
|
||||
"confirm": "Вы уверены, что хотите восстановить данные?",
|
||||
"confirm.button": "Выбрать файл резервной копии",
|
||||
|
||||
"content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.",
|
||||
"progress": {
|
||||
"completed": "Восстановление завершено",
|
||||
@ -944,6 +978,8 @@
|
||||
"app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?",
|
||||
"app_knowledge.remove_all_success": "Файлы удалены успешно",
|
||||
"app_logs": "Логи приложения",
|
||||
"backup.skip_file_data_title": "Упрощенная резервная копия",
|
||||
"backup.skip_file_data_help": "Пропустить при резервном копировании такие данные, как изображения, базы знаний и другие файлы данных, и сделать резервную копию только переписки и настроек. Это уменьшает использование места на диске и ускоряет процесс резервного копирования.",
|
||||
"clear_cache": {
|
||||
"button": "Очистка кэша",
|
||||
"confirm": "Очистка кэша удалит данные приложения. Это действие необратимо, продолжить?",
|
||||
@ -1432,6 +1468,8 @@
|
||||
"models.translate_model_description": "Модель, используемая для сервиса перевода",
|
||||
"models.translate_model_prompt_message": "Введите модель перевода",
|
||||
"models.translate_model_prompt_title": "Модель перевода",
|
||||
"models.quick_assistant_model": "Модель быстрого помощника",
|
||||
"models.quick_assistant_model_description": "Модель по умолчанию, используемая быстрым помощником",
|
||||
"moresetting": "Дополнительные настройки",
|
||||
"moresetting.check.confirm": "Подтвердить выбор",
|
||||
"moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
|
||||
@ -1541,6 +1579,7 @@
|
||||
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
|
||||
"reset_to_default": "Сбросить настройки по умолчанию",
|
||||
"search_message": "Поиск сообщения",
|
||||
"search_message_in_chat": "Поиск сообщения в текущем диалоге",
|
||||
"show_app": "Показать/скрыть приложение",
|
||||
"show_settings": "Открыть настройки",
|
||||
"title": "Горячие клавиши",
|
||||
|
||||
@ -131,7 +131,10 @@
|
||||
"manage": "管理",
|
||||
"select_model": "选择模型",
|
||||
"show.all": "显示全部",
|
||||
"update_available": "有可用更新"
|
||||
"update_available": "有可用更新",
|
||||
"includes_user_questions": "包含用户提问",
|
||||
"case_sensitive": "区分大小写",
|
||||
"whole_word": "全字匹配"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "添加助手",
|
||||
@ -163,6 +166,7 @@
|
||||
"input.estimated_tokens.tip": "预估 token 数",
|
||||
"input.expand": "展开",
|
||||
"input.file_not_supported": "模型不支持此文件类型",
|
||||
"input.file_error": "文件处理出错",
|
||||
"input.generate_image": "生成图片",
|
||||
"input.generate_image_not_supported": "模型不支持生成图片",
|
||||
"input.knowledge_base": "知识库",
|
||||
@ -213,6 +217,20 @@
|
||||
},
|
||||
"resend": "重新发送",
|
||||
"save": "保存",
|
||||
"settings.code.title": "代码块设置",
|
||||
"settings.code_editor": {
|
||||
"title": "代码编辑器",
|
||||
"highlight_active_line": "高亮当前行",
|
||||
"fold_gutter": "折叠控件",
|
||||
"autocompletion": "自动补全",
|
||||
"keymap": "快捷键"
|
||||
},
|
||||
"settings.code_execution": {
|
||||
"title": "代码执行",
|
||||
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
|
||||
"timeout_minutes": "超时时间",
|
||||
"timeout_minutes.tip": "代码执行超时时间(分钟)"
|
||||
},
|
||||
"settings.code_collapsible": "代码块可折叠",
|
||||
"settings.code_wrappable": "代码块可换行",
|
||||
"settings.code_cacheable": "代码块缓存",
|
||||
@ -304,9 +322,32 @@
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收起",
|
||||
"disable_wrap": "取消换行",
|
||||
"enable_wrap": "换行",
|
||||
"expand": "展开"
|
||||
"copy.failed": "复制失败",
|
||||
"copy.source": "复制源代码",
|
||||
"copy.success": "复制成功",
|
||||
"copy": "复制",
|
||||
"download.failed.network": "下载失败,请检查网络",
|
||||
"download.png": "下载 PNG",
|
||||
"download.source": "下载源代码",
|
||||
"download.svg": "下载 SVG",
|
||||
"download": "下载",
|
||||
"edit.save.failed.message_not_found": "保存失败,没有找到对应的消息",
|
||||
"edit.save.failed": "保存失败",
|
||||
"edit.save.success": "已保存",
|
||||
"edit.save": "保存修改",
|
||||
"edit": "编辑",
|
||||
"expand": "展开",
|
||||
"more": "更多",
|
||||
"preview.copy.image": "复制为图片",
|
||||
"preview.source": "查看源代码",
|
||||
"preview.zoom_in": "放大",
|
||||
"preview.zoom_out": "缩小",
|
||||
"preview": "预览",
|
||||
"run": "运行代码",
|
||||
"split.restore": "取消分割视图",
|
||||
"split": "分割视图",
|
||||
"wrap.off": "取消换行",
|
||||
"wrap.on": "换行"
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
@ -360,7 +401,8 @@
|
||||
"pinyin": "按拼音排序",
|
||||
"pinyin.asc": "按拼音升序",
|
||||
"pinyin.desc": "按拼音降序"
|
||||
}
|
||||
},
|
||||
"no_results": "无结果"
|
||||
},
|
||||
"docs": {
|
||||
"title": "帮助文档"
|
||||
@ -527,21 +569,6 @@
|
||||
"keep_alive_time.title": "保持活跃时间",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
"png": "下载 PNG",
|
||||
"svg": "下载 SVG"
|
||||
},
|
||||
"resize": {
|
||||
"zoom-in": "放大",
|
||||
"zoom-out": "缩小"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "预览",
|
||||
"source": "源码"
|
||||
},
|
||||
"title": "Mermaid 图表"
|
||||
},
|
||||
"message": {
|
||||
"agents": {
|
||||
"imported": "导入成功",
|
||||
@ -564,6 +591,7 @@
|
||||
"copied": "已复制",
|
||||
"copy.failed": "复制失败",
|
||||
"copy.success": "复制成功",
|
||||
"empty_url": "无法下载图片,可能是提示词包含敏感内容或违禁词汇",
|
||||
"error.chunk_overlap_too_large": "分段重叠不能大于分段大小",
|
||||
"error.dimension_too_large": "内容尺寸过大",
|
||||
"error.enter.api.host": "请输入您的 API 地址",
|
||||
@ -775,6 +803,7 @@
|
||||
"model": "版本",
|
||||
"aspect_ratio": "画幅比例",
|
||||
"style_type": "风格",
|
||||
"rendering_speed": "渲染速度",
|
||||
"learn_more": "了解更多",
|
||||
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
|
||||
"proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连",
|
||||
@ -782,6 +811,20 @@
|
||||
"image_file_retry": "请重新上传图片",
|
||||
"image_placeholder": "暂无图片",
|
||||
"image_retry": "重试",
|
||||
"translating": "翻译中...",
|
||||
"style_types": {
|
||||
"auto": "自动",
|
||||
"general": "通用",
|
||||
"realistic": "写实",
|
||||
"design": "设计",
|
||||
"3d": "3D",
|
||||
"anime": "动漫"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "默认",
|
||||
"turbo": "快速",
|
||||
"quality": "高质量"
|
||||
},
|
||||
"mode": {
|
||||
"generate": "绘图",
|
||||
"edit": "编辑",
|
||||
@ -789,20 +832,22 @@
|
||||
"upscale": "放大"
|
||||
},
|
||||
"generate": {
|
||||
"model_tip": "模型版本:V2 为接口最新模型,V2A 为快速模型、V_1 为初代模型,_TURBO 为加速版本",
|
||||
"model_tip": "模型版本:V3 为最新版本,V2 为之前版本,V2A 为快速模型、V_1 为初代模型,_TURBO 为加速版本",
|
||||
"number_images_tip": "单次出图数量",
|
||||
"seed_tip": "控制图像生成的随机性,用于复现相同的生成结果",
|
||||
"negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本",
|
||||
"magic_prompt_option_tip": "智能优化提示词以提升生成效果",
|
||||
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本"
|
||||
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本",
|
||||
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "编辑的图像",
|
||||
"model_tip": "局部编辑仅支持 V_2 和 V_2_TURBO 版本",
|
||||
"model_tip": "支持 V3 和 V2 版本",
|
||||
"number_images_tip": "生成的编辑结果数量",
|
||||
"style_type_tip": "编辑后的图像风格,仅适用于 V_2 及以上版本",
|
||||
"seed_tip": "控制编辑结果的随机性",
|
||||
"magic_prompt_option_tip": "智能优化编辑提示词"
|
||||
"magic_prompt_option_tip": "智能优化编辑提示词",
|
||||
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本"
|
||||
},
|
||||
"remix": {
|
||||
"model_tip": "选择重混使用的 AI 模型版本",
|
||||
@ -813,7 +858,8 @@
|
||||
"seed_tip": "控制重混结果的随机性",
|
||||
"style_type_tip": "重混后的图像风格,仅适用于 V_2 及以上版本",
|
||||
"negative_prompt_tip": "描述不想在重混结果中出现的元素",
|
||||
"magic_prompt_option_tip": "智能优化重混提示词"
|
||||
"magic_prompt_option_tip": "智能优化重混提示词",
|
||||
"rendering_speed_tip": "控制渲染速度与质量之间的平衡,仅适用于V_3版本"
|
||||
},
|
||||
"upscale": {
|
||||
"image_file": "需要放大的图片",
|
||||
@ -826,18 +872,6 @@
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
}
|
||||
},
|
||||
"plantuml": {
|
||||
"download": {
|
||||
"failed": "下载失败,请检查网络",
|
||||
"png": "下载 PNG",
|
||||
"svg": "下载 SVG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "预览",
|
||||
"source": "源码"
|
||||
},
|
||||
"title": "PlantUML 图表"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "帮我解释一下这个概念",
|
||||
"summarize": "帮我总结一下这段话",
|
||||
@ -857,7 +891,7 @@
|
||||
"doubao": "火山引擎",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"gitee-ai": "模力方舟",
|
||||
"github": "GitHub Models",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Grok",
|
||||
@ -946,6 +980,8 @@
|
||||
"app_knowledge.remove_all_confirm": "删除知识库文件可以减少存储空间占用,但不会删除知识库向量化数据,删除之后将无法打开源文件,是否删除?",
|
||||
"app_knowledge.remove_all_success": "文件删除成功",
|
||||
"app_logs": "应用日志",
|
||||
"backup.skip_file_data_title": "精简备份",
|
||||
"backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度。",
|
||||
"clear_cache": {
|
||||
"button": "清除缓存",
|
||||
"confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?",
|
||||
@ -1436,6 +1472,8 @@
|
||||
"models.translate_model_description": "翻译服务使用的模型",
|
||||
"models.translate_model_prompt_message": "请输入翻译模型提示词",
|
||||
"models.translate_model_prompt_title": "翻译模型提示词",
|
||||
"models.quick_assistant_model": "快捷助手模型",
|
||||
"models.quick_assistant_model_description": "快捷助手使用的默认模型",
|
||||
"moresetting": "更多设置",
|
||||
"moresetting.check.confirm": "确认勾选",
|
||||
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
|
||||
@ -1545,6 +1583,7 @@
|
||||
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
|
||||
"reset_to_default": "重置为默认",
|
||||
"search_message": "搜索消息",
|
||||
"search_message_in_chat": "在当前对话中搜索消息",
|
||||
"show_app": "显示/隐藏应用",
|
||||
"show_settings": "打开设置",
|
||||
"title": "快捷方式",
|
||||
|
||||
@ -131,7 +131,10 @@
|
||||
"manage": "管理",
|
||||
"select_model": "選擇模型",
|
||||
"show.all": "顯示全部",
|
||||
"update_available": "有可用更新"
|
||||
"update_available": "有可用更新",
|
||||
"includes_user_questions": "包含使用者提問",
|
||||
"case_sensitive": "區分大小寫",
|
||||
"whole_word": "全字匹配"
|
||||
},
|
||||
"chat": {
|
||||
"add.assistant.title": "新增助手",
|
||||
@ -163,6 +166,7 @@
|
||||
"input.estimated_tokens.tip": "預估 Token 數",
|
||||
"input.expand": "展開",
|
||||
"input.file_not_supported": "模型不支援此檔案類型",
|
||||
"input.file_error": "檔案處理錯誤",
|
||||
"input.generate_image": "生成圖片",
|
||||
"input.generate_image_not_supported": "模型不支援生成圖片",
|
||||
"input.knowledge_base": "知識庫",
|
||||
@ -199,6 +203,20 @@
|
||||
},
|
||||
"resend": "重新傳送",
|
||||
"save": "儲存",
|
||||
"settings.code.title": "程式碼區塊",
|
||||
"settings.code_editor": {
|
||||
"title": "程式碼編輯器",
|
||||
"highlight_active_line": "高亮當前行",
|
||||
"fold_gutter": "折疊控件",
|
||||
"autocompletion": "自動補全",
|
||||
"keymap": "快捷鍵"
|
||||
},
|
||||
"settings.code_execution": {
|
||||
"title": "程式碼執行",
|
||||
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
|
||||
"timeout_minutes": "超時時間",
|
||||
"timeout_minutes.tip": "程式碼執行超時時間(分鐘)"
|
||||
},
|
||||
"settings.code_collapsible": "程式碼區塊可折疊",
|
||||
"settings.code_wrappable": "程式碼區塊可自動換行",
|
||||
"settings.code_cacheable": "程式碼區塊快取",
|
||||
@ -304,9 +322,32 @@
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折疊",
|
||||
"disable_wrap": "停用自動換行",
|
||||
"enable_wrap": "自動換行",
|
||||
"expand": "展開"
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.source": "複製源碼",
|
||||
"copy.success": "已複製",
|
||||
"copy": "複製",
|
||||
"download.failed.network": "下載失敗,請檢查網路連線",
|
||||
"download.png": "下載 PNG",
|
||||
"download.source": "下載源碼",
|
||||
"download.svg": "下載 SVG",
|
||||
"download": "下載",
|
||||
"edit.save.failed.message_not_found": "保存失敗,沒有找到對應的消息",
|
||||
"edit.save.failed": "保存失敗",
|
||||
"edit.save.success": "已保存",
|
||||
"edit.save": "保存修改",
|
||||
"edit": "編輯",
|
||||
"expand": "展開",
|
||||
"more": "更多",
|
||||
"preview.copy.image": "複製為圖片",
|
||||
"preview.source": "查看源碼",
|
||||
"preview.zoom_in": "放大",
|
||||
"preview.zoom_out": "縮小",
|
||||
"preview": "預覽",
|
||||
"run": "運行代碼",
|
||||
"split.restore": "取消分割視圖",
|
||||
"split": "分割視圖",
|
||||
"wrap.off": "停用自動換行",
|
||||
"wrap.on": "自動換行"
|
||||
},
|
||||
"common": {
|
||||
"add": "新增",
|
||||
@ -360,7 +401,8 @@
|
||||
"pinyin": "按拼音排序",
|
||||
"pinyin.asc": "按拼音升序",
|
||||
"pinyin.desc": "按拼音降序"
|
||||
}
|
||||
},
|
||||
"no_results": "沒有結果"
|
||||
},
|
||||
"docs": {
|
||||
"title": "說明文件"
|
||||
@ -527,21 +569,6 @@
|
||||
"keep_alive_time.title": "保持活躍時間",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
"png": "下載 PNG",
|
||||
"svg": "下載 SVG"
|
||||
},
|
||||
"resize": {
|
||||
"zoom-in": "放大",
|
||||
"zoom-out": "縮小"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "預覽",
|
||||
"source": "原始碼"
|
||||
},
|
||||
"title": "Mermaid 圖表"
|
||||
},
|
||||
"message": {
|
||||
"agents": {
|
||||
"imported": "匯入成功",
|
||||
@ -563,7 +590,8 @@
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "已複製!",
|
||||
"copy.success": "複製成功",
|
||||
"empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙",
|
||||
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
|
||||
"error.dimension_too_large": "內容尺寸過大",
|
||||
"error.enter.api.host": "請先輸入您的 API 主機地址",
|
||||
@ -782,6 +810,20 @@
|
||||
"image_file_retry": "請重新上傳圖片",
|
||||
"image_placeholder": "無圖片",
|
||||
"image_retry": "重試",
|
||||
"translating": "翻譯中...",
|
||||
"style_types": {
|
||||
"auto": "自動",
|
||||
"general": "通用",
|
||||
"realistic": "寫實",
|
||||
"design": "設計",
|
||||
"3d": "3D",
|
||||
"anime": "動漫"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "預設",
|
||||
"turbo": "快速",
|
||||
"quality": "高品質"
|
||||
},
|
||||
"mode": {
|
||||
"generate": "繪圖",
|
||||
"edit": "編輯",
|
||||
@ -789,20 +831,22 @@
|
||||
"upscale": "放大"
|
||||
},
|
||||
"generate": {
|
||||
"model_tip": "模型版本:V2 為接口最新模型,V2A 為快速模型、V_1 為初代模型,_TURBO 為加速版本",
|
||||
"number_images_tip": "單次出圖數量",
|
||||
"seed_tip": "控制圖像生成的隨機性,用於重現相同的生成結果",
|
||||
"negative_prompt_tip": "描述不想在圖像中出現的元素,僅支援 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本",
|
||||
"magic_prompt_option_tip": "智能優化提示詞以提升生成效果",
|
||||
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本"
|
||||
"model_tip": "模型版本:V2 是最新 API 模型,V2A 是高速模型,V_1 是初代模型,_TURBO 是高速處理版",
|
||||
"number_images_tip": "一次生成的圖片數量",
|
||||
"seed_tip": "控制圖像生成的隨機性,以重現相同的生成結果",
|
||||
"negative_prompt_tip": "描述不想在圖像中出現的內容",
|
||||
"magic_prompt_option_tip": "智能優化生成效果的提示詞",
|
||||
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本",
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "編輯的圖像",
|
||||
"model_tip": "局部編輯僅支援 V_2 和 V_2_TURBO 版本",
|
||||
"image_file": "編輯圖像",
|
||||
"model_tip": "部分編輯僅支持 V_2 和 V_2_TURBO 版本",
|
||||
"number_images_tip": "生成的編輯結果數量",
|
||||
"style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本",
|
||||
"seed_tip": "控制編輯結果的隨機性",
|
||||
"magic_prompt_option_tip": "智能優化編輯提示詞"
|
||||
"magic_prompt_option_tip": "智能優化編輯提示詞",
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
|
||||
},
|
||||
"remix": {
|
||||
"model_tip": "選擇重混使用的 AI 模型版本",
|
||||
@ -813,7 +857,8 @@
|
||||
"seed_tip": "控制重混結果的隨機性",
|
||||
"style_type_tip": "重混後的圖像風格,僅適用於 V_2 及以上版本",
|
||||
"negative_prompt_tip": "描述不想在重混結果中出現的元素",
|
||||
"magic_prompt_option_tip": "智能優化重混提示詞"
|
||||
"magic_prompt_option_tip": "智能優化重混提示詞",
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
|
||||
},
|
||||
"upscale": {
|
||||
"image_file": "需要放大的圖片",
|
||||
@ -824,19 +869,8 @@
|
||||
"number_images_tip": "生成的放大結果數量",
|
||||
"seed_tip": "控制放大結果的隨機性",
|
||||
"magic_prompt_option_tip": "智能優化放大提示詞"
|
||||
}
|
||||
},
|
||||
"plantuml": {
|
||||
"download": {
|
||||
"failed": "下載失敗,請檢查網路",
|
||||
"png": "下載 PNG",
|
||||
"svg": "下載 SVG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "預覽",
|
||||
"source": "原始碼"
|
||||
},
|
||||
"title": "PlantUML 圖表"
|
||||
"rendering_speed": "渲染速度"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "幫我解釋一下這個概念",
|
||||
@ -857,7 +891,7 @@
|
||||
"doubao": "火山引擎",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"gitee-ai": "模力方舟",
|
||||
"github": "GitHub Models",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Grok",
|
||||
@ -946,6 +980,8 @@
|
||||
"app_knowledge.remove_all_confirm": "刪除知識庫文件可以減少儲存空間佔用,但不會刪除知識庫向量化資料,刪除之後將無法開啟原始檔,是否刪除?",
|
||||
"app_knowledge.remove_all_success": "檔案刪除成功",
|
||||
"app_logs": "應用程式日誌",
|
||||
"backup.skip_file_data_title": "精簡備份",
|
||||
"backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度。",
|
||||
"clear_cache": {
|
||||
"button": "清除快取",
|
||||
"confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?",
|
||||
@ -1435,6 +1471,8 @@
|
||||
"models.translate_model_description": "翻譯服務使用的模型",
|
||||
"models.translate_model_prompt_message": "請輸入翻譯模型提示詞",
|
||||
"models.translate_model_prompt_title": "翻譯模型提示詞",
|
||||
"models.quick_assistant_model": "快捷助手模型",
|
||||
"models.quick_assistant_model_description": "快捷助手使用的預設模型",
|
||||
"moresetting": "更多設定",
|
||||
"moresetting.check.confirm": "確認勾選",
|
||||
"moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
|
||||
@ -1543,6 +1581,7 @@
|
||||
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
|
||||
"reset_to_default": "重設為預設",
|
||||
"search_message": "搜尋訊息",
|
||||
"search_message_in_chat": "在當前對話中搜尋訊息",
|
||||
"show_app": "顯示/隱藏應用程式",
|
||||
"show_settings": "開啟設定",
|
||||
"title": "快速方式",
|
||||
|
||||
@ -930,6 +930,8 @@
|
||||
"app_knowledge.remove_all_confirm": "Η διαγραφή των αρχείων της βάσης γνώσεων μπορεί να μειώσει τη χρήση χώρου αποθήκευσης, αλλά δεν θα διαγράψει τα διανυσματωτικά δεδομένα της βάσης γνώσεων. Μετά τη διαγραφή, δεν θα μπορείτε να ανοίξετε τα αρχεία πηγή. Θέλετε να διαγράψετε;",
|
||||
"app_knowledge.remove_all_success": "Τα αρχεία διαγράφηκαν με επιτυχία",
|
||||
"app_logs": "Φάκελοι εφαρμογής",
|
||||
"backup.skip_file_data_title": "Συμπυκνωμένο αντίγραφο ασφαλείας",
|
||||
"backup.skip_file_data_help": "Κατά τη δημιουργία αντιγράφων ασφαλείας, παραλείψτε τις εικόνες, τις βάσεις γνώσεων και άλλα αρχεία δεδομένων. Δημιουργήστε αντίγραφα μόνο για το ιστορικό συνομιλιών και τις ρυθμίσεις. Αυτό θα μειώσει τη χρήση χώρου και θα επιταχύνει την ταχύτητα δημιουργίας αντιγράφων.",
|
||||
"clear_cache": {
|
||||
"button": "Καθαρισμός Μνήμης",
|
||||
"confirm": "Η διαγραφή της μνήμης θα διαγράψει τα στοιχεία καθαρισμού της εφαρμογής, συμπεριλαμβανομένων των στοιχείων πρόσθετων εφαρμογών. Αυτή η ενέργεια δεν είναι αναστρέψιμη. Θέλετε να συνεχίσετε;",
|
||||
@ -1655,4 +1657,4 @@
|
||||
"visualization": "προβολή"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +107,7 @@
|
||||
"backup": {
|
||||
"confirm": "¿Está seguro de que desea realizar una copia de seguridad de los datos?",
|
||||
"confirm.button": "Seleccionar ubicación de copia de seguridad",
|
||||
"confirm.file_checkbox": "El tamaño del archivo es {{size}}, ¿desea elegir el archivo de copia de seguridad?",
|
||||
"content": "Realizar una copia de seguridad de todos los datos, incluyendo registros de chat, configuraciones, bases de conocimiento y todos los demás datos. Tenga en cuenta que el proceso de copia de seguridad puede llevar algún tiempo, gracias por su paciencia.",
|
||||
"progress": {
|
||||
"completed": "Copia de seguridad completada",
|
||||
@ -1655,4 +1656,4 @@
|
||||
"visualization": "Visualización"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -930,6 +930,8 @@
|
||||
"app_knowledge.remove_all_confirm": "La suppression des fichiers de la base de connaissances libérera de l'espace de stockage, mais ne supprimera pas les données vectorisées de la base de connaissances. Après la suppression, vous ne pourrez plus ouvrir les fichiers sources. Souhaitez-vous continuer ?",
|
||||
"app_knowledge.remove_all_success": "Fichiers supprimés avec succès",
|
||||
"app_logs": "Journaux de l'application",
|
||||
"backup.skip_file_data_title": "Sauvegarde réduite",
|
||||
"backup.skip_file_data_help": "Passer outre les fichiers de données tels que les images et les bases de connaissances lors de la sauvegarde, et ne sauvegarder que les conversations et les paramètres. Cela réduit l'occupation d'espace et accélère la vitesse de sauvegarde.",
|
||||
"clear_cache": {
|
||||
"button": "Effacer le cache",
|
||||
"confirm": "L'effacement du cache supprimera les données du cache de l'application, y compris les données des mini-programmes. Cette action ne peut pas être annulée, voulez-vous continuer ?",
|
||||
@ -1655,4 +1657,4 @@
|
||||
"visualization": "Visualisation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +107,7 @@
|
||||
"backup": {
|
||||
"confirm": "Tem certeza de que deseja fazer backup dos dados?",
|
||||
"confirm.button": "Escolher local de backup",
|
||||
"confirm.file_checkbox": "Pule a cópia de segurança de arquivos de dados como imagens e banco de conhecimento e copie apenas as conversas e as configurações.",
|
||||
"content": "Fazer backup de todos os dados, incluindo registros de chat, configurações, base de conhecimento e todos os outros dados. Por favor, note que o processo de backup pode levar algum tempo. Agradecemos sua paciência.",
|
||||
"progress": {
|
||||
"completed": "Backup concluído",
|
||||
@ -931,6 +932,8 @@
|
||||
"app_knowledge.remove_all_confirm": "A exclusão dos arquivos da base de conhecimento reduzirá o uso do espaço de armazenamento, mas não excluirá os dados vetoriais da base de conhecimento. Após a exclusão, os arquivos originais não poderão ser abertos. Deseja excluir?",
|
||||
"app_knowledge.remove_all_success": "Arquivo excluído com sucesso",
|
||||
"app_logs": "Logs do aplicativo",
|
||||
"backup.skip_file_data_title": "Backup simplificado",
|
||||
"backup.skip_file_data_help": "Pule arquivos de dados como imagens e bancos de conhecimento durante o backup e realize apenas o backup das conversas e configurações. Diminua o consumo de espaço e aumente a velocidade do backup.",
|
||||
"clear_cache": {
|
||||
"button": "Limpar cache",
|
||||
"confirm": "Limpar cache removerá os dados armazenados em cache do aplicativo, incluindo dados de aplicativos minúsculos. Esta ação não pode ser desfeita, deseja continuar?",
|
||||
@ -1656,4 +1659,4 @@
|
||||
"visualization": "Visualização"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,12 +14,12 @@ function escapeRegExp(str: string) {
|
||||
}
|
||||
|
||||
// 支持泛型 T,默认 T = { type: string; textDelta: string }
|
||||
export function extractReasoningMiddleware<T extends { type: string } = { type: string; textDelta: string }>({
|
||||
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<T extends { type: string } = { type:
|
||||
controller.enqueue(chunk)
|
||||
return
|
||||
}
|
||||
// @ts-expect-error: textDelta 只在 text-delta/reasoning chunk 上
|
||||
buffer += chunk.textDelta
|
||||
// textDelta 只在 text-delta/reasoning chunk 上
|
||||
buffer += (chunk as { textDelta: string }).textDelta
|
||||
function publish(text: string) {
|
||||
if (text.length > 0) {
|
||||
const prefix = afterSwitch && (isReasoning ? !isFirstReasoning : !isFirstText) ? separator : ''
|
||||
@ -80,7 +80,7 @@ export function extractReasoningMiddleware<T extends { type: string } = { type:
|
||||
...chunk,
|
||||
type: isReasoning ? 'reasoning' : 'text-delta',
|
||||
textDelta: prefix + text
|
||||
})
|
||||
} as T)
|
||||
afterSwitch = false
|
||||
if (isReasoning) {
|
||||
isFirstReasoning = false
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { Center } from '@renderer/components/Layout'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { Empty, Input } from 'antd'
|
||||
import { Input } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { Search } from 'lucide-react'
|
||||
import React, { FC, useState } from 'react'
|
||||
@ -53,7 +53,7 @@ const AppsPage: FC = () => {
|
||||
<ContentContainer id="content-container">
|
||||
{isEmpty(filteredApps) ? (
|
||||
<Center>
|
||||
<Empty />
|
||||
<App isLast app={filteredApps[0]} />
|
||||
</Center>
|
||||
) : (
|
||||
<AppsContainer style={{ height: containerHeight }}>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Flex } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { FC, useMemo, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Inputbar from './Inputbar/Inputbar'
|
||||
@ -20,18 +24,103 @@ interface Props {
|
||||
|
||||
const Chat: FC<Props> = (props) => {
|
||||
const { assistant } = useAssistant(props.assistant.id)
|
||||
const { topicPosition, messageStyle } = useSettings()
|
||||
const { topicPosition, messageStyle, showAssistants } = useSettings()
|
||||
const { showTopics } = useShowTopics()
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
const showRightTopics = showTopics && topicPosition === 'right'
|
||||
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
|
||||
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)`
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
contentSearchRef.current?.disable()
|
||||
})
|
||||
|
||||
useShortcut('search_message_in_chat', () => {
|
||||
try {
|
||||
const selectedText = window.getSelection()?.toString().trim()
|
||||
contentSearchRef.current?.enable(selectedText)
|
||||
} catch (error) {
|
||||
console.error('Error enabling content search:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const contentSearchFilter = (node: Node): boolean => {
|
||||
if (node.parentNode) {
|
||||
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
|
||||
while (parentNode?.parentNode) {
|
||||
if (parentNode.classList.contains('MessageFooter')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterIncludeUser) {
|
||||
if (parentNode?.classList.contains('message-content-container')) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (parentNode?.classList.contains('message-content-container-assistant')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parentNode = parentNode.parentNode as HTMLElement
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const userOutlinedItemClickHandler = () => {
|
||||
setFilterIncludeUser(!filterIncludeUser)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
contentSearchRef.current?.search()
|
||||
contentSearchRef.current?.focus()
|
||||
}, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let firstUpdateCompleted = false
|
||||
const firstUpdateOrNoFirstUpdateHandler = debounce(() => {
|
||||
contentSearchRef.current?.silentSearch()
|
||||
}, 10)
|
||||
const messagesComponentUpdateHandler = () => {
|
||||
if (firstUpdateCompleted) {
|
||||
firstUpdateOrNoFirstUpdateHandler()
|
||||
}
|
||||
}
|
||||
const messagesComponentFirstUpdateHandler = () => {
|
||||
setTimeout(() => (firstUpdateCompleted = true), 300)
|
||||
firstUpdateOrNoFirstUpdateHandler()
|
||||
}
|
||||
|
||||
return (
|
||||
<Container id="chat" className={messageStyle}>
|
||||
<Main id="chat-main" vertical flex={1} justify="space-between">
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
<Main ref={mainRef} id="chat-main" vertical flex={1} justify="space-between" style={{ maxWidth }}>
|
||||
<ContentSearch
|
||||
ref={contentSearchRef}
|
||||
searchTarget={mainRef as React.RefObject<HTMLElement>}
|
||||
filter={contentSearchFilter}
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<MessagesContainer>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
</MessagesContainer>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
</QuickPanelProvider>
|
||||
@ -49,18 +138,25 @@ const Chat: FC<Props> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const MessagesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const Main = styled(Flex)`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
// 设置为containing block,方便子元素fixed定位
|
||||
transform: translateZ(0);
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export default Chat
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
@ -36,6 +37,19 @@ const HomePage: FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
|
||||
const newAssistant = assistants.find((a) => a.id === assistantId)
|
||||
if (newAssistant) {
|
||||
setActiveAssistant(newAssistant)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [assistants, setActiveAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
const canMinimize = topicPosition == 'left' ? !showAssistants : !showAssistants && !showTopics
|
||||
window.api.window.setMinimumSize(canMinimize ? 520 : 1080, 600)
|
||||
@ -47,7 +61,13 @@ const HomePage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container id="home-page">
|
||||
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
|
||||
<Navbar
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
position="left"
|
||||
/>
|
||||
<ContentContainer id="content-container">
|
||||
{showAssistants && (
|
||||
<HomeTabs
|
||||
|
||||
@ -32,7 +32,55 @@ function truncateFileName(name: string, maxLength: number = MAX_FILENAME_DISPLAY
|
||||
return name.slice(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
export const getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileUnknownFilled />
|
||||
|
||||
const ext = type.toLowerCase()
|
||||
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
return <FileImageFilled />
|
||||
}
|
||||
|
||||
if (['.doc', '.docx'].includes(ext)) {
|
||||
return <FileWordFilled />
|
||||
}
|
||||
if (['.xls', '.xlsx'].includes(ext)) {
|
||||
return <FileExcelFilled />
|
||||
}
|
||||
if (['.ppt', '.pptx'].includes(ext)) {
|
||||
return <FilePptFilled />
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return <FilePdfFilled />
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return <FileMarkdownFilled />
|
||||
}
|
||||
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return <FileZipFilled />
|
||||
}
|
||||
|
||||
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
|
||||
return <FileTextFilled />
|
||||
}
|
||||
|
||||
if (['.url'].includes(ext)) {
|
||||
return <LinkOutlined />
|
||||
}
|
||||
|
||||
if (['.sitemap'].includes(ext)) {
|
||||
return <GlobalOutlined />
|
||||
}
|
||||
|
||||
if (['.folder'].includes(ext)) {
|
||||
return <FolderOpenFilled />
|
||||
}
|
||||
|
||||
return <FileUnknownFilled />
|
||||
}
|
||||
|
||||
export const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
const isImage = (ext: string) => {
|
||||
return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
|
||||
@ -85,54 +133,6 @@ const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
}
|
||||
|
||||
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
|
||||
const getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileUnknownFilled />
|
||||
|
||||
const ext = type.toLowerCase()
|
||||
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
return <FileImageFilled />
|
||||
}
|
||||
|
||||
if (['.doc', '.docx'].includes(ext)) {
|
||||
return <FileWordFilled />
|
||||
}
|
||||
if (['.xls', '.xlsx'].includes(ext)) {
|
||||
return <FileExcelFilled />
|
||||
}
|
||||
if (['.ppt', '.pptx'].includes(ext)) {
|
||||
return <FilePptFilled />
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return <FilePdfFilled />
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return <FileMarkdownFilled />
|
||||
}
|
||||
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return <FileZipFilled />
|
||||
}
|
||||
|
||||
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
|
||||
return <FileTextFilled />
|
||||
}
|
||||
|
||||
if (['.url'].includes(ext)) {
|
||||
return <LinkOutlined />
|
||||
}
|
||||
|
||||
if (['.sitemap'].includes(ext)) {
|
||||
return <GlobalOutlined />
|
||||
}
|
||||
|
||||
if (['.folder'].includes(ext)) {
|
||||
return <FolderOpenFilled />
|
||||
}
|
||||
|
||||
return <FileUnknownFilled />
|
||||
}
|
||||
|
||||
if (isEmpty(files)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -117,6 +117,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isFileDragging, setIsFileDragging] = useState(false)
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
@ -604,27 +605,33 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault()
|
||||
for (const file of event.clipboardData.files) {
|
||||
if (file.path === '') {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
break
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 使用新的API获取文件路径
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
|
||||
if (file.path) {
|
||||
if (supportExts.includes(getFileExtension(file.path))) {
|
||||
const selectedFile = await window.api.file.get(file.path)
|
||||
// 如果没有路径,可能是剪贴板中的图像数据
|
||||
if (!filePath) {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
break
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 有路径的情况
|
||||
if (supportExts.includes(getFileExtension(filePath))) {
|
||||
const selectedFile = await window.api.file.get(filePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
} else {
|
||||
window.message.info({
|
||||
@ -632,6 +639,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] onPaste:', error)
|
||||
window.message.error(t('chat.input.file_error'))
|
||||
}
|
||||
}
|
||||
return
|
||||
@ -644,11 +654,25 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsFileDragging(true)
|
||||
}
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsFileDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsFileDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsFileDragging(false)
|
||||
|
||||
const files = await getFilesFromDropEvent(e).catch((err) => {
|
||||
Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err)
|
||||
@ -656,11 +680,22 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
})
|
||||
|
||||
if (files) {
|
||||
let supportedFiles = 0
|
||||
|
||||
files.forEach((file) => {
|
||||
if (supportExts.includes(getFileExtension(file.path))) {
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
supportedFiles++
|
||||
}
|
||||
})
|
||||
|
||||
// 如果有文件,但都不支持
|
||||
if (files.length > 0 && supportedFiles === 0) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -876,12 +911,17 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)
|
||||
|
||||
return (
|
||||
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
|
||||
<Container
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
className="inputbar">
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<QuickPanelView setInputText={setText} />
|
||||
<InputBarContainer
|
||||
id="inputbar"
|
||||
className={classNames('inputbar-container', inputFocus && 'focus')}
|
||||
className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')}
|
||||
ref={containerRef}>
|
||||
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />}
|
||||
{selectedKnowledgeBases.length > 0 && (
|
||||
@ -1062,6 +1102,23 @@ const InputBarContainer = styled.div`
|
||||
border-radius: 15px;
|
||||
padding-top: 6px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
&.file-dragging {
|
||||
border: 2px dashed #2ecc71;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(46, 204, 113, 0.03);
|
||||
border-radius: 14px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
@ -1074,7 +1131,6 @@ const Textarea = styled(TextArea)`
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: Ubuntu;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
@ -1101,7 +1157,7 @@ const ToolbarMenu = styled.div`
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const ToolbarButton = styled(Button)`
|
||||
export const ToolbarButton = styled(Button)`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
|
||||
@ -62,7 +62,6 @@ const Container = styled.div`
|
||||
z-index: 10;
|
||||
padding: 3px 10px;
|
||||
user-select: none;
|
||||
font-family: Ubuntu;
|
||||
border: 0.5px solid var(--color-text-3);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
|
||||
@ -1,179 +1,34 @@
|
||||
import { CheckOutlined, DownloadOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import UnWrapIcon from '@renderer/components/Icons/UnWrapIcon'
|
||||
import WrapIcon from '@renderer/components/Icons/WrapIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import CodeBlockView from '@renderer/components/CodeBlockView'
|
||||
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
|
||||
import Artifacts from './Artifacts'
|
||||
import Mermaid from './Mermaid'
|
||||
import { isValidPlantUML, PlantUML } from './PlantUML'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
interface CodeBlockProps {
|
||||
interface Props {
|
||||
children: string
|
||||
className?: string
|
||||
id?: string
|
||||
onSave?: (id: string, newContent: string) => void
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
const match = /language-(\w+)/.exec(className || '') || children?.includes('\n')
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
|
||||
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
|
||||
const language = match?.[1] ?? 'text'
|
||||
// const [html, setHtml] = useState<string>('')
|
||||
const { codeToHtml } = useSyntaxHighlighter()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
|
||||
const codeContentRef = useRef<HTMLDivElement>(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 <Mermaid chart={children} />
|
||||
}
|
||||
|
||||
if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUML diagram={children} />
|
||||
}
|
||||
|
||||
if (language === 'svg') {
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block">
|
||||
<CodeHeader>
|
||||
<CodeLanguage>{'<SVG>'}</CodeLanguage>
|
||||
<CopyButton text={children} />
|
||||
</CodeHeader>
|
||||
<SvgPreview>{children}</SvgPreview>
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
},
|
||||
[id, onSave]
|
||||
)
|
||||
|
||||
return match ? (
|
||||
<CodeBlockWrapper className="code-block">
|
||||
<CodeHeader>
|
||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||
</CodeHeader>
|
||||
<StickyWrapper>
|
||||
<HStack
|
||||
position="absolute"
|
||||
gap={12}
|
||||
alignItems="center"
|
||||
style={{ bottom: '0.2rem', right: '1rem', height: '27px' }}>
|
||||
{showDownloadButton && <DownloadButton language={language} data={children} />}
|
||||
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
||||
{codeCollapsible && shouldShowExpandButton && (
|
||||
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
|
||||
)}
|
||||
<CopyButton text={children} />
|
||||
</HStack>
|
||||
</StickyWrapper>
|
||||
<CodeContent
|
||||
ref={codeContentRef}
|
||||
$isShowLineNumbers={codeShowLineNumbers}
|
||||
$isUnwrapped={isUnwrapped}
|
||||
$isCodeWrappable={codeWrappable}
|
||||
// dangerouslySetInnerHTML={{ __html: html }}
|
||||
style={{
|
||||
padding: '1px',
|
||||
marginTop: 0,
|
||||
fontSize: fontSize - 1,
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
|
||||
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible',
|
||||
position: 'relative'
|
||||
}}
|
||||
/>
|
||||
{codeCollapsible && (
|
||||
<ExpandButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
showButton={shouldShowExpandButton}
|
||||
/>
|
||||
)}
|
||||
{showFooterCopyButton && (
|
||||
<CodeFooter>
|
||||
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
|
||||
</CodeFooter>
|
||||
)}
|
||||
{language === 'html' && children?.includes('</html>') && <Artifacts html={children} />}
|
||||
</CodeBlockWrapper>
|
||||
<CodeToolbarProvider>
|
||||
<CodeBlockView language={language} onSave={handleSave}>
|
||||
{children}
|
||||
</CodeBlockView>
|
||||
</CodeToolbarProvider>
|
||||
) : (
|
||||
<code className={className} style={{ textWrap: 'wrap' }}>
|
||||
{children}
|
||||
@ -181,268 +36,4 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ 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 (
|
||||
<Tooltip
|
||||
title={expanded ? t('code_block.collapse') : t('code_block.expand')}
|
||||
open={tooltipVisible}
|
||||
onOpenChange={setTooltipVisible}>
|
||||
<CollapseIconWrapper onClick={handleClick}>
|
||||
{expanded ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||
</CollapseIconWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ExpandButton: React.FC<{
|
||||
isExpanded: boolean
|
||||
onClick: () => void
|
||||
showButton: boolean
|
||||
}> = ({ isExpanded, onClick, showButton }) => {
|
||||
const { t } = useTranslation()
|
||||
if (!showButton) return null
|
||||
|
||||
return (
|
||||
<ExpandButtonWrapper onClick={onClick}>
|
||||
<div className="button-text">{isExpanded ? t('code_block.collapse') : t('code_block.expand')}</div>
|
||||
</ExpandButtonWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip title={unwrapLabel}>
|
||||
<UnwrapButtonWrapper onClick={onClick} title={unwrapLabel}>
|
||||
{unwrapped ? (
|
||||
<UnWrapIcon style={{ width: '100%', height: '100%' }} />
|
||||
) : (
|
||||
<WrapIcon style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</UnwrapButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip title={copy}>
|
||||
<CopyButtonWrapper onClick={onCopy} style={style}>
|
||||
{copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyIcon className="copy" />}
|
||||
</CopyButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const DownloadButton = ({ language, data }: { language: string; data: string }) => {
|
||||
const onDownload = () => {
|
||||
const fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
|
||||
window.api.file.save(fileName, data)
|
||||
}
|
||||
|
||||
return (
|
||||
<DownloadWrapper onClick={onDownload}>
|
||||
<DownloadOutlined />
|
||||
</DownloadWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@ -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<Props> = ({ 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) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
|
||||
code: CodeBlock,
|
||||
code: (props: any) => (
|
||||
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
|
||||
),
|
||||
img: ImagePreview,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
|
||||
} as Partial<Components>
|
||||
}, [])
|
||||
}, [onSaveCodeBlock])
|
||||
|
||||
// if (role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
// return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
@ -99,4 +113,4 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default Markdown
|
||||
export default memo(Markdown)
|
||||
|
||||
@ -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<Props> = ({ chart }) => {
|
||||
const { theme } = useTheme()
|
||||
const mermaidRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={mermaidRef} className="mermaid" onClick={onPreview} style={{ cursor: 'pointer' }}>
|
||||
{chart}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Mermaid
|
||||
@ -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<Props> = ({ 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<Blob>((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 (
|
||||
<Modal
|
||||
title={t('mermaid.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
width={1000}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
footer={[
|
||||
<Space key="download-buttons">
|
||||
{activeTab === 'source' && <Button onClick={() => handleCopy()}>{t('common.copy')}</Button>}
|
||||
{activeTab === 'preview' && (
|
||||
<>
|
||||
<Button onClick={() => handleZoom(0.1)}>{t('mermaid.resize.zoom-in')}</Button>
|
||||
<Button onClick={() => handleZoom(-0.1)}>{t('mermaid.resize.zoom-out')}</Button>
|
||||
<Button onClick={() => handleCopyImage()}>{t('common.copy')}</Button>
|
||||
<Button onClick={() => handleDownload('svg')}>{t('mermaid.download.svg')}</Button>
|
||||
<Button onClick={() => handleDownload('png')}>{t('mermaid.download.png')}</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
]}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
items={[
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('mermaid.tabs.preview'),
|
||||
children: (
|
||||
<StyledMermaid id={mermaidId} className="mermaid">
|
||||
{chart}
|
||||
</StyledMermaid>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: t('mermaid.tabs.source'),
|
||||
children: (
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: 'calc(80vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{chart}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class MermaidPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('MermaidPopup')
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
'MermaidPopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const StyledMermaid = styled.div`
|
||||
max-height: calc(80vh - 200px);
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
`
|
||||
@ -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<HTMLDivElement>
|
||||
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<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
const url = getPlantUMLImageUrl(format, diagram, isDark)
|
||||
return (
|
||||
<StyledPlantUML onClick={onClick} className={className}>
|
||||
<Spin
|
||||
spinning={loading}
|
||||
indicator={
|
||||
<LoadingOutlined
|
||||
spin
|
||||
style={{
|
||||
fontSize: 32
|
||||
}}
|
||||
/>
|
||||
}>
|
||||
<img
|
||||
src={url}
|
||||
onLoad={() => {
|
||||
setLoading(false)
|
||||
}}
|
||||
onError={(e) => {
|
||||
setLoading(false)
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.opacity = '0.5'
|
||||
target.style.filter = 'blur(2px)'
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</StyledPlantUML>
|
||||
)
|
||||
}
|
||||
|
||||
const PlantUMLPopupCantaier: React.FC<PlantUMLPopupProps> = ({ 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<Blob>((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 (
|
||||
<Modal
|
||||
title={t('plantuml.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
width={1000}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
footer={[
|
||||
<Space key="download-buttons">
|
||||
{activeTab === 'source' && (
|
||||
<Button onClick={handleCopy} icon={<CopyOutlined />}>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'preview' && (
|
||||
<>
|
||||
<Button onClick={() => handleZoom(0.1)}>{t('mermaid.resize.zoom-in')}</Button>
|
||||
<Button onClick={() => handleZoom(-0.1)}>{t('mermaid.resize.zoom-out')}</Button>
|
||||
<Button onClick={handleCopyImage}>{t('common.copy')}</Button>
|
||||
<Button onClick={() => handleDownload('svg')} loading={downloading.svg}>
|
||||
{t('plantuml.download.svg')}
|
||||
</Button>
|
||||
<Button onClick={() => handleDownload('png')} loading={downloading.png}>
|
||||
{t('plantuml.download.png')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
]}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key)}
|
||||
items={[
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('plantuml.tabs.preview'),
|
||||
children: <PlantUMLServerImage format="svg" diagram={diagram} className="plantuml-image-container" />
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: t('plantuml.tabs.source'),
|
||||
children: (
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: 'calc(80vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{diagram}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
class PlantUMLPopupTopView {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('PlantUMLPopup')
|
||||
}
|
||||
static show(diagram: string) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PlantUMLPopupCantaier
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.hide()
|
||||
}}
|
||||
diagram={diagram}
|
||||
/>,
|
||||
'PlantUMLPopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
interface PlantUMLProps {
|
||||
diagram: string
|
||||
}
|
||||
export const PlantUML: React.FC<PlantUMLProps> = ({ diagram }) => {
|
||||
// const { t } = useTranslation()
|
||||
const onPreview = () => {
|
||||
PlantUMLPopupTopView.show(diagram)
|
||||
}
|
||||
return <PlantUMLServerImage onClick={onPreview} format="svg" diagram={diagram} />
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
const SvgPreview = ({ children }: { children: string }) => {
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: children }}
|
||||
style={{
|
||||
padding: '1em',
|
||||
backgroundColor: 'white',
|
||||
border: '0.5px solid var(--color-code-background)',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SvgPreview
|
||||
@ -23,12 +23,6 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
||||
|
||||
const isThinking = useMemo(() => block.status === MessageBlockStatus.STREAMING, [block.status])
|
||||
|
||||
const fontFamily = useMemo(() => {
|
||||
return messageFont === 'serif'
|
||||
? 'serif'
|
||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
|
||||
}, [messageFont])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinking && thoughtAutoCollapse) {
|
||||
setActiveKey('')
|
||||
@ -98,7 +92,11 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
||||
),
|
||||
children: (
|
||||
// FIXME: 临时兼容
|
||||
<div style={{ fontFamily, fontSize }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<Markdown block={block} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import ContextMenu from '@renderer/components/ContextMenu'
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { fetchWebContent } from '@renderer/utils/fetch'
|
||||
@ -136,36 +137,44 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
|
||||
return (
|
||||
<WebSearchCard>
|
||||
<WebSearchCardHeader>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
<ContextMenu>
|
||||
<WebSearchCardHeader>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
)}
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title || <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
{fetchedContent && <CopyButton content={fetchedContent} />}
|
||||
</WebSearchCardHeader>
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 1 }} title={false} />
|
||||
) : (
|
||||
<WebSearchCardContent className="selectable-text">{fetchedContent}</WebSearchCardContent>
|
||||
)}
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title || <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
{fetchedContent && <CopyButton content={fetchedContent} />}
|
||||
</WebSearchCardHeader>
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 1 }} title={false} />
|
||||
) : (
|
||||
<WebSearchCardContent>{fetchedContent}</WebSearchCardContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</WebSearchCard>
|
||||
)
|
||||
}
|
||||
|
||||
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => (
|
||||
<WebSearchCard>
|
||||
<WebSearchCardHeader>
|
||||
{citation.showFavicon && <FileSearch width={16} />}
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title}
|
||||
</CitationLink>
|
||||
{citation.content && <CopyButton content={citation.content} />}
|
||||
</WebSearchCardHeader>
|
||||
<WebSearchCardContent>{citation.content && truncateText(citation.content, 100)}</WebSearchCardContent>
|
||||
</WebSearchCard>
|
||||
)
|
||||
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
return (
|
||||
<WebSearchCard>
|
||||
<ContextMenu>
|
||||
<WebSearchCardHeader>
|
||||
{citation.showFavicon && <FileSearch width={16} />}
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title}
|
||||
</CitationLink>
|
||||
{citation.content && <CopyButton content={citation.content} />}
|
||||
</WebSearchCardHeader>
|
||||
<WebSearchCardContent className="selectable-text">
|
||||
{citation.content && truncateText(citation.content, 100)}
|
||||
</WebSearchCardContent>
|
||||
</ContextMenu>
|
||||
</WebSearchCard>
|
||||
)
|
||||
}
|
||||
|
||||
const OpenButton = styled(Button)`
|
||||
display: flex;
|
||||
@ -237,6 +246,7 @@ const WebSearchCard = styled.div`
|
||||
border-radius: var(--list-item-border-radius);
|
||||
background-color: var(--color-background);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const WebSearchCardHeader = styled.div`
|
||||
@ -252,6 +262,15 @@ const WebSearchCardContent = styled.div`
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-2);
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
|
||||
&.selectable-text {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
`
|
||||
|
||||
export default CitationsList
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
import { FONT_FAMILY } from '@renderer/config/constant'
|
||||
import ContextMenu from '@renderer/components/ContextMenu'
|
||||
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Divider, Dropdown } from 'antd'
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Divider } from 'antd'
|
||||
import React, { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageContent from './MessageContent'
|
||||
import MessageEditor from './MessageEditor'
|
||||
import MessageErrorBoundary from './MessageErrorBoundary'
|
||||
import MessageHeader from './MessageHeader'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
@ -47,48 +50,58 @@ const MessageItem: FC<Props> = ({
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize } = useSettings()
|
||||
const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic)
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { editingMessageId, stopEditing } = useMessageEditing()
|
||||
const isEditing = editingMessageId === message.id
|
||||
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
useEffect(() => {
|
||||
if (isEditing && messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
})
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const handleEditSave = useCallback(
|
||||
async (blocks: MessageBlock[]) => {
|
||||
try {
|
||||
console.log('after save blocks', blocks)
|
||||
await editMessageBlocks(message.id, blocks)
|
||||
stopEditing()
|
||||
} catch (error) {
|
||||
console.error('Failed to save message blocks:', error)
|
||||
}
|
||||
},
|
||||
[message, editMessageBlocks, stopEditing]
|
||||
)
|
||||
|
||||
const handleEditResend = useCallback(
|
||||
async (blocks: MessageBlock[]) => {
|
||||
try {
|
||||
// 编辑后重新发送消息
|
||||
console.log('after resend blocks', blocks)
|
||||
await resendUserMessageWithEdit(message, blocks, assistant)
|
||||
stopEditing()
|
||||
} catch (error) {
|
||||
console.error('Failed to resend message:', error)
|
||||
}
|
||||
},
|
||||
[message, resendUserMessageWithEdit, assistant, stopEditing]
|
||||
)
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
stopEditing()
|
||||
}, [stopEditing])
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const showMenubar = !isStreaming && !message.status.includes('ing')
|
||||
|
||||
const fontFamily = useMemo(() => {
|
||||
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
|
||||
}, [messageFont])
|
||||
const showMenubar = !isStreaming && !message.status.includes('ing') && !isEditing
|
||||
|
||||
const messageBorder = showMessageDivider ? undefined : 'none'
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const _selectedText = window.getSelection()?.toString()
|
||||
if (_selectedText) {
|
||||
const quotedText =
|
||||
_selectedText
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n') + '\n-------------'
|
||||
setSelectedQuoteText(quotedText)
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setSelectedText(_selectedText)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => {
|
||||
setContextMenuPosition(null)
|
||||
}
|
||||
document.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
@ -130,46 +143,59 @@ const MessageItem: FC<Props> = ({
|
||||
'message-user': !isAssistantMessage
|
||||
})}
|
||||
ref={messageContainerRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}>
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
<div />
|
||||
</Dropdown>
|
||||
)}
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
|
||||
<MessageErrorBoundary>
|
||||
<MessageContent message={message} />
|
||||
</MessageErrorBoundary>
|
||||
{showMenubar && (
|
||||
<MessageFooter
|
||||
style={{
|
||||
border: messageBorder,
|
||||
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
|
||||
}}>
|
||||
<MessageTokens message={message} isLastMessage={isLastMessage} />
|
||||
<MessageMenubar
|
||||
<ContextMenu>
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
||||
<MessageContentContainer
|
||||
className={
|
||||
message.role === 'user'
|
||||
? 'message-content-container message-content-container-user'
|
||||
: message.role === 'assistant'
|
||||
? 'message-content-container message-content-container-assistant'
|
||||
: 'message-content-container'
|
||||
}
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize,
|
||||
background: messageBackground,
|
||||
overflowY: 'visible'
|
||||
}}>
|
||||
{isEditing ? (
|
||||
<MessageEditor
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
index={index}
|
||||
topic={topic}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
isGrouped={isGrouped}
|
||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||
setModel={setModel}
|
||||
onSave={handleEditSave}
|
||||
onResend={handleEditResend}
|
||||
onCancel={handleEditCancel}
|
||||
/>
|
||||
</MessageFooter>
|
||||
)}
|
||||
</MessageContentContainer>
|
||||
) : (
|
||||
<MessageErrorBoundary>
|
||||
<MessageContent message={message} />
|
||||
</MessageErrorBoundary>
|
||||
)}
|
||||
{showMenubar && (
|
||||
<MessageFooter
|
||||
className="MessageFooter"
|
||||
style={{
|
||||
border: messageBorder,
|
||||
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
|
||||
}}>
|
||||
<MessageTokens message={message} isLastMessage={isLastMessage} />
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
index={index}
|
||||
topic={topic}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
isGrouped={isGrouped}
|
||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||
setModel={setModel}
|
||||
/>
|
||||
</MessageFooter>
|
||||
)}
|
||||
</MessageContentContainer>
|
||||
</ContextMenu>
|
||||
</MessageContainer>
|
||||
)
|
||||
}
|
||||
@ -182,24 +208,6 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
|
||||
: undefined
|
||||
}
|
||||
|
||||
const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(selectedText)
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const MessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -20,17 +20,6 @@ const MessageContent: React.FC<Props> = ({ message }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// const SearchingContainer = styled.div`
|
||||
// display: flex;
|
||||
// flex-direction: row;
|
||||
// align-items: center;
|
||||
// background-color: var(--color-background-mute);
|
||||
// padding: 10px;
|
||||
// border-radius: 10px;
|
||||
// margin-bottom: 10px;
|
||||
// gap: 10px;
|
||||
// `
|
||||
|
||||
const MentionTag = styled.span`
|
||||
color: var(--color-link);
|
||||
`
|
||||
|
||||
367
src/renderer/src/pages/home/Messages/MessageEditor.tsx
Normal file
367
src/renderer/src/pages/home/Messages/MessageEditor.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { classNames, getFileExtension } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { findAllBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import { Save, Send, X } from 'lucide-react'
|
||||
import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AttachmentButton, { AttachmentButtonRef } from '../Inputbar/AttachmentButton'
|
||||
import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview'
|
||||
import { ToolbarButton } from '../Inputbar/Inputbar'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
onSave: (blocks: MessageBlock[]) => void
|
||||
onResend: (blocks: MessageBlock[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
|
||||
const allBlocks = findAllBlocks(message)
|
||||
const [editedBlocks, setEditedBlocks] = useState<MessageBlock[]>(allBlocks)
|
||||
const [files, setFiles] = useState<FileType[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isFileDragging, setIsFileDragging] = useState(false)
|
||||
const { assistant } = useAssistant(message.assistantId)
|
||||
const model = assistant.model || assistant.defaultModel
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
resizeTextArea()
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus({ cursor: 'end' })
|
||||
}
|
||||
}, 0)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const resizeTextArea = useCallback(() => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.style.height = 'auto'
|
||||
textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTextChange = (blockId: string, content: string) => {
|
||||
setEditedBlocks((prev) => prev.map((block) => (block.id === blockId ? { ...block, content } : block)))
|
||||
}
|
||||
|
||||
const onTranslated = (translatedText: string) => {
|
||||
const mainTextBlock = editedBlocks.find((b) => b.type === MessageBlockType.MAIN_TEXT)
|
||||
if (mainTextBlock) {
|
||||
handleTextChange(mainTextBlock.id, translatedText)
|
||||
}
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
}
|
||||
|
||||
// 处理文件删除
|
||||
const handleFileRemove = async (blockId: string) => {
|
||||
setEditedBlocks((prev) => prev.filter((block) => block.id !== blockId))
|
||||
}
|
||||
|
||||
// 处理拖拽上传
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsFileDragging(false)
|
||||
|
||||
const files = await getFilesFromDropEvent(e).catch((err) => {
|
||||
console.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err)
|
||||
return null
|
||||
})
|
||||
if (files) {
|
||||
let supportedFiles = 0
|
||||
files.forEach((file) => {
|
||||
if (supportExts.includes(getFileExtension(file.path))) {
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
supportedFiles++
|
||||
}
|
||||
})
|
||||
|
||||
// 如果有文件,但都不支持
|
||||
if (files.length > 0 && supportedFiles === 0) {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = async (withResend?: boolean) => {
|
||||
if (isProcessing) return
|
||||
setIsProcessing(true)
|
||||
const updatedBlocks = [...editedBlocks]
|
||||
if (files && files.length) {
|
||||
const uploadedFiles = await FileManager.uploadFiles(files)
|
||||
uploadedFiles.forEach((file) => {
|
||||
if (file.type === FileTypes.IMAGE) {
|
||||
const imgBlock = createImageBlock(message.id, { file, status: MessageBlockStatus.SUCCESS })
|
||||
updatedBlocks.push(imgBlock)
|
||||
} else {
|
||||
const fileBlock = createFileBlock(message.id, file, { status: MessageBlockStatus.SUCCESS })
|
||||
updatedBlocks.push(fileBlock)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (withResend) {
|
||||
onResend(updatedBlocks)
|
||||
} else {
|
||||
onSave(updatedBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
const onPaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
// 1. 文本粘贴
|
||||
const clipboardText = event.clipboardData?.getData('text')
|
||||
if (clipboardText) {
|
||||
if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) {
|
||||
// 长文本直接转文件,阻止默认粘贴
|
||||
event.preventDefault()
|
||||
|
||||
const tempFilePath = await window.api.file.create('pasted_text.txt')
|
||||
await window.api.file.write(tempFilePath, clipboardText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
setTimeout(() => resizeTextArea(), 50)
|
||||
return
|
||||
}
|
||||
// 短文本走默认粘贴行为,直接返回
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 文件/图片粘贴
|
||||
if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault()
|
||||
for (const file of event.clipboardData.files) {
|
||||
const filePath = window.api.file.getPathForFile(file)
|
||||
if (!filePath) {
|
||||
// 图像生成也支持图像编辑
|
||||
if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) {
|
||||
const tempFilePath = await window.api.file.create(file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
await window.api.file.write(tempFilePath, uint8Array)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
break
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (supportExts.includes(getFileExtension(filePath))) {
|
||||
const selectedFile = await window.api.file.get(filePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
} else {
|
||||
window.message.info({
|
||||
key: 'file_not_supported',
|
||||
content: t('chat.input.file_not_supported')
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 短文本走默认粘贴行为
|
||||
},
|
||||
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
|
||||
)
|
||||
|
||||
const autoResizeTextArea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.target
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${textarea.scrollHeight}px`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorContainer onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
|
||||
.map((block) => (
|
||||
<Textarea
|
||||
className={classNames(isFileDragging && 'file-dragging')}
|
||||
key={block.id}
|
||||
ref={textareaRef}
|
||||
variant="borderless"
|
||||
value={block.content}
|
||||
onChange={(e) => {
|
||||
handleTextChange(block.id, e.target.value)
|
||||
autoResizeTextArea(e)
|
||||
}}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
spellCheck={false}
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
style={{
|
||||
fontSize,
|
||||
padding: '0px 15px 8px 15px'
|
||||
}}>
|
||||
<TranslateButton onTranslated={onTranslated} />
|
||||
</Textarea>
|
||||
))}
|
||||
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
|
||||
files.length > 0) && (
|
||||
<FileBlocksContainer>
|
||||
{editedBlocks
|
||||
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
|
||||
.map(
|
||||
(block) =>
|
||||
block.file && (
|
||||
<CustomTag
|
||||
key={block.id}
|
||||
icon={getFileIcon(block.file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => handleFileRemove(block.id)}>
|
||||
<FileNameRender file={block.file} />
|
||||
</CustomTag>
|
||||
)
|
||||
)}
|
||||
|
||||
{files.map((file) => (
|
||||
<CustomTag
|
||||
key={file.id}
|
||||
icon={getFileIcon(file.ext)}
|
||||
color="#37a5aa"
|
||||
closable
|
||||
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
|
||||
<FileNameRender file={file} />
|
||||
</CustomTag>
|
||||
))}
|
||||
</FileBlocksContainer>
|
||||
)}
|
||||
|
||||
<ActionBar>
|
||||
<ActionBarLeft>
|
||||
<AttachmentButton
|
||||
ref={attachmentButtonRef}
|
||||
model={model}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
ToolbarButton={ToolbarButton}
|
||||
/>
|
||||
</ActionBarLeft>
|
||||
<ActionBarMiddle />
|
||||
<ActionBarRight>
|
||||
<Tooltip title={t('common.cancel')}>
|
||||
<ToolbarButton type="text" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.save')}>
|
||||
<ToolbarButton type="text" onClick={() => handleClick()}>
|
||||
<Save size={16} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('chat.resend')}>
|
||||
<ToolbarButton type="text" onClick={() => handleClick(true)}>
|
||||
<Send size={16} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ActionBarRight>
|
||||
</ActionBar>
|
||||
</EditorContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@ -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 (
|
||||
<GroupContainer
|
||||
id={`message-group-${messages[0].askId}`}
|
||||
$isGrouped={isGrouped}
|
||||
$layout={multiModelMessageStyle}
|
||||
className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
|
||||
<GridContainer
|
||||
$count={messageLength}
|
||||
<MessageEditingProvider>
|
||||
<GroupContainer
|
||||
id={`message-group-${messages[0].askId}`}
|
||||
$isGrouped={isGrouped}
|
||||
$layout={multiModelMessageStyle}
|
||||
$gridColumns={gridColumns}
|
||||
className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
|
||||
{messages.map(renderMessage)}
|
||||
</GridContainer>
|
||||
{isGrouped && (
|
||||
<MessageGroupMenuBar
|
||||
multiModelMessageStyle={multiModelMessageStyle}
|
||||
setMultiModelMessageStyle={(style) => {
|
||||
setMultiModelMessageStyle(style)
|
||||
messages.forEach((message) => {
|
||||
editMessage(message.id, { multiModelMessageStyle: style })
|
||||
})
|
||||
}}
|
||||
messages={messages}
|
||||
selectMessageId={selectedMessageId}
|
||||
setSelectedMessage={setSelectedMessage}
|
||||
topic={topic}
|
||||
/>
|
||||
)}
|
||||
</GroupContainer>
|
||||
className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
|
||||
<GridContainer
|
||||
$count={messageLength}
|
||||
$layout={multiModelMessageStyle}
|
||||
$gridColumns={gridColumns}
|
||||
className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
|
||||
{messages.map(renderMessage)}
|
||||
</GridContainer>
|
||||
{isGrouped && (
|
||||
<MessageGroupMenuBar
|
||||
multiModelMessageStyle={multiModelMessageStyle}
|
||||
setMultiModelMessageStyle={(style) => {
|
||||
setMultiModelMessageStyle(style)
|
||||
messages.forEach((message) => {
|
||||
editMessage(message.id, { multiModelMessageStyle: style })
|
||||
})
|
||||
}}
|
||||
messages={messages}
|
||||
selectMessageId={selectedMessageId}
|
||||
setSelectedMessage={setSelectedMessage}
|
||||
topic={topic}
|
||||
/>
|
||||
)}
|
||||
</GroupContainer>
|
||||
</MessageEditingProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@ -7,7 +8,7 @@ import { setFoldDisplayMode } from '@renderer/store/settings'
|
||||
import type { Model } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -25,39 +26,54 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
const { foldDisplayMode } = useSettings()
|
||||
const isCompact = foldDisplayMode === 'compact'
|
||||
|
||||
const renderLabel = useCallback(
|
||||
(message: Message) => {
|
||||
const modelTip = message.model?.name
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<Tooltip key={message.id} title={modelTip} mouseEnterDelay={0.5}>
|
||||
<AvatarWrapper
|
||||
className="avatar-wrapper"
|
||||
$isSelected={message.id === selectMessageId}
|
||||
onClick={() => {
|
||||
setSelectedMessage(message)
|
||||
}}>
|
||||
<ModelAvatar model={message.model as Model} size={22} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
)
|
||||
},
|
||||
[isCompact, selectMessageId, setSelectedMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<ModelsWrapper>
|
||||
<Container>
|
||||
<DisplayModeToggle
|
||||
displayMode={foldDisplayMode}
|
||||
onClick={() => dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}>
|
||||
<Tooltip
|
||||
title={
|
||||
foldDisplayMode === 'compact'
|
||||
isCompact
|
||||
? t(`message.message.multi_model_style.fold.expand`)
|
||||
: t('message.message.multi_model_style.fold.compress')
|
||||
}
|
||||
placement="top">
|
||||
{foldDisplayMode === 'compact' ? <ArrowsAltOutlined /> : <ShrinkOutlined />}
|
||||
{isCompact ? <ArrowsAltOutlined /> : <ShrinkOutlined />}
|
||||
</Tooltip>
|
||||
</DisplayModeToggle>
|
||||
|
||||
<ModelsContainer $displayMode={foldDisplayMode}>
|
||||
{foldDisplayMode === 'compact' ? (
|
||||
{isCompact ? (
|
||||
/* Compact style display */
|
||||
<Avatar.Group className="avatar-group">
|
||||
{messages.map((message, index) => (
|
||||
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
|
||||
<AvatarWrapper
|
||||
className="avatar-wrapper"
|
||||
isSelected={message.id === selectMessageId}
|
||||
onClick={() => {
|
||||
setSelectedMessage(message)
|
||||
}}>
|
||||
<ModelAvatar model={message.model as Model} size={28} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
<Avatar.Group className="avatar-group">{messages.map((message) => renderLabel(message))}</Avatar.Group>
|
||||
) : (
|
||||
/* Expanded style display */
|
||||
<Segmented
|
||||
@ -67,45 +83,32 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
setSelectedMessage(message)
|
||||
}}
|
||||
options={messages.map((message) => ({
|
||||
label: (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
),
|
||||
label: renderLabel(message),
|
||||
value: message.id
|
||||
}))}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</ModelsContainer>
|
||||
</ModelsWrapper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const ModelsWrapper = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
const Container = styled(HStack)`
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
`
|
||||
|
||||
const DisplayModeToggle = styled.div<{ displayMode: DisplayMode }>`
|
||||
position: absolute;
|
||||
left: 4px; /* Add more space on the left */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 5;
|
||||
width: 28px; /* Increase width */
|
||||
height: 28px; /* Add height */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px 3px 6px;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
|
||||
/* Add hover effect */
|
||||
&:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
@ -119,9 +122,7 @@ const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>`
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
padding: 0 8px;
|
||||
margin-left: 24px; /* Space for toggle button */
|
||||
|
||||
/* Hide scrollbar to match original code */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@ -131,27 +132,23 @@ const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
position: relative;
|
||||
padding: 6px 4px;
|
||||
|
||||
/* Base style - default overlapping effect */
|
||||
& > * {
|
||||
margin-left: -6px !important;
|
||||
/* Separate transition properties to avoid conflicts */
|
||||
transition:
|
||||
transform 0.18s ease-out,
|
||||
margin 0.18s ease-out !important;
|
||||
position: relative;
|
||||
/* Only use will-change for transform to reduce rendering overhead */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* First element has no left margin */
|
||||
& > *:first-child {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* Using :has() selector to handle the element before the hovered one */
|
||||
/* Element before the hovered one */
|
||||
& > *:has(+ *:hover) {
|
||||
margin-right: 2px !important;
|
||||
/* Use transform instead of margin to reduce layout recalculations */
|
||||
@ -171,52 +168,24 @@ const ModelsContainer = styled(Scrollbar)<{ $displayMode: DisplayMode }>`
|
||||
}
|
||||
`
|
||||
|
||||
const AvatarWrapper = styled.div<{ isSelected: boolean }>`
|
||||
const AvatarWrapper = styled.div<{ $isSelected: boolean }>`
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
border-radius: 50%;
|
||||
/* Keep z-index separate from transitions to avoid rendering issues */
|
||||
z-index: ${(props) => (props.isSelected ? 2 : 0)};
|
||||
background: var(--color-background);
|
||||
/* Simplify transitions to reduce jittering */
|
||||
transition:
|
||||
transform 0.18s ease-out,
|
||||
margin 0.18s ease-out,
|
||||
box-shadow 0.18s ease-out,
|
||||
filter 0.18s ease-out;
|
||||
box-shadow: 0 0 0 1px var(--color-background);
|
||||
|
||||
/* Use CSS variables to define animation parameters for easy adjustment */
|
||||
--hover-scale: 1.15;
|
||||
--hover-x-offset: 6px;
|
||||
--hover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
z-index: ${(props) => (props.$isSelected ? 1 : 0)};
|
||||
border: ${(props) => (props.$isSelected ? '2px solid var(--color-primary)' : 'none')};
|
||||
|
||||
&:hover {
|
||||
/* z-index is applied immediately, not part of the transition */
|
||||
z-index: 10;
|
||||
transform: translateX(var(--hover-x-offset)) scale(var(--hover-scale));
|
||||
box-shadow: var(--hover-shadow);
|
||||
transform: translateX(6px) scale(1.15);
|
||||
filter: brightness(1.02);
|
||||
margin-left: 8px !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.isSelected &&
|
||||
`
|
||||
border: 2px solid var(--color-primary);
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
/* z-index is applied immediately, not part of the transition */
|
||||
z-index: 10;
|
||||
border: 2px solid var(--color-primary);
|
||||
filter: brightness(1.02);
|
||||
transform: translateX(var(--hover-x-offset)) scale(var(--hover-scale));
|
||||
margin-left: 8px !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const Segmented = styled(AntdSegmented)`
|
||||
@ -224,21 +193,15 @@ const Segmented = styled(AntdSegmented)`
|
||||
background-color: transparent !important;
|
||||
|
||||
.ant-segmented-item {
|
||||
background-color: transparent !important;
|
||||
transition: none !important;
|
||||
border-radius: var(--list-item-border-radius) !important;
|
||||
box-shadow: none !important;
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
.ant-segmented-thumb,
|
||||
.ant-segmented-item-selected {
|
||||
background-color: transparent !important;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: none !important;
|
||||
border-radius: var(--list-item-border-radius) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
@ -254,4 +217,4 @@ const ModelName = styled.span`
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default MessageGroupModelList
|
||||
export default memo(MessageGroupModelList)
|
||||
|
||||
@ -143,7 +143,6 @@ const UserName = styled.div<{ isBubbleStyle?: boolean; theme?: string }>`
|
||||
const MessageTime = styled.div`
|
||||
font-size: 10px;
|
||||
color: var(--color-text-3);
|
||||
font-family: 'Ubuntu';
|
||||
`
|
||||
|
||||
export default MessageHeader
|
||||
|
||||
@ -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> = (props) => {
|
||||
deleteMessage,
|
||||
resendMessage,
|
||||
regenerateAssistantMessage,
|
||||
resendUserMessageWithEdit,
|
||||
getTranslationUpdater,
|
||||
appendAssistantResponse,
|
||||
editMessageBlocks,
|
||||
removeMessageBlock
|
||||
} = useMessageOperations(topic)
|
||||
const loading = useTopicLoading(topic)
|
||||
@ -119,92 +112,11 @@ const MessageMenubar: FC<Props> = (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) => ``)
|
||||
.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' ? (
|
||||
<ReSendButton
|
||||
icon={<i className="iconfont icon-ic_send" style={{ color: 'var(--color-primary)' }} />}
|
||||
onClick={onPress}>
|
||||
{t('chat.resend')}
|
||||
</ReSendButton>
|
||||
) : 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)
|
||||
|
||||
@ -17,11 +17,6 @@ const MessageTools: FC<Props> = ({ blocks }) => {
|
||||
const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null)
|
||||
const { t } = useTranslation()
|
||||
const { messageFont, fontSize } = useSettings()
|
||||
const fontFamily = useMemo(() => {
|
||||
return messageFont === 'serif'
|
||||
? 'serif'
|
||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
|
||||
}, [messageFont])
|
||||
|
||||
const toolResponse = blocks.metadata?.rawMcpToolResponse
|
||||
|
||||
@ -119,7 +114,11 @@ const MessageTools: FC<Props> = ({ blocks }) => {
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children: isDone && result && (
|
||||
<ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}>
|
||||
<ToolResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
|
||||
</ToolResponseContainer>
|
||||
)
|
||||
@ -168,7 +167,11 @@ const MessageTools: FC<Props> = ({ blocks }) => {
|
||||
transitionName="animation-move-down"
|
||||
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
|
||||
{expandedResponse && (
|
||||
<ExpandedResponseContainer style={{ fontFamily, fontSize }}>
|
||||
<ExpandedResponseContainer
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize
|
||||
}}>
|
||||
<Tabs
|
||||
tabBarExtraContent={
|
||||
<ActionButton
|
||||
|
||||
@ -3,6 +3,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { LOAD_MORE_COUNT } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic'
|
||||
@ -10,18 +11,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'
|
||||
@ -38,14 +42,18 @@ interface MessagesProps {
|
||||
assistant: Assistant
|
||||
topic: Topic
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
onComponentUpdate?(): void
|
||||
onFirstUpdate?(): void
|
||||
}
|
||||
|
||||
const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => {
|
||||
const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => {
|
||||
const { containerRef: scrollContainerRef, handleScroll: handleScrollPosition } = useScrollPosition(
|
||||
`topic-${topic.id}`
|
||||
)
|
||||
const { t } = useTranslation()
|
||||
const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
|
||||
const { updateTopic, addTopic } = useAssistant(assistant.id)
|
||||
const dispatch = useAppDispatch()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
@ -72,16 +80,16 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
if (scrollContainerRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTo({
|
||||
top: containerRef.current.scrollHeight
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}, [scrollContainerRef])
|
||||
|
||||
const clearTopic = useCallback(
|
||||
async (data: Topic) => {
|
||||
@ -115,14 +123,14 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => {
|
||||
await captureScrollableDivAsBlob(containerRef, async (blob) => {
|
||||
await captureScrollableDivAsBlob(scrollContainerRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||
const imageData = await captureScrollableDivAsDataURL(containerRef)
|
||||
const imageData = await captureScrollableDivAsDataURL(scrollContainerRef)
|
||||
if (imageData) {
|
||||
window.api.file.saveImage(removeSpecialCharactersForFileName(topic.name), imageData)
|
||||
}
|
||||
@ -183,7 +191,32 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
console.error(`[NEW_BRANCH] Failed to create topic branch for topic ${newTopic.id}`)
|
||||
window.message.error(t('message.branch.error')) // Example error message
|
||||
}
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(
|
||||
EVENT_NAMES.EDIT_CODE_BLOCK,
|
||||
async (data: { msgBlockId: string; codeBlockId: string; newContent: string }) => {
|
||||
const { msgBlockId, codeBlockId, newContent } = data
|
||||
|
||||
const msgBlock = messageBlocksSelectors.selectById(store.getState(), msgBlockId)
|
||||
|
||||
// FIXME: 目前 error block 没有 content
|
||||
if (msgBlock && isTextLikeBlock(msgBlock) && msgBlock.type !== MessageBlockType.ERROR) {
|
||||
try {
|
||||
const updatedRaw = updateCodeBlock(msgBlock.content, codeBlockId, newContent)
|
||||
dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } }))
|
||||
window.message.success({ content: t('code_block.edit.save.success'), key: 'save-code' })
|
||||
} catch (error) {
|
||||
console.error(`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`, error)
|
||||
window.message.error({ content: t('code_block.edit.save.failed'), key: 'save-code-failed' })
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}: no such message block or the block doesn't have a content field`
|
||||
)
|
||||
window.message.error({ content: t('code_block.edit.save.failed'), key: 'save-code-failed' })
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
return () => unsubscribes.forEach((unsub) => unsub())
|
||||
@ -196,8 +229,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
tokensCount: await estimateHistoryTokens(assistant, messages),
|
||||
contextCount: getContextCount(assistant, messages)
|
||||
})
|
||||
})
|
||||
}, [assistant, messages])
|
||||
}).then(() => onFirstUpdate?.())
|
||||
}, [assistant, messages, onFirstUpdate])
|
||||
|
||||
const loadMoreMessages = useCallback(() => {
|
||||
if (!hasMore || isLoadingMore) return
|
||||
@ -221,13 +254,18 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => onComponentUpdate?.())
|
||||
}, [])
|
||||
|
||||
const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
|
||||
return (
|
||||
<Container
|
||||
id="messages"
|
||||
style={{ maxWidth, paddingTop: showPrompt ? 10 : 0 }}
|
||||
key={assistant.id}
|
||||
ref={containerRef}
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScrollPosition}
|
||||
$right={topicPosition === 'left'}>
|
||||
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
|
||||
<InfiniteScroll
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import FloatingSidebar from '@renderer/components/Popups/FloatingSidebar'
|
||||
import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
@ -15,7 +16,7 @@ import { Assistant, Topic } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { LayoutGrid, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { FC, useCallback, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelButton from './components/SelectModelButton'
|
||||
@ -25,18 +26,47 @@ interface Props {
|
||||
activeAssistant: Assistant
|
||||
activeTopic: Topic
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
position: 'left' | 'right'
|
||||
}
|
||||
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const { topicPosition, sidebarIcons, narrowMode } = useSettings()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
const dispatch = useAppDispatch()
|
||||
const [sidebarHideCooldown, setSidebarHideCooldown] = useState(false)
|
||||
|
||||
useShortcut('toggle_show_assistants', () => {
|
||||
toggleShowAssistants()
|
||||
})
|
||||
// Function to toggle assistants with cooldown
|
||||
const handleToggleShowAssistants = useCallback(() => {
|
||||
if (showAssistants) {
|
||||
// When hiding sidebar, set cooldown
|
||||
toggleShowAssistants()
|
||||
setSidebarHideCooldown(true)
|
||||
// setTimeout(() => {
|
||||
// setSidebarHideCooldown(false)
|
||||
// }, 10000) // 10 seconds cooldown
|
||||
} else {
|
||||
// When showing sidebar, no cooldown needed
|
||||
toggleShowAssistants()
|
||||
}
|
||||
}, [showAssistants, toggleShowAssistants])
|
||||
const handleToggleShowTopics = useCallback(() => {
|
||||
if (showTopics) {
|
||||
// When hiding sidebar, set cooldown
|
||||
toggleShowTopics()
|
||||
setSidebarHideCooldown(true)
|
||||
// setTimeout(() => {
|
||||
// setSidebarHideCooldown(false)
|
||||
// }, 10000) // 10 seconds cooldown
|
||||
} else {
|
||||
// When showing sidebar, no cooldown needed
|
||||
toggleShowTopics()
|
||||
}
|
||||
}, [showTopics, toggleShowTopics])
|
||||
|
||||
useShortcut('toggle_show_assistants', handleToggleShowAssistants)
|
||||
|
||||
useShortcut('toggle_show_topics', () => {
|
||||
if (topicPosition === 'right') {
|
||||
@ -60,7 +90,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
{showAssistants && (
|
||||
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 16 : 0 }}>
|
||||
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac ? 16 : 0 }}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
@ -73,11 +103,28 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
)}
|
||||
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="home-navbar-right">
|
||||
<HStack alignItems="center">
|
||||
{!showAssistants && (
|
||||
{!showAssistants && !sidebarHideCooldown && (
|
||||
<FloatingSidebar
|
||||
activeAssistant={assistant}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position={'left'}>
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
|
||||
<NavbarIcon
|
||||
onClick={() => toggleShowAssistants()}
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</FloatingSidebar>
|
||||
)}
|
||||
{!showAssistants && sidebarHideCooldown && (
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon
|
||||
onClick={() => toggleShowAssistants()}
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}>
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}
|
||||
onMouseOut={() => setSidebarHideCooldown(false)}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
@ -105,10 +152,33 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
|
||||
</Tooltip>
|
||||
</MinAppsPopover>
|
||||
)}
|
||||
{topicPosition === 'right' && (
|
||||
<NarrowIcon onClick={toggleShowTopics}>
|
||||
{showTopics ? <PanelRightClose size={18} /> : <PanelLeftClose size={18} />}
|
||||
</NarrowIcon>
|
||||
{topicPosition === 'right' && !showTopics && !sidebarHideCooldown && (
|
||||
<FloatingSidebar
|
||||
activeAssistant={assistant}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position={'right'}>
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
|
||||
<NavbarIcon onClick={() => toggleShowTopics()}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</FloatingSidebar>
|
||||
)}
|
||||
{topicPosition === 'right' && !showTopics && sidebarHideCooldown && (
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
|
||||
<NavbarIcon onClick={() => toggleShowTopics()} onMouseOut={() => setSidebarHideCooldown(false)}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={2}>
|
||||
<NavbarIcon onClick={() => handleToggleShowTopics()}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
</NavbarRight>
|
||||
|
||||
@ -241,7 +241,6 @@ const Container = styled.div`
|
||||
padding: 0 10px;
|
||||
height: 37px;
|
||||
position: relative;
|
||||
font-family: Ubuntu;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
width: calc(var(--assistants-width) - 20px);
|
||||
|
||||
@ -89,7 +89,6 @@ const AssistantAddItem = styled.div`
|
||||
padding: 7px 12px;
|
||||
position: relative;
|
||||
padding-right: 35px;
|
||||
font-family: Ubuntu;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -457,13 +457,11 @@ const Container = styled(Scrollbar)`
|
||||
const TopicListItem = styled.div`
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
font-family: Ubuntu;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
font-family: Ubuntu;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid transparent;
|
||||
position: relative;
|
||||
|
||||
@ -20,18 +20,26 @@ interface Props {
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
position: 'left' | 'right'
|
||||
forceToSeeAllTab?: boolean
|
||||
}
|
||||
|
||||
type Tab = 'assistants' | 'topic' | 'settings'
|
||||
|
||||
let _tab: any = ''
|
||||
|
||||
const HomeTabs: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant, setActiveTopic, position }) => {
|
||||
const HomeTabs: FC<Props> = ({
|
||||
activeAssistant,
|
||||
activeTopic,
|
||||
setActiveAssistant,
|
||||
setActiveTopic,
|
||||
position,
|
||||
forceToSeeAllTab
|
||||
}) => {
|
||||
const { addAssistant } = useAssistants()
|
||||
const [tab, setTab] = useState<Tab>(position === 'left' ? _tab || 'assistants' : 'topic')
|
||||
const { topicPosition } = useSettings()
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { toggleShowTopics } = useShowTopics()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -86,20 +94,22 @@ const HomeTabs: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssistant,
|
||||
if (position === 'right' && topicPosition === 'right' && tab === 'assistants') {
|
||||
setTab('topic')
|
||||
}
|
||||
if (position === 'left' && topicPosition === 'right' && tab !== 'assistants') {
|
||||
if (position === 'left' && topicPosition === 'right' && forceToSeeAllTab != true && tab !== 'assistants') {
|
||||
setTab('assistants')
|
||||
}
|
||||
}, [position, tab, topicPosition])
|
||||
}, [position, tab, topicPosition, forceToSeeAllTab])
|
||||
|
||||
return (
|
||||
<Container style={border} className="home-tabs">
|
||||
{showTab && (
|
||||
{(showTab || (forceToSeeAllTab == true && !showTopics)) && (
|
||||
<Segmented
|
||||
value={tab}
|
||||
style={{ borderRadius: 16, paddingTop: 10, margin: '0 10px', gap: 2 }}
|
||||
options={
|
||||
[
|
||||
position === 'left' && topicPosition === 'left' ? assistantTab : undefined,
|
||||
(position === 'left' && topicPosition === 'left') || (forceToSeeAllTab == true && position === 'left')
|
||||
? assistantTab
|
||||
: undefined,
|
||||
{
|
||||
label: t('common.topics'),
|
||||
value: 'topic'
|
||||
@ -137,7 +147,6 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
max-width: var(--assistants-width);
|
||||
min-width: var(--assistants-width);
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
.collapsed {
|
||||
@ -155,6 +164,8 @@ const TabContent = styled.div`
|
||||
`
|
||||
|
||||
const Segmented = styled(AntSegmented)`
|
||||
font-family: var(--font-family);
|
||||
|
||||
&.ant-segmented {
|
||||
background-color: transparent;
|
||||
border-radius: 0 !important;
|
||||
|
||||
@ -212,7 +212,6 @@ const AddKnowledgeItem = styled.div`
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px;
|
||||
position: relative;
|
||||
font-family: Ubuntu;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
@ -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<string, any> = {
|
||||
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<string, any> = {
|
||||
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<string, any> = {
|
||||
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<string, any> = {
|
||||
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 (
|
||||
<SettingTitle key={index} style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t(item.title!)}
|
||||
@ -374,30 +767,60 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
)}
|
||||
</SettingTitle>
|
||||
)
|
||||
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 (
|
||||
<Select
|
||||
key={index}
|
||||
disabled={item.disabled}
|
||||
disabled={isDisabled}
|
||||
value={painting[item.key!] || item.initialValue}
|
||||
options={item.options}
|
||||
options={selectOptions}
|
||||
onChange={(v) => updatePaintingState({ [item.key!]: v })}
|
||||
/>
|
||||
)
|
||||
case 'radio':
|
||||
}
|
||||
case 'radio': {
|
||||
// 处理函数类型的options属性
|
||||
const radioOptions =
|
||||
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 (
|
||||
<Radio.Group
|
||||
key={index}
|
||||
value={painting[item.key!]}
|
||||
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}>
|
||||
{item.options!.map((option) => (
|
||||
{radioOptions!.map((option) => (
|
||||
<Radio.Button key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
)
|
||||
case 'slider':
|
||||
}
|
||||
case 'slider': {
|
||||
return (
|
||||
<SliderContainer key={index}>
|
||||
<Slider
|
||||
@ -416,7 +839,8 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
/>
|
||||
</SliderContainer>
|
||||
)
|
||||
case 'input':
|
||||
}
|
||||
case 'input': {
|
||||
// 处理随机种子按钮的特殊情况
|
||||
if (item.key === 'seed') {
|
||||
return (
|
||||
@ -438,7 +862,8 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
suffix={item.suffix}
|
||||
/>
|
||||
)
|
||||
case 'inputNumber':
|
||||
}
|
||||
case 'inputNumber': {
|
||||
return (
|
||||
<InputNumber
|
||||
key={index}
|
||||
@ -449,7 +874,8 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
onChange={(v) => updatePaintingState({ [item.key!]: v })}
|
||||
/>
|
||||
)
|
||||
case 'textarea':
|
||||
}
|
||||
case 'textarea': {
|
||||
return (
|
||||
<TextArea
|
||||
key={index}
|
||||
@ -459,7 +885,8 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
rows={4}
|
||||
/>
|
||||
)
|
||||
case 'switch':
|
||||
}
|
||||
case 'switch': {
|
||||
return (
|
||||
<HStack key={index}>
|
||||
<Switch
|
||||
@ -468,7 +895,8 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
case 'image':
|
||||
}
|
||||
case 'image': {
|
||||
return (
|
||||
<ImageUploadButton
|
||||
key={index}
|
||||
@ -490,6 +918,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
)}
|
||||
</ImageUploadButton>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@ -662,7 +1091,6 @@ const Textarea = styled(TextArea)`
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: Ubuntu;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
|
||||
@ -204,9 +204,26 @@ const PaintingsPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const downloadedFiles = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
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('Failed to download image:', error)
|
||||
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
|
||||
}
|
||||
})
|
||||
@ -564,7 +581,6 @@ const Textarea = styled(TextArea)`
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: Ubuntu;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: auto;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user