Merge branch 'develop'

This commit is contained in:
kangfenmao 2025-05-19 21:16:41 +08:00
commit 1469675a20
144 changed files with 11989 additions and 3721 deletions

View 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": [
{

View File

@ -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 记录
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)
[![Star History Chart](https://api.star-history.com/svg?repos=kangfenmao/cherry-studio&type=Timeline)](https://star-history.com/#kangfenmao/cherry-studio&Timeline)

View File

@ -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.

View File

@ -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
}

View File

@ -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": {

View File

@ -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), {

View File

@ -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
}

View File

@ -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
View 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
}

View File

@ -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);
}
});
}
`)
})
}
}

View File

@ -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)

View File

@ -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
})

View File

@ -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>

View 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);
}

View 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;
}

View File

@ -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);
}

View File

@ -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;
}
}
}

View 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)

View File

@ -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>
)
}

View 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)

View 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)

View 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)

View 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)

View 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: 特殊视图模式MermaidPlantUMLSVG
* - 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)

View 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 的 basicSetupoptions 优先
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)

View 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
}
}

View 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
}

View File

@ -0,0 +1,5 @@
export * from './constants'
export * from './context'
export * from './toolbar'
export * from './types'
export * from './usePreviewTools'

View 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);
}
`

View 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
}

View 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])
}

View 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);
`

View 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

View 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} />

View File

@ -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;

View File

@ -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;

View File

@ -57,7 +57,6 @@ const NameSpan = styled.span`
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
font-family: 'Ubuntu';
line-height: 30px;
`

View File

@ -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)
}

View 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

View File

@ -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 {

View File

@ -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>
)
}

View File

@ -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;
`

View File

@ -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'};

View File

@ -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'

View File

@ -163,7 +163,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'minimax',
name: '海螺',
url: 'https://hailuoai.com/',
url: 'https://chat.minimaxi.com/',
logo: HailuoModelLogo
},
{

View File

@ -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 }

View File

@ -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}

View 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
}

View 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
}

View File

@ -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) => ({ '<': '&lt;', '>': '&gt;' })[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[]

View File

@ -19,7 +19,6 @@ declare global {
message: MessageInstance
modal: HookAPI
keyv: KeyvStorage
mermaid: any
store: any
navigate: NavigateFunction
}

View File

@ -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 }))
}
}

View 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
}

View File

@ -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))
})

View File

@ -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
}
}

View File

@ -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]
)
/**

View File

@ -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,

View File

@ -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(() => {

View File

@ -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",

View File

@ -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": "ショートカット",

View File

@ -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": "Горячие клавиши",

View File

@ -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": "快捷方式",

View File

@ -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": "快速方式",

View File

@ -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": "προβολή"
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -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

View File

@ -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 }}>

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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;

View File

@ -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;

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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;
`

View File

@ -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)
}

View File

@ -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

View File

@ -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>
)

View File

@ -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

View File

@ -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;

View File

@ -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);
`

View 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)

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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

View File

@ -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) => `![image-${index}](file://${image?.file?.path})`)
.join('\n')
textToEdit = `${textToEdit}\n\n${imageMarkdown}`
}
textToEdit += mainTextContent
// if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) {
// // const processedMessage = withMessageThought(clone(message))
// // textToEdit = getMainTextContent(processedMessage)
// textToEdit = mainTextContent
// }
const editedText = await TextEditPopup.show({
text: textToEdit,
children: (props) => {
const onPress = () => {
props.onOk?.()
resendMessage = true
}
return message.role === 'user' ? (
<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)

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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);

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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