Merge branch 'main' into feat/ocr
This commit is contained in:
@@ -113,5 +113,5 @@ jobs:
|
||||
allowUpdates: true
|
||||
makeLatest: false
|
||||
tag: ${{ steps.get-tag.outputs.tag }}
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
|
||||
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
|
||||
index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644
|
||||
--- a/dist/utils/tiktoken.cjs
|
||||
+++ b/dist/utils/tiktoken.cjs
|
||||
@@ -1,25 +1,14 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encodingForModel = exports.getEncoding = void 0;
|
||||
-const lite_1 = require("js-tiktoken/lite");
|
||||
const async_caller_js_1 = require("./async_caller.cjs");
|
||||
const cache = {};
|
||||
const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({});
|
||||
async function getEncoding(encoding) {
|
||||
- if (!(encoding in cache)) {
|
||||
- cache[encoding] = caller
|
||||
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
|
||||
- .then((res) => res.json())
|
||||
- .then((data) => new lite_1.Tiktoken(data))
|
||||
- .catch((e) => {
|
||||
- delete cache[encoding];
|
||||
- throw e;
|
||||
- });
|
||||
- }
|
||||
- return await cache[encoding];
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
exports.getEncoding = getEncoding;
|
||||
async function encodingForModel(model) {
|
||||
- return getEncoding((0, lite_1.getEncodingNameForModel)(model));
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
exports.encodingForModel = encodingForModel;
|
||||
diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
|
||||
index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644
|
||||
--- a/dist/utils/tiktoken.js
|
||||
+++ b/dist/utils/tiktoken.js
|
||||
@@ -1,20 +1,9 @@
|
||||
-import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite";
|
||||
import { AsyncCaller } from "./async_caller.js";
|
||||
const cache = {};
|
||||
const caller = /* #__PURE__ */ new AsyncCaller({});
|
||||
export async function getEncoding(encoding) {
|
||||
- if (!(encoding in cache)) {
|
||||
- cache[encoding] = caller
|
||||
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
|
||||
- .then((res) => res.json())
|
||||
- .then((data) => new Tiktoken(data))
|
||||
- .catch((e) => {
|
||||
- delete cache[encoding];
|
||||
- throw e;
|
||||
- });
|
||||
- }
|
||||
- return await cache[encoding];
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
export async function encodingForModel(model) {
|
||||
- return getEncoding(getEncodingNameForModel(model));
|
||||
+ throw new Error("TikToken Not implemented");
|
||||
}
|
||||
diff --git a/package.json b/package.json
|
||||
index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -37,7 +37,6 @@
|
||||
"ansi-styles": "^5.0.0",
|
||||
"camelcase": "6",
|
||||
"decamelize": "1.2.0",
|
||||
- "js-tiktoken": "^1.0.12",
|
||||
"langsmith": ">=0.2.8 <0.4.0",
|
||||
"mustache": "^4.2.0",
|
||||
"p-queue": "^6.6.2",
|
||||
+15
-13
@@ -13,11 +13,12 @@ directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '**/*'
|
||||
- '!{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}}'
|
||||
- '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!**/{.editorconfig,.jekyll-metadata}'
|
||||
- '!src'
|
||||
- '!scripts'
|
||||
- '!local'
|
||||
@@ -36,8 +37,11 @@ files:
|
||||
- '!**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
- '!**/*.min.*.map'
|
||||
- '!**/*.d.ts'
|
||||
- '!**/dist/es6/**'
|
||||
- '!**/dist/demo/**'
|
||||
- '!**/amd/**'
|
||||
- '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}'
|
||||
- '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
|
||||
- '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}'
|
||||
- '!node_modules/rollup-plugin-visualizer'
|
||||
- '!node_modules/js-tiktoken'
|
||||
- '!node_modules/@tavily/core/node_modules/js-tiktoken'
|
||||
@@ -109,10 +113,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
⚠️ 注意:升级前请备份数据,否则将无法降级
|
||||
文生图新增服务商 DMXAPI(限时免费)
|
||||
输入框按钮支持拖拽排序
|
||||
修复知识库搜索结果 100% 问题
|
||||
修复拖拽多选消息相关问题
|
||||
修复翻译回复内容导致内存异常问题
|
||||
常规错误修复和优化
|
||||
新增划词助手
|
||||
助手支持分组
|
||||
支持主题颜色切换
|
||||
划词助手支持应用过滤
|
||||
翻译模块功能改进
|
||||
|
||||
+2
-21
@@ -9,26 +9,7 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: [
|
||||
'@cherrystudio/embedjs',
|
||||
'@cherrystudio/embedjs-openai',
|
||||
'@cherrystudio/embedjs-loader-web',
|
||||
'@cherrystudio/embedjs-loader-markdown',
|
||||
'@cherrystudio/embedjs-loader-msoffice',
|
||||
'@cherrystudio/embedjs-loader-xml',
|
||||
'@cherrystudio/embedjs-loader-pdf',
|
||||
'@cherrystudio/embedjs-loader-sitemap',
|
||||
'@cherrystudio/embedjs-libsql',
|
||||
'@cherrystudio/embedjs-loader-image',
|
||||
'@cherrystudio/mac-system-ocr',
|
||||
'p-queue',
|
||||
'webdav'
|
||||
]
|
||||
}),
|
||||
...visualizerPlugin('main')
|
||||
],
|
||||
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve('src/main'),
|
||||
@@ -38,7 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client', '@cherrystudio/mac-system-ocr']
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr']
|
||||
},
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
|
||||
+8
-12
@@ -22,7 +22,7 @@
|
||||
"dev": "electron-vite dev",
|
||||
"debug": "electron-vite dev -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn test && yarn typecheck && yarn check:i18n",
|
||||
"build:check": "yarn typecheck && yarn check:i18n && yarn test",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
@@ -38,7 +38,6 @@
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
"generate:agents": "yarn workspace @cherry-studio/database agents",
|
||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
@@ -48,6 +47,7 @@
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
"test:update": "yarn test:renderer --update",
|
||||
"test:coverage": "vitest run --coverage --silent",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
@@ -70,7 +70,6 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@mistralai/mistralai": "^1.6.0",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
@@ -87,7 +86,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -97,12 +96,10 @@
|
||||
"pdf-to-img": "^4.4.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"selection-hook": "^0.9.14",
|
||||
"selection-hook": "^0.9.19",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"webdav": "^5.8.0",
|
||||
"ws": "^8.18.1",
|
||||
"zipread": "^1.3.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -118,10 +115,11 @@
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@eslint-react/eslint-plugin": "^1.36.1",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "^0.13.0",
|
||||
"@google/genai": "^1.0.1",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.4",
|
||||
@@ -147,7 +145,6 @@
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@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",
|
||||
@@ -168,7 +165,6 @@
|
||||
"electron": "35.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^3.1.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
@@ -229,14 +225,14 @@
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
|
||||
"node-gyp": "^9.1.0",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"openai@npm:^4.77.0": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
|
||||
"canvas": "3.1.0",
|
||||
"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",
|
||||
"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"
|
||||
"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",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -11,7 +11,6 @@ export enum IpcChannel {
|
||||
App_SetLaunchToTray = 'app:set-launch-to-tray',
|
||||
App_SetTray = 'app:set-tray',
|
||||
App_SetTrayOnClose = 'app:set-tray-on-close',
|
||||
App_RestartTray = 'app:restart-tray',
|
||||
App_SetTheme = 'app:set-theme',
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
@@ -112,6 +111,7 @@ export enum IpcChannel {
|
||||
File_WriteWithId = 'file:writeWithId',
|
||||
File_SaveImage = 'file:saveImage',
|
||||
File_Base64Image = 'file:base64Image',
|
||||
File_SaveBase64Image = 'file:saveBase64Image',
|
||||
File_Download = 'file:download',
|
||||
File_Copy = 'file:copy',
|
||||
File_BinaryImage = 'file:binaryImage',
|
||||
@@ -151,7 +151,7 @@ export enum IpcChannel {
|
||||
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeChange = 'theme:change',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
@@ -193,7 +193,10 @@ export enum IpcChannel {
|
||||
Selection_WriteToClipboard = 'selection:write-to-clipboard',
|
||||
Selection_SetEnabled = 'selection:set-enabled',
|
||||
Selection_SetTriggerMode = 'selection:set-trigger-mode',
|
||||
Selection_SetFilterMode = 'selection:set-filter-mode',
|
||||
Selection_SetFilterList = 'selection:set-filter-list',
|
||||
Selection_SetFollowToolbar = 'selection:set-follow-toolbar',
|
||||
Selection_SetRemeberWinSize = 'selection:set-remeber-win-size',
|
||||
Selection_ActionWindowClose = 'selection:action-window-close',
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
interface IBlacklist {
|
||||
WINDOWS: string[]
|
||||
MAC?: string[]
|
||||
}
|
||||
|
||||
/*************************************************************************
|
||||
* 注意:请不要修改此配置,除非你非常清楚其含义、影响和行为的目的
|
||||
* Note: Do not modify this configuration unless you fully understand its meaning, implications, and intended behavior.
|
||||
* -----------------------------------------------------------------------
|
||||
* A predefined application filter list to include commonly used software
|
||||
* that does not require text selection but may conflict with it, and disable them in advance.
|
||||
* Only available in the selected mode.
|
||||
*
|
||||
* Specification: must be all lowercase, need to accurately find the actual running program name
|
||||
*************************************************************************/
|
||||
export const SELECTION_PREDEFINED_BLACKLIST: IBlacklist = {
|
||||
WINDOWS: [
|
||||
// Screenshot
|
||||
'snipaste.exe',
|
||||
'pixpin.exe',
|
||||
'sharex.exe',
|
||||
// Office
|
||||
'excel.exe',
|
||||
'powerpnt.exe',
|
||||
// Image Editor
|
||||
'photoshop.exe',
|
||||
'illustrator.exe',
|
||||
// Video Editor
|
||||
'adobe premiere pro.exe',
|
||||
'afterfx.exe',
|
||||
// Audio Editor
|
||||
'adobe audition.exe',
|
||||
// 3D Editor
|
||||
'blender.exe',
|
||||
'3dsmax.exe',
|
||||
'maya.exe',
|
||||
// CAD
|
||||
'acad.exe',
|
||||
'sldworks.exe'
|
||||
]
|
||||
}
|
||||
+6
-35
@@ -6,11 +6,10 @@ import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/pro
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
@@ -29,7 +28,7 @@ import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
@@ -114,10 +113,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
configManager.setAutoUpdate(isActive)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
|
||||
configManager.set(key, value)
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => {
|
||||
@@ -126,34 +123,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// theme
|
||||
ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => {
|
||||
const updateTitleBarOverlay = () => {
|
||||
if (!mainWindow?.setTitleBarOverlay) return
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
mainWindow.setTitleBarOverlay(isDark ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
}
|
||||
|
||||
const broadcastThemeChange = () => {
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
const effectiveTheme = isDark ? ThemeMode.dark : ThemeMode.light
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.webContents.send(IpcChannel.ThemeChange, effectiveTheme))
|
||||
}
|
||||
|
||||
const notifyThemeChange = () => {
|
||||
updateTitleBarOverlay()
|
||||
broadcastThemeChange()
|
||||
}
|
||||
|
||||
if (theme === ThemeMode.auto) {
|
||||
nativeTheme.themeSource = 'system'
|
||||
nativeTheme.on('updated', notifyThemeChange)
|
||||
} else {
|
||||
nativeTheme.themeSource = theme
|
||||
nativeTheme.off('updated', notifyThemeChange)
|
||||
}
|
||||
|
||||
updateTitleBarOverlay()
|
||||
configManager.setTheme(theme)
|
||||
notifyThemeChange()
|
||||
themeService.setTheme(theme)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => {
|
||||
@@ -251,6 +221,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
|
||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image)
|
||||
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image)
|
||||
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File)
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater } from 'electron-updater'
|
||||
import { locales } from '@main/utils/locales'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
@@ -94,15 +95,22 @@ export default class AppUpdater {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { update: updateLocale } = locale.translation
|
||||
|
||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||
if (detail === '') {
|
||||
detail = updateLocale.noReleaseNotes
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: '安装更新',
|
||||
title: updateLocale.title,
|
||||
icon,
|
||||
message: `新版本 ${this.releaseInfo.version} 已准备就绪`,
|
||||
detail: this.formatReleaseNotes(this.releaseInfo.releaseNotes),
|
||||
buttons: ['稍后安装', '立即安装'],
|
||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||
detail,
|
||||
buttons: [updateLocale.later, updateLocale.install],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
@@ -118,7 +126,7 @@ export default class AppUpdater {
|
||||
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return '暂无更新说明'
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
|
||||
@@ -19,7 +19,10 @@ export enum ConfigKeys {
|
||||
EnableDataCollection = 'enableDataCollection',
|
||||
SelectionAssistantEnabled = 'selectionAssistantEnabled',
|
||||
SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode',
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar'
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
|
||||
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
|
||||
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -35,12 +38,12 @@ export class ConfigManager {
|
||||
return this.get(ConfigKeys.Language, locale) as LanguageVarious
|
||||
}
|
||||
|
||||
setLanguage(theme: LanguageVarious) {
|
||||
this.set(ConfigKeys.Language, theme)
|
||||
setLanguage(lang: LanguageVarious) {
|
||||
this.setAndNotify(ConfigKeys.Language, lang)
|
||||
}
|
||||
|
||||
getTheme(): ThemeMode {
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.auto)
|
||||
return this.get(ConfigKeys.Theme, ThemeMode.system)
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
@@ -60,8 +63,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setTray(value: boolean) {
|
||||
this.set(ConfigKeys.Tray, value)
|
||||
this.notifySubscribers(ConfigKeys.Tray, value)
|
||||
this.setAndNotify(ConfigKeys.Tray, value)
|
||||
}
|
||||
|
||||
getTrayOnClose(): boolean {
|
||||
@@ -77,8 +79,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setZoomFactor(factor: number) {
|
||||
this.set(ConfigKeys.ZoomFactor, factor)
|
||||
this.notifySubscribers(ConfigKeys.ZoomFactor, factor)
|
||||
this.setAndNotify(ConfigKeys.ZoomFactor, factor)
|
||||
}
|
||||
|
||||
subscribe<T>(key: string, callback: (newValue: T) => void) {
|
||||
@@ -110,11 +111,10 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setShortcuts(shortcuts: Shortcut[]) {
|
||||
this.set(
|
||||
this.setAndNotify(
|
||||
ConfigKeys.Shortcuts,
|
||||
shortcuts.filter((shortcut) => shortcut.system)
|
||||
)
|
||||
this.notifySubscribers(ConfigKeys.Shortcuts, shortcuts)
|
||||
}
|
||||
|
||||
getClickTrayToShowQuickAssistant(): boolean {
|
||||
@@ -130,7 +130,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setEnableQuickAssistant(value: boolean) {
|
||||
this.set(ConfigKeys.EnableQuickAssistant, value)
|
||||
this.setAndNotify(ConfigKeys.EnableQuickAssistant, value)
|
||||
}
|
||||
|
||||
getAutoUpdate(): boolean {
|
||||
@@ -155,8 +155,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setSelectionAssistantEnabled(value: boolean) {
|
||||
this.set(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
this.notifySubscribers(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: trigger mode (selected, ctrlkey)
|
||||
@@ -165,8 +164,7 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setSelectionAssistantTriggerMode(value: string) {
|
||||
this.set(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
this.notifySubscribers(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value)
|
||||
}
|
||||
|
||||
// Selection Assistant: if action window position follow toolbar
|
||||
@@ -175,12 +173,40 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
setSelectionAssistantFollowToolbar(value: boolean) {
|
||||
this.set(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
this.notifySubscribers(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
getSelectionAssistantRemeberWinSize(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.SelectionAssistantRemeberWinSize, false)
|
||||
}
|
||||
|
||||
setSelectionAssistantRemeberWinSize(value: boolean) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterMode(): string {
|
||||
return this.get<string>(ConfigKeys.SelectionAssistantFilterMode, 'default')
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterMode(value: string) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value)
|
||||
}
|
||||
|
||||
getSelectionAssistantFilterList(): string[] {
|
||||
return this.get<string[]>(ConfigKeys.SelectionAssistantFilterList, [])
|
||||
}
|
||||
|
||||
setSelectionAssistantFilterList(value: string[]) {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
|
||||
}
|
||||
|
||||
setAndNotify(key: string, value: unknown) {
|
||||
this.set(key, value, true)
|
||||
}
|
||||
|
||||
set(key: string, value: unknown, isNotify: boolean = false) {
|
||||
this.store.set(key, value)
|
||||
isNotify && this.notifySubscribers(key, value)
|
||||
}
|
||||
|
||||
get<T>(key: string, defaultValue?: T) {
|
||||
|
||||
@@ -278,6 +278,51 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
|
||||
try {
|
||||
if (!base64Data) {
|
||||
throw new Error('Base64 data is required')
|
||||
}
|
||||
|
||||
// 移除 base64 头部信息(如果存在)
|
||||
const base64String = base64Data.replace(/^data:.*;base64,/, '')
|
||||
const buffer = Buffer.from(base64String, 'base64')
|
||||
const uuid = uuidv4()
|
||||
const ext = '.png'
|
||||
const destPath = path.join(this.storageDir, uuid + ext)
|
||||
|
||||
logger.info('[FileStorage] Saving base64 image:', {
|
||||
storageDir: this.storageDir,
|
||||
destPath,
|
||||
bufferSize: buffer.length
|
||||
})
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(this.storageDir)) {
|
||||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
id: uuid,
|
||||
origin_name: uuid + ext,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: new Date().toISOString(),
|
||||
size: buffer.length,
|
||||
ext: ext.slice(1),
|
||||
type: getFileType(ext),
|
||||
count: 1
|
||||
}
|
||||
|
||||
return fileMetadata
|
||||
} catch (error) {
|
||||
logger.error('[FileStorage] Failed to save base64 image:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
@@ -36,6 +37,11 @@ type RelativeOrientation =
|
||||
| 'middleRight'
|
||||
| 'center'
|
||||
|
||||
enum TriggerMode {
|
||||
Selected = 'selected',
|
||||
Ctrlkey = 'ctrlkey'
|
||||
}
|
||||
|
||||
/** SelectionService is a singleton class that manages the selection hook and the toolbar window
|
||||
*
|
||||
* Features:
|
||||
@@ -58,8 +64,11 @@ export class SelectionService {
|
||||
private initStatus: boolean = false
|
||||
private started: boolean = false
|
||||
|
||||
private triggerMode = 'selected'
|
||||
private triggerMode = TriggerMode.Selected
|
||||
private isFollowToolbar = true
|
||||
private isRemeberWinSize = false
|
||||
private filterMode = 'default'
|
||||
private filterList: string[] = []
|
||||
|
||||
private toolbarWindow: BrowserWindow | null = null
|
||||
private actionWindows = new Set<BrowserWindow>()
|
||||
@@ -84,6 +93,11 @@ export class SelectionService {
|
||||
private readonly ACTION_WINDOW_WIDTH = 500
|
||||
private readonly ACTION_WINDOW_HEIGHT = 400
|
||||
|
||||
private lastActionWindowSize: { width: number; height: number } = {
|
||||
width: this.ACTION_WINDOW_WIDTH,
|
||||
height: this.ACTION_WINDOW_HEIGHT
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
try {
|
||||
if (!SelectionHook) {
|
||||
@@ -136,17 +150,91 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
private initConfig() {
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode()
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
|
||||
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
|
||||
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
|
||||
this.filterMode = configManager.getSelectionAssistantFilterMode()
|
||||
this.filterList = configManager.getSelectionAssistantFilterList()
|
||||
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => {
|
||||
const oldTriggerMode = this.triggerMode
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => {
|
||||
this.triggerMode = triggerMode
|
||||
this.processTriggerMode()
|
||||
|
||||
//trigger mode changed, need to update the filter list
|
||||
if (oldTriggerMode !== triggerMode) {
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
}
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => {
|
||||
this.isFollowToolbar = isFollowToolbar
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantRemeberWinSize, (isRemeberWinSize: boolean) => {
|
||||
this.isRemeberWinSize = isRemeberWinSize
|
||||
//when off, reset the last action window size to default
|
||||
if (!this.isRemeberWinSize) {
|
||||
this.lastActionWindowSize = {
|
||||
width: this.ACTION_WINDOW_WIDTH,
|
||||
height: this.ACTION_WINDOW_HEIGHT
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantFilterMode, (filterMode: string) => {
|
||||
this.filterMode = filterMode
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantFilterList, (filterList: string[]) => {
|
||||
this.filterList = filterList
|
||||
this.setHookGlobalFilterMode(this.filterMode, this.filterList)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global filter mode for the selection-hook
|
||||
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
|
||||
* @param list - An array of strings representing the list of items to include or exclude
|
||||
*/
|
||||
private setHookGlobalFilterMode(mode: string, list: string[]) {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const modeMap = {
|
||||
default: 0,
|
||||
whitelist: 1,
|
||||
blacklist: 2
|
||||
}
|
||||
|
||||
let combinedList: string[] = list
|
||||
let combinedMode = mode
|
||||
|
||||
//only the selected mode need to combine the predefined blacklist with the user-defined blacklist
|
||||
if (this.triggerMode === TriggerMode.Selected) {
|
||||
switch (mode) {
|
||||
case 'blacklist':
|
||||
//combine the predefined blacklist with the user-defined blacklist
|
||||
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
|
||||
break
|
||||
case 'whitelist':
|
||||
combinedList = [...list]
|
||||
break
|
||||
case 'default':
|
||||
default:
|
||||
//use the predefined blacklist as the default filter list
|
||||
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
|
||||
combinedMode = 'blacklist'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.selectionHook.setGlobalFilterMode(modeMap[combinedMode], combinedList)) {
|
||||
this.logError(new Error('Failed to set selection-hook global filter mode'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,8 +248,6 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
try {
|
||||
//init basic configs
|
||||
this.initConfig()
|
||||
//make sure the toolbar window is ready
|
||||
this.createToolbarWindow()
|
||||
// Initialize preloaded windows
|
||||
@@ -175,6 +261,9 @@ export class SelectionService {
|
||||
|
||||
// Start the hook
|
||||
if (this.selectionHook.start({ debug: isDev })) {
|
||||
//init basic configs
|
||||
this.initConfig()
|
||||
|
||||
//init trigger mode configs
|
||||
this.processTriggerMode()
|
||||
|
||||
@@ -454,6 +543,30 @@ export class SelectionService {
|
||||
return startTop.y === endTop.y && startBottom.y === endBottom.y
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the text selection should be processed by filter mode&list
|
||||
* @param selectionData Text selection information and coordinates
|
||||
* @returns {boolean} True if the selection should be processed, false otherwise
|
||||
*/
|
||||
private shouldProcessTextSelection(selectionData: TextSelectionData): boolean {
|
||||
if (selectionData.programName === '' || this.filterMode === 'default') {
|
||||
return true
|
||||
}
|
||||
|
||||
const programName = selectionData.programName.toLowerCase()
|
||||
//items in filterList are already in lower case
|
||||
const isFound = this.filterList.some((item) => programName.includes(item))
|
||||
|
||||
switch (this.filterMode) {
|
||||
case 'whitelist':
|
||||
return isFound
|
||||
case 'blacklist':
|
||||
return !isFound
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text selection data and show toolbar
|
||||
* Handles different selection scenarios:
|
||||
@@ -468,6 +581,10 @@ export class SelectionService {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.shouldProcessTextSelection(selectionData)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine reference point and position for toolbar
|
||||
let refPoint: { x: number; y: number } = { x: 0, y: 0 }
|
||||
let isLogical = false
|
||||
@@ -551,12 +668,16 @@ export class SelectionService {
|
||||
selectionData.endBottom
|
||||
)
|
||||
|
||||
// Note: shift key + mouse click == DoubleClick
|
||||
|
||||
//double click to select a word
|
||||
if (isDoubleClick && isSameLine) {
|
||||
refOrientation = 'bottomMiddle'
|
||||
refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.endBottom.y + 4 }
|
||||
break
|
||||
}
|
||||
|
||||
// below: isDoubleClick || isSameLine
|
||||
if (isSameLine) {
|
||||
const direction = selectionData.mousePosEnd.x - selectionData.mousePosStart.x
|
||||
|
||||
@@ -570,6 +691,7 @@ export class SelectionService {
|
||||
break
|
||||
}
|
||||
|
||||
// below: !isDoubleClick && !isSameLine
|
||||
const direction = selectionData.mousePosEnd.y - selectionData.mousePosStart.y
|
||||
|
||||
if (direction > 0) {
|
||||
@@ -667,7 +789,11 @@ export class SelectionService {
|
||||
*/
|
||||
private handleKeyDownHide = (data: KeyboardEventData) => {
|
||||
//dont hide toolbar when ctrlkey is pressed
|
||||
if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) {
|
||||
if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) {
|
||||
return
|
||||
}
|
||||
//dont hide toolbar when shiftkey is pressed, because it's used for selection
|
||||
if (this.isShiftkey(data.vkCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -726,6 +852,11 @@ export class SelectionService {
|
||||
return vkCode === 162 || vkCode === 163
|
||||
}
|
||||
|
||||
//check if the key is shift key
|
||||
private isShiftkey(vkCode: number) {
|
||||
return vkCode === 160 || vkCode === 161
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a preloaded action window for quick response
|
||||
* Action windows handle specific operations on selected text
|
||||
@@ -733,8 +864,8 @@ export class SelectionService {
|
||||
*/
|
||||
private createPreloadedActionWindow(): BrowserWindow {
|
||||
const preloadedActionWindow = new BrowserWindow({
|
||||
width: this.ACTION_WINDOW_WIDTH,
|
||||
height: this.ACTION_WINDOW_HEIGHT,
|
||||
width: this.isRemeberWinSize ? this.lastActionWindowSize.width : this.ACTION_WINDOW_WIDTH,
|
||||
height: this.isRemeberWinSize ? this.lastActionWindowSize.height : this.ACTION_WINDOW_HEIGHT,
|
||||
minWidth: 300,
|
||||
minHeight: 200,
|
||||
frame: false,
|
||||
@@ -808,6 +939,16 @@ export class SelectionService {
|
||||
}
|
||||
})
|
||||
|
||||
//remember the action window size
|
||||
actionWindow.on('resized', () => {
|
||||
if (this.isRemeberWinSize) {
|
||||
this.lastActionWindowSize = {
|
||||
width: actionWindow.getBounds().width,
|
||||
height: actionWindow.getBounds().height
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.actionWindows.add(actionWindow)
|
||||
|
||||
// Asynchronously create a new preloaded window
|
||||
@@ -817,8 +958,6 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
public processAction(actionItem: ActionItem): void {
|
||||
console.log('processAction', this.preloadedActionWindows.length, this.actionWindows.size)
|
||||
|
||||
const actionWindow = this.popActionWindow()
|
||||
|
||||
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
|
||||
@@ -832,30 +971,58 @@ export class SelectionService {
|
||||
* @param actionWindow Window to position and show
|
||||
*/
|
||||
private showActionWindow(actionWindow: BrowserWindow) {
|
||||
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
|
||||
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
|
||||
|
||||
//if remember win size is true, use the last remembered size
|
||||
if (this.isRemeberWinSize) {
|
||||
actionWindowWidth = this.lastActionWindowSize.width
|
||||
actionWindowHeight = this.lastActionWindowSize.height
|
||||
}
|
||||
|
||||
//center way
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
if (this.isRemeberWinSize) {
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight
|
||||
})
|
||||
}
|
||||
|
||||
actionWindow.show()
|
||||
this.hideToolbar()
|
||||
return
|
||||
}
|
||||
|
||||
//follow toolbar
|
||||
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y })
|
||||
const workArea = display.workArea
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - this.ACTION_WINDOW_WIDTH) / 2)
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + this.ACTION_WINDOW_WIDTH > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - this.ACTION_WINDOW_WIDTH - GAP
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + this.ACTION_WINDOW_HEIGHT > workArea.y + workArea.height) {
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - this.ACTION_WINDOW_HEIGHT - GAP
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
@@ -863,8 +1030,8 @@ export class SelectionService {
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: this.ACTION_WINDOW_WIDTH,
|
||||
height: this.ACTION_WINDOW_HEIGHT,
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
})
|
||||
@@ -890,7 +1057,7 @@ export class SelectionService {
|
||||
* Manages appropriate event listeners for each mode
|
||||
*/
|
||||
private processTriggerMode() {
|
||||
if (this.triggerMode === 'selected') {
|
||||
if (this.triggerMode === TriggerMode.Selected) {
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
@@ -898,9 +1065,8 @@ export class SelectionService {
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.enableClipboard()
|
||||
this.selectionHook!.setSelectionPassiveMode(false)
|
||||
} else if (this.triggerMode === 'ctrlkey') {
|
||||
} else if (this.triggerMode === TriggerMode.Ctrlkey) {
|
||||
if (!this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
@@ -908,7 +1074,6 @@ export class SelectionService {
|
||||
this.isCtrlkeyListenerActive = true
|
||||
}
|
||||
|
||||
this.selectionHook!.disableClipboard()
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
}
|
||||
}
|
||||
@@ -948,6 +1113,18 @@ export class SelectionService {
|
||||
configManager.setSelectionAssistantFollowToolbar(isFollowToolbar)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_SetRemeberWinSize, (_, isRemeberWinSize: boolean) => {
|
||||
configManager.setSelectionAssistantRemeberWinSize(isRemeberWinSize)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: string) => {
|
||||
configManager.setSelectionAssistantFilterMode(filterMode)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_SetFilterList, (_, filterList: string[]) => {
|
||||
configManager.setSelectionAssistantFilterList(filterList)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
|
||||
selectionService?.processAction(actionItem)
|
||||
})
|
||||
@@ -977,7 +1154,7 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
private logInfo(message: string) {
|
||||
isDev && console.log('[SelectionService] Info: ', message)
|
||||
isDev && Logger.info('[SelectionService] Info: ', message)
|
||||
}
|
||||
|
||||
private logError(...args: [...string[], Error]) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
@@ -5,16 +5,17 @@ import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray }
|
||||
import icon from '../../../build/tray_icon.png?asset'
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
private static instance: TrayService
|
||||
private tray: Tray | null = null
|
||||
private contextMenu: Menu | null = null
|
||||
|
||||
constructor() {
|
||||
this.watchConfigChanges()
|
||||
this.updateTray()
|
||||
this.watchTrayChanges()
|
||||
TrayService.instance = this
|
||||
}
|
||||
|
||||
@@ -43,6 +44,30 @@ export class TrayService {
|
||||
|
||||
this.tray = tray
|
||||
|
||||
this.updateContextMenu()
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(this.contextMenu)
|
||||
}
|
||||
|
||||
this.tray.setToolTip('Cherry Studio')
|
||||
|
||||
this.tray.on('right-click', () => {
|
||||
if (this.contextMenu) {
|
||||
this.tray?.popUpContextMenu(this.contextMenu)
|
||||
}
|
||||
})
|
||||
|
||||
this.tray.on('click', () => {
|
||||
if (configManager.getEnableQuickAssistant() && configManager.getClickTrayToShowQuickAssistant()) {
|
||||
windowService.showMiniWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private updateContextMenu() {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
@@ -64,25 +89,7 @@ export class TrayService {
|
||||
}
|
||||
].filter(Boolean) as MenuItemConstructorOptions[]
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate(template)
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(contextMenu)
|
||||
}
|
||||
|
||||
this.tray.setToolTip('Cherry Studio')
|
||||
|
||||
this.tray.on('right-click', () => {
|
||||
this.tray?.popUpContextMenu(contextMenu)
|
||||
})
|
||||
|
||||
this.tray.on('click', () => {
|
||||
if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
|
||||
windowService.showMiniWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
this.contextMenu = Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
private updateTray() {
|
||||
@@ -94,13 +101,6 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
public restartTray() {
|
||||
if (configManager.getTray()) {
|
||||
this.destroyTray()
|
||||
this.createTray()
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTray() {
|
||||
if (this.tray) {
|
||||
this.tray.destroy()
|
||||
@@ -108,8 +108,16 @@ export class TrayService {
|
||||
}
|
||||
}
|
||||
|
||||
private watchTrayChanges() {
|
||||
configManager.subscribe<boolean>('tray', () => this.updateTray())
|
||||
private watchConfigChanges() {
|
||||
configManager.subscribe(ConfigKeys.Tray, () => this.updateTray())
|
||||
|
||||
configManager.subscribe(ConfigKeys.Language, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
}
|
||||
|
||||
private quit() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// just import the themeService to ensure the theme is initialized
|
||||
import './ThemeService'
|
||||
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getFilesDir } from '@main/utils/file'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { app, BrowserWindow, nativeTheme, shell } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
@@ -45,13 +47,6 @@ export class WindowService {
|
||||
maximize: false
|
||||
})
|
||||
|
||||
const theme = configManager.getTheme()
|
||||
if (theme === ThemeMode.auto) {
|
||||
nativeTheme.themeSource = 'system'
|
||||
} else {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
this.mainWindow = new BrowserWindow({
|
||||
x: mainWindowState.x,
|
||||
y: mainWindowState.y,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MCPServer,
|
||||
Provider,
|
||||
Shortcut,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
@@ -30,8 +31,7 @@ const api = {
|
||||
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
|
||||
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
|
||||
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
|
||||
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
|
||||
setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
@@ -99,6 +99,7 @@ const api = {
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
download: (url: string, isUseContentType?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
@@ -152,7 +153,8 @@ const api = {
|
||||
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
|
||||
},
|
||||
config: {
|
||||
set: (key: string, value: any) => ipcRenderer.invoke(IpcChannel.Config_Set, key, value),
|
||||
set: (key: string, value: any, isNotify: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Config_Set, key, value, isNotify),
|
||||
get: (key: string) => ipcRenderer.invoke(IpcChannel.Config_Get, key)
|
||||
},
|
||||
miniWindow: {
|
||||
@@ -242,6 +244,10 @@ const api = {
|
||||
setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode),
|
||||
setFollowToolbar: (isFollowToolbar: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar),
|
||||
setRemeberWinSize: (isRemeberWinSize: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -206,8 +206,14 @@
|
||||
|
||||
.ant-collapse {
|
||||
border: 1px solid var(--color-border);
|
||||
.ant-color-picker & {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 1px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', serif, Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -147,11 +147,16 @@ ul {
|
||||
background-color: var(--color-white-soft);
|
||||
}
|
||||
}
|
||||
.group-grid-container.horizontal,
|
||||
.group-grid-container.grid {
|
||||
.message-content-container-assistant {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.group-message-wrapper {
|
||||
background-color: var(--color-background);
|
||||
.message-content-container {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
@@ -170,6 +175,7 @@ span.highlight {
|
||||
background-color: var(--color-background-highlight);
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
span.highlight.selected {
|
||||
background-color: var(--color-background-highlight-accent);
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ emoji-picker {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
mjx-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
:root {
|
||||
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
||||
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18);
|
||||
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = svgContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const shadowRoot = container.shadowRoot || container.attachShadow({ mode: 'open' })
|
||||
|
||||
// 添加基础样式
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
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;
|
||||
display: block;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// 清空并重新添加内容
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
const svgContainer = document.createElement('div')
|
||||
svgContainer.innerHTML = children
|
||||
shadowRoot.appendChild(svgContainer)
|
||||
}, [children])
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
|
||||
imgSelector: '.svg-preview svg',
|
||||
imgSelector: 'svg',
|
||||
prefix: 'svg-image'
|
||||
})
|
||||
|
||||
@@ -23,18 +58,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return (
|
||||
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
)
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -5,12 +5,12 @@ export const TOOL_SPECS: Record<string, CodeToolSpec> = {
|
||||
copy: {
|
||||
id: 'copy',
|
||||
type: 'core',
|
||||
order: 10
|
||||
order: 11
|
||||
},
|
||||
download: {
|
||||
id: 'download',
|
||||
type: 'core',
|
||||
order: 11
|
||||
order: 10
|
||||
},
|
||||
edit: {
|
||||
id: 'edit',
|
||||
|
||||
@@ -32,6 +32,14 @@ export const usePreviewToolHandlers = (
|
||||
// 创建选择器函数
|
||||
const getImgElement = useCallback(() => {
|
||||
if (!containerRef.current) return null
|
||||
|
||||
// 优先尝试从 Shadow DOM 中查找
|
||||
const shadowRoot = containerRef.current.shadowRoot
|
||||
if (shadowRoot) {
|
||||
return shadowRoot.querySelector(imgSelector) as SVGElement | null
|
||||
}
|
||||
|
||||
// 降级到常规 DOM 查找
|
||||
return containerRef.current.querySelector(imgSelector) as SVGElement | null
|
||||
}, [containerRef, imgSelector])
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
|
||||
const collapseStyle = merge({}, defaultCollapseStyle, style)
|
||||
const collapseItemStyles = useMemo(() => {
|
||||
return merge({}, defaultCollapseItemStyles, styles)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeKeys])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -8,12 +7,10 @@ interface EmojiIconProps {
|
||||
}
|
||||
|
||||
const EmojiIcon: FC<EmojiIconProps> = ({ emoji, className }) => {
|
||||
const _emoji = getLeadingEmoji(emoji || '⭐️') || '⭐️'
|
||||
|
||||
return (
|
||||
<Container className={className}>
|
||||
<EmojiBackground>{_emoji}</EmojiBackground>
|
||||
{_emoji}
|
||||
<EmojiBackground>{emoji || '⭐️'}</EmojiBackground>
|
||||
{emoji}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
|
||||
if (ref.current) {
|
||||
ref.current.addEventListener('emoji-click', (event: any) => {
|
||||
event.stopPropagation()
|
||||
onEmojiClick(event.detail.emoji.unicode)
|
||||
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
|
||||
})
|
||||
}
|
||||
}, [onEmojiClick])
|
||||
|
||||
@@ -38,8 +38,9 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
const onAfterClose = () => {
|
||||
resolve(null)
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
@@ -61,7 +62,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
afterClose={onAfterClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
@@ -95,16 +96,7 @@ export default class PromptPopup {
|
||||
}
|
||||
static show(props: PromptPopupShowParams) {
|
||||
return new Promise<string>((resolve) => {
|
||||
TopView.show(
|
||||
<PromptPopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
'PromptPopup'
|
||||
)
|
||||
TopView.show(<PromptPopupContainer {...props} resolve={resolve} />, 'PromptPopup')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ShowParams {
|
||||
text: string
|
||||
textareaProps?: TextAreaProps
|
||||
modalProps?: ModalProps
|
||||
showTranslate?: boolean
|
||||
children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode
|
||||
}
|
||||
|
||||
@@ -25,7 +26,14 @@ interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, resolve, children }) => {
|
||||
const PopupContainer: React.FC<Props> = ({
|
||||
text,
|
||||
textareaProps,
|
||||
modalProps,
|
||||
resolve,
|
||||
children,
|
||||
showTranslate = true
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [textValue, setTextValue] = useState(text)
|
||||
@@ -148,12 +156,14 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
onInput={resizeTextArea}
|
||||
onChange={(e) => setTextValue(e.target.value)}
|
||||
/>
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
{showTranslate && (
|
||||
<TranslateButton
|
||||
onClick={handleTranslate}
|
||||
aria-label="Translate text"
|
||||
disabled={isTranslating || !textValue.trim()}>
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</TranslateButton>
|
||||
)}
|
||||
</TextAreaContainer>
|
||||
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { theme } from 'antd'
|
||||
import Color from 'color'
|
||||
import { t } from 'i18next'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -40,8 +39,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
throw new Error('QuickPanel must be used within a QuickPanelProvider')
|
||||
}
|
||||
|
||||
const { token } = theme.useToken()
|
||||
const colorPrimary = Color(token.colorPrimary || '#008000')
|
||||
const { colorPrimary } = useUserTheme()
|
||||
const selectedColor = colorPrimary.alpha(0.15).toString()
|
||||
const selectedColorHover = colorPrimary.alpha(0.2).toString()
|
||||
|
||||
|
||||
@@ -4,47 +4,53 @@ import styled from 'styled-components'
|
||||
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
right?: boolean
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
|
||||
const clearScrollingTimeout = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
|
||||
}, [])
|
||||
|
||||
const throttledInternalScrollHandler = throttle(handleScroll, 200)
|
||||
const handleScroll = useCallback(() => {
|
||||
setIsScrolling(true)
|
||||
clearScrollingTimeout()
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsScrolling(false)
|
||||
timeoutRef.current = null
|
||||
}, 1500)
|
||||
}, [clearScrollingTimeout])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [
|
||||
handleScroll
|
||||
])
|
||||
|
||||
// Combined scroll handler
|
||||
const combinedOnScroll = useCallback(() => {
|
||||
// Event is available if needed by internal handler
|
||||
throttledInternalScrollHandler() // Call internal logic
|
||||
throttledInternalScrollHandler()
|
||||
if (externalOnScroll) {
|
||||
externalOnScroll() // Call external logic (from useScrollPosition)
|
||||
externalOnScroll()
|
||||
}
|
||||
}, [throttledInternalScrollHandler, externalOnScroll])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
clearScrollingTimeout()
|
||||
throttledInternalScrollHandler.cancel()
|
||||
}
|
||||
}, [throttledInternalScrollHandler])
|
||||
}, [throttledInternalScrollHandler, clearScrollingTimeout])
|
||||
|
||||
return (
|
||||
<Container
|
||||
{...htmlProps} // Pass other HTML attributes
|
||||
$isScrolling={isScrolling}
|
||||
$right={right}
|
||||
onScroll={combinedOnScroll} // Use the combined handler
|
||||
ref={passedRef}>
|
||||
{children}
|
||||
@@ -52,15 +58,13 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: exter
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $isScrolling: boolean; $right?: boolean }>`
|
||||
const Container = styled.div<{ $isScrolling: boolean }>`
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: ${(props) =>
|
||||
props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''})` : 'transparent'};
|
||||
background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')};
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''}-hover)` : 'transparent'};
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
|
||||
|
||||
// Mock Redux store
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
settings: (state = { userTheme: { colorPrimary: '#1677ff' } }) => state
|
||||
}
|
||||
})
|
||||
|
||||
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
|
||||
return Array.from({ length }, (_, i) => ({
|
||||
label: `${prefix} ${i + 1}`,
|
||||
@@ -37,6 +46,14 @@ function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function wrapWithProviders(children: React.ReactNode) {
|
||||
return (
|
||||
<Provider store={mockStore}>
|
||||
<QuickPanelProvider>{children}</QuickPanelProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('QuickPanelView', () => {
|
||||
beforeEach(() => {
|
||||
// 添加一个假的 .inputbar textarea 到 document.body
|
||||
@@ -54,11 +71,7 @@ describe('QuickPanelView', () => {
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render without crashing when wrapped in QuickPanelProvider', () => {
|
||||
render(
|
||||
<QuickPanelProvider>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
</QuickPanelProvider>
|
||||
)
|
||||
render(wrapWithProviders(<QuickPanelView setInputText={vi.fn()} />))
|
||||
|
||||
// 检查面板容器是否存在且初始不可见
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
@@ -69,10 +82,12 @@ describe('QuickPanelView', () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
<QuickPanelProvider>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</QuickPanelProvider>
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
// 检查面板可见
|
||||
@@ -111,10 +126,12 @@ describe('QuickPanelView', () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
<QuickPanelProvider>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</QuickPanelProvider>
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
// 检查第一个 item 是否有 focused
|
||||
@@ -128,10 +145,12 @@ describe('QuickPanelView', () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
<QuickPanelProvider>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</QuickPanelProvider>
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
@@ -148,10 +167,12 @@ describe('QuickPanelView', () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
<QuickPanelProvider>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</QuickPanelProvider>
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
@@ -169,10 +190,12 @@ describe('QuickPanelView', () => {
|
||||
const list = createList(100, 'Item')
|
||||
|
||||
render(
|
||||
<QuickPanelProvider>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</QuickPanelProvider>
|
||||
wrapWithProviders(
|
||||
<>
|
||||
<QuickPanelView setInputText={vi.fn()} />
|
||||
<OpenPanelOnMount list={list} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
|
||||
@@ -130,8 +130,8 @@ describe('Scrollbar', () => {
|
||||
|
||||
// 验证 throttle 被调用
|
||||
expect(throttle).toHaveBeenCalled()
|
||||
// 验证 throttle 调用时使用了 200ms 延迟
|
||||
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 200)
|
||||
// 验证 throttle 调用时使用了 100ms 延迟和正确的选项
|
||||
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 100, { leading: true, trailing: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,21 +160,6 @@ describe('Scrollbar', () => {
|
||||
})
|
||||
|
||||
describe('props handling', () => {
|
||||
it('should handle right prop correctly', () => {
|
||||
const { container } = render(
|
||||
<Scrollbar data-testid="scrollbar" right>
|
||||
内容
|
||||
</Scrollbar>
|
||||
)
|
||||
|
||||
const scrollbar = screen.getByTestId('scrollbar')
|
||||
|
||||
// 验证 right 属性被正确传递
|
||||
expect(scrollbar).toBeDefined()
|
||||
// snapshot 测试 styled-components 样式
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should handle ref forwarding', () => {
|
||||
const ref = { current: null }
|
||||
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Scrollbar > props handling > should handle right prop correctly 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb {
|
||||
transition: background 2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="scrollbar"
|
||||
>
|
||||
内容
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
.c0 {
|
||||
overflow-y: auto;
|
||||
@@ -33,7 +11,7 @@ exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
|
||||
}
|
||||
|
||||
.c0::-webkit-scrollbar-thumb:hover {
|
||||
background: transparent;
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
<div
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
@@ -44,7 +45,7 @@ const Sidebar: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { theme, settingTheme, toggleTheme } = useTheme()
|
||||
const { theme, settedTheme, toggleTheme } = useTheme()
|
||||
const avatar = useAvatar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -104,13 +105,13 @@ const Sidebar: FC = () => {
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
|
||||
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settedTheme}`)}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="right">
|
||||
<Icon theme={theme} onClick={() => toggleTheme()}>
|
||||
{settingTheme === 'dark' ? (
|
||||
{settedTheme === ThemeMode.dark ? (
|
||||
<Moon size={20} className="icon" />
|
||||
) : settingTheme === 'light' ? (
|
||||
) : settedTheme === ThemeMode.light ? (
|
||||
<Sun size={20} className="icon" />
|
||||
) : (
|
||||
<SunMoon size={20} className="icon" />
|
||||
@@ -137,7 +138,7 @@ const MainMenus: FC = () => {
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { sidebarIcons, defaultPaintingProvider } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
const { theme } = useTheme()
|
||||
@@ -158,7 +159,7 @@ const MainMenus: FC = () => {
|
||||
const pathMap = {
|
||||
assistants: '/',
|
||||
agents: '/agents',
|
||||
paintings: '/paintings',
|
||||
paintings: `/paintings/${defaultPaintingProvider}`,
|
||||
translate: '/translate',
|
||||
minapp: '/apps',
|
||||
knowledge: '/knowledge',
|
||||
|
||||
@@ -15,3 +15,18 @@ export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
// Messages loading configuration
|
||||
export const INITIAL_MESSAGES_COUNT = 20
|
||||
export const LOAD_MORE_COUNT = 20
|
||||
|
||||
export const DEFAULT_COLOR_PRIMARY = '#00b96b'
|
||||
export const THEME_COLOR_PRESETS = [
|
||||
DEFAULT_COLOR_PRIMARY,
|
||||
'#FF5470', // Coral Pink
|
||||
'#14B8A6', // Teal
|
||||
'#6366F1', // Indigo
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#3B82F6', // Blue
|
||||
'#F59E0B', // Amber
|
||||
'#6D28D9', // Violet
|
||||
'#0EA5E9', // Sky Blue
|
||||
'#0284C7' // Light Blue
|
||||
]
|
||||
|
||||
@@ -2315,7 +2315,8 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean {
|
||||
}
|
||||
|
||||
return (
|
||||
model.id.toLowerCase().includes('qwen3') ||
|
||||
model.id.toLowerCase().startsWith('qwen3') ||
|
||||
model.id.toLowerCase().startsWith('qwen/qwen3') ||
|
||||
[
|
||||
'qwen-plus-latest',
|
||||
'qwen-plus-0428',
|
||||
@@ -2617,7 +2618,7 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
|
||||
|
||||
// Claude models
|
||||
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 64000 }
|
||||
'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
|
||||
}
|
||||
|
||||
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
|
||||
|
||||
@@ -124,8 +124,8 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://o3.fan',
|
||||
apiKey: 'https://o3.fan/token',
|
||||
docs: 'https://docs.o3.fan',
|
||||
models: 'https://docs.o3.fan/models'
|
||||
docs: '',
|
||||
models: 'https://o3.fan/info/models/'
|
||||
}
|
||||
},
|
||||
burncloud: {
|
||||
@@ -144,11 +144,10 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppinfra.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
models:
|
||||
'https://ppinfra.com/model-api/product/llm-api?utm_source=github_cherry-studio&utm_medium=github_readme&utm_campaign=link'
|
||||
models: 'https://ppio.cn/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
|
||||
@@ -15,13 +15,18 @@ import { FC, PropsWithChildren } from 'react'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
|
||||
const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { language } = useSettings()
|
||||
const {
|
||||
language,
|
||||
userTheme: { colorPrimary }
|
||||
} = useSettings()
|
||||
const { theme: _theme } = useTheme()
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={getAntdLocale(language)}
|
||||
theme={{
|
||||
cssVar: true,
|
||||
hashed: false,
|
||||
algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm],
|
||||
components: {
|
||||
Menu: {
|
||||
@@ -43,7 +48,7 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: '#00b96b',
|
||||
colorPrimary: colorPrimary,
|
||||
fontFamily: 'var(--font-family)'
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode
|
||||
settingTheme: ThemeMode
|
||||
settedTheme: ThemeMode
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: ThemeMode.auto,
|
||||
settingTheme: ThemeMode.auto,
|
||||
theme: ThemeMode.system,
|
||||
settedTheme: ThemeMode.dark,
|
||||
toggleTheme: () => {}
|
||||
})
|
||||
|
||||
@@ -20,47 +21,48 @@ interface ThemeProviderProps extends PropsWithChildren {
|
||||
defaultTheme?: ThemeMode
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => {
|
||||
const { theme, setTheme } = useSettings()
|
||||
const [effectiveTheme, setEffectiveTheme] = useState(theme)
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 用户设置的主题
|
||||
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
|
||||
const [actualTheme, setActualTheme] = useState<ThemeMode>(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
|
||||
)
|
||||
const { initUserTheme } = useUserTheme()
|
||||
|
||||
const toggleTheme = () => {
|
||||
// 主题顺序是light, dark, auto, 所以需要先判断当前主题,然后取下一个主题
|
||||
switch (theme) {
|
||||
case ThemeMode.light:
|
||||
setTheme(ThemeMode.dark)
|
||||
break
|
||||
case ThemeMode.dark:
|
||||
setTheme(ThemeMode.auto)
|
||||
break
|
||||
case ThemeMode.auto:
|
||||
setTheme(ThemeMode.light)
|
||||
break
|
||||
}
|
||||
const nextTheme = {
|
||||
[ThemeMode.light]: ThemeMode.dark,
|
||||
[ThemeMode.dark]: ThemeMode.system,
|
||||
[ThemeMode.system]: ThemeMode.light
|
||||
}[settedTheme]
|
||||
setSettedTheme(nextTheme || ThemeMode.system)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.api?.setTheme(defaultTheme || theme)
|
||||
}, [defaultTheme, theme])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('theme-mode', effectiveTheme)
|
||||
}, [effectiveTheme])
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial theme and OS attributes on body
|
||||
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
|
||||
const themeChangeListenerRemover = window.electron.ipcRenderer.on(
|
||||
IpcChannel.ThemeChange,
|
||||
(_, realTheam: ThemeMode) => {
|
||||
setEffectiveTheme(realTheam)
|
||||
}
|
||||
)
|
||||
return () => {
|
||||
themeChangeListenerRemover()
|
||||
}
|
||||
})
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
|
||||
return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
|
||||
// if theme is old auto, then set theme to system
|
||||
// we can delete this after next big release
|
||||
if (settedTheme !== ThemeMode.dark && settedTheme !== ThemeMode.light && settedTheme !== ThemeMode.system) {
|
||||
setSettedTheme(ThemeMode.system)
|
||||
}
|
||||
|
||||
initUserTheme()
|
||||
|
||||
// listen for theme updates from main process
|
||||
return window.electron.ipcRenderer.on(IpcChannel.ThemeUpdated, (_, actualTheme: ThemeMode) => {
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
setActualTheme(actualTheme)
|
||||
})
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.setTheme(settedTheme)
|
||||
}, [settedTheme])
|
||||
|
||||
return <ThemeContext value={{ theme: actualTheme, settedTheme, toggleTheme }}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export const useTheme = () => use(ThemeContext)
|
||||
|
||||
@@ -332,11 +332,11 @@ export function useMessageOperations(topic: Topic) {
|
||||
}
|
||||
|
||||
// 6. Log operations for debugging
|
||||
console.log('[editMessageBlocks] Operations:', {
|
||||
blocksToRemove: blockIdsToRemove.length,
|
||||
blocksToUpdate: blocksToUpdate.length,
|
||||
blocksToAdd: blocksToAdd.length
|
||||
})
|
||||
// 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
|
||||
|
||||
@@ -2,14 +2,17 @@ import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setActionItems,
|
||||
setActionWindowOpacity,
|
||||
setFilterList,
|
||||
setFilterMode,
|
||||
setIsAutoClose,
|
||||
setIsAutoPin,
|
||||
setIsCompact,
|
||||
setIsFollowToolbar,
|
||||
setIsRemeberWinSize,
|
||||
setSelectionEnabled,
|
||||
setTriggerMode
|
||||
} from '@renderer/store/selectionStore'
|
||||
import { ActionItem, TriggerMode } from '@renderer/types/selectionTypes'
|
||||
import { ActionItem, FilterMode, TriggerMode } from '@renderer/types/selectionTypes'
|
||||
|
||||
export function useSelectionAssistant() {
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -38,6 +41,18 @@ export function useSelectionAssistant() {
|
||||
dispatch(setIsFollowToolbar(isFollowToolbar))
|
||||
window.api.selection.setFollowToolbar(isFollowToolbar)
|
||||
},
|
||||
setIsRemeberWinSize: (isRemeberWinSize: boolean) => {
|
||||
dispatch(setIsRemeberWinSize(isRemeberWinSize))
|
||||
window.api.selection.setRemeberWinSize(isRemeberWinSize)
|
||||
},
|
||||
setFilterMode: (mode: FilterMode) => {
|
||||
dispatch(setFilterMode(mode))
|
||||
window.api.selection.setFilterMode(mode)
|
||||
},
|
||||
setFilterList: (list: string[]) => {
|
||||
dispatch(setFilterList(list))
|
||||
window.api.selection.setFilterList(list)
|
||||
},
|
||||
setActionWindowOpacity: (opacity: number) => {
|
||||
dispatch(setActionWindowOpacity(opacity))
|
||||
},
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setShowAssistants, setShowTopics, toggleShowAssistants, toggleShowTopics } from '@renderer/store/settings'
|
||||
import {
|
||||
setAssistantsTabSortType,
|
||||
setShowAssistants,
|
||||
setShowTopics,
|
||||
toggleShowAssistants,
|
||||
toggleShowTopics
|
||||
} from '@renderer/store/settings'
|
||||
import { AssistantsSortType } from '@renderer/types'
|
||||
|
||||
export function useShowAssistants() {
|
||||
const showAssistants = useAppSelector((state) => state.settings.showAssistants)
|
||||
@@ -22,3 +29,13 @@ export function useShowTopics() {
|
||||
toggleShowTopics: () => dispatch(toggleShowTopics())
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssistantsTabSortType() {
|
||||
const assistantsTabSortType = useAppSelector((state) => state.settings.assistantsTabSortType)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
assistantsTabSortType,
|
||||
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { flatMap, groupBy, uniq } from 'lodash'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useAssistants } from './useAssistant'
|
||||
|
||||
// 定义useTags的返回类型,包含所有标签和获取特定标签的助手函数
|
||||
// 为了不增加新的概念,标签直接作为助手的属性,所以这里的标签是指助手的标签属性
|
||||
// 但是为了方便管理,增加了一个获取特定标签的助手函数
|
||||
|
||||
export const useTags = () => {
|
||||
const { assistants } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 计算所有标签
|
||||
const allTags = useMemo(() => {
|
||||
return uniq(flatMap(assistants, (assistant) => assistant.tags || []))
|
||||
}, [assistants])
|
||||
|
||||
const getAssistantsByTag = useCallback(
|
||||
(tag: string) => assistants.filter((assistant) => assistant.tags?.includes(tag)),
|
||||
[assistants]
|
||||
)
|
||||
|
||||
const getGroupedAssistants = useMemo(() => {
|
||||
// 按标签分组,处理多标签的情况
|
||||
const assistantsByTags = flatMap(assistants, (assistant) => {
|
||||
const tags = assistant.tags?.length ? assistant.tags : [t('assistants.tags.untagged')]
|
||||
return tags.map((tag) => ({ tag, assistant }))
|
||||
})
|
||||
|
||||
// 按标签分组并构建结果
|
||||
const grouped = Object.entries(groupBy(assistantsByTags, 'tag')).map(([tag, group]) => ({
|
||||
tag,
|
||||
assistants: group.map((g) => g.assistant)
|
||||
}))
|
||||
|
||||
// 将未标记的组移到最前面
|
||||
const untaggedIndex = grouped.findIndex((g) => g.tag === t('assistants.tags.untagged'))
|
||||
if (untaggedIndex > -1) {
|
||||
const [untagged] = grouped.splice(untaggedIndex, 1)
|
||||
grouped.unshift(untagged)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}, [assistants, t])
|
||||
|
||||
return {
|
||||
allTags,
|
||||
getAssistantsByTag,
|
||||
getGroupedAssistants
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
|
||||
const data = { ...topic, name: summaryText }
|
||||
_setActiveTopic(data)
|
||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
} else {
|
||||
window.message?.error(i18n.t('message.error.fetchTopicName'))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setUserTheme, UserTheme } from '@renderer/store/settings'
|
||||
import Color from 'color'
|
||||
|
||||
export default function useUserTheme() {
|
||||
const userTheme = useAppSelector((state) => state.settings.userTheme)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const initUserTheme = (theme: UserTheme = userTheme) => {
|
||||
const colorPrimary = Color(theme.colorPrimary)
|
||||
|
||||
document.body.style.setProperty('--color-primary', colorPrimary.toString())
|
||||
document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString())
|
||||
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())
|
||||
}
|
||||
|
||||
return {
|
||||
colorPrimary: Color(userTheme.colorPrimary),
|
||||
|
||||
initUserTheme,
|
||||
|
||||
setUserTheme(userTheme: UserTheme) {
|
||||
dispatch(setUserTheme(userTheme))
|
||||
|
||||
initUserTheme(userTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "Enter name",
|
||||
"add.prompt": "Prompt",
|
||||
"add.prompt.placeholder": "Enter prompt",
|
||||
"add.prompt.variables.tip": "Available variables: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "Create Agent",
|
||||
"import": {
|
||||
"title": "Import from External",
|
||||
@@ -101,6 +102,22 @@
|
||||
"titlePlaceholder": "Enter title",
|
||||
"contentLabel": "Content",
|
||||
"contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}."
|
||||
},
|
||||
"list": {
|
||||
"showByList": "List View",
|
||||
"showByTags": "Tag View"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "Untagged",
|
||||
"none": "No tags",
|
||||
"manage": "Tag Management",
|
||||
"modify": "Modify Tag",
|
||||
"add": "Add Tag",
|
||||
"delete": "Delete Tag",
|
||||
"deleteConfirm": "Are you sure to delete this tag?",
|
||||
"settings": {
|
||||
"title": "Tag Settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -241,7 +258,7 @@
|
||||
"settings.show_line_numbers": "Show line numbers in code",
|
||||
"settings.temperature": "Temperature",
|
||||
"settings.temperature.tip": "Higher values make the model more creative and unpredictable, while lower values make it more deterministic and precise.",
|
||||
"settings.thought_auto_collapse": "Automatically Collapse Thought Content",
|
||||
"settings.thought_auto_collapse": "Collapse Thought Content",
|
||||
"settings.thought_auto_collapse.tip": "Automatically collapse thought content after thinking ends",
|
||||
"settings.top_p": "Top-P",
|
||||
"settings.top_p.tip": "Default value is 1, the smaller the value, the less variety in the answers, the easier to understand, the larger the value, the larger the range of the AI's vocabulary, the more diverse",
|
||||
@@ -614,6 +631,7 @@
|
||||
"error.enter.api.key": "Please enter your API key first",
|
||||
"error.enter.model": "Please select a model first",
|
||||
"error.enter.name": "Please enter the name of the knowledge base",
|
||||
"error.fetchTopicName": "Failed to name the topic",
|
||||
"error.get_embedding_dimensions": "Failed to get embedding dimensions",
|
||||
"error.invalid.api.host": "Invalid API Host",
|
||||
"error.invalid.api.key": "Invalid API Key",
|
||||
@@ -640,7 +658,7 @@
|
||||
"message.code_style": "Code style",
|
||||
"message.delete.content": "Are you sure you want to delete this message?",
|
||||
"message.delete.title": "Delete Message",
|
||||
"message.multi_model_style": "Multi-model response style",
|
||||
"message.multi_model_style": "Group style",
|
||||
"message.multi_model_style.fold": "Fold view",
|
||||
"message.multi_model_style.fold.compress": "Switch to compact layout",
|
||||
"message.multi_model_style.fold.expand": "Switch to expanded layout",
|
||||
@@ -824,14 +842,15 @@
|
||||
"seed_desc_tip": "The same seed and prompt can generate similar images, setting -1 will generate different results each time",
|
||||
"title": "Images",
|
||||
"magic_prompt_option": "Magic Prompt",
|
||||
"model": "Model Version",
|
||||
"model": "Model",
|
||||
"aspect_ratio": "Aspect Ratio",
|
||||
"style_type": "Style",
|
||||
"rendering_speed": "Rendering Speed",
|
||||
"learn_more": "Learn More",
|
||||
"paint_course": "tutorial",
|
||||
"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",
|
||||
"prompt_placeholder_en": "Enter your image description, currently Imagen only supports English prompts",
|
||||
"proxy_required": "Open the proxy and enable “TUN mode” to view generated images or copy them to the browser for opening. In the future, domestic direct connection will be supported",
|
||||
"image_file_required": "Please upload an image first",
|
||||
"image_file_retry": "Please re-upload an image first",
|
||||
"image_placeholder": "No image available",
|
||||
@@ -850,6 +869,34 @@
|
||||
"turbo": "Turbo",
|
||||
"quality": "Quality"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "Auto",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "Auto",
|
||||
"low": "Low"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "Auto",
|
||||
"transparent": "Transparent",
|
||||
"opaque": "Opaque"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "Square",
|
||||
"portrait": "Portrait",
|
||||
"landscape": "Landscape"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "Allow all",
|
||||
"allow_adult": "Allow adult",
|
||||
"allow_none": "Not allowed"
|
||||
},
|
||||
"quality": "Quality",
|
||||
"moderation": "Moderation",
|
||||
"background": "Background",
|
||||
"mode": {
|
||||
"generate": "Draw",
|
||||
"edit": "Edit",
|
||||
@@ -863,7 +910,9 @@
|
||||
"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",
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3"
|
||||
"rendering_speed_tip": "Controls rendering speed vs. quality trade-off, only available for V_3",
|
||||
"person_generation": "Generate person",
|
||||
"person_generation_tip": "Allow model to generate person images"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "Edited Image",
|
||||
@@ -896,7 +945,10 @@
|
||||
"seed_tip": "Controls upscaling randomness",
|
||||
"magic_prompt_option_tip": "Intelligently enhances upscaling prompts"
|
||||
},
|
||||
"text_desc_required": "Please enter image description first"
|
||||
"text_desc_required": "Please enter image description first",
|
||||
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
|
||||
"auto_create_paint": "Auto-create image",
|
||||
"auto_create_paint_tip": "After the image is generated, a new image will be created automatically."
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Explain this concept to me",
|
||||
@@ -950,7 +1002,7 @@
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu",
|
||||
"qiniu": "Qiniu AI",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1217,6 +1269,7 @@
|
||||
"display.sidebar.translate.icon": "Show Translate icon",
|
||||
"display.sidebar.visible": "Show icons",
|
||||
"display.title": "Display Settings",
|
||||
"display.zoom.title": "Zoom Settings",
|
||||
"display.topic.title": "Topic Settings",
|
||||
"miniapps": {
|
||||
"title": "Mini Apps Settings",
|
||||
@@ -1452,14 +1505,14 @@
|
||||
"messages.input.send_shortcuts": "Send shortcuts",
|
||||
"messages.input.show_estimated_tokens": "Show estimated tokens",
|
||||
"messages.input.title": "Input Settings",
|
||||
"messages.input.enable_quick_triggers": "Enable '/' and '@' triggers",
|
||||
"messages.input.enable_quick_triggers": "Enable / and @ triggers",
|
||||
"messages.input.enable_delete_model": "Enable the backspace key to delete models/attachments.",
|
||||
"messages.markdown_rendering_input_message": "Markdown render input message",
|
||||
"messages.math_engine": "Math engine",
|
||||
"messages.math_engine.none": "None",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
|
||||
"messages.model.title": "Model Settings",
|
||||
"messages.navigation": "Message Navigation",
|
||||
"messages.navigation": "Navigation bar",
|
||||
"messages.navigation.anchor": "Message Anchor",
|
||||
"messages.navigation.buttons": "Navigation Buttons",
|
||||
"messages.navigation.none": "None",
|
||||
@@ -1495,6 +1548,7 @@
|
||||
"models.check.start": "Start",
|
||||
"models.check.title": "Model health check",
|
||||
"models.check.use_all_keys": "Key(s)",
|
||||
"models.check.disclaimer": "Health check requires sending requests, please use it with caution. Models that charge per request may incur additional costs, please bear the responsibility.",
|
||||
"models.default_assistant_model": "Default Assistant Model",
|
||||
"models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used",
|
||||
"models.empty": "No models found",
|
||||
@@ -1642,10 +1696,11 @@
|
||||
"zoom_out": "Zoom Out",
|
||||
"zoom_reset": "Reset Zoom"
|
||||
},
|
||||
"theme.auto": "Auto",
|
||||
"theme.system": "System",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"theme.title": "Theme",
|
||||
"theme.color_primary": "Primary Color",
|
||||
"theme.window.style.opaque": "Opaque Window",
|
||||
"theme.window.style.title": "Window Style",
|
||||
"theme.window.style.transparent": "Transparent Window",
|
||||
@@ -1763,6 +1818,7 @@
|
||||
"close": "Close",
|
||||
"closed": "Translation closed",
|
||||
"copied": "Translation content copied",
|
||||
"detected.language": "Detected Language",
|
||||
"empty": "Translation content is empty",
|
||||
"not.found": "Translation content not found",
|
||||
"confirm": {
|
||||
@@ -1781,8 +1837,16 @@
|
||||
"input.placeholder": "Enter text to translate",
|
||||
"output.placeholder": "Translation",
|
||||
"processing": "Translation in progress...",
|
||||
"scroll_sync.disable": "Disable synced scroll",
|
||||
"scroll_sync.enable": "Enable synced scroll",
|
||||
"language.same": "Source and target languages are the same",
|
||||
"language.not_pair": "Source language is different from the set language",
|
||||
"settings": {
|
||||
"title": "Translation Settings",
|
||||
"model": "Model Settings",
|
||||
"model_desc": "Model used for translation service",
|
||||
"bidirectional": "Bidirectional Translation Settings",
|
||||
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
|
||||
"scroll_sync": "Scroll Sync Settings"
|
||||
},
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline",
|
||||
"menu": {
|
||||
@@ -1800,6 +1864,13 @@
|
||||
"show_window": "Show Window",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"update": {
|
||||
"title": "Update",
|
||||
"message": "New version {{version}} is ready, do you want to install it now?",
|
||||
"later": "Later",
|
||||
"install": "Install",
|
||||
"noReleaseNotes": "No release notes"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Selection Assistant",
|
||||
"action": {
|
||||
@@ -1818,9 +1889,10 @@
|
||||
"original_show": "Show Original",
|
||||
"original_hide": "Hide Original",
|
||||
"original_copy": "Copy Original",
|
||||
"esc_close": "Esc to Close",
|
||||
"esc_stop": "Esc to Stop",
|
||||
"c_copy": "C to Copy"
|
||||
"esc_close": "Esc: Close",
|
||||
"esc_stop": "Esc: Stop",
|
||||
"c_copy": "C: Copy",
|
||||
"r_regenerate": "R: Regenerate"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1849,6 +1921,10 @@
|
||||
"title": "Follow Toolbar",
|
||||
"description": "Window position will follow the toolbar. When disabled, it will always be centered."
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "Remember Size",
|
||||
"description": "Window will display at the last adjusted size during the application running"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "Auto Close",
|
||||
"description": "Automatically close the window when it's not pinned and loses focus"
|
||||
@@ -1876,6 +1952,20 @@
|
||||
"delete_confirm": "Are you sure you want to delete this custom action?",
|
||||
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"filter_mode": {
|
||||
"title": "Application Filter",
|
||||
"description": "Can limit the selection assistant to only work in specific applications (whitelist) or not work (blacklist)",
|
||||
"default": "Off",
|
||||
"whitelist": "Whitelist",
|
||||
"blacklist": "Blacklist"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "Filter List",
|
||||
"description": "Advanced feature, recommended for users with experience"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "Add Custom Action",
|
||||
@@ -1932,6 +2022,10 @@
|
||||
},
|
||||
"test": "Test"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "Application Filter List",
|
||||
"user_tips": "Please enter the executable file name of the application, one per line, case insensitive, can be fuzzy matched. For example: chrome.exe, weixin.exe, Cherry Studio.exe, etc."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "名前を入力",
|
||||
"add.prompt": "プロンプト",
|
||||
"add.prompt.placeholder": "プロンプトを入力",
|
||||
"add.prompt.variables.tip": "利用可能な変数:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "エージェントを作成",
|
||||
"import": {
|
||||
"title": "外部からインポート",
|
||||
@@ -99,6 +100,22 @@
|
||||
"settings.knowledge_base.recognition": "ナレッジベースの呼び出し",
|
||||
"settings.knowledge_base.recognition.off": "強制検索",
|
||||
"settings.knowledge_base.recognition.on": "意図認識",
|
||||
"list": {
|
||||
"showByList": "リスト表示",
|
||||
"showByTags": "タグ表示"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "未分類",
|
||||
"none": "タグなし",
|
||||
"manage": "タグ管理",
|
||||
"add": "タグ追加",
|
||||
"modify": "タグ修正",
|
||||
"delete": "タグ削除",
|
||||
"deleteConfirm": "このタグを削除してもよろしいですか?",
|
||||
"settings": {
|
||||
"title": "タグ設定"
|
||||
}
|
||||
},
|
||||
"settings.tool_use_mode": "工具調用方式",
|
||||
"settings.tool_use_mode.function": "関数",
|
||||
"settings.tool_use_mode.prompt": "提示詞"
|
||||
@@ -614,6 +631,7 @@
|
||||
"error.enter.api.key": "APIキーを入力してください",
|
||||
"error.enter.model": "モデルを選択してください",
|
||||
"error.enter.name": "ナレッジベース名を入力してください",
|
||||
"error.fetchTopicName": "トピックの命名に失敗しました",
|
||||
"error.get_embedding_dimensions": "埋込み次元を取得できませんでした",
|
||||
"error.invalid.api.host": "無効なAPIアドレスです",
|
||||
"error.invalid.api.key": "無効なAPIキーです",
|
||||
@@ -824,13 +842,14 @@
|
||||
"seed_desc_tip": "同じシードとプロンプトで類似した画像を生成できますが、-1 に設定すると毎回異なる結果が生成されます",
|
||||
"title": "画像",
|
||||
"magic_prompt_option": "プロンプト強化",
|
||||
"model": "モデルバージョン",
|
||||
"model": "モデル",
|
||||
"aspect_ratio": "画幅比例",
|
||||
"style_type": "スタイル",
|
||||
"learn_more": "詳しくはこちら",
|
||||
"prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します",
|
||||
"prompt_placeholder_en": "「英語」の説明を入力します。Imagenは現在、英語のプロンプト語のみをサポートしています",
|
||||
"paint_course": "チュートリアル",
|
||||
"proxy_required": "現在、プロキシを開く必要があります。これは、将来サポートされる予定です",
|
||||
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
|
||||
"image_file_required": "画像を先にアップロードしてください",
|
||||
"image_file_retry": "画像を先にアップロードしてください",
|
||||
"image_placeholder": "画像がありません",
|
||||
@@ -848,6 +867,34 @@
|
||||
"turbo": "高速",
|
||||
"quality": "高品質"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "自動",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "自動",
|
||||
"low": "低"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "自動",
|
||||
"transparent": "透明",
|
||||
"opaque": "不透明"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "正方形",
|
||||
"portrait": "縦図",
|
||||
"landscape": "横図"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "許可する",
|
||||
"allow_adult": "許可する",
|
||||
"allow_none": "許可しない"
|
||||
},
|
||||
"quality": "品質",
|
||||
"moderation": "敏感度",
|
||||
"background": "背景",
|
||||
"mode": {
|
||||
"generate": "画像生成",
|
||||
"edit": "部分編集",
|
||||
@@ -861,7 +908,9 @@
|
||||
"negative_prompt_tip": "画像に含めたくない内容を説明します",
|
||||
"magic_prompt_option_tip": "生成効果を向上させるための提示詞を最適化します",
|
||||
"style_type_tip": "画像生成スタイル、V_2 以上のバージョンでのみ適用",
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です"
|
||||
"rendering_speed_tip": "レンダリング速度と品質のバランスを調整します。V_3バージョンでのみ利用可能です",
|
||||
"person_generation": "人物生成",
|
||||
"person_generation_tip": "人物画像を生成する"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "編集画像",
|
||||
@@ -896,7 +945,10 @@
|
||||
},
|
||||
"rendering_speed": "レンダリング速度",
|
||||
"translating": "翻訳中...",
|
||||
"text_desc_required": "画像の説明を先に入力してください"
|
||||
"text_desc_required": "画像の説明を先に入力してください",
|
||||
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
|
||||
"auto_create_paint": "画像を自動作成",
|
||||
"auto_create_paint_tip": "画像が生成された後、自動的に新しい画像が作成されます。"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "この概念を説明してください",
|
||||
@@ -950,7 +1002,7 @@
|
||||
"zhinao": "360智脳",
|
||||
"zhipu": "智譜AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "七牛云",
|
||||
"qiniu": "七牛云 AI 推理",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1215,6 +1267,7 @@
|
||||
"display.sidebar.translate.icon": "翻訳のアイコンを表示",
|
||||
"display.sidebar.visible": "アイコンを表示",
|
||||
"display.title": "表示設定",
|
||||
"display.zoom.title": "ズーム設定",
|
||||
"display.topic.title": "トピック設定",
|
||||
"miniapps": {
|
||||
"title": "ミニアプリ設定",
|
||||
@@ -1448,7 +1501,7 @@
|
||||
"messages.input.send_shortcuts": "送信ショートカット",
|
||||
"messages.input.show_estimated_tokens": "推定トークン数を表示",
|
||||
"messages.input.title": "入力設定",
|
||||
"messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。",
|
||||
"messages.input.enable_quick_triggers": "/ と @ を有効にしてクイックメニューを表示します。",
|
||||
"messages.input.enable_delete_model": "バックスペースキーでモデル/添付ファイルを削除します。",
|
||||
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
|
||||
"messages.math_engine": "数式エンジン",
|
||||
@@ -1491,6 +1544,7 @@
|
||||
"models.check.start": "開始",
|
||||
"models.check.title": "モデル健康チェック",
|
||||
"models.check.use_all_keys": "キー",
|
||||
"models.check.disclaimer": "健康チェックはリクエストを送信するため、費用が発生する可能性があります。慎重に使用してください。",
|
||||
"models.default_assistant_model": "デフォルトアシスタントモデル",
|
||||
"models.default_assistant_model_description": "新しいアシスタントを作成する際に使用されるモデル。アシスタントがモデルを設定していない場合、このモデルが使用されます",
|
||||
"models.empty": "モデルが見つかりません",
|
||||
@@ -1632,10 +1686,11 @@
|
||||
"zoom_out": "ズームアウト",
|
||||
"zoom_reset": "ズームをリセット"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.system": "システム",
|
||||
"theme.dark": "ダーク",
|
||||
"theme.light": "ライト",
|
||||
"theme.title": "テーマ",
|
||||
"theme.color_primary": "テーマ色",
|
||||
"theme.window.style.opaque": "不透明ウィンドウ",
|
||||
"theme.window.style.title": "ウィンドウスタイル",
|
||||
"theme.window.style.transparent": "透明ウィンドウ",
|
||||
@@ -1781,13 +1836,22 @@
|
||||
"input.placeholder": "翻訳するテキストを入力",
|
||||
"output.placeholder": "翻訳",
|
||||
"processing": "翻訳中...",
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"language.same": "ソース言語と目標言語が同じです",
|
||||
"language.not_pair": "ソース言語が設定された言語と異なります",
|
||||
"settings": {
|
||||
"title": "翻訳設定",
|
||||
"model": "モデル設定",
|
||||
"model_desc": "翻訳サービスで使用されるモデル",
|
||||
"bidirectional": "双方向翻訳設定",
|
||||
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||
"scroll_sync": "スクロール同期設定"
|
||||
},
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"detected.language": "検出された言語"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "終了",
|
||||
@@ -1800,6 +1864,13 @@
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新",
|
||||
"message": "新バージョン {{version}} が利用可能です。今すぐインストールしますか?",
|
||||
"later": "後で",
|
||||
"install": "今すぐインストール",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "テキスト選択ツール",
|
||||
"action": {
|
||||
@@ -1820,7 +1891,8 @@
|
||||
"original_copy": "原文をコピー",
|
||||
"esc_close": "Escで閉じる",
|
||||
"esc_stop": "Escで停止",
|
||||
"c_copy": "Cでコピー"
|
||||
"c_copy": "Cでコピー",
|
||||
"r_regenerate": "Rで再生成"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1849,6 +1921,10 @@
|
||||
"title": "ツールバーに追従",
|
||||
"description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)"
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "サイズを記憶",
|
||||
"description": "アプリケーション実行中、ウィンドウは最後に調整されたサイズで表示されます"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自動閉じる",
|
||||
"description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる"
|
||||
@@ -1876,6 +1952,20 @@
|
||||
"delete_confirm": "このカスタム機能を削除しますか?",
|
||||
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "進階",
|
||||
"filter_mode": {
|
||||
"title": "アプリケーションフィルター",
|
||||
"description": "特定のアプリケーションでのみ選択ツールを有効にするか、無効にするかを選択できます。",
|
||||
"default": "オフ",
|
||||
"whitelist": "ホワイトリスト",
|
||||
"blacklist": "ブラックリスト"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "フィルターリスト",
|
||||
"description": "進階機能です。経験豊富なユーザー向けです。"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "カスタム機能追加",
|
||||
@@ -1932,8 +2022,12 @@
|
||||
},
|
||||
"test": "テスト"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "アプリケーションフィルターリスト",
|
||||
"user_tips": "アプリケーションの実行ファイル名を1行ずつ入力してください。大文字小文字は区別しません。例: chrome.exe, weixin.exe, Cherry Studio.exe, など。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "Введите имя",
|
||||
"add.prompt": "Промпт",
|
||||
"add.prompt.placeholder": "Введите промпт",
|
||||
"add.prompt.variables.tip": "Доступные переменные: {{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "Создать агента",
|
||||
"delete.popup.content": "Вы уверены, что хотите удалить этого агента?",
|
||||
"edit.message.add.title": "Добавить",
|
||||
@@ -101,6 +102,22 @@
|
||||
"titlePlaceholder": "Введите заголовок",
|
||||
"contentLabel": "Содержание",
|
||||
"contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}."
|
||||
},
|
||||
"list": {
|
||||
"showByList": "Список",
|
||||
"showByTags": "По тегам"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "Несгруппированные метки",
|
||||
"none": "Нет тегов",
|
||||
"manage": "Управление тегами",
|
||||
"add": "Добавить тег",
|
||||
"modify": "Изменить тег",
|
||||
"delete": "Удалить тег",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить этот тег?",
|
||||
"settings": {
|
||||
"title": "Настройки тегов"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -614,6 +631,7 @@
|
||||
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
|
||||
"error.enter.model": "Пожалуйста, выберите модель",
|
||||
"error.enter.name": "Пожалуйста, введите название базы знаний",
|
||||
"error.fetchTopicName": "Не удалось назвать тему",
|
||||
"error.get_embedding_dimensions": "Не удалось получить размерность встраивания",
|
||||
"error.invalid.api.host": "Неверный API адрес",
|
||||
"error.invalid.api.key": "Неверный API ключ",
|
||||
@@ -824,14 +842,15 @@
|
||||
"seed_desc_tip": "Одинаковые сиды и промпты могут генерировать похожие изображения, установка -1 будет создавать разные результаты каждый раз",
|
||||
"title": "Изображения",
|
||||
"magic_prompt_option": "Улучшение промпта",
|
||||
"model": "Версия",
|
||||
"model": "Модель",
|
||||
"aspect_ratio": "Пропорции изображения",
|
||||
"style_type": "Стиль",
|
||||
"rendering_speed": "Скорость рендеринга",
|
||||
"learn_more": "Узнать больше",
|
||||
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"prompt_placeholder_en": "Введите” английский “описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
|
||||
"paint_course": "Руководство / Учебник",
|
||||
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
|
||||
"proxy_required": "Открыть прокси и включить “TUN режим” для просмотра сгенерированных изображений или скопировать их в браузер для открытия. В будущем будет поддерживаться прямое соединение",
|
||||
"image_file_required": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_file_retry": "Пожалуйста, сначала загрузите изображение",
|
||||
"image_placeholder": "Изображение недоступно",
|
||||
@@ -845,11 +864,39 @@
|
||||
"3d": "3D",
|
||||
"anime": "Аниме"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "Авто",
|
||||
"low": "Низкое",
|
||||
"medium": "Среднее",
|
||||
"high": "Высокое"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "Авто",
|
||||
"low": "Низкое"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "Авто",
|
||||
"transparent": "Прозрачный",
|
||||
"opaque": "Непрозрачный"
|
||||
},
|
||||
"rendering_speeds": {
|
||||
"default": "По умолчанию",
|
||||
"turbo": "Быстро",
|
||||
"quality": "Качественно"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "Квадрат",
|
||||
"portrait": "Портрет",
|
||||
"landscape": "Пейзаж"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "Разрешено все",
|
||||
"allow_adult": "Разрешено взрослые",
|
||||
"allow_none": "Не разрешено"
|
||||
},
|
||||
"quality": "Качество",
|
||||
"moderation": "Сенсорность",
|
||||
"background": "Фон",
|
||||
"mode": {
|
||||
"generate": "Рисование",
|
||||
"edit": "Редактирование",
|
||||
@@ -863,7 +910,9 @@
|
||||
"negative_prompt_tip": "Описывает, что вы не хотите видеть в изображении",
|
||||
"magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта генерации",
|
||||
"style_type_tip": "Стиль генерации изображений, доступен только для версий V_2 и выше",
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3"
|
||||
"rendering_speed_tip": "Управляет балансом между скоростью рендеринга и качеством, доступно только для V_3",
|
||||
"person_generation": "Генерация персонажа",
|
||||
"person_generation_tip": "Разрешить модель генерировать изображения людей"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "Изображение для редактирования",
|
||||
@@ -896,7 +945,10 @@
|
||||
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов",
|
||||
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
|
||||
},
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения"
|
||||
"text_desc_required": "Пожалуйста, сначала введите описание изображения",
|
||||
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
|
||||
"auto_create_paint": "Автоматическое создание изображения",
|
||||
"auto_create_paint_tip": "После генерации изображения будет автоматически создано новое."
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
@@ -950,7 +1002,7 @@
|
||||
"zhinao": "360AI",
|
||||
"zhipu": "ZHIPU AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu",
|
||||
"qiniu": "Qiniu AI",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1215,6 +1267,7 @@
|
||||
"display.sidebar.translate.icon": "Показывать иконку перевода",
|
||||
"display.sidebar.visible": "Показывать иконки",
|
||||
"display.title": "Настройки отображения",
|
||||
"display.zoom.title": "Настройки масштаба",
|
||||
"display.topic.title": "Настройки топиков",
|
||||
"miniapps": {
|
||||
"title": "Настройки мини-приложений",
|
||||
@@ -1448,7 +1501,7 @@
|
||||
"messages.input.send_shortcuts": "Горячие клавиши для отправки",
|
||||
"messages.input.show_estimated_tokens": "Показывать затраты токенов",
|
||||
"messages.input.title": "Настройки ввода",
|
||||
"messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.",
|
||||
"messages.input.enable_quick_triggers": "Включите / и @, чтобы вызвать быстрое меню.",
|
||||
"messages.input.enable_delete_model": "Включите удаление модели/вложения с помощью клавиши Backspace",
|
||||
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
|
||||
"messages.math_engine": "Математический движок",
|
||||
@@ -1491,6 +1544,7 @@
|
||||
"models.check.start": "Начать",
|
||||
"models.check.title": "Проверка состояния моделей",
|
||||
"models.check.use_all_keys": "Использовать все ключи",
|
||||
"models.check.disclaimer": "Проверка состояния моделей требует отправки запросов, пожалуйста, используйте эту функцию с осторожностью. Модели, которые взимают плату за запросы, могут привести к дополнительным расходам, пожалуйста, самостоятельно несем ответственность за них.",
|
||||
"models.default_assistant_model": "Модель ассистента по умолчанию",
|
||||
"models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель",
|
||||
"models.empty": "Модели не найдены",
|
||||
@@ -1632,10 +1686,11 @@
|
||||
"zoom_out": "Уменьшить",
|
||||
"zoom_reset": "Сбросить масштаб"
|
||||
},
|
||||
"theme.auto": "Автоматически",
|
||||
"theme.system": "Системная",
|
||||
"theme.dark": "Темная",
|
||||
"theme.light": "Светлая",
|
||||
"theme.title": "Тема",
|
||||
"theme.color_primary": "Цвет темы",
|
||||
"theme.window.style.opaque": "Непрозрачное окно",
|
||||
"theme.window.style.title": "Стиль окна",
|
||||
"theme.window.style.transparent": "Прозрачное окно",
|
||||
@@ -1781,13 +1836,22 @@
|
||||
"input.placeholder": "Введите текст для перевода",
|
||||
"output.placeholder": "Перевод",
|
||||
"processing": "Перевод в процессе...",
|
||||
"scroll_sync.disable": "Отключить синхронизацию прокрутки",
|
||||
"scroll_sync.enable": "Включить синхронизацию прокрутки",
|
||||
"language.same": "Исходный и целевой языки совпадают",
|
||||
"language.not_pair": "Исходный язык отличается от настроенного",
|
||||
"settings": {
|
||||
"title": "Настройки перевода",
|
||||
"model": "Настройки модели",
|
||||
"model_desc": "Модель, используемая для службы перевода",
|
||||
"bidirectional": "Настройки двунаправленного перевода",
|
||||
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||
},
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести",
|
||||
"menu": {
|
||||
"description": "Перевести содержимое текущего ввода"
|
||||
}
|
||||
},
|
||||
"detected.language": "Обнаруженный язык"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "Выйти",
|
||||
@@ -1800,6 +1864,13 @@
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновление",
|
||||
"message": "Новая версия {{version}} готова, установить сейчас?",
|
||||
"later": "Позже",
|
||||
"install": "Установить",
|
||||
"noReleaseNotes": "Нет заметок об обновлении"
|
||||
},
|
||||
"selection": {
|
||||
"name": "Помощник выбора",
|
||||
"action": {
|
||||
@@ -1820,7 +1891,8 @@
|
||||
"original_copy": "Копировать оригинал",
|
||||
"esc_close": "Esc - закрыть",
|
||||
"esc_stop": "Esc - остановить",
|
||||
"c_copy": "C - копировать"
|
||||
"c_copy": "C - копировать",
|
||||
"r_regenerate": "R - перегенерировать"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1849,6 +1921,10 @@
|
||||
"title": "Следовать за панелью",
|
||||
"description": "Окно будет следовать за панелью. Иначе - по центру."
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "Запомнить размер",
|
||||
"description": "При отключенном режиме, окно будет восстанавливаться до последнего размера при запуске приложения"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "Автозакрытие",
|
||||
"description": "Закрывать окно при потере фокуса (если не закреплено)"
|
||||
@@ -1876,6 +1952,20 @@
|
||||
"delete_confirm": "Удалить это действие?",
|
||||
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Расширенные",
|
||||
"filter_mode": {
|
||||
"title": "Режим фильтрации",
|
||||
"description": "Можно ограничить выборку по определенным приложениям (белый список) или исключить их (черный список)",
|
||||
"default": "Выключено",
|
||||
"whitelist": "Белый список",
|
||||
"blacklist": "Черный список"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "Список фильтрации",
|
||||
"description": "Расширенная функция, рекомендуется для пользователей с опытом"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "Добавить действие",
|
||||
@@ -1932,8 +2022,12 @@
|
||||
},
|
||||
"test": "Тест"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "Список фильтрации",
|
||||
"user_tips": "Введите имя исполняемого файла приложения, один на строку, не учитывая регистр, можно использовать подстановку *"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "输入名称",
|
||||
"add.prompt": "提示词",
|
||||
"add.prompt.placeholder": "输入提示词",
|
||||
"add.prompt.variables.tip": "可用的变量:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "创建智能体",
|
||||
"import": {
|
||||
"title": "从外部导入",
|
||||
@@ -101,6 +102,22 @@
|
||||
"titlePlaceholder": "输入标题",
|
||||
"contentLabel": "内容",
|
||||
"contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}"
|
||||
},
|
||||
"list": {
|
||||
"showByList": "列表展示",
|
||||
"showByTags": "标签展示"
|
||||
},
|
||||
"tags": {
|
||||
"none": "暂无标签",
|
||||
"manage": "标签管理",
|
||||
"add": "添加标签",
|
||||
"untagged": "未分组",
|
||||
"modify": "修改标签",
|
||||
"delete": "删除标签",
|
||||
"deleteConfirm": "确定要删除这个标签吗?",
|
||||
"settings": {
|
||||
"title": "标签设置"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
@@ -614,6 +631,7 @@
|
||||
"error.enter.api.key": "请输入您的 API 密钥",
|
||||
"error.enter.model": "请选择一个模型",
|
||||
"error.enter.name": "请输入知识库名称",
|
||||
"error.fetchTopicName": "话题命名失败",
|
||||
"error.get_embedding_dimensions": "获取嵌入维度失败",
|
||||
"error.invalid.api.host": "无效的 API 地址",
|
||||
"error.invalid.api.key": "无效的 API 密钥",
|
||||
@@ -831,6 +849,7 @@
|
||||
"learn_more": "了解更多",
|
||||
"paint_course": "教程",
|
||||
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
|
||||
"prompt_placeholder_en": "输入”英文“图片描述,目前 Imagen 仅支持英文提示词",
|
||||
"proxy_required": "打开代理并开启”TUN模式“查看生成图片或复制到浏览器打开,后续会支持国内直连",
|
||||
"image_file_required": "请先上传图片",
|
||||
"image_file_retry": "请重新上传图片",
|
||||
@@ -850,6 +869,34 @@
|
||||
"turbo": "快速",
|
||||
"quality": "高质量"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "自动",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "自动",
|
||||
"low": "低"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "自动",
|
||||
"transparent": "透明",
|
||||
"opaque": "不透明"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "允许所有",
|
||||
"allow_adult": "允许成人",
|
||||
"allow_none": "不允许"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "方形",
|
||||
"portrait": "竖图",
|
||||
"landscape": "横图"
|
||||
},
|
||||
"quality": "质量",
|
||||
"moderation": "敏感度",
|
||||
"background": "背景",
|
||||
"mode": {
|
||||
"generate": "绘图",
|
||||
"edit": "编辑",
|
||||
@@ -863,7 +910,9 @@
|
||||
"negative_prompt_tip": "描述不想在图像中出现的元素,仅支持 V_1、V_1_TURBO、V_2 和 V_2_TURBO 版本",
|
||||
"magic_prompt_option_tip": "智能优化提示词以提升生成效果",
|
||||
"style_type_tip": "图像生成风格,仅适用于 V_2 及以上版本",
|
||||
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本"
|
||||
"rendering_speed_tip": "控制渲染速度与质量的平衡,仅适用于 V_3 版本",
|
||||
"person_generation": "生成人物",
|
||||
"person_generation_tip": "允许模型生成人物图像"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "编辑的图像",
|
||||
@@ -896,7 +945,10 @@
|
||||
"seed_tip": "控制放大结果的随机性",
|
||||
"magic_prompt_option_tip": "智能优化放大提示词"
|
||||
},
|
||||
"text_desc_required": "请先输入图片描述"
|
||||
"text_desc_required": "请先输入图片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"auto_create_paint": "自动新建图片",
|
||||
"auto_create_paint_tip": "在图片生成后,会自动新建图片"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "帮我解释一下这个概念",
|
||||
@@ -1217,6 +1269,7 @@
|
||||
"display.sidebar.translate.icon": "显示翻译图标",
|
||||
"display.sidebar.visible": "显示的图标",
|
||||
"display.title": "显示设置",
|
||||
"display.zoom.title": "缩放设置",
|
||||
"display.topic.title": "话题设置",
|
||||
"miniapps": {
|
||||
"title": "小程序设置",
|
||||
@@ -1495,6 +1548,7 @@
|
||||
"models.check.start": "开始",
|
||||
"models.check.title": "模型健康检测",
|
||||
"models.check.use_all_keys": "使用密钥",
|
||||
"models.check.disclaimer": "健康检查需要发送请求,请谨慎使用。按次收费的模型可能产生更多费用,请自行承担。",
|
||||
"models.default_assistant_model": "默认助手模型",
|
||||
"models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型",
|
||||
"models.empty": "没有模型",
|
||||
@@ -1642,9 +1696,11 @@
|
||||
"zoom_out": "缩小界面",
|
||||
"zoom_reset": "重置缩放"
|
||||
},
|
||||
"theme.system": "系统",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "浅色",
|
||||
"theme.title": "主题",
|
||||
"theme.color_primary": "主题颜色",
|
||||
"theme.window.style.opaque": "不透明窗口",
|
||||
"theme.window.style.title": "窗口样式",
|
||||
"theme.window.style.transparent": "透明窗口",
|
||||
@@ -1784,8 +1840,19 @@
|
||||
"input.placeholder": "输入文本进行翻译",
|
||||
"output.placeholder": "翻译",
|
||||
"processing": "翻译中...",
|
||||
"language.same": "源语言和目标语言相同",
|
||||
"language.not_pair": "源语言与设置的语言不同",
|
||||
"settings": {
|
||||
"title": "翻译设置",
|
||||
"model": "模型设置",
|
||||
"model_desc": "翻译服务使用的模型",
|
||||
"bidirectional": "双向翻译设置",
|
||||
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||
"scroll_sync": "滚动同步设置"
|
||||
},
|
||||
"title": "翻译",
|
||||
"tooltip.newline": "换行",
|
||||
"detected.language": "检测到的语言",
|
||||
"scroll_sync.disable": "禁用滚动同步",
|
||||
"scroll_sync.enable": "启用滚动同步"
|
||||
},
|
||||
@@ -1800,6 +1867,13 @@
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "发现新版本 {{version}},是否立即安装?",
|
||||
"later": "稍后",
|
||||
"install": "立即安装",
|
||||
"noReleaseNotes": "暂无更新日志"
|
||||
},
|
||||
"selection": {
|
||||
"name": "划词助手",
|
||||
"action": {
|
||||
@@ -1820,7 +1894,8 @@
|
||||
"original_copy": "复制原文",
|
||||
"esc_close": "Esc 关闭",
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 复制"
|
||||
"c_copy": "C 复制",
|
||||
"r_regenerate": "R 重新生成"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1849,6 +1924,10 @@
|
||||
"title": "跟随工具栏",
|
||||
"description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示"
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "记住大小",
|
||||
"description": "应用运行期间,窗口会按上次调整的大小显示"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自动关闭",
|
||||
"description": "当窗口未置顶且失去焦点时,将自动关闭该窗口"
|
||||
@@ -1876,6 +1955,20 @@
|
||||
"delete_confirm": "确定要删除这个自定义功能吗?",
|
||||
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级",
|
||||
"filter_mode": {
|
||||
"title": "应用筛选",
|
||||
"description": "可以限制划词助手只在特定应用中生效(白名单)或不生效(黑名单)",
|
||||
"default": "关闭",
|
||||
"whitelist": "白名单",
|
||||
"blacklist": "黑名单"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "筛选名单",
|
||||
"description": "高级功能,建议有经验的用户在了解的情况下再进行设置"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "添加自定义功能",
|
||||
@@ -1932,6 +2025,10 @@
|
||||
},
|
||||
"test": "测试"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "应用筛选名单",
|
||||
"user_tips": "请输入应用的执行文件名,每行一个,不区分大小写,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"add.name.placeholder": "輸入名稱",
|
||||
"add.prompt": "提示詞",
|
||||
"add.prompt.placeholder": "輸入提示詞",
|
||||
"add.prompt.variables.tip": "可用的變數:{{date}}, {{time}}, {{datetime}}, {{system}}, {{arch}}, {{language}}, {{model_name}}",
|
||||
"add.title": "建立智慧代理人",
|
||||
"import": {
|
||||
"title": "從外部導入",
|
||||
@@ -99,6 +100,22 @@
|
||||
"settings.knowledge_base.recognition": "調用知識庫",
|
||||
"settings.knowledge_base.recognition.off": "強制檢索",
|
||||
"settings.knowledge_base.recognition.on": "意圖識別",
|
||||
"list": {
|
||||
"showByList": "列表展示",
|
||||
"showByTags": "標籤展示"
|
||||
},
|
||||
"tags": {
|
||||
"untagged": "未分組",
|
||||
"none": "暫無標籤",
|
||||
"manage": "標籤管理",
|
||||
"add": "添加標籤",
|
||||
"modify": "修改標籤",
|
||||
"delete": "刪除標籤",
|
||||
"deleteConfirm": "確定要刪除這個標籤嗎?",
|
||||
"settings": {
|
||||
"title": "標籤設定"
|
||||
}
|
||||
},
|
||||
"settings.tool_use_mode": "工具調用方式",
|
||||
"settings.tool_use_mode.function": "函數",
|
||||
"settings.tool_use_mode.prompt": "提示詞"
|
||||
@@ -602,11 +619,11 @@
|
||||
"citations": "引用內容",
|
||||
"copied": "已複製!",
|
||||
"copy.failed": "複製失敗",
|
||||
"copy.success": "複製成功",
|
||||
"delete.confirm.title": "刪除確認",
|
||||
"delete.confirm.content": "確認刪除選中的 {{count}} 條訊息嗎?",
|
||||
"delete.failed": "刪除失敗",
|
||||
"delete.success": "刪除成功",
|
||||
"copy.success": "複製成功",
|
||||
"empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙",
|
||||
"error.chunk_overlap_too_large": "分段重疊不能大於分段大小",
|
||||
"error.dimension_too_large": "內容尺寸過大",
|
||||
@@ -614,6 +631,7 @@
|
||||
"error.enter.api.key": "請先輸入您的 API 金鑰",
|
||||
"error.enter.model": "請先選擇一個模型",
|
||||
"error.enter.name": "請先輸入知識庫名稱",
|
||||
"error.fetchTopicName": "話題命名失敗",
|
||||
"error.get_embedding_dimensions": "取得嵌入維度失敗",
|
||||
"error.invalid.api.host": "無效的 API 位址",
|
||||
"error.invalid.api.key": "無效的 API 金鑰",
|
||||
@@ -824,13 +842,14 @@
|
||||
"seed_desc_tip": "相同的種子和提示詞可以生成相似的圖片,設置 -1 每次生成都不一樣",
|
||||
"title": "繪圖",
|
||||
"magic_prompt_option": "提示詞增強",
|
||||
"model": "版本",
|
||||
"model": "模型",
|
||||
"aspect_ratio": "畫幅比例",
|
||||
"style_type": "風格",
|
||||
"learn_more": "了解更多",
|
||||
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹",
|
||||
"prompt_placeholder_en": "輸入”英文“圖片描述,目前 Imagen 僅支持英文提示詞",
|
||||
"paint_course": "教程",
|
||||
"proxy_required": "目前需要打開代理才能查看生成圖片,後續會支持國內直連",
|
||||
"proxy_required": "打開代理並開啟”TUN模式“查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連",
|
||||
"image_file_required": "請先上傳圖片",
|
||||
"image_file_retry": "請重新上傳圖片",
|
||||
"image_placeholder": "無圖片",
|
||||
@@ -849,6 +868,34 @@
|
||||
"turbo": "快速",
|
||||
"quality": "高品質"
|
||||
},
|
||||
"quality_options": {
|
||||
"auto": "自動",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"moderation_options": {
|
||||
"auto": "自動",
|
||||
"low": "低"
|
||||
},
|
||||
"background_options": {
|
||||
"auto": "自動",
|
||||
"transparent": "透明",
|
||||
"opaque": "不透明"
|
||||
},
|
||||
"aspect_ratios": {
|
||||
"square": "方形",
|
||||
"portrait": "豎圖",
|
||||
"landscape": "橫圖"
|
||||
},
|
||||
"person_generation_options": {
|
||||
"allow_all": "允許所有",
|
||||
"allow_adult": "允許成人",
|
||||
"allow_none": "不允許"
|
||||
},
|
||||
"quality": "品質",
|
||||
"moderation": "敏感度",
|
||||
"background": "背景",
|
||||
"mode": {
|
||||
"generate": "繪圖",
|
||||
"edit": "編輯",
|
||||
@@ -862,7 +909,9 @@
|
||||
"negative_prompt_tip": "描述不想在圖像中出現的內容",
|
||||
"magic_prompt_option_tip": "智能優化生成效果的提示詞",
|
||||
"style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本",
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本"
|
||||
"rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於V_3版本",
|
||||
"person_generation": "人物生成",
|
||||
"person_generation_tip": "允許模型生成人物圖像"
|
||||
},
|
||||
"edit": {
|
||||
"image_file": "編輯圖像",
|
||||
@@ -896,7 +945,10 @@
|
||||
"magic_prompt_option_tip": "智能優化放大提示詞"
|
||||
},
|
||||
"rendering_speed": "渲染速度",
|
||||
"text_desc_required": "請先輸入圖片描述"
|
||||
"text_desc_required": "請先輸入圖片描述",
|
||||
"req_error_text": "运行失败,请重试。提示词避免“版权词”和”敏感词”哦。",
|
||||
"auto_create_paint": "自動新增圖片",
|
||||
"auto_create_paint_tip": "圖片生成後,會自動新增圖片"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "幫我解釋一下這個概念",
|
||||
@@ -950,7 +1002,7 @@
|
||||
"zhinao": "360 智腦",
|
||||
"zhipu": "智譜 AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "七牛雲",
|
||||
"qiniu": "七牛雲 AI 推理",
|
||||
"tokenflux": "TokenFlux"
|
||||
},
|
||||
"restore": {
|
||||
@@ -1217,6 +1269,7 @@
|
||||
"display.sidebar.translate.icon": "顯示翻譯圖示",
|
||||
"display.sidebar.visible": "顯示的圖示",
|
||||
"display.title": "顯示設定",
|
||||
"display.zoom.title": "縮放設定",
|
||||
"display.topic.title": "話題設定",
|
||||
"miniapps": {
|
||||
"title": "小程式設置",
|
||||
@@ -1451,7 +1504,7 @@
|
||||
"messages.input.send_shortcuts": "傳送快捷鍵",
|
||||
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
|
||||
"messages.input.title": "輸入設定",
|
||||
"messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單",
|
||||
"messages.input.enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
|
||||
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件",
|
||||
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
|
||||
"messages.math_engine": "數學公式引擎",
|
||||
@@ -1494,6 +1547,7 @@
|
||||
"models.check.start": "開始",
|
||||
"models.check.title": "模型健康檢查",
|
||||
"models.check.use_all_keys": "使用密鑰",
|
||||
"models.check.disclaimer": "健康檢查需要發送請求,請謹慎使用。按次收費的模型可能產生更多費用,請自行承擔。",
|
||||
"models.default_assistant_model": "預設助手模型",
|
||||
"models.default_assistant_model_description": "建立新助手時使用的模型,如果助手未設定模型,則使用此模型",
|
||||
"models.empty": "找不到模型",
|
||||
@@ -1635,10 +1689,11 @@
|
||||
"zoom_reset": "重設縮放",
|
||||
"exit_fullscreen": "退出螢幕"
|
||||
},
|
||||
"theme.auto": "自動",
|
||||
"theme.system": "系統",
|
||||
"theme.dark": "深色",
|
||||
"theme.light": "淺色",
|
||||
"theme.title": "主題",
|
||||
"theme.color_primary": "主題顏色",
|
||||
"theme.window.style.opaque": "不透明視窗",
|
||||
"theme.window.style.title": "視窗樣式",
|
||||
"theme.window.style.transparent": "透明視窗",
|
||||
@@ -1781,13 +1836,22 @@
|
||||
"input.placeholder": "輸入文字進行翻譯",
|
||||
"output.placeholder": "翻譯",
|
||||
"processing": "翻譯中...",
|
||||
"scroll_sync.disable": "關閉滾動同步",
|
||||
"scroll_sync.enable": "開啟滾動同步",
|
||||
"language.same": "源語言和目標語言相同",
|
||||
"language.not_pair": "源語言與設定的語言不同",
|
||||
"settings": {
|
||||
"title": "翻譯設定",
|
||||
"model": "模型設定",
|
||||
"model_desc": "翻譯服務使用的模型",
|
||||
"bidirectional": "雙向翻譯設定",
|
||||
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||
"scroll_sync": "滾動同步設定"
|
||||
},
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行",
|
||||
"menu": {
|
||||
"description": "對當前輸入框內容進行翻譯"
|
||||
}
|
||||
},
|
||||
"detected.language": "檢測到的語言"
|
||||
},
|
||||
"tray": {
|
||||
"quit": "結束",
|
||||
@@ -1800,6 +1864,13 @@
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "視覺化"
|
||||
},
|
||||
"update": {
|
||||
"title": "更新提示",
|
||||
"message": "新版本 {{version}} 已準備就緒,是否立即安裝?",
|
||||
"later": "稍後",
|
||||
"install": "立即安裝",
|
||||
"noReleaseNotes": "暫無更新日誌"
|
||||
},
|
||||
"selection": {
|
||||
"name": "劃詞助手",
|
||||
"action": {
|
||||
@@ -1820,7 +1891,8 @@
|
||||
"original_copy": "複製原文",
|
||||
"esc_close": "Esc 關閉",
|
||||
"esc_stop": "Esc 停止",
|
||||
"c_copy": "C 複製"
|
||||
"c_copy": "C 複製",
|
||||
"r_regenerate": "R 重新生成"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -1849,6 +1921,10 @@
|
||||
"title": "跟隨工具列",
|
||||
"description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示"
|
||||
},
|
||||
"remember_size": {
|
||||
"title": "記住大小",
|
||||
"description": "應用運行期間,視窗會按上次調整的大小顯示"
|
||||
},
|
||||
"auto_close": {
|
||||
"title": "自動關閉",
|
||||
"description": "當視窗未置頂且失去焦點時,將自動關閉該視窗"
|
||||
@@ -1876,6 +1952,20 @@
|
||||
"delete_confirm": "確定要刪除這個自訂功能嗎?",
|
||||
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "進階",
|
||||
"filter_mode": {
|
||||
"title": "應用篩選",
|
||||
"description": "可以限制劃詞助手只在特定應用中生效(白名單)或不生效(黑名單)",
|
||||
"default": "關閉",
|
||||
"whitelist": "白名單",
|
||||
"blacklist": "黑名單"
|
||||
},
|
||||
"filter_list": {
|
||||
"title": "篩選名單",
|
||||
"description": "進階功能,建議有經驗的用戶在了解情況下再進行設置"
|
||||
}
|
||||
},
|
||||
"user_modal": {
|
||||
"title": {
|
||||
"add": "新增自訂功能",
|
||||
@@ -1932,8 +2022,12 @@
|
||||
},
|
||||
"test": "測試"
|
||||
}
|
||||
},
|
||||
"filter_modal": {
|
||||
"title": "應用篩選名單",
|
||||
"user_tips": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
"error.enter.api.key": "Παρακαλώ εισάγετε το κλειδί API σας",
|
||||
"error.enter.model": "Παρακαλώ επιλέξτε ένα μοντέλο",
|
||||
"error.enter.name": "Παρακαλώ εισάγετε ένα όνομα για τη βάση γνώσεων",
|
||||
"error.fetchTopicName": "Αποτυχία ονοματοδοσίας θέματος",
|
||||
"error.get_embedding_dimensions": "Απέτυχε η πρόσληψη διαστάσεων ενσωμάτωσης",
|
||||
"error.invalid.api.host": "Μη έγκυρη διεύθυνση API",
|
||||
"error.invalid.api.key": "Μη έγκυρο κλειδί API",
|
||||
@@ -880,7 +881,7 @@
|
||||
"zhinao": "360 Intelligent Brain",
|
||||
"zhipu": "Zhipu AI",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Cloud"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Είστε σίγουροι ότι θέλετε να επαναφέρετε τα δεδομένα;",
|
||||
@@ -1140,6 +1141,7 @@
|
||||
"display.sidebar.translate.icon": "Εμφάνιση εικονιδίου μετάφρασης",
|
||||
"display.sidebar.visible": "Εμφανιζόμενα εικονίδια",
|
||||
"display.title": "Ρυθμίσεις εμφάνισης",
|
||||
"display.zoom.title": "Ρυθμίσεις κλίμακας",
|
||||
"display.topic.title": "Ρυθμίσεις Θεμάτων",
|
||||
"font_size.title": "Μέγεθος γραμμάτων των μηνυμάτων",
|
||||
"general": "Γενικές ρυθμίσεις",
|
||||
@@ -1477,7 +1479,7 @@
|
||||
"zoom_out": "Σμικρύνση εμφάνισης",
|
||||
"zoom_reset": "Επαναφορά εμφάνισης"
|
||||
},
|
||||
"theme.auto": "Αυτόματο",
|
||||
"theme.system": "Σύστημα",
|
||||
"theme.dark": "Σκοτεινό",
|
||||
"theme.light": "Φωτεινό",
|
||||
"theme.title": "Θέμα",
|
||||
@@ -1656,6 +1658,13 @@
|
||||
"quit": "Έξοδος",
|
||||
"show_window": "Εμφάνιση Παραθύρου",
|
||||
"visualization": "προβολή"
|
||||
},
|
||||
"update": {
|
||||
"title": "Ενημέρωση",
|
||||
"message": "Νέα έκδοση {{version}} είναι έτοιμη, θέλετε να την εγκαταστήσετε τώρα;",
|
||||
"later": "Μετά",
|
||||
"install": "Εγκατάσταση",
|
||||
"noReleaseNotes": "Χωρίς σημειώσεις"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
"error.enter.api.key": "Ingrese su clave API",
|
||||
"error.enter.model": "Seleccione un modelo",
|
||||
"error.enter.name": "Ingrese el nombre de la base de conocimiento",
|
||||
"error.fetchTopicName": "Error al nombrar el tema",
|
||||
"error.get_embedding_dimensions": "Fallo al obtener las dimensiones de incrustación",
|
||||
"error.invalid.api.host": "Dirección API inválida",
|
||||
"error.invalid.api.key": "Clave API inválida",
|
||||
@@ -849,7 +850,7 @@
|
||||
"doubao": "Volcán Motor",
|
||||
"fireworks": "Fuegos Artificiales",
|
||||
"gemini": "Géminis",
|
||||
"gitee-ai": "Gitee IA",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Modelos",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Grok",
|
||||
@@ -881,7 +882,7 @@
|
||||
"zhinao": "360 Inteligente",
|
||||
"zhipu": "ZhiPu IA",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Yun"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "¿Está seguro de que desea restaurar los datos?",
|
||||
@@ -1139,6 +1140,7 @@
|
||||
"display.sidebar.translate.icon": "Mostrar icono de traducción",
|
||||
"display.sidebar.visible": "Iconos visibles",
|
||||
"display.title": "Configuración de visualización",
|
||||
"display.zoom.title": "Configuración de zoom",
|
||||
"display.topic.title": "Configuración de tema",
|
||||
"font_size.title": "Tamaño de fuente de mensajes",
|
||||
"general": "Configuración general",
|
||||
@@ -1476,7 +1478,7 @@
|
||||
"zoom_out": "Reducir interfaz",
|
||||
"zoom_reset": "Restablecer zoom"
|
||||
},
|
||||
"theme.auto": "Automático",
|
||||
"theme.system": "Sistema",
|
||||
"theme.dark": "Oscuro",
|
||||
"theme.light": "Claro",
|
||||
"theme.title": "Tema",
|
||||
@@ -1655,6 +1657,13 @@
|
||||
"quit": "Salir",
|
||||
"show_window": "Mostrar Ventana",
|
||||
"visualization": "Visualización"
|
||||
},
|
||||
"update": {
|
||||
"title": "Actualización",
|
||||
"message": "Nueva versión {{version}} disponible, ¿desea instalarla ahora?",
|
||||
"later": "Más tarde",
|
||||
"install": "Instalar",
|
||||
"noReleaseNotes": "Sin notas de la versión"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
"error.enter.api.key": "Veuillez entrer votre clé API",
|
||||
"error.enter.model": "Veuillez sélectionner un modèle",
|
||||
"error.enter.name": "Veuillez entrer le nom de la base de connaissances",
|
||||
"error.fetchTopicName": "Échec de la dénomination du sujet",
|
||||
"error.get_embedding_dimensions": "Impossible d'obtenir les dimensions d'encodage",
|
||||
"error.invalid.api.host": "Adresse API invalide",
|
||||
"error.invalid.api.key": "Clé API invalide",
|
||||
@@ -848,7 +849,7 @@
|
||||
"doubao": "Huoshan Engine",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee IA",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Modèles",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Grok",
|
||||
@@ -880,7 +881,7 @@
|
||||
"zhinao": "360 ZhiNao",
|
||||
"zhipu": "ZhiPu IA",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Cloud"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Êtes-vous sûr de vouloir restaurer les données ?",
|
||||
@@ -1140,6 +1141,7 @@
|
||||
"display.sidebar.translate.icon": "Afficher l'icône de traduction",
|
||||
"display.sidebar.visible": "Icônes affichées",
|
||||
"display.title": "Paramètres d'affichage",
|
||||
"display.zoom.title": "Paramètres de zoom",
|
||||
"display.topic.title": "Paramètres de sujet",
|
||||
"font_size.title": "Taille de police des messages",
|
||||
"general": "Paramètres généraux",
|
||||
@@ -1477,7 +1479,7 @@
|
||||
"zoom_out": "Réduire l'interface",
|
||||
"zoom_reset": "Réinitialiser le zoom"
|
||||
},
|
||||
"theme.auto": "Automatique",
|
||||
"theme.system": "Système",
|
||||
"theme.dark": "Sombre",
|
||||
"theme.light": "Clair",
|
||||
"theme.title": "Thème",
|
||||
@@ -1656,6 +1658,13 @@
|
||||
"quit": "Quitter",
|
||||
"show_window": "Afficher la fenêtre",
|
||||
"visualization": "Visualisation"
|
||||
},
|
||||
"update": {
|
||||
"title": "Mise à jour",
|
||||
"message": "Nouvelle version {{version}} disponible, voulez-vous l'installer maintenant ?",
|
||||
"later": "Plus tard",
|
||||
"install": "Installer",
|
||||
"noReleaseNotes": "Aucune note de version"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@
|
||||
"error.enter.api.key": "Insira sua chave API",
|
||||
"error.enter.model": "Selecione um modelo",
|
||||
"error.enter.name": "Insira o nome da base de conhecimento",
|
||||
"error.fetchTopicName": "Falha ao nomear o tópico",
|
||||
"error.get_embedding_dimensions": "Falha ao obter dimensões de incorporação",
|
||||
"error.invalid.api.host": "Endereço API inválido",
|
||||
"error.invalid.api.key": "Chave API inválida",
|
||||
@@ -850,7 +851,7 @@
|
||||
"doubao": "Volcano Engine",
|
||||
"fireworks": "Fogos de Artifício",
|
||||
"gemini": "Gêmeos",
|
||||
"gitee-ai": "Gitee IA",
|
||||
"gitee-ai": "Gitee AI",
|
||||
"github": "GitHub Models",
|
||||
"gpustack": "GPUStack",
|
||||
"grok": "Compreender",
|
||||
@@ -882,7 +883,7 @@
|
||||
"zhinao": "360 Inteligência Artificial",
|
||||
"zhipu": "ZhiPu IA",
|
||||
"voyageai": "Voyage AI",
|
||||
"qiniu": "Qiniu Cloud"
|
||||
"qiniu": "Qiniu AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "Tem certeza de que deseja restaurar os dados?",
|
||||
@@ -1142,6 +1143,7 @@
|
||||
"display.sidebar.translate.icon": "Mostrar ícone de tradução",
|
||||
"display.sidebar.visible": "Ícones visíveis",
|
||||
"display.title": "Configurações de exibição",
|
||||
"display.zoom.title": "Configurações de zoom",
|
||||
"display.topic.title": "Configurações de tópico",
|
||||
"font_size.title": "Tamanho da fonte da mensagem",
|
||||
"general": "Configurações gerais",
|
||||
@@ -1479,7 +1481,7 @@
|
||||
"zoom_out": "Diminuir interface",
|
||||
"zoom_reset": "Redefinir zoom"
|
||||
},
|
||||
"theme.auto": "Automático",
|
||||
"theme.system": "Sistema",
|
||||
"theme.dark": "Escuro",
|
||||
"theme.light": "Claro",
|
||||
"theme.title": "Tema",
|
||||
@@ -1658,6 +1660,13 @@
|
||||
"quit": "Sair",
|
||||
"show_window": "Exibir Janela",
|
||||
"visualization": "Visualização"
|
||||
},
|
||||
"update": {
|
||||
"title": "Atualização",
|
||||
"message": "Nova versão {{version}} disponível, deseja instalar agora?",
|
||||
"later": "Mais tarde",
|
||||
"install": "Instalar",
|
||||
"noReleaseNotes": "Sem notas de versão"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Col, Image, Row, Spin, Table } from 'antd'
|
||||
import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import GeminiFiles from './GeminiFiles'
|
||||
import MistralFiles from './MistralFiles'
|
||||
interface ContentViewProps {
|
||||
id: FileTypes | 'all' | string
|
||||
files?: FileMetadata[]
|
||||
@@ -45,14 +43,6 @@ const ContentView: React.FC<ContentViewProps> = ({ id, files, dataSource, column
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini_')) {
|
||||
return <GeminiFiles id={id.replace('gemini_', '') as string} />
|
||||
}
|
||||
|
||||
if (id.startsWith('mistral_')) {
|
||||
return <MistralFiles id={id.replace('mistral_', '') as string} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
import GeminiFiles from './GeminiFiles'
|
||||
|
||||
interface FileItemProps {
|
||||
id: FileTypes | 'all' | string
|
||||
@@ -58,10 +57,6 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini_')) {
|
||||
return <GeminiFiles id={id.replace('gemini_', '') as string} />
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualList
|
||||
data={list}
|
||||
|
||||
@@ -36,8 +36,8 @@ const Chat: FC<Props> = (props) => {
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
const showRightTopics = showTopics && topicPosition === 'right'
|
||||
const minusAssistantsWidth = showAssistants ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
|
||||
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
|
||||
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})`
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
@@ -115,16 +115,14 @@ const Chat: FC<Props> = (props) => {
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<MessagesContainer>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
</MessagesContainer>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
@@ -143,13 +141,6 @@ 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;
|
||||
|
||||
@@ -74,29 +74,33 @@ const MentionModelsButton: FC<Props> = ({ ref, mentionModels, onMentionModel, To
|
||||
}
|
||||
|
||||
providers.forEach((p) => {
|
||||
const providerModels = p.models
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
|
||||
.map((m) => ({
|
||||
label: (
|
||||
<>
|
||||
<ProviderName>{p.isSystem ? t(`provider.${p.id}`) : p.name}</ProviderName>
|
||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
||||
</>
|
||||
),
|
||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.id)} size={20}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
const providerModels = sortBy(
|
||||
p.models
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m))),
|
||||
['group', 'name']
|
||||
)
|
||||
|
||||
if (providerModels.length > 0) {
|
||||
items.push(...sortBy(providerModels, ['label']))
|
||||
const providerModelItems = providerModels.map((m) => ({
|
||||
label: (
|
||||
<>
|
||||
<ProviderName>{p.isSystem ? t(`provider.${p.id}`) : p.name}</ProviderName>
|
||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
||||
</>
|
||||
),
|
||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m.id)} size={20}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
|
||||
action: () => onMentionModel(m),
|
||||
isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
|
||||
if (providerModelItems.length > 0) {
|
||||
items.push(...providerModelItems)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { Tooltip } from 'antd'
|
||||
import React from 'react'
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CitationTooltipProps {
|
||||
@@ -13,56 +13,62 @@ interface CitationTooltipProps {
|
||||
}
|
||||
|
||||
const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation }) => {
|
||||
let hostname = ''
|
||||
try {
|
||||
hostname = new URL(citation.url).hostname
|
||||
} catch {
|
||||
hostname = citation.url
|
||||
}
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(citation.url).hostname
|
||||
} catch {
|
||||
return citation.url
|
||||
}
|
||||
}, [citation.url])
|
||||
|
||||
const sourceTitle = useMemo(() => {
|
||||
return citation.title?.trim() || hostname
|
||||
}, [citation.title, hostname])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
window.open(citation.url, '_blank', 'noopener,noreferrer')
|
||||
}, [citation.url])
|
||||
|
||||
// 自定义悬浮卡片内容
|
||||
const tooltipContent = (
|
||||
<TooltipContentWrapper>
|
||||
<TooltipHeader onClick={() => window.open(citation.url, '_blank')}>
|
||||
<Favicon hostname={hostname} alt={citation.title || hostname} />
|
||||
<TooltipTitle title={citation.title || hostname}>{citation.title || hostname}</TooltipTitle>
|
||||
</TooltipHeader>
|
||||
{citation.content && <TooltipBody>{citation.content}</TooltipBody>}
|
||||
<TooltipFooter onClick={() => window.open(citation.url, '_blank')}>{hostname}</TooltipFooter>
|
||||
</TooltipContentWrapper>
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<div>
|
||||
<TooltipHeader role="button" aria-label={`Open ${sourceTitle} in new tab`} onClick={handleClick}>
|
||||
<Favicon hostname={hostname} alt={sourceTitle} />
|
||||
<TooltipTitle role="heading" aria-level={3} title={sourceTitle}>
|
||||
{sourceTitle}
|
||||
</TooltipTitle>
|
||||
</TooltipHeader>
|
||||
{citation.content?.trim() && (
|
||||
<TooltipBody role="article" aria-label="Citation content">
|
||||
{citation.content}
|
||||
</TooltipBody>
|
||||
)}
|
||||
<TooltipFooter role="button" aria-label={`Visit ${hostname}`} onClick={handleClick}>
|
||||
{hostname}
|
||||
</TooltipFooter>
|
||||
</div>
|
||||
),
|
||||
[citation.content, hostname, handleClick, sourceTitle]
|
||||
)
|
||||
|
||||
return (
|
||||
<StyledTooltip
|
||||
title={tooltipContent}
|
||||
<Tooltip
|
||||
overlay={tooltipContent}
|
||||
placement="top"
|
||||
arrow={false}
|
||||
overlayInnerStyle={{
|
||||
backgroundColor: 'var(--color-background-mute)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: 0,
|
||||
borderRadius: '8px'
|
||||
color="var(--color-background-mute)"
|
||||
styles={{
|
||||
body: {
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
</StyledTooltip>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// 使用styled-components来自定义Tooltip的样式,包括箭头
|
||||
const StyledTooltip = styled(Tooltip)`
|
||||
.ant-tooltip-arrow {
|
||||
.ant-tooltip-arrow-content {
|
||||
background-color: var(--color-background-1);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TooltipContentWrapper = styled.div`
|
||||
padding: 12px;
|
||||
background-color: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
`
|
||||
|
||||
const TooltipHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -108,4 +114,4 @@ const TooltipFooter = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default CitationTooltip
|
||||
export default memo(CitationTooltip)
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CitationTooltip from '../CitationTooltip'
|
||||
|
||||
// Mock dependencies
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <div data-testid="mock-favicon" {...props} />
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tooltip: ({ children, overlay, title, placement, color, styles, ...props }: any) => (
|
||||
<div
|
||||
data-testid="tooltip-wrapper"
|
||||
data-placement={placement}
|
||||
data-color={color}
|
||||
data-styles={JSON.stringify(styles)}
|
||||
{...props}>
|
||||
{children}
|
||||
<div data-testid="tooltip-content">{overlay || title}</div>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
const originalWindowOpen = window.open
|
||||
|
||||
describe('CitationTooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: mockWindowOpen,
|
||||
writable: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Test data factory
|
||||
const createCitationData = (overrides = {}) => ({
|
||||
url: 'https://example.com/article',
|
||||
title: 'Example Article',
|
||||
content: 'This is the article content for testing purposes.',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const renderCitationTooltip = (citation: any, children = <span>Trigger</span>) => {
|
||||
return render(<CitationTooltip citation={citation}>{children}</CitationTooltip>)
|
||||
}
|
||||
|
||||
const expectWindowOpenCalled = (url: string) => {
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const getTooltipContent = () => screen.getByTestId('tooltip-content')
|
||||
|
||||
const getCitationHeaderButton = () => screen.getByRole('button', { name: /open .* in new tab/i })
|
||||
const getCitationFooterButton = () => screen.getByRole('button', { name: /visit .*/i })
|
||||
const getCitationTitle = () => screen.getByRole('heading', { level: 3 })
|
||||
const getCitationContent = () => screen.queryByRole('article', { name: /citation content/i })
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render children and basic tooltip structure', () => {
|
||||
const citation = createCitationData()
|
||||
renderCitationTooltip(citation, <span>Click me</span>)
|
||||
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip-wrapper')).toBeInTheDocument()
|
||||
expect(getTooltipContent()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Favicon with correct props', () => {
|
||||
const citation = createCitationData({
|
||||
url: 'https://example.com',
|
||||
title: 'Example Title'
|
||||
})
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const favicon = screen.getByTestId('mock-favicon')
|
||||
expect(favicon).toHaveAttribute('hostname', 'example.com')
|
||||
expect(favicon).toHaveAttribute('alt', 'Example Title')
|
||||
})
|
||||
|
||||
it('should pass correct props to Tooltip component', () => {
|
||||
const citation = createCitationData()
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip-wrapper')
|
||||
expect(tooltip).toHaveAttribute('data-placement', 'top')
|
||||
expect(tooltip).toHaveAttribute('data-color', 'var(--color-background-mute)')
|
||||
|
||||
const styles = JSON.parse(tooltip.getAttribute('data-styles') || '{}')
|
||||
expect(styles.body).toEqual({
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px'
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const citation = createCitationData()
|
||||
const { container } = render(
|
||||
<CitationTooltip citation={citation}>
|
||||
<span>Test content</span>
|
||||
</CitationTooltip>
|
||||
)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL processing and hostname extraction', () => {
|
||||
it('should extract hostname from valid URLs', () => {
|
||||
const testCases = [
|
||||
{ url: 'https://www.example.com/path/to/page?query=1', expected: 'www.example.com' },
|
||||
{ url: 'http://test.com', expected: 'test.com' },
|
||||
{ url: 'https://api.v2.example.com/endpoint', expected: 'api.v2.example.com' },
|
||||
{ url: 'ftp://files.domain.net', expected: 'files.domain.net' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ url, expected }) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ url }))
|
||||
expect(screen.getByText(expected)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle URLs with ports correctly', () => {
|
||||
const citation = createCitationData({ url: 'https://localhost:3000/api/data' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
// URL.hostname strips the port
|
||||
expect(screen.getByText('localhost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to original URL when parsing fails', () => {
|
||||
const testCases = ['not-a-valid-url', '', 'http://']
|
||||
|
||||
testCases.forEach((invalidUrl) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ url: invalidUrl }))
|
||||
const favicon = screen.getByTestId('mock-favicon')
|
||||
expect(favicon).toHaveAttribute('hostname', invalidUrl)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('content display and title logic', () => {
|
||||
it('should display citation title when provided', () => {
|
||||
const citation = createCitationData({ title: 'Custom Article Title' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('Custom Article Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument() // hostname in footer
|
||||
})
|
||||
|
||||
it('should fallback to hostname when title is empty or whitespace', () => {
|
||||
const testCases = [
|
||||
{ title: undefined, url: 'https://fallback-test.com' },
|
||||
{ title: '', url: 'https://empty-title.com' },
|
||||
{ title: ' ', url: 'https://whitespace-title.com' },
|
||||
{ title: '\n\t \n', url: 'https://mixed-whitespace.com' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ title, url }) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ title, url }))
|
||||
const titleElement = getCitationTitle()
|
||||
const expectedHostname = new URL(url).hostname
|
||||
expect(titleElement).toHaveTextContent(expectedHostname)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display content when provided and meaningful', () => {
|
||||
const citation = createCitationData({ content: 'Meaningful article content' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('Meaningful article content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content section when content is empty or whitespace', () => {
|
||||
const testCases = [undefined, null, '', ' ', '\n\t \n']
|
||||
|
||||
testCases.forEach((content) => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ content }))
|
||||
expect(getCitationContent()).not.toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle long content with proper styling', () => {
|
||||
const longContent =
|
||||
'This is a very long content that should be clamped to three lines using CSS line-clamp property for better visual presentation in the tooltip interface.'
|
||||
const citation = createCitationData({ content: longContent })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const contentElement = screen.getByText(longContent)
|
||||
expect(contentElement).toHaveStyle({
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle special characters in title and content', () => {
|
||||
const citation = createCitationData({
|
||||
title: 'Article with Special: <>{}[]()&"\'`',
|
||||
content: 'Content with chars: <>{}[]()&"\'`'
|
||||
})
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('Article with Special: <>{}[]()&"\'`')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content with chars: <>{}[]()&"\'`')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should open URL when header is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ url: 'https://header-click.com' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const header = getCitationHeaderButton()
|
||||
await user.click(header)
|
||||
|
||||
expectWindowOpenCalled('https://header-click.com')
|
||||
})
|
||||
|
||||
it('should open URL when footer is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ url: 'https://footer-click.com' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const footer = getCitationFooterButton()
|
||||
await user.click(footer)
|
||||
|
||||
expectWindowOpenCalled('https://footer-click.com')
|
||||
})
|
||||
|
||||
it('should not trigger click when content area is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ content: 'Non-clickable content' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const content = screen.getByText('Non-clickable content')
|
||||
await user.click(content)
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle invalid URLs gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const citation = createCitationData({ url: 'invalid-url' })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const footer = getCitationFooterButton()
|
||||
await user.click(footer)
|
||||
|
||||
expectWindowOpenCalled('invalid-url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('real-world usage scenarios', () => {
|
||||
it('should work with actual citation link structure', () => {
|
||||
const citation = createCitationData({
|
||||
url: 'https://research.example.com/study',
|
||||
title: 'Research Study on AI',
|
||||
content:
|
||||
'This study demonstrates significant improvements in AI capabilities through novel training methodologies and evaluation frameworks.'
|
||||
})
|
||||
|
||||
const citationLink = (
|
||||
<a href="https://research.example.com/study" target="_blank" rel="noreferrer">
|
||||
<sup>1</sup>
|
||||
</a>
|
||||
)
|
||||
|
||||
renderCitationTooltip(citation, citationLink)
|
||||
|
||||
// Should display all citation information
|
||||
expect(screen.getByText('Research Study on AI')).toBeInTheDocument()
|
||||
expect(screen.getByText('research.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText(/This study demonstrates/)).toBeInTheDocument()
|
||||
|
||||
// Should contain the sup element
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle truncated content as used in real implementation', () => {
|
||||
const fullContent = 'A'.repeat(250) // Longer than typical 200 char limit
|
||||
const citation = createCitationData({ content: fullContent })
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText(fullContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing title with hostname fallback in real scenario', () => {
|
||||
const citation = createCitationData({
|
||||
url: 'https://docs.python.org/3/library/urllib.html',
|
||||
title: undefined, // Common case when title extraction fails
|
||||
content: 'urllib.request module documentation for Python 3'
|
||||
})
|
||||
renderCitationTooltip(citation)
|
||||
|
||||
const titleElement = getCitationTitle()
|
||||
expect(titleElement).toHaveTextContent('docs.python.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle malformed URLs', () => {
|
||||
const malformedUrls = ['http://', 'https://', '://missing-protocol']
|
||||
|
||||
malformedUrls.forEach((url) => {
|
||||
expect(() => {
|
||||
const { unmount } = renderCitationTooltip(createCitationData({ url }))
|
||||
unmount()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing children gracefully', () => {
|
||||
const citation = createCitationData()
|
||||
|
||||
expect(() => {
|
||||
render(<CitationTooltip citation={citation}>{null}</CitationTooltip>)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle extremely long URLs without breaking', () => {
|
||||
const longUrl = 'https://extremely-long-domain-name.example.com/' + 'a'.repeat(500)
|
||||
const citation = createCitationData({ url: longUrl })
|
||||
|
||||
expect(() => {
|
||||
renderCitationTooltip(citation)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance', () => {
|
||||
it('should memoize calculations correctly', () => {
|
||||
const citation = createCitationData({ url: 'https://memoize-test.com' })
|
||||
const { rerender } = renderCitationTooltip(citation)
|
||||
|
||||
expect(screen.getByText('memoize-test.com')).toBeInTheDocument()
|
||||
|
||||
// Re-render with same props should work correctly
|
||||
rerender(
|
||||
<CitationTooltip citation={citation}>
|
||||
<span>Trigger</span>
|
||||
</CitationTooltip>
|
||||
)
|
||||
expect(screen.getByText('memoize-test.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when citation data changes', () => {
|
||||
const citation1 = createCitationData({ url: 'https://first.com' })
|
||||
const { rerender } = renderCitationTooltip(citation1)
|
||||
|
||||
expect(screen.getByText('first.com')).toBeInTheDocument()
|
||||
|
||||
const citation2 = createCitationData({ url: 'https://second.com' })
|
||||
rerender(
|
||||
<CitationTooltip citation={citation2}>
|
||||
<span>Trigger</span>
|
||||
</CitationTooltip>
|
||||
)
|
||||
|
||||
expect(screen.getByText('second.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText('first.com')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Markdown from '../Markdown'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseTranslation = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
vi.mock('@renderer/services/EventService', () => ({
|
||||
EVENT_NAMES: {
|
||||
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK'
|
||||
},
|
||||
EventEmitter: {
|
||||
emit: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock utilities
|
||||
vi.mock('@renderer/utils', () => ({
|
||||
parseJSON: vi.fn((str) => {
|
||||
try {
|
||||
return JSON.parse(str || '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/formats', () => ({
|
||||
escapeBrackets: vi.fn((str) => str),
|
||||
removeSvgEmptyLines: vi.fn((str) => str)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/markdown', () => ({
|
||||
findCitationInChildren: vi.fn(() => '{"id": 1, "url": "https://example.com"}'),
|
||||
getCodeBlockId: vi.fn(() => 'code-block-1')
|
||||
}))
|
||||
|
||||
// Mock components with more realistic behavior
|
||||
vi.mock('../CodeBlock', () => ({
|
||||
__esModule: true,
|
||||
default: ({ id, onSave, children }: any) => (
|
||||
<div data-testid="code-block" data-id={id}>
|
||||
<code>{children}</code>
|
||||
<button type="button" onClick={() => onSave(id, 'new content')}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../ImagePreview', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img data-testid="image-preview" {...props} />
|
||||
}))
|
||||
|
||||
vi.mock('../Link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ citationData, children, ...props }: any) => (
|
||||
<a data-testid="citation-link" data-citation={citationData} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
|
||||
}))
|
||||
|
||||
// Mock plugins
|
||||
vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() }))
|
||||
|
||||
// Mock ReactMarkdown with realistic rendering
|
||||
vi.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, components, className }: any) => (
|
||||
<div data-testid="markdown-content" className={className}>
|
||||
{children}
|
||||
{/* Simulate component rendering */}
|
||||
{components?.a && <span data-testid="has-link-component">link</span>}
|
||||
{components?.code && (
|
||||
<div data-testid="has-code-component">
|
||||
{components.code({ children: 'test code', node: { position: { start: { line: 1 } } } })}
|
||||
</div>
|
||||
)}
|
||||
{components?.img && <span data-testid="has-img-component">img</span>}
|
||||
{components?.style && <span data-testid="has-style-component">style</span>}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('Markdown', () => {
|
||||
let mockEventEmitter: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default settings
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key)
|
||||
})
|
||||
|
||||
// Get mocked EventEmitter
|
||||
const { EventEmitter } = await import('@renderer/services/EventService')
|
||||
mockEventEmitter = EventEmitter
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Test data helpers
|
||||
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
|
||||
id: 'test-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.MAIN_TEXT,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: '# Test Markdown\n\nThis is **bold** text.',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render markdown content with correct structure', () => {
|
||||
const block = createMainTextBlock({ content: 'Test content' })
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveClass('markdown')
|
||||
expect(markdown).toHaveTextContent('Test content')
|
||||
})
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
const block = createMainTextBlock({ content: '' })
|
||||
|
||||
expect(() => render(<Markdown block={block} />)).not.toThrow()
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show paused message when content is empty and status is paused', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: '',
|
||||
status: MessageBlockStatus.PAUSED
|
||||
})
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toHaveTextContent('Paused')
|
||||
})
|
||||
|
||||
it('should prioritize actual content over paused status', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Real content',
|
||||
status: MessageBlockStatus.PAUSED
|
||||
})
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toHaveTextContent('Real content')
|
||||
expect(markdown).not.toHaveTextContent('Paused')
|
||||
})
|
||||
|
||||
it('should process content through format utilities', async () => {
|
||||
const { escapeBrackets, removeSvgEmptyLines } = await import('@renderer/utils/formats')
|
||||
const content = 'Content with [brackets] and SVG'
|
||||
|
||||
render(<Markdown block={createMainTextBlock({ content })} />)
|
||||
|
||||
expect(escapeBrackets).toHaveBeenCalledWith(content)
|
||||
expect(removeSvgEmptyLines).toHaveBeenCalledWith(content)
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(<Markdown block={createMainTextBlock()} />)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('block type support', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'MainTextMessageBlock',
|
||||
block: createMainTextBlock({ content: 'Main text content' }),
|
||||
expectedContent: 'Main text content'
|
||||
},
|
||||
{
|
||||
name: 'ThinkingMessageBlock',
|
||||
block: {
|
||||
id: 'thinking-1',
|
||||
messageId: 'msg-1',
|
||||
type: MessageBlockType.THINKING,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Thinking content',
|
||||
thinking_millsec: 5000
|
||||
} as ThinkingMessageBlock,
|
||||
expectedContent: 'Thinking content'
|
||||
},
|
||||
{
|
||||
name: 'TranslationMessageBlock',
|
||||
block: {
|
||||
id: 'translation-1',
|
||||
messageId: 'msg-1',
|
||||
type: MessageBlockType.TRANSLATION,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Translated content',
|
||||
targetLanguage: 'en'
|
||||
} as TranslationMessageBlock,
|
||||
expectedContent: 'Translated content'
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ name, block, expectedContent }) => {
|
||||
it(`should handle ${name} correctly`, () => {
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveTextContent(expectedContent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('math engine configuration', () => {
|
||||
it('should configure KaTeX when mathEngine is KaTeX', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Component should render successfully with KaTeX configuration
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should configure MathJax when mathEngine is MathJax', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Component should render successfully with MathJax configuration
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not load math plugins when mathEngine is none', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'none' })
|
||||
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Component should render successfully without math plugins
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom components', () => {
|
||||
it('should integrate Link component for citations', () => {
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('has-link-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should integrate CodeBlock component with edit functionality', () => {
|
||||
const block = createMainTextBlock({ id: 'test-block-123' })
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
expect(screen.getByTestId('has-code-component')).toBeInTheDocument()
|
||||
|
||||
// Test code block edit event
|
||||
const saveButton = screen.getByText('Save')
|
||||
saveButton.click()
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', {
|
||||
msgBlockId: 'test-block-123',
|
||||
codeBlockId: 'code-block-1',
|
||||
newContent: 'new content'
|
||||
})
|
||||
})
|
||||
|
||||
it('should integrate ImagePreview component', () => {
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle style tags with Shadow DOM', () => {
|
||||
const block = createMainTextBlock({ content: '<style>body { color: red; }</style>' })
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
expect(screen.getByTestId('has-style-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML content support', () => {
|
||||
it('should handle mixed markdown and HTML content', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: '# Header\n<div>HTML content</div>\n**Bold text**'
|
||||
})
|
||||
|
||||
expect(() => render(<Markdown block={block} />)).not.toThrow()
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveTextContent('# Header')
|
||||
expect(markdown).toHaveTextContent('HTML content')
|
||||
expect(markdown).toHaveTextContent('**Bold text**')
|
||||
})
|
||||
|
||||
it('should handle malformed content gracefully', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: '<unclosed-tag>content\n# Invalid markdown **unclosed'
|
||||
})
|
||||
|
||||
expect(() => render(<Markdown block={block} />)).not.toThrow()
|
||||
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('component behavior', () => {
|
||||
it('should re-render when content changes', () => {
|
||||
const { rerender } = render(<Markdown block={createMainTextBlock({ content: 'Initial' })} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Initial')
|
||||
|
||||
rerender(<Markdown block={createMainTextBlock({ content: 'Updated' })} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('Updated')
|
||||
})
|
||||
|
||||
it('should re-render when math engine changes', () => {
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
|
||||
const { rerender } = render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
|
||||
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
|
||||
rerender(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
// Should still render correctly with new math engine
|
||||
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CitationTooltip > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c0:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
font-size: 12px;
|
||||
color: var(--color-link);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c3:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
<div
|
||||
data-color="var(--color-background-mute)"
|
||||
data-placement="top"
|
||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
||||
data-testid="tooltip-wrapper"
|
||||
>
|
||||
<span>
|
||||
Test content
|
||||
</span>
|
||||
<div
|
||||
data-testid="tooltip-content"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
aria-label="Open Example Article in new tab"
|
||||
class="c0"
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
alt="Example Article"
|
||||
data-testid="mock-favicon"
|
||||
hostname="example.com"
|
||||
/>
|
||||
<div
|
||||
aria-level="3"
|
||||
class="c1"
|
||||
role="heading"
|
||||
title="Example Article"
|
||||
>
|
||||
Example Article
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Citation content"
|
||||
class="c2"
|
||||
role="article"
|
||||
>
|
||||
This is the article content for testing purposes.
|
||||
</div>
|
||||
<div
|
||||
aria-label="Visit example.com"
|
||||
class="c3"
|
||||
role="button"
|
||||
>
|
||||
example.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Markdown > rendering > should match snapshot 1`] = `
|
||||
<div
|
||||
class="markdown"
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
# Test Markdown
|
||||
|
||||
This is **bold** text.
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<div
|
||||
data-testid="has-code-component"
|
||||
>
|
||||
<div
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
>
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -53,6 +53,16 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
||||
|
||||
const SearchEntryPoint = styled.div`
|
||||
margin: 10px 2px;
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
.carousel {
|
||||
white-space: normal;
|
||||
.chip {
|
||||
margin: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default React.memo(CitationBlock)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { selectFormattedCitationsByBlockId } from '@renderer/store/messageBlock'
|
||||
import { type Model, WebSearchSource } from '@renderer/types'
|
||||
import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
import { cleanMarkdownContent } from '@renderer/utils/formats'
|
||||
import { cleanMarkdownContent, encodeHTML } from '@renderer/utils/formats'
|
||||
import { Flex } from 'antd'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -13,18 +13,6 @@ import styled from 'styled-components'
|
||||
|
||||
import Markdown from '../../Markdown/Markdown'
|
||||
|
||||
// HTML实体编码辅助函数
|
||||
const encodeHTML = (str: string): string => {
|
||||
const entities: { [key: string]: string } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return str.replace(/[&<>"']/g, (match) => entities[match])
|
||||
}
|
||||
|
||||
interface Props {
|
||||
block: MainTextMessageBlock
|
||||
citationBlockId?: string
|
||||
@@ -163,7 +151,7 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
|
||||
</Flex>
|
||||
)}
|
||||
{role === 'user' && !renderInputMessageAsMarkdown ? (
|
||||
<p className="markdown" style={{ marginBottom: 5 }}>
|
||||
<p className="markdown" style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>
|
||||
{block.content}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import type { Model } from '@renderer/types'
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import type { MainTextMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MainTextBlock from '../MainTextBlock'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseSelector = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', async () => {
|
||||
const actual = await import('react-redux')
|
||||
return {
|
||||
...actual,
|
||||
useSelector: () => mockUseSelector(),
|
||||
useDispatch: () => vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock store to avoid withTypes issues
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useAppSelector: vi.fn(),
|
||||
useAppDispatch: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
// Mock store selectors
|
||||
vi.mock('@renderer/store/messageBlock', async () => {
|
||||
const actual = await import('@renderer/store/messageBlock')
|
||||
return {
|
||||
...actual,
|
||||
selectFormattedCitationsByBlockId: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
|
||||
// Mock utilities
|
||||
vi.mock('@renderer/utils/formats', () => ({
|
||||
cleanMarkdownContent: vi.fn((content: string) => content),
|
||||
encodeHTML: vi.fn((content: string) => content.replace(/"/g, '"'))
|
||||
}))
|
||||
|
||||
// Mock services
|
||||
vi.mock('@renderer/services/ModelService', () => ({
|
||||
getModelUniqId: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock Markdown component
|
||||
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ block }: any) => (
|
||||
<div data-testid="mock-markdown" data-content={block.content}>
|
||||
Markdown: {block.content}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('MainTextBlock', () => {
|
||||
// Get references to mocked modules
|
||||
let mockGetModelUniqId: any
|
||||
let mockCleanMarkdownContent: any
|
||||
|
||||
// Create a mock store for Provider
|
||||
const mockStore = configureStore({
|
||||
reducer: {
|
||||
messageBlocks: (state = {}) => state
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Get the mocked functions
|
||||
const { getModelUniqId } = await import('@renderer/services/ModelService')
|
||||
const { cleanMarkdownContent } = await import('@renderer/utils/formats')
|
||||
mockGetModelUniqId = getModelUniqId as any
|
||||
mockCleanMarkdownContent = cleanMarkdownContent as any
|
||||
|
||||
// Default mock implementations
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
mockUseSelector.mockReturnValue([]) // Empty citations by default
|
||||
mockGetModelUniqId.mockImplementation((model: Model) => `${model.id}-${model.name}`)
|
||||
})
|
||||
|
||||
// Test data factory functions
|
||||
const createMainTextBlock = (overrides: Partial<MainTextMessageBlock> = {}): MainTextMessageBlock => ({
|
||||
id: 'test-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.MAIN_TEXT,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'Test content',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model =>
|
||||
({
|
||||
id: 'test-model-1',
|
||||
name: 'Test Model',
|
||||
provider: 'test-provider',
|
||||
...overrides
|
||||
}) as Model
|
||||
|
||||
// Helper functions
|
||||
const renderMainTextBlock = (props: {
|
||||
block: MainTextMessageBlock
|
||||
role: 'user' | 'assistant'
|
||||
mentions?: Model[]
|
||||
citationBlockId?: string
|
||||
}) => {
|
||||
return render(
|
||||
<Provider store={mockStore}>
|
||||
<MainTextBlock {...props} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// User-focused query helpers
|
||||
const getRenderedMarkdown = () => screen.queryByTestId('mock-markdown')
|
||||
const getRenderedPlainText = () => screen.queryByRole('paragraph')
|
||||
const getMentionElements = () => screen.queryAllByText(/@/)
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render in markdown mode for assistant messages', () => {
|
||||
const block = createMainTextBlock({ content: 'Assistant response' })
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
// User should see markdown-rendered content
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown: Assistant response')).toBeInTheDocument()
|
||||
expect(getRenderedPlainText()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in plain text mode for user messages when setting disabled', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
const block = createMainTextBlock({ content: 'User message\nWith line breaks' })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
// User should see plain text with preserved formatting
|
||||
expect(getRenderedPlainText()).toBeInTheDocument()
|
||||
expect(getRenderedPlainText()!.textContent).toBe('User message\nWith line breaks')
|
||||
expect(getRenderedMarkdown()).not.toBeInTheDocument()
|
||||
|
||||
// Check preserved whitespace
|
||||
const textElement = getRenderedPlainText()!
|
||||
expect(textElement).toHaveStyle({ whiteSpace: 'pre-wrap' })
|
||||
})
|
||||
|
||||
it('should render user messages as markdown when setting enabled', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
|
||||
const block = createMainTextBlock({ content: 'User **bold** content' })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown: User **bold** content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve complex formatting in plain text mode', () => {
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
const complexContent = `Line 1
|
||||
Indented line
|
||||
**Bold not parsed**
|
||||
- List not parsed`
|
||||
|
||||
const block = createMainTextBlock({ content: complexContent })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
|
||||
const textElement = getRenderedPlainText()!
|
||||
expect(textElement.textContent).toBe(complexContent)
|
||||
expect(textElement).toHaveClass('markdown')
|
||||
})
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
const block = createMainTextBlock({ content: '' })
|
||||
expect(() => {
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentions functionality', () => {
|
||||
it('should display model mentions when provided', () => {
|
||||
const block = createMainTextBlock({ content: 'Content with mentions' })
|
||||
const mentions = [
|
||||
createModel({ id: 'model-1', name: 'deepseek-r1' }),
|
||||
createModel({ id: 'model-2', name: 'claude-sonnet-4' })
|
||||
]
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions })
|
||||
|
||||
// User should see mention tags
|
||||
expect(screen.getByText('@deepseek-r1')).toBeInTheDocument()
|
||||
expect(screen.getByText('@claude-sonnet-4')).toBeInTheDocument()
|
||||
|
||||
// Service should be called for model processing
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledTimes(2)
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[0])
|
||||
expect(mockGetModelUniqId).toHaveBeenCalledWith(mentions[1])
|
||||
})
|
||||
|
||||
it('should not display mentions when none provided', () => {
|
||||
const block = createMainTextBlock({ content: 'No mentions content' })
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions: [] })
|
||||
expect(getMentionElements()).toHaveLength(0)
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions: undefined })
|
||||
expect(getMentionElements()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should style mentions correctly for user visibility', () => {
|
||||
const block = createMainTextBlock({ content: 'Styled mentions test' })
|
||||
const mentions = [createModel({ id: 'model-1', name: 'Test Model' })]
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant', mentions })
|
||||
|
||||
const mentionElement = screen.getByText('@Test Model')
|
||||
expect(mentionElement).toHaveStyle({ color: 'var(--color-link)' })
|
||||
|
||||
// Check container layout
|
||||
const container = mentionElement.closest('[style*="gap"]')
|
||||
expect(container).toHaveStyle({
|
||||
gap: '8px',
|
||||
marginBottom: '10px'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('content processing', () => {
|
||||
it('should filter tool_use tags from content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'single tool_use tag',
|
||||
content: 'Before <tool_use>tool content</tool_use> after',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiple tool_use tags',
|
||||
content: 'Start <tool_use>tool1</tool_use> middle <tool_use>tool2</tool_use> end',
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'multiline tool_use',
|
||||
content: `Text before
|
||||
<tool_use>
|
||||
multiline
|
||||
tool content
|
||||
</tool_use>
|
||||
text after`,
|
||||
expectsFiltering: true
|
||||
},
|
||||
{
|
||||
name: 'malformed tool_use',
|
||||
content: 'Before <tool_use>unclosed tag',
|
||||
expectsFiltering: false // Should preserve malformed tags
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ content, expectsFiltering }) => {
|
||||
const block = createMainTextBlock({ content })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
const renderedContent = getRenderedMarkdown()
|
||||
expect(renderedContent).toBeInTheDocument()
|
||||
|
||||
if (expectsFiltering) {
|
||||
// Check that tool_use content is not visible to user
|
||||
expect(screen.queryByText(/tool content|tool1|tool2|multiline/)).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process content through format utilities', () => {
|
||||
const block = createMainTextBlock({ content: 'Content to process' })
|
||||
mockUseSelector.mockReturnValue([{ id: '1', content: 'Citation content', number: 1 }])
|
||||
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'test-citations'
|
||||
})
|
||||
|
||||
// Verify utility functions are called
|
||||
expect(mockCleanMarkdownContent).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('citation integration', () => {
|
||||
it('should display content normally when no citations are present', () => {
|
||||
const block = createMainTextBlock({ content: 'Content without citations' })
|
||||
mockUseSelector.mockReturnValue([])
|
||||
|
||||
renderMainTextBlock({ block, role: 'assistant' })
|
||||
|
||||
expect(screen.getByText('Markdown: Content without citations')).toBeInTheDocument()
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should integrate with citation system when citations exist', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Content with citation [1]',
|
||||
citationReferences: [{ citationBlockSource: WebSearchSource.OPENAI }]
|
||||
})
|
||||
|
||||
const mockCitations = [
|
||||
{
|
||||
id: '1',
|
||||
number: 1,
|
||||
url: 'https://example.com',
|
||||
title: 'Example Citation',
|
||||
content: 'Citation content'
|
||||
}
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(mockCitations)
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'citation-test'
|
||||
})
|
||||
|
||||
// Verify citation integration works
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
|
||||
// Verify content processing occurred
|
||||
expect(mockCleanMarkdownContent).toHaveBeenCalledWith('Citation content')
|
||||
})
|
||||
|
||||
it('should handle different citation sources correctly', () => {
|
||||
const testSources = [WebSearchSource.OPENAI, 'DEFAULT' as any, 'CUSTOM' as any]
|
||||
|
||||
testSources.forEach((source) => {
|
||||
const block = createMainTextBlock({
|
||||
content: `Citation test for ${source}`,
|
||||
citationReferences: [{ citationBlockSource: source }]
|
||||
})
|
||||
|
||||
mockUseSelector.mockReturnValue([{ id: '1', number: 1, url: 'https://test.com', title: 'Test' }])
|
||||
|
||||
const { unmount } = renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: `test-${source}`
|
||||
})
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple citations gracefully', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Multiple citations [1] and [2]',
|
||||
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
|
||||
})
|
||||
|
||||
const multipleCitations = [
|
||||
{ id: '1', number: 1, url: 'https://first.com', title: 'First' },
|
||||
{ id: '2', number: 2, url: 'https://second.com', title: 'Second' }
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(multipleCitations)
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'multi-test' })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings integration', () => {
|
||||
it('should respond to markdown rendering setting changes', () => {
|
||||
const block = createMainTextBlock({ content: 'Settings test content' })
|
||||
|
||||
// Test with markdown enabled
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: true })
|
||||
const { unmount } = renderMainTextBlock({ block, role: 'user' })
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test with markdown disabled
|
||||
mockUseSettings.mockReturnValue({ renderInputMessageAsMarkdown: false })
|
||||
renderMainTextBlock({ block, role: 'user' })
|
||||
expect(getRenderedPlainText()).toBeInTheDocument()
|
||||
expect(getRenderedMarkdown()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and robustness', () => {
|
||||
it('should handle large content without performance issues', () => {
|
||||
const largeContent = 'A'.repeat(1000) + ' with citations [1]'
|
||||
const block = createMainTextBlock({ content: largeContent })
|
||||
|
||||
const largeCitations = [
|
||||
{
|
||||
id: '1',
|
||||
number: 1,
|
||||
url: 'https://large.com',
|
||||
title: 'Large',
|
||||
content: 'B'.repeat(500)
|
||||
}
|
||||
]
|
||||
|
||||
mockUseSelector.mockReturnValue(largeCitations)
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'large-test'
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters and Unicode gracefully', () => {
|
||||
const specialContent = '测试内容 🚀 📝 ✨ <>&"\'` [1]'
|
||||
const block = createMainTextBlock({ content: specialContent })
|
||||
|
||||
mockUseSelector.mockReturnValue([{ id: '1', number: 1, title: '特殊字符测试', content: '内容 with 🎉' }])
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
citationBlockId: 'unicode-test'
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null and undefined values gracefully', () => {
|
||||
const block = createMainTextBlock({ content: 'Null safety test' })
|
||||
|
||||
expect(() => {
|
||||
renderMainTextBlock({
|
||||
block,
|
||||
role: 'assistant',
|
||||
mentions: undefined,
|
||||
citationBlockId: undefined
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should integrate properly with Redux store', () => {
|
||||
const block = createMainTextBlock({
|
||||
content: 'Redux integration test',
|
||||
citationReferences: [{ citationBlockSource: 'DEFAULT' as any }]
|
||||
})
|
||||
|
||||
mockUseSelector.mockReturnValue([])
|
||||
renderMainTextBlock({ block, role: 'assistant', citationBlockId: 'redux-test' })
|
||||
|
||||
// Verify Redux integration
|
||||
expect(mockUseSelector).toHaveBeenCalled()
|
||||
expect(getRenderedMarkdown()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,424 @@
|
||||
import type { ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ThinkingBlock from '../ThinkingBlock'
|
||||
|
||||
// Mock dependencies
|
||||
const mockUseSettings = vi.fn()
|
||||
const mockUseTranslation = vi.fn()
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@renderer/hooks/useSettings', () => ({
|
||||
useSettings: () => mockUseSettings()
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => mockUseTranslation()
|
||||
}))
|
||||
|
||||
// Mock antd components
|
||||
vi.mock('antd', () => ({
|
||||
Collapse: ({ activeKey, onChange, items, className, size, expandIconPosition }: any) => (
|
||||
<div
|
||||
data-testid="collapse-container"
|
||||
className={className}
|
||||
data-active-key={activeKey}
|
||||
data-size={size}
|
||||
data-expand-icon-position={expandIconPosition}>
|
||||
{items.map((item: any) => (
|
||||
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
|
||||
<div data-testid={`collapse-header-${item.key}`} onClick={() => onChange()}>
|
||||
{item.label}
|
||||
</div>
|
||||
{activeKey === item.key && <div data-testid={`collapse-content-${item.key}`}>{item.children}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ title, children, mouseEnterDelay }: any) => (
|
||||
<div data-testid="tooltip" title={title} data-mouse-enter-delay={mouseEnterDelay}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
CheckOutlined: ({ style }: any) => (
|
||||
<span data-testid="check-icon" style={style}>
|
||||
✓
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Lightbulb: ({ size }: any) => (
|
||||
<span data-testid="lightbulb-icon" data-size={size}>
|
||||
💡
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
|
||||
// Mock motion
|
||||
vi.mock('motion/react', () => ({
|
||||
motion: {
|
||||
span: ({ children, variants, animate, initial, style }: any) => (
|
||||
<span
|
||||
data-testid="motion-span"
|
||||
data-variants={JSON.stringify(variants)}
|
||||
data-animate={animate}
|
||||
data-initial={initial}
|
||||
style={style}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock motion variants
|
||||
vi.mock('@renderer/utils/motionVariants', () => ({
|
||||
lightbulbVariants: {
|
||||
active: { rotate: 10, scale: 1.1 },
|
||||
idle: { rotate: 0, scale: 1 }
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Markdown component
|
||||
vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ block }: any) => (
|
||||
<div data-testid="mock-markdown" data-block-id={block.id}>
|
||||
Markdown: {block.content}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('ThinkingBlock', () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Default mock implementations
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
mockUseTranslation.mockReturnValue({
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === 'chat.thinking' && params?.seconds) {
|
||||
return `Thinking... ${params.seconds}s`
|
||||
}
|
||||
if (key === 'chat.deeply_thought' && params?.seconds) {
|
||||
return `Thought for ${params.seconds}s`
|
||||
}
|
||||
if (key === 'message.copied') return 'Copied!'
|
||||
if (key === 'message.copy.failed') return 'Copy failed'
|
||||
if (key === 'common.copy') return 'Copy'
|
||||
return key
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Test data factory functions
|
||||
const createThinkingBlock = (overrides: Partial<ThinkingMessageBlock> = {}): ThinkingMessageBlock => ({
|
||||
id: 'test-thinking-block-1',
|
||||
messageId: 'test-message-1',
|
||||
type: MessageBlockType.THINKING,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: 'I need to think about this carefully...',
|
||||
thinking_millsec: 5000,
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const renderThinkingBlock = (block: ThinkingMessageBlock) => {
|
||||
return render(<ThinkingBlock block={block} />)
|
||||
}
|
||||
|
||||
const getThinkingContent = () => screen.queryByText(/markdown:/i)
|
||||
const getCopyButton = () => screen.queryByRole('button', { name: /copy/i })
|
||||
const getThinkingTimeText = () => screen.getByText(/thinking|thought/i)
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render thinking content when provided', () => {
|
||||
const block = createThinkingBlock({ content: 'Deep thoughts about AI' })
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// User should see the thinking content
|
||||
expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lightbulb-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when content is empty', () => {
|
||||
const testCases = ['', undefined]
|
||||
|
||||
testCases.forEach((content) => {
|
||||
const block = createThinkingBlock({ content: content as any })
|
||||
const { container, unmount } = renderThinkingBlock(block)
|
||||
expect(container.firstChild).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show copy button only when thinking is complete', () => {
|
||||
// When thinking (streaming)
|
||||
const thinkingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(thinkingBlock)
|
||||
|
||||
expect(getCopyButton()).not.toBeInTheDocument()
|
||||
|
||||
// When thinking is complete
|
||||
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
expect(getCopyButton()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const block = createThinkingBlock()
|
||||
const { container } = renderThinkingBlock(block)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinking time display', () => {
|
||||
it('should display appropriate time messages based on status', () => {
|
||||
// Completed thinking
|
||||
const completedBlock = createThinkingBlock({
|
||||
thinking_millsec: 3500,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(completedBlock)
|
||||
|
||||
const timeText = getThinkingTimeText()
|
||||
expect(timeText).toHaveTextContent('3.5s')
|
||||
expect(timeText).toHaveTextContent('Thought for')
|
||||
unmount()
|
||||
|
||||
// Active thinking
|
||||
const thinkingBlock = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
renderThinkingBlock(thinkingBlock)
|
||||
|
||||
const activeTimeText = getThinkingTimeText()
|
||||
expect(activeTimeText).toHaveTextContent('1.0s')
|
||||
expect(activeTimeText).toHaveTextContent('Thinking...')
|
||||
})
|
||||
|
||||
it('should update thinking time in real-time when active', () => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// Initial state
|
||||
expect(getThinkingTimeText()).toHaveTextContent('1.0s')
|
||||
|
||||
// After time passes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(getThinkingTimeText()).toHaveTextContent('1.5s')
|
||||
})
|
||||
|
||||
it('should handle extreme thinking times correctly', () => {
|
||||
const testCases = [
|
||||
{ thinking_millsec: 0, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: undefined, expectedTime: '0.0s' },
|
||||
{ thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day
|
||||
{ thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days
|
||||
]
|
||||
|
||||
testCases.forEach(({ thinking_millsec, expectedTime }) => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
expect(getThinkingTimeText()).toHaveTextContent(expectedTime)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop timer when thinking status changes to completed', () => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: 1000,
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
const { rerender } = renderThinkingBlock(block)
|
||||
|
||||
// Advance timer while thinking
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(getThinkingTimeText()).toHaveTextContent('2.0s')
|
||||
|
||||
// Complete thinking
|
||||
const completedBlock = createThinkingBlock({
|
||||
thinking_millsec: 1000, // Original time doesn't matter
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
// Timer should stop - text should change from "Thinking..." to "Thought for"
|
||||
const timeText = getThinkingTimeText()
|
||||
expect(timeText).toHaveTextContent('Thought for')
|
||||
expect(timeText).toHaveTextContent('2.0s')
|
||||
|
||||
// Further time advancement shouldn't change the display
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(timeText).toHaveTextContent('2.0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapse behavior', () => {
|
||||
it('should respect auto-collapse setting for initial state', () => {
|
||||
// Test expanded by default (auto-collapse disabled)
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
const block = createThinkingBlock()
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
// Content should be visible when expanded
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test collapsed by default (auto-collapse enabled)
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: true
|
||||
})
|
||||
|
||||
renderThinkingBlock(block)
|
||||
|
||||
// Content should not be visible when collapsed
|
||||
expect(getThinkingContent()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should auto-collapse when thinking completes if setting enabled', () => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
messageFont: 'sans-serif',
|
||||
fontSize: 14,
|
||||
thoughtAutoCollapse: true
|
||||
})
|
||||
|
||||
const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(streamingBlock)
|
||||
|
||||
// Should be expanded while thinking
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
|
||||
// Stop thinking
|
||||
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
|
||||
rerender(<ThinkingBlock block={completedBlock} />)
|
||||
|
||||
// Should be collapsed after thinking completes
|
||||
expect(getThinkingContent()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('font and styling', () => {
|
||||
it('should apply font settings to thinking content', () => {
|
||||
const testCases = [
|
||||
{
|
||||
settings: { messageFont: 'serif', fontSize: 16 },
|
||||
expectedFont: 'var(--font-family-serif)',
|
||||
expectedSize: '16px'
|
||||
},
|
||||
{
|
||||
settings: { messageFont: 'sans-serif', fontSize: 14 },
|
||||
expectedFont: 'var(--font-family)',
|
||||
expectedSize: '14px'
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ settings, expectedFont, expectedSize }) => {
|
||||
mockUseSettings.mockReturnValue({
|
||||
...settings,
|
||||
thoughtAutoCollapse: false
|
||||
})
|
||||
|
||||
const block = createThinkingBlock()
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
// Find the styled content container
|
||||
const contentContainer = screen.getByTestId('collapse-content-thought')
|
||||
const styledDiv = contentContainer.querySelector('div')
|
||||
|
||||
expect(styledDiv).toHaveStyle({
|
||||
fontFamily: expectedFont,
|
||||
fontSize: expectedSize
|
||||
})
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration and edge cases', () => {
|
||||
it('should handle content updates correctly', () => {
|
||||
const block1 = createThinkingBlock({ content: 'Original thought' })
|
||||
const { rerender } = renderThinkingBlock(block1)
|
||||
|
||||
expect(screen.getByText('Markdown: Original thought')).toBeInTheDocument()
|
||||
|
||||
const block2 = createThinkingBlock({ content: 'Updated thought' })
|
||||
rerender(<ThinkingBlock block={block2} />)
|
||||
|
||||
expect(screen.getByText('Markdown: Updated thought')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Markdown: Original thought')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clean up timer on unmount', () => {
|
||||
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
|
||||
unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid status changes gracefully', () => {
|
||||
const block = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
|
||||
const { rerender } = renderThinkingBlock(block)
|
||||
|
||||
// Rapidly toggle between states
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.STREAMING })} />)
|
||||
rerender(<ThinkingBlock block={createThinkingBlock({ status: MessageBlockStatus.SUCCESS })} />)
|
||||
}
|
||||
|
||||
// Should still render correctly
|
||||
expect(getThinkingContent()).toBeInTheDocument()
|
||||
expect(getCopyButton()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.c3 {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.c3:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.c3:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.c3 .iconfont {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 message-thought-container"
|
||||
data-active-key="thought"
|
||||
data-expand-icon-position="end"
|
||||
data-size="small"
|
||||
data-testid="collapse-container"
|
||||
>
|
||||
<div
|
||||
data-testid="collapse-item-thought"
|
||||
>
|
||||
<div
|
||||
data-testid="collapse-header-thought"
|
||||
>
|
||||
<div
|
||||
class="c1"
|
||||
>
|
||||
<span
|
||||
data-animate="idle"
|
||||
data-initial="idle"
|
||||
data-testid="motion-span"
|
||||
data-variants="{"active":{"rotate":10,"scale":1.1},"idle":{"rotate":0,"scale":1}}"
|
||||
style="height: 18px;"
|
||||
>
|
||||
<span
|
||||
data-size="18"
|
||||
data-testid="lightbulb-icon"
|
||||
>
|
||||
💡
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="c2"
|
||||
>
|
||||
Thought for 5.0s
|
||||
</span>
|
||||
<div
|
||||
data-mouse-enter-delay="0.8"
|
||||
data-testid="tooltip"
|
||||
title="Copy"
|
||||
>
|
||||
<button
|
||||
aria-label="Copy"
|
||||
class="c3 message-action-button"
|
||||
>
|
||||
<i
|
||||
class="iconfont icon-copy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="collapse-content-thought"
|
||||
>
|
||||
<div
|
||||
style="font-family: var(--font-family); font-size: 14px;"
|
||||
>
|
||||
<div
|
||||
data-block-id="test-thinking-block-1"
|
||||
data-testid="mock-markdown"
|
||||
>
|
||||
Markdown:
|
||||
I need to think about this carefully...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -199,7 +199,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { userName } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { settedTheme } = useTheme()
|
||||
|
||||
const topicId = conversationId
|
||||
|
||||
@@ -491,7 +491,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="react-flow-container"
|
||||
colorMode={theme === 'auto' ? 'system' : theme}>
|
||||
colorMode={settedTheme}>
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
nodeStrokeWidth={3}
|
||||
|
||||
@@ -17,6 +17,9 @@ import styled from 'styled-components'
|
||||
|
||||
import ChatFlowHistory from './ChatFlowHistory'
|
||||
|
||||
// Exclude some areas from the navigation
|
||||
const EXCLUDED_SELECTORS = ['.MessageFooter', '.code-toolbar', '.ant-collapse-header', '.group-menu-bar', '.code-block']
|
||||
|
||||
interface ChatNavigationProps {
|
||||
containerId: string
|
||||
}
|
||||
@@ -233,6 +236,8 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
// Set up scroll event listener and mouse position tracking
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(containerId)
|
||||
const messagesContainer = container?.closest('.messages-container') as HTMLElement
|
||||
|
||||
if (!container) return
|
||||
|
||||
// Handle scroll events on the container
|
||||
@@ -266,10 +271,14 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
}
|
||||
|
||||
const rightPosition = window.innerWidth - rightOffset - triggerWidth
|
||||
const topPosition = window.innerHeight * 0.3 // 30% from top
|
||||
const topPosition = window.innerHeight * 0.35 // 35% from top
|
||||
const height = window.innerHeight * 0.3 // 30% of window height
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const isInExcludedArea = EXCLUDED_SELECTORS.some((selector) => target.closest(selector))
|
||||
|
||||
const isInTriggerArea =
|
||||
!isInExcludedArea &&
|
||||
e.clientX > rightPosition &&
|
||||
e.clientX < rightPosition + triggerWidth &&
|
||||
e.clientY > topPosition &&
|
||||
@@ -287,11 +296,21 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
|
||||
// Use passive: true for better scroll performance
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
if (messagesContainer) {
|
||||
// Listen to the messages container (but with global coordinates)
|
||||
messagesContainer.addEventListener('mousemove', handleMouseMove)
|
||||
} else {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
if (messagesContainer) {
|
||||
messagesContainer.removeEventListener('mousemove', handleMouseMove)
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
@@ -311,7 +330,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
<>
|
||||
<NavigationContainer $isVisible={isVisible} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<ButtonGroup>
|
||||
<Tooltip title={t('chat.navigation.close')} placement="left">
|
||||
<Tooltip title={t('chat.navigation.close')} placement="left" mouseEnterDelay={0.5}>
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
@@ -320,7 +339,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.top')} placement="left">
|
||||
<Tooltip title={t('chat.navigation.top')} placement="left" mouseEnterDelay={0.5}>
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
@@ -329,7 +348,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.prev')} placement="left">
|
||||
<Tooltip title={t('chat.navigation.prev')} placement="left" mouseEnterDelay={0.5}>
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
@@ -338,7 +357,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.next')} placement="left">
|
||||
<Tooltip title={t('chat.navigation.next')} placement="left" mouseEnterDelay={0.5}>
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
@@ -347,7 +366,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.bottom')} placement="left">
|
||||
<Tooltip title={t('chat.navigation.bottom')} placement="left" mouseEnterDelay={0.5}>
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<VerticalAlignBottomOutlined />}
|
||||
@@ -356,7 +375,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.history')} placement="left">
|
||||
<Tooltip title={t('chat.navigation.history')} placement="left" mouseEnterDelay={0.5}>
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
|
||||
@@ -21,6 +21,7 @@ import MessageErrorBoundary from './MessageErrorBoundary'
|
||||
import MessageHeader from './MessageHeader'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
import MessageTokens from './MessageTokens'
|
||||
import { estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@@ -52,7 +53,7 @@ const MessageItem: FC<Props> = ({
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize, narrowMode, messageStyle } = useSettings()
|
||||
const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic)
|
||||
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { editingMessageId, stopEditing } = useMessageEditing()
|
||||
const isEditing = editingMessageId === message.id
|
||||
@@ -69,14 +70,15 @@ const MessageItem: FC<Props> = ({
|
||||
const handleEditSave = useCallback(
|
||||
async (blocks: MessageBlock[]) => {
|
||||
try {
|
||||
console.log('after save blocks', blocks)
|
||||
await editMessageBlocks(message.id, blocks)
|
||||
const usage = await estimateMessageUsage(message)
|
||||
editMessage(message.id, { usage: usage })
|
||||
stopEditing()
|
||||
} catch (error) {
|
||||
console.error('Failed to save message blocks:', error)
|
||||
}
|
||||
},
|
||||
[message, editMessageBlocks, stopEditing]
|
||||
[message, editMessageBlocks, stopEditing, editMessage]
|
||||
)
|
||||
|
||||
const handleEditResend = useCallback(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Flex } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -12,9 +13,11 @@ interface Props {
|
||||
const MessageContent: React.FC<Props> = ({ message }) => {
|
||||
return (
|
||||
<>
|
||||
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
|
||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
{!isEmpty(message.mentions) && (
|
||||
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
|
||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
)}
|
||||
<MessageBlockRenderer blocks={message.blocks} message={message} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -204,8 +204,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem
|
||||
</MessageWrapper>
|
||||
}
|
||||
trigger={gridPopoverTrigger}
|
||||
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
|
||||
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}>
|
||||
<div style={{ cursor: 'pointer' }}>{messageContent}</div>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -154,7 +154,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
)
|
||||
|
||||
const isEditable = useMemo(() => {
|
||||
return findMainTextBlocks(message).length === 1
|
||||
return findMainTextBlocks(message).length > 0 // 使用 MCP Server 后会有大于一段 MatinTextBlock
|
||||
}, [message])
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Popover } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -40,15 +41,24 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const tokensInfo = (
|
||||
<span className="tokens">
|
||||
Tokens:
|
||||
<span>{message?.usage?.total_tokens}</span>
|
||||
<span>↑{message?.usage?.prompt_tokens}</span>
|
||||
<span>↓{message?.usage?.completion_tokens}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessageMetadata className={`message-tokens ${hasMetrics ? 'has-metrics' : ''}`} onClick={locateMessage}>
|
||||
<span className="metrics">{metrixs}</span>
|
||||
<span className="tokens">
|
||||
Tokens:
|
||||
<span>{message?.usage?.total_tokens}</span>
|
||||
<span>↑{message?.usage?.prompt_tokens}</span>
|
||||
<span>↓{message?.usage?.completion_tokens}</span>
|
||||
</span>
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
{hasMetrics ? (
|
||||
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
|
||||
{tokensInfo}
|
||||
</Popover>
|
||||
) : (
|
||||
tokensInfo
|
||||
)}
|
||||
</MessageMetadata>
|
||||
)
|
||||
}
|
||||
@@ -64,10 +74,6 @@ const MessageMetadata = styled.div`
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
|
||||
.metrics {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tokens {
|
||||
display: block;
|
||||
|
||||
@@ -75,16 +81,6 @@ const MessageMetadata = styled.div`
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-metrics:hover {
|
||||
.metrics {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tokens {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default MessgeTokens
|
||||
|
||||
@@ -53,7 +53,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
`topic-${topic.id}`
|
||||
)
|
||||
const { t } = useTranslation()
|
||||
const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
|
||||
const { showPrompt, messageNavigation } = useSettings()
|
||||
const { updateTopic, addTopic } = useAssistant(assistant.id)
|
||||
const dispatch = useAppDispatch()
|
||||
const [displayMessages, setDisplayMessages] = useState<Message[]>([])
|
||||
@@ -86,13 +86,6 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
setHasMore(messages.length > displayCount)
|
||||
}, [messages, displayCount])
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
const showRightTopics = showTopics && topicPosition === 'right'
|
||||
const minusAssistantsWidth = showAssistants ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? `- var(--assistants-width) - var(--scrollbar-width)` : ''
|
||||
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})`
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -274,17 +267,13 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
|
||||
const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
|
||||
return (
|
||||
<Container
|
||||
<MessagesContainer
|
||||
id="messages"
|
||||
className="messages-container"
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxWidth,
|
||||
paddingTop: showPrompt ? 10 : 0
|
||||
}}
|
||||
style={{ position: 'relative', paddingTop: showPrompt ? 10 : 0 }}
|
||||
key={assistant.id}
|
||||
onScroll={handleScrollPosition}
|
||||
$right={topicPosition === 'left'}>
|
||||
onScroll={handleScrollPosition}>
|
||||
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
|
||||
<InfiniteScroll
|
||||
dataLength={displayMessages.length}
|
||||
@@ -315,14 +304,13 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
</NarrowLayout>
|
||||
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
|
||||
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
|
||||
|
||||
<SelectionBox
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
messageElements={messageElements.current}
|
||||
handleSelectMessage={handleSelectMessage}
|
||||
/>
|
||||
</Container>
|
||||
</MessagesContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -380,13 +368,14 @@ interface ContainerProps {
|
||||
$right?: boolean
|
||||
}
|
||||
|
||||
const Container = styled(Scrollbar)<ContainerProps>`
|
||||
const MessagesContainer = styled(Scrollbar)<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 0 20px;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-background);
|
||||
z-index: 1;
|
||||
margin-right: 2px;
|
||||
`
|
||||
|
||||
export default Messages
|
||||
|
||||
@@ -5,6 +5,7 @@ import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const isFullscreen = useFullscreen()
|
||||
const { topicPosition, sidebarIcons, narrowMode } = useSettings()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -90,7 +92,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
{showAssistants && (
|
||||
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac ? 16 : 0 }}>
|
||||
<NavbarIcon onClick={handleToggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
@@ -113,7 +115,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
|
||||
<NavbarIcon
|
||||
onClick={() => toggleShowAssistants()}
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}>
|
||||
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
@@ -123,7 +125,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon
|
||||
onClick={() => toggleShowAssistants()}
|
||||
style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}
|
||||
style={{ marginRight: 8, marginLeft: isMac && !isFullscreen ? 4 : -12 }}
|
||||
onMouseOut={() => setSidebarHideCooldown(false)}>
|
||||
<PanelRightClose size={18} />
|
||||
</NavbarIcon>
|
||||
|
||||
@@ -3,7 +3,10 @@ import DragableList from '@renderer/components/DragableList'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
|
||||
import { useTags } from '@renderer/hooks/useTags'
|
||||
import { Assistant, AssistantsSortType } from '@renderer/types'
|
||||
import { Divider, Tooltip } from 'antd'
|
||||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -16,7 +19,6 @@ interface AssistantsTabProps {
|
||||
onCreateAssistant: () => void
|
||||
onCreateDefaultAssistant: () => void
|
||||
}
|
||||
|
||||
const Assistants: FC<AssistantsTabProps> = ({
|
||||
activeAssistant,
|
||||
setActiveAssistant,
|
||||
@@ -27,6 +29,8 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const { addAgent } = useAgents()
|
||||
const { t } = useTranslation()
|
||||
const { getGroupedAssistants } = useTags()
|
||||
const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onDelete = useCallback(
|
||||
@@ -41,6 +45,52 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
[activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant]
|
||||
)
|
||||
|
||||
const handleSortByChange = useCallback(
|
||||
(sortType: AssistantsSortType) => {
|
||||
setAssistantsTabSortType(sortType)
|
||||
},
|
||||
[setAssistantsTabSortType]
|
||||
)
|
||||
|
||||
if (assistantsTabSortType === 'tags') {
|
||||
return (
|
||||
<Container className="assistants-tab" ref={containerRef}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
{getGroupedAssistants.map((group) => (
|
||||
<TagsContainer key={group.tag}>
|
||||
<GroupTitle>
|
||||
<Tooltip title={group.tag}>
|
||||
<GroupTitleName>{group.tag}</GroupTitleName>
|
||||
</Tooltip>
|
||||
<Divider style={{ margin: '12px 0' }}></Divider>
|
||||
</GroupTitle>
|
||||
{group.assistants.map((assistant) => (
|
||||
<AssistantItem
|
||||
key={assistant.id}
|
||||
assistant={assistant}
|
||||
isActive={assistant.id === activeAssistant.id}
|
||||
sortBy={assistantsTabSortType}
|
||||
onSwitch={setActiveAssistant}
|
||||
onDelete={onDelete}
|
||||
addAgent={addAgent}
|
||||
addAssistant={addAssistant}
|
||||
onCreateDefaultAssistant={onCreateDefaultAssistant}
|
||||
handleSortByChange={handleSortByChange}
|
||||
/>
|
||||
))}
|
||||
</TagsContainer>
|
||||
))}
|
||||
</div>
|
||||
<AssistantAddItem onClick={onCreateAssistant}>
|
||||
<AssistantName>
|
||||
<PlusOutlined style={{ color: 'var(--color-text-2)', marginRight: 4 }} />
|
||||
{t('chat.add.assistant.title')}
|
||||
</AssistantName>
|
||||
</AssistantAddItem>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="assistants-tab" ref={containerRef}>
|
||||
<DragableList
|
||||
@@ -54,11 +104,13 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
key={assistant.id}
|
||||
assistant={assistant}
|
||||
isActive={assistant.id === activeAssistant.id}
|
||||
sortBy={assistantsTabSortType}
|
||||
onSwitch={setActiveAssistant}
|
||||
onDelete={onDelete}
|
||||
addAgent={addAgent}
|
||||
addAssistant={addAssistant}
|
||||
onCreateDefaultAssistant={onCreateDefaultAssistant}
|
||||
handleSortByChange={handleSortByChange}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
@@ -82,6 +134,12 @@ const Container = styled(Scrollbar)`
|
||||
padding: 10px;
|
||||
`
|
||||
|
||||
const TagsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const AssistantAddItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -103,6 +161,29 @@ const AssistantAddItem = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const GroupTitle = styled.div`
|
||||
padding: 8px 0px;
|
||||
position: relative;
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: -8px;
|
||||
`
|
||||
|
||||
const GroupTitleName = styled.div`
|
||||
max-width: 50%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
box-sizing: border-box;
|
||||
padding: 0 4px;
|
||||
color: var(--color-text);
|
||||
position: absolute;
|
||||
transform: translateY(2px);
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const AssistantName = styled.div`
|
||||
color: var(--color-text);
|
||||
display: -webkit-box;
|
||||
|
||||
@@ -227,9 +227,9 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
}>
|
||||
<SettingGroup style={{ marginTop: 5 }}>
|
||||
<Row align="middle">
|
||||
<Label>{t('chat.settings.temperature')}</Label>
|
||||
<SettingRowTitleSmall>{t('chat.settings.temperature')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.temperature.tip')}>
|
||||
<CircleHelp size={14} color="var(--color-text-2)" />
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</Row>
|
||||
<Row align="middle" gutter={10}>
|
||||
@@ -245,9 +245,9 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</Col>
|
||||
</Row>
|
||||
<Row align="middle">
|
||||
<Label>{t('chat.settings.context_count')}</Label>
|
||||
<SettingRowTitleSmall>{t('chat.settings.context_count')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.context_count.tip')}>
|
||||
<CircleHelp size={14} color="var(--color-text-2)" />
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</Row>
|
||||
<Row align="middle" gutter={10}>
|
||||
@@ -276,12 +276,12 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<HStack alignItems="center">
|
||||
<Label>{t('chat.settings.max_tokens')}</Label>
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.max_tokens')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.max_tokens.tip')}>
|
||||
<CircleHelp size={14} color="var(--color-text-2)" />
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Row>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={enableMaxTokens}
|
||||
@@ -709,12 +709,6 @@ const Container = styled(Scrollbar)`
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
const Label = styled.p`
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
`
|
||||
|
||||
const SettingRowTitleSmall = styled(SettingRowTitle)`
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
@@ -54,7 +54,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
const { assistants } = useAssistants()
|
||||
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
|
||||
const { t } = useTranslation()
|
||||
const { showTopicTime, topicPosition, pinTopicsToTop } = useSettings()
|
||||
const { showTopicTime, pinTopicsToTop } = useSettings()
|
||||
|
||||
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
|
||||
|
||||
@@ -175,6 +175,8 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
const summaryText = await fetchMessagesSummary({ messages, assistant })
|
||||
if (summaryText) {
|
||||
updateTopic({ ...topic, name: summaryText, isNameManuallyEdited: false })
|
||||
} else {
|
||||
window.message?.error(t('message.error.fetchTopicName'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +396,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
||||
<Container right={topicPosition === 'right'} className="topics-tab">
|
||||
<Container className="topics-tab">
|
||||
<DragableList list={sortedTopics} onUpdate={updateTopics}>
|
||||
{(topic) => {
|
||||
const isActive = topic.id === activeTopic?.id
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
MinusCircleOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
SmileOutlined,
|
||||
SortAscendingOutlined,
|
||||
@@ -10,185 +11,115 @@ import {
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import EmojiIcon from '@renderer/components/EmojiIcon'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTags } from '@renderer/hooks/useTags'
|
||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
import { getDefaultModel, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Assistant, AssistantsSortType } from '@renderer/types'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { Dropdown } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
import { omit } from 'lodash'
|
||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { AlignJustify, Plus, Settings2, Tag, Tags } from 'lucide-react'
|
||||
import { FC, memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import AssistantTagsPopup from './AssistantTagsPopup'
|
||||
|
||||
interface AssistantItemProps {
|
||||
assistant: Assistant
|
||||
isActive: boolean
|
||||
sortBy: AssistantsSortType
|
||||
onSwitch: (assistant: Assistant) => void
|
||||
onDelete: (assistant: Assistant) => void
|
||||
onCreateDefaultAssistant: () => void
|
||||
addAgent: (agent: any) => void
|
||||
addAssistant: (assistant: Assistant) => void
|
||||
onTagClick?: (tag: string) => void
|
||||
handleSortByChange?: (sortType: AssistantsSortType) => void
|
||||
}
|
||||
|
||||
const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch, onDelete, addAgent, addAssistant }) => {
|
||||
const AssistantItem: FC<AssistantItemProps> = ({
|
||||
assistant,
|
||||
isActive,
|
||||
sortBy,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
addAgent,
|
||||
addAssistant,
|
||||
handleSortByChange
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { removeAllTopics } = useAssistant(assistant.id) // 使用当前助手的ID
|
||||
const { allTags } = useTags()
|
||||
const { removeAllTopics } = useAssistant(assistant.id)
|
||||
const { clickAssistantToShowTopic, topicPosition, assistantIconType, setAssistantIconType } = useSettings()
|
||||
const defaultModel = getDefaultModel()
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setIsPending(false)
|
||||
return
|
||||
}
|
||||
|
||||
const hasPending = assistant.topics.some((topic) => hasTopicPendingRequests(topic.id))
|
||||
if (hasPending) {
|
||||
setIsPending(true)
|
||||
}
|
||||
setIsPending(hasPending)
|
||||
}, [isActive, assistant.topics])
|
||||
|
||||
const sortByPinyinAsc = useCallback(() => {
|
||||
const sorted = [...assistants].sort((a, b) => {
|
||||
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
|
||||
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
|
||||
return pinyinA.localeCompare(pinyinB)
|
||||
})
|
||||
updateAssistants(sorted)
|
||||
updateAssistants(sortAssistantsByPinyin(assistants, true))
|
||||
}, [assistants, updateAssistants])
|
||||
|
||||
const sortByPinyinDesc = useCallback(() => {
|
||||
const sorted = [...assistants].sort((a, b) => {
|
||||
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
|
||||
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
|
||||
return pinyinB.localeCompare(pinyinA)
|
||||
})
|
||||
updateAssistants(sorted)
|
||||
updateAssistants(sortAssistantsByPinyin(assistants, false))
|
||||
}, [assistants, updateAssistants])
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(assistant: Assistant): ItemType[] => [
|
||||
{
|
||||
label: t('assistants.edit.title'),
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => AssistantSettingsPopup.show({ assistant })
|
||||
},
|
||||
{
|
||||
label: t('assistants.copy.title'),
|
||||
key: 'duplicate',
|
||||
icon: <CopyIcon />,
|
||||
onClick: async () => {
|
||||
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] }
|
||||
addAssistant(_assistant)
|
||||
onSwitch(_assistant)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.clear.title'),
|
||||
key: 'clear',
|
||||
icon: <MinusCircleOutlined />,
|
||||
onClick: () => {
|
||||
window.modal.confirm({
|
||||
title: t('assistants.clear.title'),
|
||||
content: t('assistants.clear.content'),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => removeAllTopics() // 使用当前助手的removeAllTopics
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.save.title'),
|
||||
key: 'save-to-agent',
|
||||
icon: <SaveOutlined />,
|
||||
onClick: async () => {
|
||||
const agent = omit(assistant, ['model', 'emoji'])
|
||||
agent.id = uuid()
|
||||
agent.type = 'agent'
|
||||
addAgent(agent)
|
||||
window.message.success({
|
||||
content: t('assistants.save.success'),
|
||||
key: 'save-to-agent'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.icon.type'),
|
||||
key: 'icon-type',
|
||||
icon: <SmileOutlined />,
|
||||
children: [
|
||||
{
|
||||
label: t('settings.assistant.icon.type.model'),
|
||||
key: 'model',
|
||||
onClick: () => setAssistantIconType('model')
|
||||
},
|
||||
{
|
||||
label: t('settings.assistant.icon.type.emoji'),
|
||||
key: 'emoji',
|
||||
onClick: () => setAssistantIconType('emoji')
|
||||
},
|
||||
{
|
||||
label: t('settings.assistant.icon.type.none'),
|
||||
key: 'none',
|
||||
onClick: () => setAssistantIconType('none')
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: t('common.sort.pinyin.asc'),
|
||||
key: 'sort-asc',
|
||||
icon: <SortAscendingOutlined />,
|
||||
onClick: () => sortByPinyinAsc()
|
||||
},
|
||||
{
|
||||
label: t('common.sort.pinyin.desc'),
|
||||
key: 'sort-desc',
|
||||
icon: <SortDescendingOutlined />,
|
||||
onClick: () => sortByPinyinDesc()
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: t('common.delete'),
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
window.modal.confirm({
|
||||
title: t('assistants.delete.title'),
|
||||
content: t('assistants.delete.content'),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => onDelete(assistant)
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
getMenuItems({
|
||||
assistant,
|
||||
t,
|
||||
allTags,
|
||||
assistants,
|
||||
updateAssistants,
|
||||
addAgent,
|
||||
addAssistant,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
removeAllTopics,
|
||||
setAssistantIconType,
|
||||
sortBy,
|
||||
handleSortByChange,
|
||||
sortByPinyinAsc,
|
||||
sortByPinyinDesc
|
||||
}),
|
||||
[
|
||||
assistant,
|
||||
t,
|
||||
allTags,
|
||||
assistants,
|
||||
updateAssistants,
|
||||
addAgent,
|
||||
addAssistant,
|
||||
onDelete,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
removeAllTopics,
|
||||
setAssistantIconType,
|
||||
sortBy,
|
||||
handleSortByChange,
|
||||
sortByPinyinAsc,
|
||||
sortByPinyinDesc,
|
||||
t
|
||||
sortByPinyinDesc
|
||||
]
|
||||
)
|
||||
|
||||
const handleSwitch = useCallback(async () => {
|
||||
await modelGenerating()
|
||||
|
||||
if (clickAssistantToShowTopic) {
|
||||
if (topicPosition === 'left') {
|
||||
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
|
||||
@@ -201,11 +132,14 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
}
|
||||
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
|
||||
|
||||
const assistantName = assistant.name || t('chat.default.name')
|
||||
const fullAssistantName = assistant.emoji ? `${assistant.emoji} ${assistantName}` : assistantName
|
||||
const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name, t])
|
||||
const fullAssistantName = useMemo(
|
||||
() => (assistant.emoji ? `${assistant.emoji} ${assistantName}` : assistantName),
|
||||
[assistant.emoji, assistantName]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
{assistantIconType === 'model' ? (
|
||||
@@ -217,7 +151,7 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
) : (
|
||||
assistantIconType === 'emoji' && (
|
||||
<EmojiIcon
|
||||
emoji={assistant.emoji || assistantName.slice(0, 1)}
|
||||
emoji={assistant.emoji || getLeadingEmoji(assistantName)}
|
||||
className={isPending && !isActive ? 'animation-pulse' : ''}
|
||||
/>
|
||||
)
|
||||
@@ -234,6 +168,217 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
)
|
||||
}
|
||||
|
||||
// 提取排序相关的工具函数
|
||||
const sortAssistantsByPinyin = (assistants: Assistant[], isAscending: boolean) => {
|
||||
return [...assistants].sort((a, b) => {
|
||||
const pinyinA = tinyPinyin.convertToPinyin(a.name, '', true)
|
||||
const pinyinB = tinyPinyin.convertToPinyin(b.name, '', true)
|
||||
return isAscending ? pinyinA.localeCompare(pinyinB) : pinyinB.localeCompare(pinyinA)
|
||||
})
|
||||
}
|
||||
|
||||
// 提取标签相关的操作函数
|
||||
const handleTagOperation = (
|
||||
tag: string,
|
||||
assistant: Assistant,
|
||||
assistants: Assistant[],
|
||||
updateAssistants: (assistants: Assistant[]) => void
|
||||
) => {
|
||||
if (assistant.tags?.includes(tag)) {
|
||||
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [] } : a)))
|
||||
} else {
|
||||
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tag] } : a)))
|
||||
}
|
||||
}
|
||||
|
||||
// 提取创建菜单项的函数
|
||||
const createTagMenuItems = (
|
||||
allTags: string[],
|
||||
assistant: Assistant,
|
||||
assistants: Assistant[],
|
||||
updateAssistants: (assistants: Assistant[]) => void,
|
||||
t: (key: string) => string
|
||||
): MenuProps['items'] => {
|
||||
const items: MenuProps['items'] = [
|
||||
...allTags.map((tag) => ({
|
||||
label: tag,
|
||||
icon: assistant.tags?.includes(tag) ? <DeleteOutlined size={14} /> : <Tag size={12} />,
|
||||
danger: assistant.tags?.includes(tag),
|
||||
key: `all-tag-${tag}`,
|
||||
onClick: () => handleTagOperation(tag, assistant, assistants, updateAssistants)
|
||||
}))
|
||||
]
|
||||
|
||||
if (allTags.length > 0) {
|
||||
items.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: t('assistants.tags.add'),
|
||||
key: 'new-tag',
|
||||
icon: <Plus size={16} />,
|
||||
onClick: async () => {
|
||||
const tagName = await PromptPopup.show({
|
||||
title: t('assistants.tags.add'),
|
||||
message: ''
|
||||
})
|
||||
|
||||
if (tagName && tagName.trim()) {
|
||||
updateAssistants(assistants.map((a) => (a.id === assistant.id ? { ...a, tags: [tagName.trim()] } : a)))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (allTags.length > 0) {
|
||||
items.push({
|
||||
label: t('assistants.tags.manage'),
|
||||
key: 'manage-tags',
|
||||
icon: <Settings2 size={16} />,
|
||||
onClick: () => {
|
||||
AssistantTagsPopup.show({ title: t('assistants.tags.manage') })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// 提取创建菜单配置的函数
|
||||
function getMenuItems({
|
||||
assistant,
|
||||
t,
|
||||
allTags,
|
||||
assistants,
|
||||
updateAssistants,
|
||||
addAgent,
|
||||
addAssistant,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
removeAllTopics,
|
||||
setAssistantIconType,
|
||||
sortBy,
|
||||
handleSortByChange,
|
||||
sortByPinyinAsc,
|
||||
sortByPinyinDesc
|
||||
}): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
label: t('assistants.edit.title'),
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => AssistantSettingsPopup.show({ assistant })
|
||||
},
|
||||
{
|
||||
label: t('assistants.copy.title'),
|
||||
key: 'duplicate',
|
||||
icon: <CopyIcon />,
|
||||
onClick: async () => {
|
||||
const _assistant: Assistant = { ...assistant, id: uuid(), topics: [getDefaultTopic(assistant.id)] }
|
||||
addAssistant(_assistant)
|
||||
onSwitch(_assistant)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.clear.title'),
|
||||
key: 'clear',
|
||||
icon: <MinusCircleOutlined />,
|
||||
onClick: () => {
|
||||
window.modal.confirm({
|
||||
title: t('assistants.clear.title'),
|
||||
content: t('assistants.clear.content'),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: removeAllTopics
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.save.title'),
|
||||
key: 'save-to-agent',
|
||||
icon: <SaveOutlined />,
|
||||
onClick: async () => {
|
||||
const agent = omit(assistant, ['model', 'emoji'])
|
||||
agent.id = uuid()
|
||||
agent.type = 'agent'
|
||||
addAgent(agent)
|
||||
window.message.success({
|
||||
content: t('assistants.save.success'),
|
||||
key: 'save-to-agent'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.icon.type'),
|
||||
key: 'icon-type',
|
||||
icon: <SmileOutlined />,
|
||||
children: [
|
||||
{
|
||||
label: t('settings.assistant.icon.type.model'),
|
||||
key: 'model',
|
||||
onClick: () => setAssistantIconType('model')
|
||||
},
|
||||
{
|
||||
label: t('settings.assistant.icon.type.emoji'),
|
||||
key: 'emoji',
|
||||
onClick: () => setAssistantIconType('emoji')
|
||||
},
|
||||
{
|
||||
label: t('settings.assistant.icon.type.none'),
|
||||
key: 'none',
|
||||
onClick: () => setAssistantIconType('none')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
label: t('assistants.tags.manage'),
|
||||
key: 'all-tags',
|
||||
icon: <PlusOutlined />,
|
||||
children: createTagMenuItems(allTags, assistant, assistants, updateAssistants, t)
|
||||
},
|
||||
{
|
||||
label: sortBy === 'list' ? t('assistants.list.showByTags') : t('assistants.list.showByList'),
|
||||
key: 'switch-view',
|
||||
icon: sortBy === 'list' ? <Tags size={14} /> : <AlignJustify size={14} />,
|
||||
onClick: () => {
|
||||
sortBy === 'list' ? handleSortByChange?.('tags') : handleSortByChange?.('list')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('common.sort.pinyin.asc'),
|
||||
key: 'sort-asc',
|
||||
icon: <SortAscendingOutlined />,
|
||||
onClick: sortByPinyinAsc
|
||||
},
|
||||
{
|
||||
label: t('common.sort.pinyin.desc'),
|
||||
key: 'sort-desc',
|
||||
icon: <SortDescendingOutlined />,
|
||||
onClick: sortByPinyinDesc
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
label: t('common.delete'),
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
window.modal.confirm({
|
||||
title: t('assistants.delete.title'),
|
||||
content: t('assistants.delete.content'),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => onDelete(assistant)
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -255,8 +400,6 @@ const Container = styled.div`
|
||||
&.active {
|
||||
background-color: var(--color-background-soft);
|
||||
border: 0.5px solid var(--color-border);
|
||||
.name {
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -280,7 +423,6 @@ const MenuButton = styled.div`
|
||||
align-items: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
border-radius: 11px;
|
||||
position: absolute;
|
||||
@@ -301,4 +443,4 @@ const TopicCount = styled.div`
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
export default AssistantItem
|
||||
export default memo(AssistantItem)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useTags } from '@renderer/hooks/useTags'
|
||||
import { Button, Empty, Modal } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ShowParams {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { allTags, getAssistantsByTag } = useTags()
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const onDelete = (removedTag: string) => {
|
||||
window.modal.confirm({
|
||||
title: t('assistants.tags.deleteConfirm'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
const relatedAssistants = getAssistantsByTag(removedTag)
|
||||
if (!isEmpty(relatedAssistants)) {
|
||||
updateAssistants(
|
||||
assistants.map((assistant) => {
|
||||
const findedAssitant = relatedAssistants.find((_assistant) => _assistant.id === assistant.id)
|
||||
return findedAssitant ? { ...findedAssitant, tags: [] } : assistant
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
AssistantTagsPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
footer={null}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Container>
|
||||
{allTags.map((tag) => (
|
||||
<TagItem key={tag}>
|
||||
<Box mr={8}>{tag}</Box>
|
||||
<Button type="text" icon={<Trash size={16} />} danger onClick={() => onDelete(tag)} />
|
||||
</TagItem>
|
||||
))}
|
||||
{allTags.length === 0 && <Empty description="" />}
|
||||
</Container>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 12px 0;
|
||||
height: 50vh;
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const TagItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const TopViewKey = 'AssistantTagsPopup'
|
||||
|
||||
export default class AssistantTagsPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import AiProvider from '@renderer/providers/AiProvider'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -22,17 +23,16 @@ import { Avatar, Button, Input, InputNumber, Radio, Segmented, Select, Slider, S
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { Info } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||
import Artboard from './Artboard'
|
||||
import { type ConfigItem, createModeConfigs } from './config/aihubmixConfig'
|
||||
import { DEFAULT_PAINTING } from './config/constants'
|
||||
import PaintingsList from './PaintingsList'
|
||||
import Artboard from './components/Artboard'
|
||||
import PaintingsList from './components/PaintingsList'
|
||||
import { type ConfigItem, createModeConfigs, DEFAULT_PAINTING } from './config/aihubmixConfig'
|
||||
|
||||
// 使用函数创建配置项
|
||||
const modeConfigs = createModeConfigs()
|
||||
@@ -69,17 +69,17 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
const modeOptions = [
|
||||
{ label: t('paintings.mode.generate'), value: 'generate' },
|
||||
// { label: t('paintings.mode.edit'), value: 'edit' },
|
||||
{ label: t('paintings.mode.remix'), value: 'remix' },
|
||||
{ label: t('paintings.mode.upscale'), value: 'upscale' }
|
||||
]
|
||||
|
||||
const getNewPainting = () => {
|
||||
const getNewPainting = useCallback(() => {
|
||||
return {
|
||||
...DEFAULT_PAINTING,
|
||||
model: mode === 'generate' ? 'gpt-image-1' : 'V_3',
|
||||
id: uuid()
|
||||
}
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
const textareaRef = useRef<any>(null)
|
||||
|
||||
@@ -89,6 +89,47 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
updatePainting(mode, updatedPainting)
|
||||
}
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
window.modal.error({
|
||||
content: getErrorMessage(error),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const downloadImages = async (urls: string[]) => {
|
||||
const downloadedFiles = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
if (!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)
|
||||
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
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||
}
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (painting.files.length > 0) {
|
||||
const confirmed = await window.modal.confirm({
|
||||
@@ -129,14 +170,35 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
dispatch(setGenerating(true))
|
||||
|
||||
let body: string | FormData = ''
|
||||
const headers: Record<string, string> = {
|
||||
let headers: Record<string, string> = {
|
||||
'Api-Key': aihubmixProvider.apiKey
|
||||
}
|
||||
let url = aihubmixProvider.apiHost + `/ideogram/` + mode
|
||||
|
||||
// 不使用 AiProvider 的通用规则,而是直接调用自定义接口
|
||||
try {
|
||||
if (mode === 'generate') {
|
||||
if (painting.model === 'V_3') {
|
||||
if (painting.model.startsWith('imagen-')) {
|
||||
const AI = new AiProvider(aihubmixProvider)
|
||||
const base64s = await AI.generateImage({
|
||||
prompt,
|
||||
model: painting.model,
|
||||
config: {
|
||||
aspectRatio: painting.aspectRatio?.replace('ASPECT_', '').replace('_', ':'),
|
||||
numberOfImages: painting.model.startsWith('imagen-4.0-ultra-generate-exp') ? 1 : painting.numberOfImages,
|
||||
personGeneration: painting.personGeneration
|
||||
}
|
||||
})
|
||||
if (base64s?.length > 0) {
|
||||
const validFiles = await Promise.all(
|
||||
base64s.map(async (base64) => {
|
||||
return await window.api.file.saveBase64Image(base64)
|
||||
})
|
||||
)
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) })
|
||||
}
|
||||
return
|
||||
} else if (painting.model === 'V_3') {
|
||||
// V3 API uses different endpoint and parameters format
|
||||
const formData = new FormData()
|
||||
formData.append('prompt', prompt)
|
||||
@@ -214,67 +276,47 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
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 FileMetadata => file !== null)
|
||||
const validFiles = await downloadImages(urls)
|
||||
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
|
||||
})
|
||||
}
|
||||
handleError(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
setAbortController(null)
|
||||
}
|
||||
} else {
|
||||
// Existing V1/V2 API
|
||||
const requestData = {
|
||||
image_request: {
|
||||
let requestData: any = {}
|
||||
if (painting.model === 'gpt-image-1') {
|
||||
requestData = {
|
||||
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'
|
||||
size: painting.size === 'auto' ? undefined : painting.size,
|
||||
n: painting.n,
|
||||
quality: painting.quality,
|
||||
moderation: painting.moderation
|
||||
}
|
||||
url = aihubmixProvider.apiHost + `/v1/images/generations`
|
||||
headers = {
|
||||
Authorization: `Bearer ${aihubmixProvider.apiKey}`
|
||||
}
|
||||
} else {
|
||||
// Existing V1/V2 API
|
||||
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)
|
||||
@@ -352,37 +394,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
// 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 FileMetadata => file !== null)
|
||||
const validFiles = await downloadImages(urls)
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls })
|
||||
}
|
||||
@@ -405,119 +417,6 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
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)
|
||||
}
|
||||
|
||||
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 FileMetadata => 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,
|
||||
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({
|
||||
@@ -549,9 +448,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
|
||||
// 只针对非V3模型使用通用接口
|
||||
if (!painting.model?.includes('V_3')) {
|
||||
if (!painting.model?.includes('V_3') || mode === 'upscale') {
|
||||
// 直接调用自定义接口
|
||||
const response = await fetch(`${aihubmixProvider.apiHost}/ideogram/${mode}`, { method: 'POST', headers, body })
|
||||
const response = await fetch(url, { method: 'POST', headers, body })
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
@@ -561,53 +460,27 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
const data = await response.json()
|
||||
console.log('通用API响应:', data)
|
||||
const urls = data.data.map((item) => item.url)
|
||||
const urls = data.data.filter((item) => item.url).map((item) => item.url)
|
||||
const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json)
|
||||
|
||||
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 = await downloadImages(urls)
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls })
|
||||
}
|
||||
|
||||
if (base64s?.length > 0) {
|
||||
const validFiles = await Promise.all(
|
||||
base64s.map(async (base64) => {
|
||||
return await window.api.file.saveBase64Image(base64)
|
||||
})
|
||||
)
|
||||
|
||||
const validFiles = downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||
|
||||
await FileManager.addFiles(validFiles)
|
||||
|
||||
updatePaintingState({ files: validFiles, urls })
|
||||
updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) })
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
window.modal.error({
|
||||
content: getErrorMessage(error),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
handleError(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
dispatch(setGenerating(false))
|
||||
@@ -617,43 +490,15 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
const handleRetry = async (painting: PaintingAction) => {
|
||||
setIsLoading(true)
|
||||
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
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const validFiles = downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||
|
||||
await FileManager.addFiles(validFiles)
|
||||
|
||||
updatePaintingState({ files: validFiles, urls: painting.urls })
|
||||
setIsLoading(false)
|
||||
try {
|
||||
const validFiles = await downloadImages(painting.urls)
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls: painting.urls })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -754,20 +599,8 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
|
||||
// 渲染配置项的函数
|
||||
const renderConfigItem = (item: ConfigItem, index: number) => {
|
||||
const renderConfigForm = (item: ConfigItem) => {
|
||||
switch (item.type) {
|
||||
case 'title': {
|
||||
return (
|
||||
<SettingTitle key={index} style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t(item.title!)}
|
||||
{item.tooltip && (
|
||||
<Tooltip title={t(item.tooltip)}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</SettingTitle>
|
||||
)
|
||||
}
|
||||
case 'select': {
|
||||
// 处理函数类型的disabled属性
|
||||
const isDisabled = typeof item.disabled === 'function' ? item.disabled(item, painting) : item.disabled
|
||||
@@ -786,10 +619,11 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={index}
|
||||
style={{ width: '100%' }}
|
||||
listHeight={500}
|
||||
disabled={isDisabled}
|
||||
value={painting[item.key!] || item.initialValue}
|
||||
options={selectOptions}
|
||||
options={selectOptions as any}
|
||||
onChange={(v) => updatePaintingState({ [item.key!]: v })}
|
||||
/>
|
||||
)
|
||||
@@ -809,8 +643,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
return (
|
||||
<Radio.Group
|
||||
key={index}
|
||||
value={painting[item.key!]}
|
||||
value={painting[item.key!] || item.initialValue}
|
||||
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}>
|
||||
{radioOptions!.map((option) => (
|
||||
<Radio.Button key={option.value} value={option.value}>
|
||||
@@ -822,96 +655,82 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
case 'slider': {
|
||||
return (
|
||||
<SliderContainer key={index}>
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={painting[item.key!] as number}
|
||||
value={(painting[item.key!] || item.initialValue) as number}
|
||||
onChange={(v) => updatePaintingState({ [item.key!]: v })}
|
||||
/>
|
||||
<StyledInputNumber
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={painting[item.key!] as number}
|
||||
value={(painting[item.key!] || item.initialValue) as number}
|
||||
onChange={(v) => updatePaintingState({ [item.key!]: v })}
|
||||
/>
|
||||
</SliderContainer>
|
||||
)
|
||||
}
|
||||
case 'input': {
|
||||
// 处理随机种子按钮的特殊情况
|
||||
if (item.key === 'seed') {
|
||||
return (
|
||||
<Input
|
||||
key={index}
|
||||
value={painting[item.key] as string}
|
||||
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}
|
||||
suffix={
|
||||
<RedoOutlined onClick={handleRandomSeed} style={{ cursor: 'pointer', color: 'var(--color-text-2)' }} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'input':
|
||||
return (
|
||||
<Input
|
||||
key={index}
|
||||
value={painting[item.key!] as string}
|
||||
value={(painting[item.key!] || item.initialValue) as string}
|
||||
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}
|
||||
suffix={item.suffix}
|
||||
suffix={
|
||||
item.key === 'seed' ? (
|
||||
<RedoOutlined onClick={handleRandomSeed} style={{ cursor: 'pointer', color: 'var(--color-text-2)' }} />
|
||||
) : (
|
||||
item.suffix
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'inputNumber': {
|
||||
case 'inputNumber':
|
||||
return (
|
||||
<InputNumber
|
||||
key={index}
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
style={{ width: '100%' }}
|
||||
value={painting[item.key!] as number}
|
||||
value={(painting[item.key!] || item.initialValue) as number}
|
||||
onChange={(v) => updatePaintingState({ [item.key!]: v })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'textarea': {
|
||||
case 'textarea':
|
||||
return (
|
||||
<TextArea
|
||||
key={index}
|
||||
value={painting[item.key!] as string}
|
||||
value={(painting[item.key!] || item.initialValue) as string}
|
||||
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}
|
||||
spellCheck={false}
|
||||
rows={4}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'switch': {
|
||||
case 'switch':
|
||||
return (
|
||||
<HStack key={index}>
|
||||
<HStack>
|
||||
<Switch
|
||||
checked={painting[item.key!] as boolean}
|
||||
checked={(painting[item.key!] || item.initialValue) as boolean}
|
||||
onChange={(checked) => updatePaintingState({ [item.key!]: checked })}
|
||||
/>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
case 'image': {
|
||||
return (
|
||||
<ImageUploadButton
|
||||
key={index}
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
listType="picture-card"
|
||||
onChange={async ({ file }) => {
|
||||
const path = file.originFileObj?.path || ''
|
||||
setFileMap({ ...fileMap, [path]: file.originFileObj as unknown as FileMetadata })
|
||||
beforeUpload={(file) => {
|
||||
const path = URL.createObjectURL(file)
|
||||
setFileMap({ ...fileMap, [path]: file as unknown as FileMetadata })
|
||||
updatePaintingState({ [item.key!]: path })
|
||||
return false // 阻止默认上传行为
|
||||
}}>
|
||||
{painting[item.key!] ? (
|
||||
<ImagePreview>
|
||||
<img src={'file://' + painting[item.key!]} alt="预览图" />
|
||||
<img src={painting[item.key!]} alt="预览图" />
|
||||
</ImagePreview>
|
||||
) : (
|
||||
<ImageSizeImage src={IcImageUp} theme={theme} />
|
||||
@@ -924,6 +743,23 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染配置项的函数
|
||||
const renderConfigItem = (item: ConfigItem, index: number) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t(item.title!)}
|
||||
{item.tooltip && (
|
||||
<Tooltip title={t(item.tooltip)}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
)}
|
||||
</SettingTitle>
|
||||
{renderConfigForm(item)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onSelectPainting = (newPainting: PaintingAction) => {
|
||||
if (generating) return
|
||||
setPainting(newPainting)
|
||||
@@ -936,12 +772,13 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
addPainting(mode, newPainting)
|
||||
setPainting(newPainting)
|
||||
}
|
||||
}, [filteredPaintings, mode, addPainting, painting])
|
||||
}, [filteredPaintings, mode, addPainting, painting, getNewPainting])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = spaceClickTimer.current
|
||||
return () => {
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
@@ -985,7 +822,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
</Select>
|
||||
|
||||
{/* 使用JSON配置渲染设置项 */}
|
||||
{modeConfigs[mode].map(renderConfigItem)}
|
||||
{modeConfigs[mode].filter((item) => (item.condition ? item.condition(painting) : true)).map(renderConfigItem)}
|
||||
</LeftContainer>
|
||||
<MainContainer>
|
||||
{/* 添加功能切换分段控制器 */}
|
||||
@@ -1009,7 +846,13 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
value={painting.prompt}
|
||||
spellCheck={false}
|
||||
onChange={(e) => updatePaintingState({ prompt: e.target.value })}
|
||||
placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder_edit')}
|
||||
placeholder={
|
||||
isTranslating
|
||||
? t('paintings.translating')
|
||||
: painting.model?.startsWith('imagen-')
|
||||
? t('paintings.prompt_placeholder_en')
|
||||
: t('paintings.prompt_placeholder_edit')
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Toolbar>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PlusOutlined, RedoOutlined } from '@ant-design/icons'
|
||||
import DMXAPIToImg from '@renderer/assets/images/providers/DMXAPI-to-img.webp'
|
||||
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
@@ -13,9 +14,9 @@ import FileManager from '@renderer/services/FileManager'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import type { FileMetadata, PaintingsState } from '@renderer/types'
|
||||
import { getErrorMessage, uuid } from '@renderer/utils'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { DmxapiPainting, PaintingAction } from '@types'
|
||||
import { Avatar, Button, Input, Radio, Select, Tooltip } from 'antd'
|
||||
import { Avatar, Button, Input, Radio, Select, Switch, Tooltip } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { Info } from 'lucide-react'
|
||||
import React, { FC } from 'react'
|
||||
@@ -26,7 +27,8 @@ import styled from 'styled-components'
|
||||
|
||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||
import { SettingHelpLink, SettingTitle } from '../settings'
|
||||
import Artboard from './Artboard'
|
||||
import Artboard from './components/Artboard'
|
||||
import PaintingsList from './components/PaintingsList'
|
||||
import {
|
||||
COURSE_URL,
|
||||
DEFAULT_PAINTING,
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
STYLE_TYPE_OPTIONS,
|
||||
TEXT_TO_IMAGES_MODELS
|
||||
} from './config/DmxapiConfig'
|
||||
import PaintingsList from './PaintingsList'
|
||||
|
||||
const generateRandomSeed = () => Math.floor(Math.random() * 1000000).toString()
|
||||
|
||||
@@ -71,6 +72,16 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getNewPaintingPanel = (updates: Partial<DmxapiPainting>) => {
|
||||
const copyPainting = {
|
||||
...painting,
|
||||
...updates,
|
||||
id: uuid()
|
||||
}
|
||||
|
||||
setPainting(addPainting('DMXAPIPaintings', copyPainting))
|
||||
}
|
||||
|
||||
const modelOptions = TEXT_TO_IMAGES_MODELS.map((model) => ({
|
||||
label: model.name,
|
||||
value: model.id
|
||||
@@ -108,6 +119,10 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeAutoCreate = (v: boolean) => {
|
||||
updatePaintingState({ autoCreate: v })
|
||||
}
|
||||
|
||||
const onInputSeed = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
// 允许空值或合法整数,且大于等于 -1
|
||||
@@ -194,8 +209,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error?.message || '操作失败')
|
||||
throw new Error('操作失败,请稍后重试')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -251,7 +265,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
checkProviderStatus()
|
||||
|
||||
// 处理已有文件
|
||||
if (painting.files.length > 0) {
|
||||
if (painting.files.length > 0 && !painting.autoCreate) {
|
||||
const confirmed = await window.modal.confirm({
|
||||
content: t('paintings.regenerate.confirm'),
|
||||
centered: true
|
||||
@@ -277,11 +291,25 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const downloadedFiles = await downloadImages(urls)
|
||||
const validFiles = downloadedFiles.filter((file): file is FileMetadata => file !== null)
|
||||
|
||||
// 删除之前的图片
|
||||
await FileManager.deleteFiles(painting.files)
|
||||
// 保存文件并更新状态
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls })
|
||||
if (validFiles?.length > 0) {
|
||||
if (painting.autoCreate && painting.files.length > 0) {
|
||||
// 保存文件并更新状态
|
||||
await FileManager.addFiles(validFiles)
|
||||
getNewPaintingPanel({ files: validFiles, urls })
|
||||
} else {
|
||||
// 删除之前的图片
|
||||
await FileManager.deleteFiles(painting.files)
|
||||
|
||||
// 保存文件并更新状态
|
||||
await FileManager.addFiles(validFiles)
|
||||
updatePaintingState({ files: validFiles, urls })
|
||||
}
|
||||
} else {
|
||||
window.message.warning({
|
||||
content: t('paintings.req_error_text'),
|
||||
key: 'empty-url-warning'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误处理
|
||||
@@ -290,7 +318,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
content:
|
||||
error.message.startsWith('paintings.') || error.message.startsWith('error.')
|
||||
? t(error.message)
|
||||
: getErrorMessage(error),
|
||||
: t('paintings.req_error_text'),
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
@@ -348,6 +376,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
return () => {
|
||||
if (spaceClickTimer.current) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
}
|
||||
@@ -441,6 +470,16 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
))}
|
||||
</RadioTextBox>
|
||||
</SliderContainer>
|
||||
|
||||
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
|
||||
{t('paintings.auto_create_paint')}
|
||||
<Tooltip title={t('paintings.auto_create_paint_tip')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<HStack>
|
||||
<Switch checked={painting.autoCreate} onChange={(checked) => onChangeAutoCreate(checked)} />
|
||||
</HStack>
|
||||
</LeftContainer>
|
||||
<MainContainer>
|
||||
<Artboard
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { FC } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setDefaultPaintingProvider } from '@renderer/store/settings'
|
||||
import { PaintingProvider } from '@renderer/types'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { Route, Routes, useParams } from 'react-router-dom'
|
||||
|
||||
import AihubmixPage from './AihubmixPage'
|
||||
import DmxapiPage from './DmxapiPage'
|
||||
import SiliconPage from './PaintingsPage'
|
||||
import SiliconPage from './SiliconPage'
|
||||
|
||||
const Options = ['aihubmix', 'silicon', 'dmxapi']
|
||||
|
||||
const PaintingsRoutePage: FC = () => {
|
||||
const params = useParams()
|
||||
const provider = params['*']
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('defaultPaintingProvider', provider)
|
||||
if (provider && Options.includes(provider)) {
|
||||
dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
|
||||
}
|
||||
}, [provider, dispatch])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<AihubmixPage Options={Options} />} />
|
||||
<Route path="*" element={<AihubmixPage Options={Options} />} />
|
||||
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} />
|
||||
<Route path="/silicon" element={<SiliconPage Options={Options} />} />
|
||||
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} />
|
||||
|
||||
+4
-4
@@ -35,8 +35,8 @@ import styled from 'styled-components'
|
||||
|
||||
import SendMessageButton from '../home/Inputbar/SendMessageButton'
|
||||
import { SettingTitle } from '../settings'
|
||||
import Artboard from './Artboard'
|
||||
import PaintingsList from './PaintingsList'
|
||||
import Artboard from './components/Artboard'
|
||||
import PaintingsList from './components/PaintingsList'
|
||||
|
||||
const IMAGE_SIZES = [
|
||||
{
|
||||
@@ -88,7 +88,7 @@ const DEFAULT_PAINTING: Painting = {
|
||||
|
||||
// let _painting: Painting
|
||||
|
||||
const PaintingsPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const { t } = useTranslation()
|
||||
const { paintings, addPainting, removePainting, updatePainting } = usePaintings()
|
||||
const [painting, setPainting] = useState<Painting>(paintings[0] || DEFAULT_PAINTING)
|
||||
@@ -645,4 +645,4 @@ const StyledInputNumber = styled(InputNumber)`
|
||||
width: 70px;
|
||||
`
|
||||
|
||||
export default PaintingsPage
|
||||
export default SiliconPage
|
||||
+3
-3
@@ -7,7 +7,7 @@ import React, { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ImagePreview from '../home/Markdown/ImagePreview'
|
||||
import ImagePreview from '../../home/Markdown/ImagePreview'
|
||||
|
||||
interface ArtboardProps {
|
||||
painting: Painting
|
||||
@@ -98,8 +98,8 @@ const Artboard: FC<ArtboardProps> = ({
|
||||
{painting.urls.length > 0 && retry ? (
|
||||
<div>
|
||||
<ImageList>
|
||||
{painting.urls.map((url) => (
|
||||
<ImageListItem key={url}>{url}</ImageListItem>
|
||||
{painting.urls.map((url, index) => (
|
||||
<ImageListItem key={url || index}>{url}</ImageListItem>
|
||||
))}
|
||||
</ImageList>
|
||||
<div>
|
||||
@@ -90,5 +90,6 @@ export const DEFAULT_PAINTING: DmxapiPainting = {
|
||||
n: 1,
|
||||
seed: '',
|
||||
style_type: '',
|
||||
model: TEXT_TO_IMAGES_MODELS[0].id
|
||||
model: TEXT_TO_IMAGES_MODELS[0].id,
|
||||
autoCreate: false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { PaintingAction, PaintingsState } from '@renderer/types'
|
||||
import type { PaintingAction } from '@renderer/types'
|
||||
|
||||
import { ASPECT_RATIOS, RENDERING_SPEED_OPTIONS, STYLE_TYPES, V3_STYLE_TYPES } from './constants'
|
||||
import {
|
||||
ASPECT_RATIOS,
|
||||
BACKGROUND_OPTIONS,
|
||||
MODERATION_OPTIONS,
|
||||
PERSON_GENERATION_OPTIONS,
|
||||
QUALITY_OPTIONS,
|
||||
RENDERING_SPEED_OPTIONS,
|
||||
STYLE_TYPES,
|
||||
V3_STYLE_TYPES
|
||||
} from './constants'
|
||||
|
||||
// 配置项类型定义
|
||||
export type ConfigItem = {
|
||||
@@ -19,7 +28,14 @@ export type ConfigItem = {
|
||||
title?: string
|
||||
tooltip?: string
|
||||
options?:
|
||||
| Array<{ label: string; value: string | number; icon?: string; onlyV2?: boolean }>
|
||||
| Array<{
|
||||
label: string
|
||||
title?: string
|
||||
value?: string | number
|
||||
icon?: string
|
||||
onlyV2?: boolean
|
||||
options?: Array<{ label: string; value: string | number; icon?: string; onlyV2?: boolean }>
|
||||
}>
|
||||
| ((
|
||||
config: ConfigItem,
|
||||
painting: Partial<PaintingAction>
|
||||
@@ -32,189 +48,201 @@ export type ConfigItem = {
|
||||
disabled?: boolean | ((config: ConfigItem, painting: Partial<PaintingAction>) => boolean)
|
||||
initialValue?: string | number
|
||||
required?: boolean
|
||||
condition?: (painting: PaintingAction) => boolean
|
||||
}
|
||||
|
||||
export type AihubmixMode = keyof PaintingsState
|
||||
export type AihubmixMode = 'generate' | 'remix' | 'upscale'
|
||||
|
||||
// 创建配置项函数
|
||||
export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
|
||||
return {
|
||||
paintings: [],
|
||||
DMXAPIPaintings: [],
|
||||
generate: [
|
||||
{ type: 'title', title: 'paintings.model', tooltip: 'paintings.generate.model_tip' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'model',
|
||||
title: 'paintings.model',
|
||||
tooltip: 'paintings.generate.model_tip',
|
||||
options: [
|
||||
{ label: 'ideogram_V_3', value: 'V_3' },
|
||||
{ label: 'ideogram_V_2', value: 'V_2' },
|
||||
{ label: 'ideogram_V_2_TURBO', value: 'V_2_TURBO' },
|
||||
{ label: 'ideogram_V_2A', value: 'V_2A' },
|
||||
{ label: 'ideogram_V_2A_TURBO', value: 'V_2A_TURBO' },
|
||||
{ label: 'ideogram_V_1', value: 'V_1' },
|
||||
{ label: 'ideogram_V_1_TURBO', value: 'V_1_TURBO' }
|
||||
{
|
||||
label: 'OpenAI',
|
||||
title: 'OpenAI',
|
||||
options: [{ label: 'gpt-image-1', value: 'gpt-image-1' }]
|
||||
},
|
||||
{
|
||||
label: 'Gemini',
|
||||
title: 'Gemini',
|
||||
options: [
|
||||
{ label: 'imagen-4.0-preview', value: 'imagen-4.0-generate-preview-05-20' },
|
||||
{ label: 'imagen-4.0-ultra-exp', value: 'imagen-4.0-ultra-generate-exp-05-20' },
|
||||
{ label: 'imagen-3.0', value: 'imagen-3.0-generate-001' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'ideogram',
|
||||
title: 'ideogram',
|
||||
options: [
|
||||
{ label: 'ideogram_V_3', value: 'V_3' },
|
||||
{ label: 'ideogram_V_2', value: 'V_2' },
|
||||
{ label: 'ideogram_V_2_TURBO', value: 'V_2_TURBO' },
|
||||
{ label: 'ideogram_V_2A', value: 'V_2A' },
|
||||
{ label: 'ideogram_V_2A_TURBO', value: 'V_2A_TURBO' },
|
||||
{ label: 'ideogram_V_1', value: 'V_1' },
|
||||
{ label: 'ideogram_V_1_TURBO', value: 'V_1_TURBO' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 'title', title: 'paintings.rendering_speed', tooltip: 'paintings.generate.rendering_speed_tip' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'renderingSpeed',
|
||||
title: 'paintings.rendering_speed',
|
||||
tooltip: 'paintings.generate.rendering_speed_tip',
|
||||
options: RENDERING_SPEED_OPTIONS,
|
||||
initialValue: 'DEFAULT',
|
||||
disabled: (_config, painting) => {
|
||||
const model = painting?.model
|
||||
return !model || !model.includes('V_3')
|
||||
}
|
||||
condition: (painting) => painting.model === 'V_3'
|
||||
},
|
||||
{ type: 'title', title: 'paintings.aspect_ratio' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'aspectRatio',
|
||||
options: ASPECT_RATIOS.map((size) => ({
|
||||
label: size.label,
|
||||
value: size.value,
|
||||
icon: size.icon
|
||||
}))
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.generate.number_images_tip'
|
||||
title: 'paintings.aspect_ratio',
|
||||
options: ASPECT_RATIOS,
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('V_'))
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
key: 'numImages',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.generate.number_images_tip',
|
||||
min: 1,
|
||||
max: 8
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.style_type',
|
||||
tooltip: 'paintings.generate.style_type_tip'
|
||||
max: 8,
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('V_'))
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
key: 'styleType',
|
||||
title: 'paintings.style_type',
|
||||
tooltip: 'paintings.generate.style_type_tip',
|
||||
options: (_config, painting) => {
|
||||
// 根据模型选择显示不同的样式类型选项
|
||||
return painting?.model?.includes('V_3') ? V3_STYLE_TYPES : STYLE_TYPES
|
||||
},
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.seed',
|
||||
tooltip: 'paintings.generate.seed_tip'
|
||||
disabled: false,
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('V_'))
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
key: 'seed'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.negative_prompt',
|
||||
tooltip: 'paintings.generate.negative_prompt_tip'
|
||||
key: 'seed',
|
||||
title: 'paintings.seed',
|
||||
tooltip: 'paintings.generate.seed_tip',
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('V_'))
|
||||
},
|
||||
{
|
||||
type: 'textarea',
|
||||
key: 'negativePrompt'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.magic_prompt_option',
|
||||
tooltip: 'paintings.generate.magic_prompt_option_tip'
|
||||
key: 'negativePrompt',
|
||||
title: 'paintings.negative_prompt',
|
||||
tooltip: 'paintings.generate.negative_prompt_tip',
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('V_'))
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
key: 'magicPromptOption'
|
||||
}
|
||||
],
|
||||
edit: [
|
||||
{ type: 'title', title: 'paintings.edit.image_file' },
|
||||
{
|
||||
type: 'image',
|
||||
key: 'imageFile'
|
||||
key: 'magicPromptOption',
|
||||
title: 'paintings.magic_prompt_option',
|
||||
tooltip: 'paintings.generate.magic_prompt_option_tip',
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('V_'))
|
||||
},
|
||||
{ type: 'title', title: 'paintings.model', tooltip: 'paintings.edit.model_tip' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'model',
|
||||
key: 'size',
|
||||
title: 'paintings.aspect_ratio',
|
||||
options: [
|
||||
{ label: 'ideogram_V_3', value: 'V_3' },
|
||||
{ label: 'ideogram_V_2', value: 'V_2' },
|
||||
{ label: 'ideogram_V_2_TURBO', value: 'V_2_TURBO' },
|
||||
{ label: 'ideogram_V_2A', value: 'V_2A' },
|
||||
{ label: 'ideogram_V_2A_TURBO', value: 'V_2A_TURBO' },
|
||||
{ label: 'ideogram_V_1', value: 'V_1' },
|
||||
{ label: 'ideogram_V_1_TURBO', value: 'V_1_TURBO' }
|
||||
]
|
||||
},
|
||||
{ type: 'title', title: 'paintings.rendering_speed', tooltip: 'paintings.edit.rendering_speed_tip' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'renderingSpeed',
|
||||
options: RENDERING_SPEED_OPTIONS,
|
||||
initialValue: 'DEFAULE',
|
||||
disabled: (_config, painting) => {
|
||||
const model = painting?.model
|
||||
return !model || !model.includes('V_3')
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.edit.number_images_tip'
|
||||
{ label: '自动', value: 'auto' },
|
||||
{ label: '1:1', value: '1024x1024' },
|
||||
{ label: '3:2', value: '1536x1024' },
|
||||
{ label: '2:3', value: '1024x1536' }
|
||||
],
|
||||
initialValue: '1024x1024',
|
||||
condition: (painting) => painting.model === 'gpt-image-1'
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
key: 'numImages',
|
||||
key: 'n',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.generate.number_images_tip',
|
||||
min: 1,
|
||||
max: 8
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.style_type',
|
||||
tooltip: 'paintings.edit.style_type_tip'
|
||||
max: 10,
|
||||
initialValue: 1,
|
||||
condition: (painting) => painting.model === 'gpt-image-1'
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
key: 'styleType',
|
||||
options: (_config, painting) => {
|
||||
// 根据模型选择显示不同的样式类型选项
|
||||
return painting?.model?.includes('V_3') ? V3_STYLE_TYPES : STYLE_TYPES
|
||||
},
|
||||
disabled: false
|
||||
key: 'quality',
|
||||
title: 'paintings.quality',
|
||||
options: QUALITY_OPTIONS,
|
||||
initialValue: 'auto',
|
||||
condition: (painting) => painting.model === 'gpt-image-1'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.seed',
|
||||
tooltip: 'paintings.edit.seed_tip'
|
||||
type: 'select',
|
||||
key: 'moderation',
|
||||
title: 'paintings.moderation',
|
||||
options: MODERATION_OPTIONS,
|
||||
initialValue: 'auto',
|
||||
condition: (painting) => painting.model === 'gpt-image-1'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
key: 'seed'
|
||||
type: 'select',
|
||||
key: 'background',
|
||||
title: 'paintings.background',
|
||||
options: BACKGROUND_OPTIONS,
|
||||
initialValue: 'auto',
|
||||
condition: (painting) => painting.model === 'gpt-image-1'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.magic_prompt_option',
|
||||
tooltip: 'paintings.edit.magic_prompt_option_tip'
|
||||
type: 'slider',
|
||||
key: 'numberOfImages',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.generate.number_images_tip',
|
||||
min: 1,
|
||||
max: 4,
|
||||
initialValue: 4,
|
||||
condition: (painting) =>
|
||||
Boolean(painting.model?.startsWith('imagen-') && painting.model !== 'imagen-4.0-ultra-generate-exp-05-20')
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
key: 'magicPromptOption'
|
||||
type: 'select',
|
||||
key: 'aspectRatio',
|
||||
title: 'paintings.aspect_ratio',
|
||||
options: [
|
||||
{ label: '1:1', value: 'ASPECT_1_1' },
|
||||
{ label: '3:4', value: 'ASPECT_3_4' },
|
||||
{ label: '4:3', value: 'ASPECT_4_3' },
|
||||
{ label: '9:16', value: 'ASPECT_9_16' },
|
||||
{ label: '16:9', value: 'ASPECT_16_9' }
|
||||
],
|
||||
initialValue: 'ASPECT_1_1',
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('imagen-'))
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
key: 'personGeneration',
|
||||
title: 'paintings.generate.person_generation',
|
||||
tooltip: 'paintings.generate.person_generation_tip',
|
||||
options: PERSON_GENERATION_OPTIONS,
|
||||
initialValue: 'ALLOW_ALL',
|
||||
condition: (painting) => Boolean(painting.model?.startsWith('imagen-'))
|
||||
}
|
||||
],
|
||||
remix: [
|
||||
{ type: 'title', title: 'paintings.remix.image_file' },
|
||||
{
|
||||
type: 'image',
|
||||
key: 'imageFile'
|
||||
key: 'imageFile',
|
||||
title: 'paintings.remix.image_file'
|
||||
},
|
||||
{ type: 'title', title: 'paintings.model', tooltip: 'paintings.remix.model_tip' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'model',
|
||||
title: 'paintings.model',
|
||||
tooltip: 'paintings.remix.model_tip',
|
||||
options: [
|
||||
{ label: 'ideogram_V_3', value: 'V_3' },
|
||||
{ label: 'ideogram_V_2', value: 'V_2' },
|
||||
@@ -225,10 +253,10 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
|
||||
{ label: 'ideogram_V_1_TURBO', value: 'V_1_TURBO' }
|
||||
]
|
||||
},
|
||||
{ type: 'title', title: 'paintings.rendering_speed', tooltip: 'paintings.remix.rendering_speed_tip' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'renderingSpeed',
|
||||
title: 'paintings.rendering_speed',
|
||||
options: RENDERING_SPEED_OPTIONS,
|
||||
initialValue: 'DEFAULT',
|
||||
disabled: (_config, painting) => {
|
||||
@@ -236,42 +264,32 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
|
||||
return !model || !model.includes('V_3')
|
||||
}
|
||||
},
|
||||
{ type: 'title', title: 'paintings.aspect_ratio' },
|
||||
{
|
||||
type: 'select',
|
||||
key: 'aspectRatio',
|
||||
options: ASPECT_RATIOS.map((size) => ({
|
||||
label: size.label,
|
||||
value: size.value,
|
||||
icon: size.icon
|
||||
}))
|
||||
title: 'paintings.aspect_ratio',
|
||||
options: ASPECT_RATIOS
|
||||
},
|
||||
{ type: 'title', title: 'paintings.remix.image_weight' },
|
||||
{
|
||||
type: 'slider',
|
||||
key: 'imageWeight',
|
||||
title: 'paintings.remix.image_weight',
|
||||
min: 1,
|
||||
max: 100
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.remix.number_images_tip'
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
key: 'numImages',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.remix.number_images_tip',
|
||||
min: 1,
|
||||
max: 8
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.style_type',
|
||||
tooltip: 'paintings.remix.style_type_tip'
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
key: 'styleType',
|
||||
title: 'paintings.style_type',
|
||||
tooltip: 'paintings.remix.style_type_tip',
|
||||
options: (_config, painting) => {
|
||||
// 根据模型选择显示不同的样式类型选项
|
||||
return painting?.model?.includes('V_3') ? V3_STYLE_TYPES : STYLE_TYPES
|
||||
@@ -279,78 +297,93 @@ export const createModeConfigs = (): Record<AihubmixMode, ConfigItem[]> => {
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
type: 'input',
|
||||
key: 'seed',
|
||||
title: 'paintings.seed',
|
||||
tooltip: 'paintings.remix.seed_tip'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
key: 'seed'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
type: 'textarea',
|
||||
key: 'negativePrompt',
|
||||
title: 'paintings.negative_prompt',
|
||||
tooltip: 'paintings.remix.negative_prompt_tip'
|
||||
},
|
||||
{
|
||||
type: 'textarea',
|
||||
key: 'negativePrompt'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
type: 'switch',
|
||||
key: 'magicPromptOption',
|
||||
title: 'paintings.magic_prompt_option',
|
||||
tooltip: 'paintings.remix.magic_prompt_option_tip'
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
key: 'magicPromptOption'
|
||||
}
|
||||
],
|
||||
upscale: [
|
||||
{ type: 'title', title: 'paintings.upscale.image_file' },
|
||||
{
|
||||
type: 'image',
|
||||
key: 'imageFile',
|
||||
title: 'paintings.upscale.image_file',
|
||||
required: true
|
||||
},
|
||||
{ type: 'title', title: 'paintings.upscale.resemblance', tooltip: 'paintings.upscale.resemblance_tip' },
|
||||
{ type: 'slider', key: 'resemblance', min: 1, max: 100 },
|
||||
{ type: 'title', title: 'paintings.upscale.detail', tooltip: 'paintings.upscale.detail_tip' },
|
||||
{
|
||||
type: 'slider',
|
||||
key: 'detail',
|
||||
key: 'resemblance',
|
||||
title: 'paintings.upscale.resemblance',
|
||||
min: 1,
|
||||
max: 100
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.upscale.number_images_tip'
|
||||
type: 'slider',
|
||||
key: 'detail',
|
||||
title: 'paintings.upscale.detail',
|
||||
tooltip: 'paintings.upscale.detail_tip',
|
||||
min: 1,
|
||||
max: 100
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
key: 'numImages',
|
||||
title: 'paintings.number_images',
|
||||
tooltip: 'paintings.upscale.number_images_tip',
|
||||
min: 1,
|
||||
max: 8
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
type: 'input',
|
||||
key: 'seed',
|
||||
title: 'paintings.seed',
|
||||
tooltip: 'paintings.upscale.seed_tip'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
key: 'seed'
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
type: 'switch',
|
||||
key: 'magicPromptOption',
|
||||
title: 'paintings.magic_prompt_option',
|
||||
tooltip: 'paintings.upscale.magic_prompt_option_tip'
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
key: 'magicPromptOption'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 几种默认的绘画配置
|
||||
export const DEFAULT_PAINTING: PaintingAction = {
|
||||
id: 'aihubmix_1',
|
||||
model: 'gpt-image-1',
|
||||
aspectRatio: 'ASPECT_1_1',
|
||||
numImages: 1,
|
||||
styleType: 'AUTO',
|
||||
prompt: '',
|
||||
negativePrompt: '',
|
||||
magicPromptOption: true,
|
||||
seed: '',
|
||||
imageWeight: 50,
|
||||
resemblance: 50,
|
||||
detail: 50,
|
||||
imageFile: undefined,
|
||||
mask: undefined,
|
||||
files: [],
|
||||
urls: [],
|
||||
renderingSpeed: 'DEFAULT',
|
||||
size: '1024x1024',
|
||||
background: 'auto',
|
||||
quality: 'auto',
|
||||
moderation: 'auto',
|
||||
n: 1,
|
||||
numberOfImages: 4
|
||||
}
|
||||
|
||||
@@ -1,87 +1,78 @@
|
||||
import ImageSize1_1 from '@renderer/assets/images/paintings/image-size-1-1.svg'
|
||||
import ImageSize1_2 from '@renderer/assets/images/paintings/image-size-1-2.svg'
|
||||
import ImageSize3_2 from '@renderer/assets/images/paintings/image-size-3-2.svg'
|
||||
import ImageSize3_4 from '@renderer/assets/images/paintings/image-size-3-4.svg'
|
||||
import ImageSize9_16 from '@renderer/assets/images/paintings/image-size-9-16.svg'
|
||||
import ImageSize16_9 from '@renderer/assets/images/paintings/image-size-16-9.svg'
|
||||
import type { PaintingAction } from '@renderer/types'
|
||||
|
||||
// 几种默认的绘画配置
|
||||
export const DEFAULT_PAINTING: PaintingAction = {
|
||||
id: 'aihubmix_1',
|
||||
model: 'V_3',
|
||||
aspectRatio: 'ASPECT_1_1',
|
||||
numImages: 1,
|
||||
styleType: 'AUTO',
|
||||
prompt: '',
|
||||
negativePrompt: '',
|
||||
magicPromptOption: true,
|
||||
seed: '',
|
||||
imageWeight: 50,
|
||||
resemblance: 50,
|
||||
detail: 50,
|
||||
imageFile: undefined,
|
||||
mask: undefined,
|
||||
files: [],
|
||||
urls: [],
|
||||
renderingSpeed: 'DEFAULT'
|
||||
}
|
||||
|
||||
export const ASPECT_RATIOS = [
|
||||
{
|
||||
label: '1:1',
|
||||
value: 'ASPECT_1_1',
|
||||
icon: ImageSize1_1
|
||||
label: 'paintings.aspect_ratios.square',
|
||||
options: [
|
||||
{
|
||||
label: '1:1',
|
||||
value: 'ASPECT_1_1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '3:1',
|
||||
value: 'ASPECT_3_1',
|
||||
icon: ImageSize3_2
|
||||
label: 'paintings.aspect_ratios.landscape',
|
||||
options: [
|
||||
{
|
||||
label: '1:2',
|
||||
value: 'ASPECT_1_2'
|
||||
},
|
||||
{
|
||||
label: '1:3',
|
||||
value: 'ASPECT_1_3'
|
||||
},
|
||||
{
|
||||
label: '2:3',
|
||||
value: 'ASPECT_2_3'
|
||||
},
|
||||
{
|
||||
label: '3:4',
|
||||
value: 'ASPECT_3_4'
|
||||
},
|
||||
{
|
||||
label: '4:5',
|
||||
value: 'ASPECT_4_5'
|
||||
},
|
||||
{
|
||||
label: '9:16',
|
||||
value: 'ASPECT_9_16'
|
||||
},
|
||||
{
|
||||
label: '10:16',
|
||||
value: 'ASPECT_10_16'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '1:3',
|
||||
value: 'ASPECT_1_3',
|
||||
icon: ImageSize1_2
|
||||
},
|
||||
{
|
||||
label: '3:2',
|
||||
value: 'ASPECT_3_2',
|
||||
icon: ImageSize3_2
|
||||
},
|
||||
{
|
||||
label: '2:3',
|
||||
value: 'ASPECT_2_3',
|
||||
icon: ImageSize1_2
|
||||
},
|
||||
{
|
||||
label: '4:3',
|
||||
value: 'ASPECT_4_3',
|
||||
icon: ImageSize3_4
|
||||
},
|
||||
{
|
||||
label: '3:4',
|
||||
value: 'ASPECT_3_4',
|
||||
icon: ImageSize3_4
|
||||
},
|
||||
{
|
||||
label: '16:9',
|
||||
value: 'ASPECT_16_9',
|
||||
icon: ImageSize16_9
|
||||
},
|
||||
{
|
||||
label: '9:16',
|
||||
value: 'ASPECT_9_16',
|
||||
icon: ImageSize9_16
|
||||
},
|
||||
{
|
||||
label: '16:10',
|
||||
value: 'ASPECT_16_10',
|
||||
icon: ImageSize16_9
|
||||
},
|
||||
{
|
||||
label: '10:16',
|
||||
value: 'ASPECT_10_16',
|
||||
icon: ImageSize9_16
|
||||
label: 'paintings.aspect_ratios.landscape',
|
||||
options: [
|
||||
{
|
||||
label: '2:1',
|
||||
value: 'ASPECT_2_1'
|
||||
},
|
||||
{
|
||||
label: '3:1',
|
||||
value: 'ASPECT_3_1'
|
||||
},
|
||||
{
|
||||
label: '3:2',
|
||||
value: 'ASPECT_3_2'
|
||||
},
|
||||
{
|
||||
label: '4:3',
|
||||
value: 'ASPECT_4_3'
|
||||
},
|
||||
{
|
||||
label: '5:4',
|
||||
value: 'ASPECT_5_4'
|
||||
},
|
||||
{
|
||||
label: '16:9',
|
||||
value: 'ASPECT_16_9'
|
||||
},
|
||||
{
|
||||
label: '16:10',
|
||||
value: 'ASPECT_16_10'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -132,3 +123,27 @@ export const RENDERING_SPEED_OPTIONS = [
|
||||
value: 'QUALITY'
|
||||
}
|
||||
]
|
||||
|
||||
export const QUALITY_OPTIONS = [
|
||||
{ label: 'paintings.quality_options.auto', value: 'auto' },
|
||||
{ label: 'paintings.quality_options.low', value: 'low' },
|
||||
{ label: 'paintings.quality_options.medium', value: 'medium' },
|
||||
{ label: 'paintings.quality_options.high', value: 'high' }
|
||||
]
|
||||
|
||||
export const MODERATION_OPTIONS = [
|
||||
{ label: 'paintings.moderation_options.auto', value: 'auto' },
|
||||
{ label: 'paintings.moderation_options.low', value: 'low' }
|
||||
]
|
||||
|
||||
export const BACKGROUND_OPTIONS = [
|
||||
{ label: 'paintings.background_options.auto', value: 'auto' },
|
||||
{ label: 'paintings.background_options.transparent', value: 'transparent' },
|
||||
{ label: 'paintings.background_options.opaque', value: 'opaque' }
|
||||
]
|
||||
|
||||
export const PERSON_GENERATION_OPTIONS = [
|
||||
{ label: 'paintings.person_generation_options.allow_all', value: 'ALLOW_ALL' },
|
||||
{ label: 'paintings.person_generation_options.allow_adult', value: 'ALLOW_ADULT' },
|
||||
{ label: 'paintings.person_generation_options.allow_none', value: 'DONT_ALLOW' }
|
||||
]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'emoji-picker-element'
|
||||
|
||||
import { CloseCircleFilled } from '@ant-design/icons'
|
||||
import { CloseCircleFilled, QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import { Assistant, AssistantSettings } from '@renderer/types'
|
||||
import { getLeadingEmoji } from '@renderer/utils'
|
||||
import { Button, Input, Popover } from 'antd'
|
||||
import { Button, Input, Popover, Tooltip } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -90,9 +90,12 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</HStack>
|
||||
<Box mt={8} mb={8} style={{ fontWeight: 'bold' }}>
|
||||
{t('common.prompt')}
|
||||
</Box>
|
||||
<HStack mt={8} mb={8} alignItems="center" gap={4}>
|
||||
<Box style={{ fontWeight: 'bold' }}>{t('common.prompt')}</Box>
|
||||
<Tooltip title={t('agents.add.prompt.variables.tip')}>
|
||||
<QuestionCircleOutlined size={14} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<TextAreaContainer>
|
||||
{showMarkdown ? (
|
||||
<MarkdownContainer onClick={() => setShowMarkdown(false)}>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { SyncOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isMac, THEME_COLOR_PRESETS } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
AssistantIconType,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
setSidebarIcons
|
||||
} from '@renderer/store/settings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { Button, Input, Segmented, Switch } from 'antd'
|
||||
import { Button, ColorPicker, Input, Segmented, Switch } from 'antd'
|
||||
import { Minus, Plus, RotateCcw } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -23,10 +25,35 @@ import styled from 'styled-components'
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
import SidebarIconsManager from './SidebarIconsManager'
|
||||
|
||||
const ColorCircleWrapper = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const ColorCircle = styled.div<{ color: string; isActive?: boolean }>`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) => props.color};
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid ${(props) => (props.isActive ? 'var(--color-border)' : 'transparent')};
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
|
||||
const DisplaySettings: FC = () => {
|
||||
const {
|
||||
setTheme,
|
||||
theme,
|
||||
windowStyle,
|
||||
setWindowStyle,
|
||||
topicPosition,
|
||||
@@ -36,12 +63,15 @@ const DisplaySettings: FC = () => {
|
||||
pinTopicsToTop,
|
||||
customCss,
|
||||
sidebarIcons,
|
||||
assistantIconType
|
||||
setTheme,
|
||||
assistantIconType,
|
||||
userTheme
|
||||
} = useSettings()
|
||||
const { theme: themeMode } = useTheme()
|
||||
const { theme, settedTheme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const [currentZoom, setCurrentZoom] = useState(1.0)
|
||||
const { setUserTheme } = useUserTheme()
|
||||
|
||||
const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
|
||||
const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
|
||||
@@ -53,6 +83,16 @@ const DisplaySettings: FC = () => {
|
||||
[setWindowStyle]
|
||||
)
|
||||
|
||||
const handleColorPrimaryChange = useCallback(
|
||||
(colorHex: string) => {
|
||||
setUserTheme({
|
||||
...userTheme,
|
||||
colorPrimary: colorHex
|
||||
})
|
||||
},
|
||||
[setUserTheme, userTheme]
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setVisibleIcons([...DEFAULT_SIDEBAR_ICONS])
|
||||
setDisabledIcons([])
|
||||
@@ -80,11 +120,11 @@ const DisplaySettings: FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
value: ThemeMode.auto,
|
||||
value: ThemeMode.system,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<SyncOutlined />
|
||||
<span>{t('settings.theme.auto')}</span>
|
||||
<span>{t('settings.theme.system')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -127,15 +167,57 @@ const DisplaySettings: FC = () => {
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingContainer theme={themeMode}>
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.title')}</SettingRowTitle>
|
||||
<Segmented value={theme} shape="round" onChange={setTheme} options={themeOptions} />
|
||||
<Segmented value={settedTheme} shape="round" onChange={setTheme} options={themeOptions} />
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.color_primary')}</SettingRowTitle>
|
||||
<HStack gap="12px" alignItems="center">
|
||||
<HStack gap="12px">
|
||||
{THEME_COLOR_PRESETS.map((color) => (
|
||||
<ColorCircleWrapper key={color}>
|
||||
<ColorCircle
|
||||
color={color}
|
||||
isActive={userTheme.colorPrimary === color}
|
||||
onClick={() => handleColorPrimaryChange(color)}
|
||||
/>
|
||||
</ColorCircleWrapper>
|
||||
))}
|
||||
</HStack>
|
||||
<ColorPicker
|
||||
className="color-picker"
|
||||
value={userTheme.colorPrimary}
|
||||
onChange={(color) => handleColorPrimaryChange(color.toHexString())}
|
||||
showText
|
||||
style={{ width: '110px' }}
|
||||
presets={[
|
||||
{
|
||||
label: 'Presets',
|
||||
colors: THEME_COLOR_PRESETS
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
{isMac && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.window.style.transparent')}</SettingRowTitle>
|
||||
<Switch checked={windowStyle === 'transparent'} onChange={handleWindowStyleChange} />
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.zoom.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.zoom.title')}</SettingRowTitle>
|
||||
<ZoomButtonGroup>
|
||||
@@ -149,15 +231,6 @@ const DisplaySettings: FC = () => {
|
||||
/>
|
||||
</ZoomButtonGroup>
|
||||
</SettingRow>
|
||||
{isMac && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.theme.window.style.transparent')}</SettingRowTitle>
|
||||
<Switch checked={windowStyle === 'transparent'} onChange={handleWindowStyleChange} />
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.display.topic.title')}</SettingTitle>
|
||||
|
||||
@@ -19,7 +19,6 @@ const GeneralSettings: FC = () => {
|
||||
const {
|
||||
language,
|
||||
proxyUrl: storeProxyUrl,
|
||||
theme,
|
||||
setLaunch,
|
||||
setTray,
|
||||
launchOnBoot,
|
||||
@@ -30,7 +29,7 @@ const GeneralSettings: FC = () => {
|
||||
enableDataCollection
|
||||
} = useSettings()
|
||||
const [proxyUrl, setProxyUrl] = useState<string | undefined>(storeProxyUrl)
|
||||
const { theme: themeMode } = useTheme()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const updateTray = (isShowTray: boolean) => {
|
||||
setTray(isShowTray)
|
||||
@@ -116,7 +115,7 @@ const GeneralSettings: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={themeMode}>
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.general.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { Center, VStack } from '@renderer/components/Layout'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled, setIsUvInstalled } from '@renderer/store/mcp'
|
||||
import { Alert, Button } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -13,8 +15,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
const [isUvInstalled, setIsUvInstalled] = useState(true)
|
||||
const [isBunInstalled, setIsBunInstalled] = useState(true)
|
||||
const dispatch = useAppDispatch()
|
||||
const isUvInstalled = useAppSelector((state) => state.mcp.isUvInstalled)
|
||||
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
|
||||
|
||||
const [isInstallingUv, setIsInstallingUv] = useState(false)
|
||||
const [isInstallingBun, setIsInstallingBun] = useState(false)
|
||||
const [uvPath, setUvPath] = useState<string | null>(null)
|
||||
@@ -22,14 +26,13 @@ const InstallNpxUv: FC<Props> = ({ mini = false }) => {
|
||||
const [binariesDir, setBinariesDir] = useState<string | null>(null)
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const checkBinaries = async () => {
|
||||
const uvExists = await window.api.isBinaryExist('uv')
|
||||
const bunExists = await window.api.isBinaryExist('bun')
|
||||
const { uvPath, bunPath, dir } = await window.api.mcp.getInstallInfo()
|
||||
|
||||
setIsUvInstalled(uvExists)
|
||||
setIsBunInstalled(bunExists)
|
||||
dispatch(setIsUvInstalled(uvExists))
|
||||
dispatch(setIsBunInstalled(bunExists))
|
||||
setUvPath(uvPath)
|
||||
setBunPath(bunPath)
|
||||
setBinariesDir(dir)
|
||||
|
||||
@@ -120,13 +120,13 @@ const MiniAppSettings: FC = () => {
|
||||
</Tooltip>
|
||||
<Slider
|
||||
min={1}
|
||||
max={5}
|
||||
max={10}
|
||||
value={maxKeepAliveMinapps}
|
||||
onChange={handleCacheChange}
|
||||
marks={{
|
||||
1: '1',
|
||||
3: '3',
|
||||
5: '5'
|
||||
5: '5',
|
||||
10: 'Max'
|
||||
}}
|
||||
tooltip={{ formatter: (value) => `${value}` }}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TopView } from '@renderer/components/TopView'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { maskApiKey } from '@renderer/utils/api'
|
||||
import { Button, Modal, Radio, Segmented, Space, Typography } from 'antd'
|
||||
import { Alert } from 'antd'
|
||||
import { useCallback, useMemo, useReducer } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -166,6 +167,8 @@ const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => {
|
||||
</Button>
|
||||
</Space>
|
||||
}>
|
||||
<Alert message={t('settings.models.check.disclaimer')} type="warning" showIcon style={{ fontSize: 12 }} />
|
||||
|
||||
{/* API key selection section - only shown for 'single' mode and multiple keys */}
|
||||
{keyCheckMode === 'single' && hasMultipleKeys && (
|
||||
<Box style={{ marginBottom: 16 }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user