Compare commits
134 Commits
v1.4.9
...
feat/sideb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4434eb7c8 | ||
|
|
762732af9d | ||
|
|
5b9ff3053b | ||
|
|
8340922263 | ||
|
|
a93cab6b43 | ||
|
|
9a81c400ab | ||
|
|
808a22d5c6 | ||
|
|
10e512f32e | ||
|
|
4d75515bd6 | ||
|
|
3d6c84de6d | ||
|
|
3dd393b840 | ||
|
|
8f86c53941 | ||
|
|
a7b78c547a | ||
|
|
bcc1046cdf | ||
|
|
c05c06b7a1 | ||
|
|
446ebae175 | ||
|
|
ba742b7b1f | ||
|
|
7c6db809bb | ||
|
|
855499681f | ||
|
|
92be3c0f56 | ||
|
|
2a72f391b7 | ||
|
|
db642f0837 | ||
|
|
fca93b6c51 | ||
|
|
7e672d86e7 | ||
|
|
e9112cad0f | ||
|
|
ffbd6445df | ||
|
|
dff44f2721 | ||
|
|
3afa81eb5d | ||
|
|
3350c3e2e5 | ||
|
|
f85f46c248 | ||
|
|
05f3b88f30 | ||
|
|
f8c6b5c05f | ||
|
|
97dbfe492e | ||
|
|
186f0ed06f | ||
|
|
daf134f331 | ||
|
|
3f7f78da15 | ||
|
|
1d289621fc | ||
|
|
d7002cda11 | ||
|
|
559fcecf77 | ||
|
|
1d854c232e | ||
|
|
8c6684cbdf | ||
|
|
c7ab71f01f | ||
|
|
9b57351d1e | ||
|
|
f9e88fb6ee | ||
|
|
074ba0ae05 | ||
|
|
4a8a5e8428 | ||
|
|
f7fa665f3a | ||
|
|
e273ddcfb0 | ||
|
|
41d3a1fd55 | ||
|
|
7237ba34db | ||
|
|
fbf89b3f0a | ||
|
|
5d9b47198b | ||
|
|
db1c03f9fa | ||
|
|
bedea8aaaa | ||
|
|
27d959caed | ||
|
|
0609b93a14 | ||
|
|
c7a0b05841 | ||
|
|
5ca0ce682b | ||
|
|
7c3752a8e6 | ||
|
|
7ae10be387 | ||
|
|
7767dfaac7 | ||
|
|
c89ff17b36 | ||
|
|
0810a63fd8 | ||
|
|
ff261fb52b | ||
|
|
8f8deb9275 | ||
|
|
0a7e591f0e | ||
|
|
c1e8f1063a | ||
|
|
4317f4b672 | ||
|
|
202504fd17 | ||
|
|
05727c637f | ||
|
|
c7843ca288 | ||
|
|
facf29e02b | ||
|
|
66d280136c | ||
|
|
91892ea619 | ||
|
|
7d70425c75 | ||
|
|
20b3db0c01 | ||
|
|
f1804bc3a0 | ||
|
|
2ec0c29087 | ||
|
|
20a572aa46 | ||
|
|
142b624001 | ||
|
|
3775562956 | ||
|
|
3544c40d9a | ||
|
|
36fa3af9e9 | ||
|
|
eb832cc25a | ||
|
|
0378f9ceb1 | ||
|
|
99b23e1d5d | ||
|
|
8d23c810fe | ||
|
|
cdfa2ac13a | ||
|
|
58f3edb352 | ||
|
|
edeb9f84f9 | ||
|
|
2757fcf6b9 | ||
|
|
5339f4a9a3 | ||
|
|
e786feb165 | ||
|
|
5794c36d0d | ||
|
|
7d8b88c56e | ||
|
|
d0daeddf14 | ||
|
|
2fe2ae797b | ||
|
|
9bb66227b4 | ||
|
|
5f4736e8c1 | ||
|
|
7a44910847 | ||
|
|
509632030b | ||
|
|
09fe2aa67b | ||
|
|
793c641e1c | ||
|
|
38eb206a8a | ||
|
|
3d9236a09a | ||
|
|
e6fd9b5678 | ||
|
|
d41f175a05 | ||
|
|
6d6a554fd3 | ||
|
|
cb1fcf7d2d | ||
|
|
6ed30fd78a | ||
|
|
69acb2fccd | ||
|
|
d95a4e56f5 | ||
|
|
b15dac9ef4 | ||
|
|
73fced37b4 | ||
|
|
1124090d87 | ||
|
|
a92fa1b1ba | ||
|
|
13fdfc58b6 | ||
|
|
048a9135ac | ||
|
|
b5636646c9 | ||
|
|
d9def89ced | ||
|
|
b16d0069bf | ||
|
|
30823691f9 | ||
|
|
8be98ccbb3 | ||
|
|
922e85754a | ||
|
|
f041f9a231 | ||
|
|
0d9f1882b9 | ||
|
|
26d823e0a5 | ||
|
|
daa89df479 | ||
|
|
527740bf42 | ||
|
|
881e0b4713 | ||
|
|
88e251cee7 | ||
|
|
76387643f7 | ||
|
|
b41c89972b | ||
|
|
11a8154458 |
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -77,9 +77,10 @@ jobs:
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -93,10 +94,11 @@ jobs:
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -105,9 +107,10 @@ jobs:
|
||||
yarn build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "editorconfig.editorconfig"]
|
||||
}
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -4,6 +4,7 @@
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
|
||||
@@ -117,9 +117,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
服务商:新增 NewAPI 服务商支持
|
||||
绘图:新增 NewAPI 绘图服务商支持
|
||||
备份:支持 s3 兼容存储备份
|
||||
服务商:支持多个密钥管理,支持配置自定义请求头
|
||||
设置:支持禁用硬件加速
|
||||
其他:性能优化和错误改进
|
||||
• [新增] MCP 工具调用自动审批流程
|
||||
• [优化] 输入框快捷弹窗多选交互支持
|
||||
• [新增] 网页内容生成实时预览功能
|
||||
• [支持] Grok-4 大语言模型接入
|
||||
• [修复] Anthropic 模型输出截断缺陷
|
||||
|
||||
25
package.json
25
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.10",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -27,12 +27,12 @@
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
"build:win:arm64": "dotenv npm run build && electron-builder --win --arm64",
|
||||
"build:mac": "dotenv electron-vite build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv electron-vite build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv electron-vite build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv electron-vite build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv electron-vite build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv electron-vite build && electron-builder --linux --x64",
|
||||
"build:mac": "dotenv npm run build && electron-builder --mac --arm64 --x64",
|
||||
"build:mac:arm64": "dotenv npm run build && electron-builder --mac --arm64",
|
||||
"build:mac:x64": "dotenv npm run build && electron-builder --mac --x64",
|
||||
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
||||
"build:npm": "node scripts/build-npm.js",
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
@@ -71,7 +71,7 @@
|
||||
"notion-helper": "^1.3.22",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"selection-hook": "^1.0.5",
|
||||
"selection-hook": "^1.0.6",
|
||||
"turndown": "7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -92,6 +92,7 @@
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.0.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
@@ -141,6 +142,8 @@
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"@vitest/ui": "^3.1.4",
|
||||
"@vitest/web-worker": "^3.1.4",
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
|
||||
"archiver": "^7.0.1",
|
||||
@@ -173,6 +176,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"framer-motion": "^12.17.3",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
@@ -203,8 +207,8 @@
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
"react-router": "^7.6.2",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-spinners": "^0.14.1",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.1",
|
||||
@@ -225,6 +229,7 @@
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^1.1.0",
|
||||
"typescript": "^5.6.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
"vitest": "^3.1.4",
|
||||
|
||||
@@ -147,6 +147,7 @@ export enum IpcChannel {
|
||||
File_Base64File = 'file:base64File',
|
||||
File_GetPdfInfo = 'file:getPdfInfo',
|
||||
Fs_Read = 'fs:read',
|
||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
@@ -241,5 +242,12 @@ export enum IpcChannel {
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
Selection_UpdateActionData = 'selection:update-action-data',
|
||||
|
||||
// Navigation
|
||||
Navigation_Url = 'navigation:url',
|
||||
Navigation_Close = 'navigation:close',
|
||||
|
||||
// Settings Window
|
||||
SettingsWindow_Show = 'settings-window:show'
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ if (isDev) {
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Notification } from 'src/renderer/src/types/notification'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { CacheService } from './services/CacheService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@@ -30,6 +31,7 @@ import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { SettingsWindowService } from './services/SettingsWindowService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
@@ -399,6 +401,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath)
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
@@ -576,4 +579,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => {
|
||||
configManager.setDisableHardwareAcceleration(isDisable)
|
||||
})
|
||||
|
||||
// Navigation
|
||||
ipcMain.handle(IpcChannel.Navigation_Url, (_, url: string) => {
|
||||
CacheService.set('navigation-url', url)
|
||||
})
|
||||
|
||||
// Settings Window
|
||||
SettingsWindowService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@@ -5,26 +5,19 @@ import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-op
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
if (provider === 'voyageai') {
|
||||
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
})
|
||||
} else {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
if (baseURL.includes('v1/')) {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
|
||||
/**
|
||||
* 支持设置嵌入维度的模型
|
||||
*/
|
||||
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
export class VoyageEmbeddings extends BaseEmbeddings {
|
||||
private model: _VoyageEmbeddings
|
||||
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
|
||||
super()
|
||||
if (!this.configuration) this.configuration = {}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
|
||||
if (!this.configuration) {
|
||||
throw new Error('Pass in a configuration.')
|
||||
}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
console.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
|
||||
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
|
||||
} else {
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
if (!this.configuration?.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
return this.configuration?.outputDimension
|
||||
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
|
||||
}
|
||||
|
||||
override async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
|
||||
45
src/main/knowledage/embeddings/utils.ts
Normal file
45
src/main/knowledage/embeddings/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
|
||||
// NOTE: 下面的暂时没用上,但先留着吧
|
||||
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
|
||||
|
||||
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
|
||||
|
||||
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
|
||||
|
||||
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
|
||||
|
||||
export const SUPPORTED_DIM_MODELS = [
|
||||
...VOYAGE_SUPPORTED_DIM_MODELS,
|
||||
...OPENAI_SUPPORTED_DIM_MODELS,
|
||||
...DASHSCOPE_SUPPORTED_DIM_MODELS,
|
||||
...OPENSOURCE_SUPPORTED_DIM_MODELS,
|
||||
...GOOGLE_SUPPORTED_DIM_MODELS
|
||||
]
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称。
|
||||
* 例如:
|
||||
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 基础名称
|
||||
*/
|
||||
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
const parts = id.split(delimiter)
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称并转换为小写。
|
||||
* 例如:
|
||||
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 小写的基础名称
|
||||
*/
|
||||
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
return getBaseModelName(id, delimiter).toLowerCase()
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export async function addFileLoader(
|
||||
// HTML类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: readTextFileWithAutoEncoding(file.path),
|
||||
urlOrContent: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -124,7 +124,7 @@ export async function addFileLoader(
|
||||
|
||||
case 'json':
|
||||
try {
|
||||
jsonObject = JSON.parse(readTextFileWithAutoEncoding(file.path))
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
|
||||
} catch (error) {
|
||||
jsonParsed = false
|
||||
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
|
||||
@@ -140,7 +140,7 @@ export async function addFileLoader(
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({
|
||||
text: readTextFileWithAutoEncoding(file.path),
|
||||
text: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
|
||||
@@ -217,7 +217,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
private async convertFile(uid: string, filePath: string): Promise<void> {
|
||||
const fileName = path.basename(filePath).split('.')[0]
|
||||
const fileName = path.parse(filePath).name
|
||||
const config = {
|
||||
...this.createAuthConfig(),
|
||||
headers: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface CacheItem<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
duration: number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export class CacheService {
|
||||
@@ -11,9 +11,9 @@ export class CacheService {
|
||||
* Set cache
|
||||
* @param key Cache key
|
||||
* @param data Cache data
|
||||
* @param duration Cache duration (in milliseconds)
|
||||
* @param duration Cache duration (in milliseconds), if not set the cache will never expire
|
||||
*/
|
||||
static set<T>(key: string, data: T, duration: number): void {
|
||||
static set<T>(key: string, data: T, duration?: number): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
@@ -30,6 +30,11 @@ export class CacheService {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
// If duration is undefined, cache never expires
|
||||
if (item.duration === undefined) {
|
||||
return item.data
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - item.timestamp > item.duration) {
|
||||
this.remove(key)
|
||||
@@ -63,6 +68,11 @@ export class CacheService {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return false
|
||||
|
||||
// If duration is undefined, cache never expires
|
||||
if (item.duration === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - item.timestamp > item.duration) {
|
||||
this.remove(key)
|
||||
|
||||
@@ -231,7 +231,11 @@ class FileStorage {
|
||||
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
|
||||
}
|
||||
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
public readFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
id: string,
|
||||
detectEncoding: boolean = false
|
||||
): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
const fileExtension = path.extname(filePath)
|
||||
@@ -259,8 +263,11 @@ class FileStorage {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = readTextFileWithAutoEncoding(filePath)
|
||||
return result
|
||||
if (detectEncoding) {
|
||||
return readTextFileWithAutoEncoding(filePath)
|
||||
} else {
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return 'failed to read file'
|
||||
@@ -417,6 +424,19 @@ class FileStorage {
|
||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过相对路径打开文件,跨设备时使用
|
||||
* @param file
|
||||
*/
|
||||
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
|
||||
const filePath = path.join(this.storageDir, file.name)
|
||||
if (fs.existsSync(filePath)) {
|
||||
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
} else {
|
||||
logger.warn('[IPC - Warning] File does not exist:', filePath)
|
||||
}
|
||||
}
|
||||
|
||||
public save = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isMac, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
@@ -509,54 +509,55 @@ export class SelectionService {
|
||||
//should set every time the window is shown
|
||||
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
|
||||
|
||||
// [macOS] a series of hacky ways only for macOS
|
||||
if (isMac) {
|
||||
// [macOS] a hacky way
|
||||
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
|
||||
// so we just don't set `skipTransformProcessType: true` when in self app
|
||||
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
|
||||
|
||||
if (!isSelf) {
|
||||
// [macOS] an ugly hacky way
|
||||
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
|
||||
// so we set `focusable: true` before showing, and then set false after showing
|
||||
this.toolbarWindow!.setFocusable(false)
|
||||
|
||||
// [macOS]
|
||||
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
|
||||
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
|
||||
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
}
|
||||
|
||||
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||
this.toolbarWindow!.showInactive()
|
||||
|
||||
// [macOS] restore the focusable status
|
||||
this.toolbarWindow!.setFocusable(true)
|
||||
|
||||
if (!isMac) {
|
||||
this.toolbarWindow!.show()
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
this.startHideByMouseKeyListener()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* The following is for Windows
|
||||
*/
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
this.toolbarWindow!.show()
|
||||
// [macOS] a hacky way
|
||||
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
|
||||
// so we just don't set `skipTransformProcessType: true` when in self app
|
||||
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
|
||||
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
if (!isSelf) {
|
||||
// [macOS] an ugly hacky way
|
||||
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
|
||||
// so we set `focusable: true` before showing, and then set false after showing
|
||||
this.toolbarWindow!.setFocusable(false)
|
||||
|
||||
// [macOS]
|
||||
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
|
||||
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
|
||||
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
}
|
||||
|
||||
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||
this.toolbarWindow!.showInactive()
|
||||
|
||||
// [macOS] restore the focusable status
|
||||
this.toolbarWindow!.setFocusable(true)
|
||||
|
||||
this.startHideByMouseKeyListener()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -911,6 +912,7 @@ export class SelectionService {
|
||||
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
|
||||
}
|
||||
|
||||
// [macOS] isFullscreen is only available on macOS
|
||||
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
|
||||
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
|
||||
}
|
||||
@@ -1218,20 +1220,26 @@ export class SelectionService {
|
||||
return actionWindow
|
||||
}
|
||||
|
||||
public processAction(actionItem: ActionItem): void {
|
||||
/**
|
||||
* Process action item
|
||||
* @param actionItem Action item to process
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
|
||||
const actionWindow = this.popActionWindow()
|
||||
|
||||
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
|
||||
|
||||
this.showActionWindow(actionWindow)
|
||||
this.showActionWindow(actionWindow, isFullScreen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show action window with proper positioning relative to toolbar
|
||||
* Ensures window stays within screen boundaries
|
||||
* @param actionWindow Window to position and show
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
private showActionWindow(actionWindow: BrowserWindow): void {
|
||||
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
|
||||
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
|
||||
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
|
||||
|
||||
@@ -1241,11 +1249,14 @@ export class SelectionService {
|
||||
actionWindowHeight = this.lastActionWindowSize.height
|
||||
}
|
||||
|
||||
//center way
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
/********************************************
|
||||
* Setting the position of the action window
|
||||
********************************************/
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
|
||||
// Center of the screen
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
|
||||
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
|
||||
|
||||
@@ -1255,54 +1266,107 @@ export class SelectionService {
|
||||
x: Math.round(centerX),
|
||||
y: Math.round(centerY)
|
||||
})
|
||||
} else {
|
||||
// Follow toolbar position
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
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 - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small 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 + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
})
|
||||
}
|
||||
|
||||
if (!isMac) {
|
||||
actionWindow.show()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//follow toolbar
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
// act normally when the app is not in fullscreen mode
|
||||
if (!isFullScreen) {
|
||||
actionWindow.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
// [macOS] an UGLY HACKY way for fullscreen override settings
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
// FIXME sometimes the dock will be shown when the action window is shown
|
||||
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
|
||||
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
|
||||
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small 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 + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
|
||||
actionWindow.setFocusable(false)
|
||||
actionWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
|
||||
// just store the dock icon status, and show it again
|
||||
const isDockShown = app.dock?.isVisible()
|
||||
|
||||
// DO NOT set `skipTransformProcessType: true`,
|
||||
// it will cause the action window to be shown on other space
|
||||
actionWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true
|
||||
})
|
||||
|
||||
actionWindow.show()
|
||||
actionWindow.showInactive()
|
||||
|
||||
// show the dock again if last time it was shown
|
||||
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
|
||||
if (!app.dock?.isVisible() && isDockShown) {
|
||||
app.dock?.show()
|
||||
}
|
||||
|
||||
// unset everything
|
||||
setTimeout(() => {
|
||||
actionWindow.setVisibleOnAllWorkspaces(false, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
actionWindow.setAlwaysOnTop(false)
|
||||
|
||||
actionWindow.setFocusable(true)
|
||||
|
||||
// regain the focus when all the works done
|
||||
actionWindow.focus()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
public closeActionWindow(actionWindow: BrowserWindow): void {
|
||||
@@ -1408,8 +1472,9 @@ export class SelectionService {
|
||||
configManager.setSelectionAssistantFilterList(filterList)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
|
||||
selectionService?.processAction(actionItem)
|
||||
// [macOS] only macOS has the available isFullscreen mode
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
|
||||
selectionService?.processAction(actionItem, isFullScreen)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {
|
||||
|
||||
149
src/main/services/SettingsWindowService.ts
Normal file
149
src/main/services/SettingsWindowService.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isLinux, isMac } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
|
||||
export class SettingsWindowService {
|
||||
private static instance: SettingsWindowService | null = null
|
||||
private settingsWindow: BrowserWindow | null = null
|
||||
|
||||
public static getInstance(): SettingsWindowService {
|
||||
if (!SettingsWindowService.instance) {
|
||||
SettingsWindowService.instance = new SettingsWindowService()
|
||||
}
|
||||
return SettingsWindowService.instance
|
||||
}
|
||||
|
||||
public createSettingsWindow(defaultTab?: string): BrowserWindow {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.show()
|
||||
this.settingsWindow.focus()
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
this.setupSettingsWindow()
|
||||
this.loadSettingsWindowContent(defaultTab)
|
||||
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
private setupSettingsWindow() {
|
||||
if (!this.settingsWindow) return
|
||||
|
||||
this.settingsWindow.on('ready-to-show', () => {
|
||||
this.settingsWindow?.show()
|
||||
})
|
||||
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null
|
||||
})
|
||||
|
||||
this.settingsWindow.on('close', () => {
|
||||
// Clean up when window is closed
|
||||
})
|
||||
|
||||
// Handle theme changes
|
||||
nativeTheme.on('updated', () => {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.setTitleBarOverlay(
|
||||
nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private loadSettingsWindowContent(defaultTab?: string) {
|
||||
if (!this.settingsWindow) return
|
||||
|
||||
const queryParam = defaultTab ? `?tab=${defaultTab}` : ''
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
this.settingsWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/settingsWindow.html' + queryParam)
|
||||
} else {
|
||||
this.settingsWindow.loadFile(join(__dirname, '../renderer/settingsWindow.html'))
|
||||
if (defaultTab) {
|
||||
this.settingsWindow.webContents.once('did-finish-load', () => {
|
||||
this.settingsWindow?.webContents.send(IpcChannel.SettingsWindow_Show, { defaultTab })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public showSettingsWindow(defaultTab?: string) {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
if (this.settingsWindow.isMinimized()) {
|
||||
this.settingsWindow.restore()
|
||||
}
|
||||
|
||||
if (!isLinux) {
|
||||
this.settingsWindow.setVisibleOnAllWorkspaces(true)
|
||||
}
|
||||
|
||||
this.settingsWindow.show()
|
||||
this.settingsWindow.focus()
|
||||
|
||||
if (!isLinux) {
|
||||
this.settingsWindow.setVisibleOnAllWorkspaces(false)
|
||||
}
|
||||
} else {
|
||||
this.createSettingsWindow(defaultTab)
|
||||
}
|
||||
}
|
||||
|
||||
public hideSettingsWindow() {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.hide()
|
||||
}
|
||||
}
|
||||
|
||||
public closeSettingsWindow() {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
public getSettingsWindow(): BrowserWindow | null {
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
public static registerIpcHandler() {
|
||||
const { ipcMain } = require('electron')
|
||||
const service = SettingsWindowService.getInstance()
|
||||
|
||||
ipcMain.handle(IpcChannel.SettingsWindow_Show, (_, options?: { defaultTab?: string }) => {
|
||||
service.showSettingsWindow(options?.defaultTab)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsWindowService = SettingsWindowService.getInstance()
|
||||
@@ -5,10 +5,12 @@ import Logger from 'electron-log'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { settingsWindowService } from './SettingsWindowService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
let showMiniWindowAccelerator: string | null = null
|
||||
let showSettingsAccelerator: string | null = null
|
||||
let selectionAssistantToggleAccelerator: string | null = null
|
||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
||||
|
||||
@@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
|
||||
case 'zoom_reset':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
|
||||
case 'show_settings':
|
||||
return () => {
|
||||
settingsWindowService.showSettingsWindow()
|
||||
}
|
||||
case 'show_app':
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
@@ -146,9 +152,13 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
// only register universal shortcuts when needed
|
||||
if (
|
||||
onlyUniversalShortcuts &&
|
||||
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
|
||||
shortcut.key
|
||||
)
|
||||
![
|
||||
'show_app',
|
||||
'mini_window',
|
||||
'show_settings',
|
||||
'selection_assistant_toggle',
|
||||
'selection_assistant_select_text'
|
||||
].includes(shortcut.key)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -171,6 +181,10 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'show_settings':
|
||||
showSettingsAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_toggle':
|
||||
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
@@ -222,6 +236,12 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showSettingsAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_settings' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showSettingsAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantToggleAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
|
||||
@@ -258,6 +278,7 @@ export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
showMiniWindowAccelerator = null
|
||||
showSettingsAccelerator = null
|
||||
selectionAssistantToggleAccelerator = null
|
||||
selectionAssistantSelectTextAccelerator = null
|
||||
windowOnHandlers.forEach((handlers, window) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { CacheService } from './CacheService'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { contextMenu } from './ContextMenu'
|
||||
import { initSessionUserAgent } from './WebviewService'
|
||||
@@ -63,7 +64,7 @@ export class WindowService {
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -313,6 +314,11 @@ export class WindowService {
|
||||
return app.quit()
|
||||
}
|
||||
|
||||
if (CacheService.get('navigation-url') !== '/') {
|
||||
event.preventDefault()
|
||||
return mainWindow.webContents.send(IpcChannel.Navigation_Close)
|
||||
}
|
||||
|
||||
// 托盘及关闭行为设置
|
||||
const isShowTray = configManager.getTray()
|
||||
const isTrayOnClose = configManager.getTrayOnClose()
|
||||
@@ -435,8 +441,9 @@ export class WindowService {
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'followWindow',
|
||||
vibrancy: isMac ? 'under-window' : undefined,
|
||||
visualEffectState: isMac ? 'followWindow' : undefined,
|
||||
backgroundMaterial: isWin ? 'acrylic' : undefined,
|
||||
center: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
|
||||
@@ -44,7 +44,9 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
// }
|
||||
// }
|
||||
// cherrystudio://mcp/install?servers={base64Encode(JSON.stringify(jsonConfig))}
|
||||
|
||||
const data = params.get('servers')
|
||||
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('install MCP servers from urlschema: ', stringify)
|
||||
@@ -63,10 +65,8 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
}
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/settings/mcp')")
|
||||
}
|
||||
windowService.getMainWindow()?.show()
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import iconv from 'iconv-lite'
|
||||
import { detectAll as detectEncodingAll } from 'jschardet'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { detectEncoding, readTextFileWithAutoEncoding } from '../file'
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:fs/promises')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
@@ -244,102 +247,52 @@ describe('file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 在 describe('file') 块内部添加新的 describe 块
|
||||
describe('detectEncoding', () => {
|
||||
const mockFilePath = '/path/to/mock/file.txt'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.openSync).mockReturnValue(123)
|
||||
vi.mocked(fs.closeSync).mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('should correctly detect UTF-8 encoding', () => {
|
||||
// 准备UTF-8编码的Buffer
|
||||
const content = '这是UTF-8测试内容'
|
||||
const buffer = Buffer.from(content, 'utf-8')
|
||||
|
||||
// 模拟文件读取
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return 1024
|
||||
})
|
||||
|
||||
const encoding = detectEncoding(mockFilePath)
|
||||
expect(encoding).toBe('UTF-8')
|
||||
})
|
||||
|
||||
it('should correctly detect GB2312 encoding', () => {
|
||||
// 使用iconv创建GB2312编码内容
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const gb2312Buffer = iconv.encode(content, 'GB2312')
|
||||
|
||||
// 模拟文件读取
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(gb2312Buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return gb2312Buffer.length
|
||||
})
|
||||
|
||||
const encoding = detectEncoding(mockFilePath)
|
||||
expect(encoding).toMatch(/GB2312|GB18030/i)
|
||||
})
|
||||
|
||||
it('should correctly detect ASCII encoding', () => {
|
||||
// 准备ASCII编码内容
|
||||
const content = 'ASCII content'
|
||||
const buffer = Buffer.from(content, 'ascii')
|
||||
|
||||
// 模拟文件读取
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return buffer.length
|
||||
})
|
||||
|
||||
const encoding = detectEncoding(mockFilePath)
|
||||
expect(encoding.toLowerCase()).toBe('ascii')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextFileWithAutoEncoding', () => {
|
||||
const mockFilePath = '/path/to/mock/file.txt'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.openSync).mockReturnValue(123)
|
||||
vi.mocked(fs.closeSync).mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('should read file with auto encoding', () => {
|
||||
it('should read file with auto encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return buffer.length
|
||||
})
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
|
||||
|
||||
const result = readTextFileWithAutoEncoding(mockFilePath)
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
|
||||
it('should try to fix bad detected encoding', () => {
|
||||
it('should try to fix bad detected encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
vi.mocked(fs.readSync).mockImplementation((_, buf) => {
|
||||
const targetBuffer = new Uint8Array(buf.buffer)
|
||||
const sourceBuffer = new Uint8Array(buffer)
|
||||
targetBuffer.set(sourceBuffer)
|
||||
return buffer.length
|
||||
})
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(buffer)
|
||||
vi.mocked(vi.fn(detectEncoding)).mockReturnValue('UTF-8')
|
||||
const result = readTextFileWithAutoEncoding(mockFilePath)
|
||||
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 fs.open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
vi.mocked(vi.fn(detectEncodingAll)).mockReturnValue([
|
||||
{ encoding: 'UTF-8', confidence: 0.9 },
|
||||
{ encoding: 'GB2312', confidence: 0.8 }
|
||||
])
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import * as fs from 'node:fs'
|
||||
import { open, readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileMetadata, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import iconv from 'iconv-lite'
|
||||
import { detect as detectEncoding_, detectAll as detectEncodingAll } from 'jschardet'
|
||||
import * as jschardet from 'jschardet'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
@@ -206,56 +207,48 @@ export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 jschardet 库检测文件编码格式
|
||||
* @param filePath - 文件路径
|
||||
* @returns 返回文件的编码格式,如 UTF-8, ascii, GB2312 等
|
||||
*/
|
||||
export function detectEncoding(filePath: string): string {
|
||||
// 读取文件前1KB来检测编码
|
||||
const buffer = Buffer.alloc(1024)
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
fs.readSync(fd, buffer, 0, 1024, 0)
|
||||
fs.closeSync(fd)
|
||||
const { encoding } = detectEncoding_(buffer)
|
||||
return encoding
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
* @returns 解码后的文件内容
|
||||
*/
|
||||
export function readTextFileWithAutoEncoding(filePath: string) {
|
||||
const encoding = detectEncoding(filePath)
|
||||
const data = fs.readFileSync(filePath)
|
||||
const content = iconv.decode(data, encoding)
|
||||
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
||||
// 读取前1MB以检测编码
|
||||
const buffer = Buffer.alloc(1 * MB)
|
||||
const fh = await open(filePath, 'r')
|
||||
const { buffer: bufferRead } = await fh.read(buffer, 0, 1 * MB, 0)
|
||||
await fh.close()
|
||||
|
||||
if (content.includes('\uFFFD') && encoding !== 'UTF-8') {
|
||||
Logger.error(`文件 ${filePath} 自动识别编码为 ${encoding},但包含错误字符。尝试其他编码`)
|
||||
const buffer = Buffer.alloc(1024)
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
fs.readSync(fd, buffer, 0, 1024, 0)
|
||||
fs.closeSync(fd)
|
||||
const encodings = detectEncodingAll(buffer)
|
||||
if (encodings.length > 0) {
|
||||
for (const item of encodings) {
|
||||
if (item.encoding === encoding) {
|
||||
continue
|
||||
}
|
||||
Logger.log(`尝试使用 ${item.encoding} 解码文件 ${filePath}`)
|
||||
const content = iconv.decode(buffer, item.encoding)
|
||||
if (!content.includes('\uFFFD')) {
|
||||
Logger.log(`文件 ${filePath} 解码成功,编码为 ${item.encoding}`)
|
||||
return content
|
||||
} else {
|
||||
Logger.error(`文件 ${filePath} 使用 ${item.encoding} 解码失败,尝试下一个编码`)
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.error(`文件 ${filePath} 所有可能的编码均解码失败,尝试使用 UTF-8 解码`)
|
||||
return iconv.decode(buffer, 'UTF-8')
|
||||
// 获取文件编码格式,最多取前两个可能的编码
|
||||
const encodings = jschardet
|
||||
.detectAll(bufferRead)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
encoding: item.encoding === 'ascii' ? 'UTF-8' : item.encoding
|
||||
}))
|
||||
.filter((item, index, array) => array.findIndex((prevItem) => prevItem.encoding === item.encoding) === index)
|
||||
.slice(0, 2)
|
||||
|
||||
if (encodings.length === 0) {
|
||||
Logger.error('Failed to detect encoding. Use utf-8 to decode.')
|
||||
const data = await readFile(filePath)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
return content
|
||||
const data = await readFile(filePath)
|
||||
|
||||
for (const item of encodings) {
|
||||
const encoding = item.encoding
|
||||
const content = iconv.decode(data, encoding)
|
||||
if (content.includes('\uFFFD')) {
|
||||
Logger.error(
|
||||
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
|
||||
)
|
||||
} else {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ const api = {
|
||||
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
read: (fileId: string, detectEncoding?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
|
||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
@@ -146,7 +147,8 @@ const api = {
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
@@ -309,14 +311,32 @@ const api = {
|
||||
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),
|
||||
processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
|
||||
navigation: {
|
||||
url: (url: string) => ipcRenderer.invoke(IpcChannel.Navigation_Url, url)
|
||||
},
|
||||
setDisableHardwareAcceleration: (isDisable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable)
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
|
||||
|
||||
// Settings Window
|
||||
showSettingsWindow: (options?: { defaultTab?: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.SettingsWindow_Show, options),
|
||||
|
||||
on: (channel: string, func: any) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, ...args: any[]) => {
|
||||
func(...args)
|
||||
}
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => {
|
||||
ipcRenderer.off(channel, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
23
src/renderer/settingsWindow.html
Normal file
23
src/renderer/settingsWindow.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/settings/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,10 +2,10 @@ import '@renderer/databases'
|
||||
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { Provider } from 'react-redux'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import AppLayout from './components/Layout/AppLayout'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
@@ -13,14 +13,8 @@ import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
import { ChatProvider } from './hooks/useChat'
|
||||
import Routes from './Routes'
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
@@ -31,22 +25,16 @@ function App(): React.ReactElement {
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<HashRouter>
|
||||
<TopViewContainer>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
<ChatProvider>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
</ChatProvider>
|
||||
</TopViewContainer>
|
||||
</HashRouter>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
|
||||
29
src/renderer/src/Routes.tsx
Normal file
29
src/renderer/src/Routes.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import McpServersPage from './pages/mcp-servers'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
const RouteContainer = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/mcp-servers/*" element={<McpServersPage />} />
|
||||
{/* <Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} /> */}
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteContainer
|
||||
@@ -254,7 +254,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
for (const fileBlock of textFileBlocks) {
|
||||
const file = fileBlock.file
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
|
||||
text = text + fileNameRow + fileContent + divider
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ import {
|
||||
LLMWebSearchCompleteChunk,
|
||||
LLMWebSearchInProgressChunk,
|
||||
MCPToolCreatedChunk,
|
||||
TextCompleteChunk,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk
|
||||
TextStartChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { type Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
@@ -231,7 +231,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -519,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return () => {
|
||||
let accumulatedJson = ''
|
||||
const toolCalls: Record<number, ToolUseBlock> = {}
|
||||
const ChunkIdTypeMap: Record<number, ChunkType> = {}
|
||||
return {
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
@@ -615,16 +614,16 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'text': {
|
||||
if (!ChunkIdTypeMap[rawChunk.index]) {
|
||||
ChunkIdTypeMap[rawChunk.index] = ChunkType.TEXT_DELTA // 用textdelta代表文本块
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
break
|
||||
}
|
||||
case 'thinking':
|
||||
case 'redacted_thinking': {
|
||||
if (!ChunkIdTypeMap[rawChunk.index]) {
|
||||
ChunkIdTypeMap[rawChunk.index] = ChunkType.THINKING_DELTA // 用thinkingdelta代表思考块
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -661,15 +660,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
if (ChunkIdTypeMap[rawChunk.index] === ChunkType.TEXT_DELTA) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE
|
||||
} as TextCompleteChunk)
|
||||
} else if (ChunkIdTypeMap[rawChunk.index] === ChunkType.THINKING_DELTA) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_COMPLETE
|
||||
} as ThinkingCompleteChunk)
|
||||
}
|
||||
const toolCall = toolCalls[rawChunk.index]
|
||||
if (toolCall) {
|
||||
try {
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
GeminiOptions,
|
||||
@@ -288,7 +288,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
continue
|
||||
}
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
})
|
||||
@@ -547,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
let isFirstTextChunk = true
|
||||
let isFirstThinkingChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
candidate.content.parts?.forEach((part) => {
|
||||
const text = part.text || ''
|
||||
if (part.thought) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: text
|
||||
})
|
||||
} else if (part.text) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: text
|
||||
@@ -593,6 +607,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: [...toolCalls]
|
||||
})
|
||||
toolCalls.length = 0
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
OpenAISdkMessageParam,
|
||||
@@ -307,7 +307,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
@@ -699,6 +701,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: reasoningText
|
||||
@@ -707,6 +715,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
|
||||
@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
@@ -66,6 +66,9 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
*/
|
||||
public getClient(model: Model) {
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiVersion: 'preview' }
|
||||
}
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
@@ -77,15 +80,25 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
|
||||
return new AzureOpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiVersion: this.provider.apiVersion,
|
||||
baseURL: this.provider.apiHost
|
||||
})
|
||||
} else {
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async createCompletions(
|
||||
@@ -173,7 +186,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'input_text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -354,16 +367,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
(m) => (m as OpenAI.Responses.EasyInputMessage).role === 'assistant'
|
||||
) as OpenAI.Responses.EasyInputMessage
|
||||
const finalUserMessage = userMessage.pop() as OpenAI.Responses.EasyInputMessage
|
||||
if (
|
||||
finalAssistantMessage &&
|
||||
Array.isArray(finalAssistantMessage.content) &&
|
||||
finalUserMessage &&
|
||||
Array.isArray(finalUserMessage.content)
|
||||
) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
if (finalUserMessage && Array.isArray(finalUserMessage.content)) {
|
||||
if (finalAssistantMessage && Array.isArray(finalAssistantMessage.content)) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
} else {
|
||||
userMessage.push(finalUserMessage)
|
||||
}
|
||||
}
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
}
|
||||
|
||||
// 4. 最终请求消息
|
||||
@@ -424,6 +436,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
let hasReasoningSummary = false
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
@@ -435,6 +449,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
if (output.content[0].type === 'output_text') {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: output.content[0].text
|
||||
@@ -451,6 +471,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
break
|
||||
case 'reasoning':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: output.summary.map((s) => s.text).join('\n')
|
||||
@@ -510,6 +536,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
hasReasoningSummary = true
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: chunk.delta
|
||||
@@ -535,6 +567,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
break
|
||||
case 'response.output_text.delta': {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: chunk.delta
|
||||
|
||||
@@ -75,11 +75,12 @@ export default class AiProvider {
|
||||
} else {
|
||||
// Existing logic for other models
|
||||
if (!params.enableReasoning) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
|
||||
// builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
builder.remove(ThinkChunkMiddlewareName)
|
||||
}
|
||||
// 注意:用client判断会导致typescript类型收窄
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
|
||||
@@ -252,7 +252,9 @@ async function executeToolCalls(
|
||||
('name' in toolCall &&
|
||||
(toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) ||
|
||||
confirmed.tool.name === toolCall.id ||
|
||||
confirmed.tool.id === toolCall.id
|
||||
confirmed.tool.id === toolCall.id ||
|
||||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
|
||||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { ChunkType, TextCompleteChunk, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
import { CompletionsContext, CompletionsMiddleware } from '../types'
|
||||
@@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 用于跨chunk的状态管理
|
||||
let accumulatedTextContent = ''
|
||||
let hasTextCompleteEventEnqueue = false
|
||||
const enhancedTextStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -53,18 +52,7 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 创建新的chunk,包含处理后的文本
|
||||
controller.enqueue(chunk)
|
||||
} else if (chunk.type === ChunkType.TEXT_COMPLETE) {
|
||||
const textChunk = chunk as TextCompleteChunk
|
||||
controller.enqueue({
|
||||
...textChunk,
|
||||
text: accumulatedTextContent
|
||||
})
|
||||
if (params.onResponse) {
|
||||
params.onResponse(accumulatedTextContent, true)
|
||||
}
|
||||
hasTextCompleteEventEnqueue = true
|
||||
accumulatedTextContent = ''
|
||||
} else if (accumulatedTextContent && !hasTextCompleteEventEnqueue) {
|
||||
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
const finalText = accumulatedTextContent
|
||||
ctx._internal.customState!.accumulatedText = finalText
|
||||
@@ -89,7 +77,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
})
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
hasTextCompleteEventEnqueue = true
|
||||
accumulatedTextContent = ''
|
||||
} else {
|
||||
// 其他类型的chunk直接传递
|
||||
|
||||
@@ -65,17 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(enhancedChunk)
|
||||
} else if (chunk.type === ChunkType.THINKING_COMPLETE) {
|
||||
const thinkingCompleteChunk = chunk as ThinkingCompleteChunk
|
||||
controller.enqueue({
|
||||
...thinkingCompleteChunk,
|
||||
text: accumulatedThinkingContent,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
})
|
||||
hasThinkingContent = false
|
||||
accumulatedThinkingContent = ''
|
||||
thinkingStartTime = 0
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0) {
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) {
|
||||
// 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE
|
||||
const thinkingCompleteChunk: ThinkingCompleteChunk = {
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ChunkType, TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk'
|
||||
import {
|
||||
ChunkType,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
|
||||
import Logger from 'electron-log/renderer'
|
||||
|
||||
@@ -59,6 +65,8 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
let hasThinkingContent = false
|
||||
let thinkingStartTime = 0
|
||||
|
||||
let isFirstTextChunk = true
|
||||
|
||||
const processedStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -87,6 +95,9 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
if (!hasThinkingContent) {
|
||||
hasThinkingContent = true
|
||||
thinkingStartTime = Date.now()
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
}
|
||||
|
||||
if (extractionResult.content?.trim()) {
|
||||
@@ -98,6 +109,12 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
}
|
||||
} else {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
// 发送清理后的文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
@@ -107,7 +124,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (chunk.type !== ChunkType.TEXT_START) {
|
||||
// 其他类型的chunk直接传递(包括 THINKING_DELTA, THINKING_COMPLETE 等)
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ function createToolUseExtractionTransform(
|
||||
toolCounter += toolUseResponses.length
|
||||
|
||||
if (toolUseResponses.length > 0) {
|
||||
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
|
||||
// 生成 MCP_TOOL_CREATED chunk
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
}
|
||||
|
||||
.minapp-drawer {
|
||||
max-width: calc(100vw - var(--sidebar-width));
|
||||
.ant-drawer-content-wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -33,7 +32,7 @@
|
||||
position: absolute;
|
||||
-webkit-app-region: drag;
|
||||
min-height: calc(var(--navbar-height) + 0.5px);
|
||||
width: calc(100vw - var(--sidebar-width));
|
||||
width: 100%;
|
||||
margin-top: -0.5px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
--color-text-secondary: rgba(235, 235, 245, 0.7);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border: #383838;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
@@ -44,7 +44,7 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: #222;
|
||||
--color-list-item: #252525;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #111111;
|
||||
@@ -54,25 +54,14 @@
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background-win: rgba(20, 20, 20, 0.75);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 20px;
|
||||
|
||||
--color-status-success: #52c41a;
|
||||
--color-status-error: #ff4d4f;
|
||||
--color-status-warning: #faad14;
|
||||
@@ -124,8 +113,8 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: #eee;
|
||||
--color-list-item-hover: #f5f5f5;
|
||||
--color-list-item: rgba(0, 0, 0, 0.05);
|
||||
--color-list-item-hover: rgba(0, 0, 0, 0.03);
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
@@ -134,6 +123,7 @@
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background-win: rgba(255, 255, 255, 0.75);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: transparent;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
.context-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@use './variables.scss';
|
||||
@use './color.scss';
|
||||
@use './font.scss';
|
||||
@use './markdown.scss';
|
||||
@@ -139,7 +140,7 @@ ul {
|
||||
}
|
||||
}
|
||||
.message-content-container {
|
||||
border-radius: 10px 0 10px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px 10px 16px;
|
||||
background-color: var(--chat-background-user);
|
||||
align-self: self-end;
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
margin: 1.5em 0 1em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 2em;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding-bottom: 0.3em;
|
||||
@@ -53,8 +55,9 @@
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
margin: 1.3em 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 5px;
|
||||
@@ -82,7 +85,7 @@
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
pre {
|
||||
margin: 1.5em 0;
|
||||
margin: 1.5em 0 !important;
|
||||
}
|
||||
&::marker {
|
||||
color: var(--color-text-3);
|
||||
@@ -108,6 +111,7 @@
|
||||
li code {
|
||||
background: var(--color-background-mute);
|
||||
padding: 3px 5px;
|
||||
margin: 0 2px;
|
||||
border-radius: 5px;
|
||||
word-break: keep-all;
|
||||
white-space: pre;
|
||||
@@ -122,9 +126,7 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(.mermaid),
|
||||
&:has(.plantuml-preview),
|
||||
&:has(.svg-preview) {
|
||||
&:has(.special-preview) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
@@ -148,16 +150,19 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
font-family: var(--font-family);
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 1.5em;
|
||||
background-color: var(--color-background-soft);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
table {
|
||||
--table-border-radius: 8px;
|
||||
margin: 1em 0;
|
||||
margin: 2em 0;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
border-radius: var(--table-border-radius);
|
||||
overflow: hidden;
|
||||
@@ -182,13 +187,19 @@
|
||||
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
a,
|
||||
|
||||
18
src/renderer/src/assets/styles/variables.scss
Normal file
18
src/renderer/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
:root {
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--list-item-border-radius: 15px;
|
||||
--border-width: 0.5px;
|
||||
|
||||
--main-height: 100vh;
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Alert } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LOCALSTORAGE_KEY = 'openai_alert_closed'
|
||||
|
||||
const OpenAIAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
interface Props {
|
||||
message?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
const OpenAIAlert = ({ message = t('settings.provider.openai.alert'), key = LOCALSTORAGE_KEY }: Props) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
const closed = localStorage.getItem(key)
|
||||
setVisible(!closed)
|
||||
}, [])
|
||||
}, [key])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
|
||||
message={t('settings.provider.openai.alert')}
|
||||
message={message}
|
||||
closable
|
||||
afterClose={() => {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, '1')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodePreviewProps {
|
||||
children: string
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
interface CodePreviewProps extends BasicPreviewProps {
|
||||
language: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const MAX_COLLAPSE_HEIGHT = 350
|
||||
@@ -164,19 +164,11 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
/*
|
||||
* FIXME: @tanstack/react-virtual 使用绝对定位,但是会导致
|
||||
* 有气泡样式 `self-end` 并且气泡中只有代码块时整个代码块收缩
|
||||
* 到最小宽度(目前应该是工具栏的宽度)。改为相对定位可以保证宽
|
||||
* 度足够,目前没有发现其他副作用。
|
||||
* 如果发现破坏虚拟列表功能,或者将来有更好的解决方案,再调整。
|
||||
*/
|
||||
position: 'relative',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||
willChange: 'transform'
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>
|
||||
|
||||
102
src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx
Normal file
102
src/renderer/src/components/CodeBlockView/GraphvizPreview.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
// 管理 viz 实例
|
||||
const vizInitializer = new AsyncInitializer(async () => {
|
||||
const module = await import('@viz-js/viz')
|
||||
return await module.instance()
|
||||
})
|
||||
|
||||
/** 预览 Graphviz 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
*/
|
||||
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const graphvizRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'graphviz',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
// 实际的渲染函数
|
||||
const renderGraphviz = useCallback(async (content: string) => {
|
||||
if (!content || !graphvizRef.current) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const viz = await vizInitializer.get()
|
||||
const svgElement = viz.renderSVGElement(content)
|
||||
|
||||
// 清空容器并添加新的 SVG
|
||||
graphvizRef.current.innerHTML = ''
|
||||
graphvizRef.current.appendChild(svgElement)
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message || 'DOT syntax error or rendering failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderGraphviz(content))
|
||||
}, 300),
|
||||
[renderGraphviz]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsLoading(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, debouncedRender])
|
||||
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{error && <PreviewError>{error}</PreviewError>}
|
||||
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGraphviz = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default memo(GraphvizPreview)
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
* 在应用内打开
|
||||
*/
|
||||
const handleOpenInApp = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: filePath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
export default Artifacts
|
||||
404
src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
Normal file
404
src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, Download, Globe, Sparkles } from 'lucide-react'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClipLoader } from 'react-spinners'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const htmlContent = html || ''
|
||||
const hasContent = htmlContent.trim().length > 0
|
||||
|
||||
// 判断是否正在流式生成的逻辑
|
||||
const isStreaming = useMemo(() => {
|
||||
if (!hasContent) return false
|
||||
|
||||
const trimmedHtml = htmlContent.trim()
|
||||
|
||||
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
|
||||
if (/<\/html\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
|
||||
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 HTML 是否看起来是完整的
|
||||
const indicators = {
|
||||
// 1. 检查常见的 HTML 结构完整性
|
||||
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
|
||||
|
||||
// 2. 检查 body 标签完整性
|
||||
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
|
||||
|
||||
// 3. 检查是否以未闭合的标签结尾
|
||||
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
|
||||
|
||||
// 4. 检查是否有未配对的标签
|
||||
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
|
||||
|
||||
// 5. 检查是否以常见的"流式结束"模式结尾
|
||||
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
|
||||
}
|
||||
|
||||
// 如果有明显的未完成标志,则认为正在生成
|
||||
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 HTML 结构但不完整
|
||||
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 body 结构但不完整
|
||||
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 对于简单的 HTML 片段,检查是否看起来是完整的
|
||||
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
|
||||
// 如果是简单片段且没有明显的结束标志,可能还在生成
|
||||
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
|
||||
}
|
||||
|
||||
return false
|
||||
}, [htmlContent, hasContent])
|
||||
|
||||
// 检查未配对标签的辅助函数
|
||||
function checkUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
|
||||
// HTML5 void 元素(自闭合元素)的完整列表
|
||||
const voidElements = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
]
|
||||
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true // 找到不匹配的闭合标签
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0 // 还有未闭合的标签
|
||||
}
|
||||
|
||||
// 获取格式化的代码预览
|
||||
function getFormattedCodePreview(html: string): string {
|
||||
const trimmed = html.trim()
|
||||
const lines = trimmed.split('\n')
|
||||
const lastFewLines = lines.slice(-3) // 显示最后3行
|
||||
return lastFewLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器中打开
|
||||
*/
|
||||
const handleOpenInEditor = () => {
|
||||
setIsPopupOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const handleClosePopup = () => {
|
||||
setIsPopupOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, htmlContent)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到本地
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
window.message.success({ content: t('message.download.success'), key: 'download' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container $isStreaming={isStreaming}>
|
||||
<Header>
|
||||
<IconWrapper $isStreaming={isStreaming}>
|
||||
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
|
||||
</IconWrapper>
|
||||
<TitleSection>
|
||||
<Title>{title}</Title>
|
||||
<TypeBadge>
|
||||
<Code size={12} />
|
||||
<span>HTML</span>
|
||||
</TypeBadge>
|
||||
</TitleSection>
|
||||
</Header>
|
||||
<Content>
|
||||
{isStreaming && !hasContent ? (
|
||||
<GeneratingContainer>
|
||||
<ClipLoader size={20} color="var(--color-primary)" />
|
||||
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
|
||||
</GeneratingContainer>
|
||||
) : isStreaming && hasContent ? (
|
||||
<>
|
||||
<TerminalPreview $theme={theme}>
|
||||
<TerminalContent $theme={theme}>
|
||||
<TerminalLine>
|
||||
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
||||
<TerminalCodeLine $theme={theme}>
|
||||
{getFormattedCodePreview(htmlContent)}
|
||||
<TerminalCursor $theme={theme} />
|
||||
</TerminalCodeLine>
|
||||
</TerminalLine>
|
||||
</TerminalContent>
|
||||
</TerminalPreview>
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</>
|
||||
) : (
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
|
||||
{t('code_block.download')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)}
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div<{ $isStreaming: boolean }>`
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 16px 0;
|
||||
`
|
||||
|
||||
const GeneratingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
min-height: 78px;
|
||||
`
|
||||
|
||||
const GeneratingText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
|
||||
background-size: 200% 100%;
|
||||
animation: ${shimmer} 3s ease-in-out infinite;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||
transition: background 0.3s ease;
|
||||
|
||||
${(props) =>
|
||||
props.$isStreaming &&
|
||||
`
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
|
||||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
||||
`}
|
||||
`
|
||||
|
||||
const TitleSection = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 0 !important;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const TypeBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-background-mute);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
padding: 0;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
margin: 16px !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
margin: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
`
|
||||
|
||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
min-height: 80px;
|
||||
`
|
||||
|
||||
const TerminalLine = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
background-color: transparent !important;
|
||||
`
|
||||
|
||||
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
animation: ${keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`} 1s infinite;
|
||||
margin-left: 2px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsCard
|
||||
459
src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
Normal file
459
src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface HtmlArtifactsPopupProps {
|
||||
open: boolean
|
||||
title: string
|
||||
html: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = 'split' | 'code' | 'preview'
|
||||
|
||||
// 视图模式配置
|
||||
const VIEW_MODE_CONFIG = {
|
||||
split: {
|
||||
key: 'split' as const,
|
||||
icon: MonitorSpeaker,
|
||||
i18nKey: 'html_artifacts.split'
|
||||
},
|
||||
code: {
|
||||
key: 'code' as const,
|
||||
icon: Code,
|
||||
i18nKey: 'html_artifacts.code'
|
||||
},
|
||||
preview: {
|
||||
key: 'preview' as const,
|
||||
icon: Monitor,
|
||||
i18nKey: 'html_artifacts.preview'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 抽取头部组件
|
||||
interface ModalHeaderProps {
|
||||
title: string
|
||||
isFullscreen: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onToggleFullscreen: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
|
||||
title,
|
||||
isFullscreen,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onToggleFullscreen,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const viewButtons = useMemo(() => {
|
||||
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
|
||||
<ViewButton
|
||||
key={key}
|
||||
size="small"
|
||||
type={viewMode === key ? 'primary' : 'default'}
|
||||
icon={<Icon size={14} />}
|
||||
onClick={() => onViewModeChange(key)}>
|
||||
{t(i18nKey)}
|
||||
</ViewButton>
|
||||
))
|
||||
}, [viewMode, onViewModeChange, t])
|
||||
|
||||
return (
|
||||
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
<HeaderCenter>
|
||||
<ViewControls>{viewButtons}</ViewControls>
|
||||
</HeaderCenter>
|
||||
<HeaderRight>
|
||||
<Button
|
||||
onClick={onToggleFullscreen}
|
||||
type="text"
|
||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
className="nodrag"
|
||||
/>
|
||||
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
|
||||
</HeaderRight>
|
||||
</ModalHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取代码编辑器组件
|
||||
interface CodeSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
onCodeChange: (code: string) => void
|
||||
}
|
||||
|
||||
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<CodeSection $visible={visible}>
|
||||
<CodeEditorWrapper>
|
||||
<CodeEditor
|
||||
value={html}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onCodeChange}
|
||||
style={{ height: '100%' }}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
}}
|
||||
/>
|
||||
</CodeEditorWrapper>
|
||||
</CodeSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取预览组件
|
||||
interface PreviewSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
|
||||
const htmlContent = html || ''
|
||||
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(htmlContent)
|
||||
const currentRenderedHtmlRef = useRef(htmlContent)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 更新最新的HTML内容引用
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = htmlContent
|
||||
}, [htmlContent])
|
||||
|
||||
// 固定频率渲染 HTML 内容,每2秒钟检查并更新一次
|
||||
useEffect(() => {
|
||||
// 立即设置初始内容
|
||||
setDebouncedHtml(htmlContent)
|
||||
currentRenderedHtmlRef.current = htmlContent
|
||||
|
||||
// 设置定时器,每2秒检查一次内容是否有变化
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
|
||||
setDebouncedHtml(latestHtmlRef.current)
|
||||
currentRenderedHtmlRef.current = latestHtmlRef.current
|
||||
}
|
||||
}, 2000) // 2秒固定频率
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
|
||||
if (!visible) return null
|
||||
const isHtmlEmpty = !debouncedHtml.trim()
|
||||
|
||||
return (
|
||||
<PreviewSection $visible={visible}>
|
||||
{isHtmlEmpty ? (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
) : (
|
||||
<PreviewFrame
|
||||
key={debouncedHtml} // 强制重新创建iframe当内容变化时
|
||||
srcDoc={debouncedHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 主弹窗组件
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
}, [html])
|
||||
|
||||
// 计算视图可见性
|
||||
const viewVisibility = useMemo(
|
||||
() => ({
|
||||
code: viewMode === 'split' || viewMode === 'code',
|
||||
preview: viewMode === 'split' || viewMode === 'preview'
|
||||
}),
|
||||
[viewMode]
|
||||
)
|
||||
|
||||
// 计算Modal属性
|
||||
const modalProps = useMemo(
|
||||
() => ({
|
||||
width: isFullscreen ? '100vw' : '90vw',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
|
||||
}),
|
||||
[isFullscreen]
|
||||
)
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCodeChange = useCallback((newCode: string) => {
|
||||
setCurrentHtml(newCode)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
$isFullscreen={isFullscreen}
|
||||
title={
|
||||
<ModalHeaderComponent
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
afterClose={handleClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
{...modalProps}
|
||||
footer={null}
|
||||
closable={false}>
|
||||
<Container>
|
||||
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
|
||||
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
|
||||
</Container>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件保持不变
|
||||
const commonModalBodyStyles = `
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
`
|
||||
|
||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
${(props) =>
|
||||
props.$isFullscreen
|
||||
? `
|
||||
.ant-modal-wrap {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
height: calc(100vh - 45px) !important;
|
||||
${commonModalBodyStyles}
|
||||
max-height: initial !important;
|
||||
}
|
||||
`
|
||||
: `
|
||||
.ant-modal-body {
|
||||
height: 80vh !important;
|
||||
${commonModalBodyStyles}
|
||||
min-height: 600px !important;
|
||||
}
|
||||
`}
|
||||
|
||||
.ant-modal-body {
|
||||
${commonModalBodyStyles}
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
|
||||
overflow: hidden;
|
||||
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 10px 12px !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
const ModalHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
|
||||
`
|
||||
|
||||
const HeaderCenter = styled.div`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TitleText = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
display: flex;
|
||||
width: auto;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
-webkit-app-region: no-drag;
|
||||
`
|
||||
|
||||
const ViewButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.ant-btn-default {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const CodeSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const CodeEditorWrapper = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.monaco-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'block' : 'none')};
|
||||
`
|
||||
|
||||
const PreviewFrame = styled.iframe`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
`
|
||||
const EmptyPreview = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex, Spin } from 'antd'
|
||||
@@ -7,16 +7,14 @@ import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
@@ -143,7 +141,7 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
@@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
const StyledError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||
function encode64(data: Uint8Array) {
|
||||
let r = ''
|
||||
@@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
|
||||
)
|
||||
}
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
14
src/renderer/src/components/CodeBlockView/PreviewError.tsx
Normal file
14
src/renderer/src/components/CodeBlockView/PreviewError.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { memo } from 'react'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
const PreviewError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(PreviewError)
|
||||
@@ -1,15 +1,12 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
return <div ref={svgContainerRef} className="svg-preview special-preview" />
|
||||
}
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
20
src/renderer/src/components/CodeBlockView/constants.ts
Normal file
20
src/renderer/src/components/CodeBlockView/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import GraphvizPreview from './GraphvizPreview'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
/**
|
||||
* 特殊视图语言列表
|
||||
*/
|
||||
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
|
||||
|
||||
/**
|
||||
* 特殊视图组件映射表
|
||||
*/
|
||||
export const SPECIAL_VIEW_COMPONENTS = {
|
||||
mermaid: MermaidPreview,
|
||||
plantuml: PlantUmlPreview,
|
||||
svg: SvgPreview,
|
||||
dot: GraphvizPreview,
|
||||
graphviz: GraphvizPreview
|
||||
} as const
|
||||
2
src/renderer/src/components/CodeBlockView/index.ts
Normal file
2
src/renderer/src/components/CodeBlockView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
14
src/renderer/src/components/CodeBlockView/types.ts
Normal file
14
src/renderer/src/components/CodeBlockView/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CodeTool } from '@renderer/components/CodeToolbar'
|
||||
|
||||
/**
|
||||
* 预览组件的基本 props
|
||||
*/
|
||||
export interface BasicPreviewProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模式
|
||||
*/
|
||||
export type ViewMode = 'source' | 'special' | 'split'
|
||||
@@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CodePreview from './CodePreview'
|
||||
import HtmlArtifacts from './HtmlArtifacts'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||
import StatusBar from './StatusBar'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
type ViewMode = 'source' | 'special' | 'split'
|
||||
import { ViewMode } from './types'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
@@ -42,9 +39,10 @@ interface Props {
|
||||
* - quick 工具
|
||||
* - core 工具
|
||||
*/
|
||||
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [output, setOutput] = useState('')
|
||||
@@ -56,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
|
||||
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
|
||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
@@ -200,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
|
||||
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
|
||||
|
||||
if (!SpecialView) return null
|
||||
|
||||
// PlantUML 语法验证
|
||||
if (language === 'plantuml' && !isValidPlantUML(children)) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
|
||||
return <SpecialView setTools={setTools}>{children}</SpecialView>
|
||||
}, [children, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
@@ -228,27 +228,29 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
const renderArtifacts = useMemo(() => {
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifacts html={children} />
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifactsCard html={children} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar tools={tools} />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* FIXME: 最小宽度用于解决两个问题。
|
||||
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||
*/
|
||||
min-width: 45ch;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
@@ -290,10 +292,12 @@ const SplitViewWrapper = styled.div`
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(:has(+ .html-artifacts)) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
@@ -12,45 +12,111 @@ const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 特殊语言加载器
|
||||
*/
|
||||
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
|
||||
dot: async () => {
|
||||
const mod = await import('@viz-js/lang-dot')
|
||||
return mod.dot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言扩展
|
||||
*/
|
||||
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
// 尝试加载特殊语言
|
||||
const specialLoader = specialLanguageLoaders[normalizedLang]
|
||||
if (specialLoader) {
|
||||
try {
|
||||
return await specialLoader()
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load language ${normalizedLang}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 uiw/codemirror 包含的语言
|
||||
try {
|
||||
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
return extension || null
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load language ${normalizedLang}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 linter 扩展
|
||||
*/
|
||||
async function loadLinterExtension(language: string): Promise<Extension | null> {
|
||||
const loader = linterLoaders[language]
|
||||
if (!loader) return null
|
||||
|
||||
try {
|
||||
return await loader()
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load linter for ${language}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言相关扩展
|
||||
*/
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
let cancelled = false
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
const loadAllExtensions = async () => {
|
||||
try {
|
||||
// 加载所有扩展
|
||||
const [languageResult, linterResult] = await Promise.allSettled([
|
||||
loadLanguageExtension(language, languageMap),
|
||||
lint ? loadLinterExtension(language) : Promise.resolve(null)
|
||||
])
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
if (cancelled) return
|
||||
|
||||
const results: Extension[] = []
|
||||
|
||||
// 语言扩展
|
||||
if (languageResult.status === 'fulfilled' && languageResult.value) {
|
||||
results.push(languageResult.value)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lint) return
|
||||
// linter 扩展
|
||||
if (linterResult.status === 'fulfilled' && linterResult.value) {
|
||||
results.push(linterResult.value)
|
||||
}
|
||||
|
||||
const loader = linterLoaders[language]
|
||||
if (loader) {
|
||||
loader()
|
||||
.then((extension) => {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load linter for ${language}`, error)
|
||||
})
|
||||
setExtensions(results)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.debug('Failed to load language extensions:', error)
|
||||
setExtensions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [language, lint])
|
||||
|
||||
loadAllExtensions()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [language, lint, languageMap])
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
Text as UnWrapIcon,
|
||||
WrapText as WrapIcon
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||
@@ -42,6 +41,7 @@ interface Props {
|
||||
extensions?: Extension[]
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
style?: React.CSSProperties
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,8 @@ const CodeEditor = ({
|
||||
maxHeight,
|
||||
options,
|
||||
extensions,
|
||||
style
|
||||
style,
|
||||
editable = true
|
||||
}: Props) => {
|
||||
const {
|
||||
fontSize,
|
||||
@@ -190,7 +191,7 @@ const CodeEditor = ({
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
editable={editable}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={customExtensions}
|
||||
|
||||
@@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [allRanges, setAllRanges] = useState<Range[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
[allRanges, currentIndex]
|
||||
)
|
||||
|
||||
const search = useCallback(() => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
}, [target, filter, isCaseSensitive, isWholeWord])
|
||||
const search = useCallback(
|
||||
(jump = false) => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(jump && ranges.length > 0 ? 0 : -1)
|
||||
}
|
||||
},
|
||||
[target, filter, isCaseSensitive, isWholeWord]
|
||||
)
|
||||
|
||||
const implementation = useMemo(
|
||||
() => ({
|
||||
@@ -207,7 +210,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
search()
|
||||
search(false)
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -231,11 +234,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
},
|
||||
search: () => {
|
||||
search()
|
||||
search(true)
|
||||
locateByIndex(true)
|
||||
},
|
||||
silentSearch: () => {
|
||||
search()
|
||||
search(false)
|
||||
locateByIndex(false)
|
||||
},
|
||||
focus: () => {
|
||||
@@ -302,7 +305,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
search()
|
||||
search(true)
|
||||
}
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
|
||||
|
||||
@@ -345,36 +348,35 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<ToolBar>
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
size={18}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-primary)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
|
||||
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
<WholeWord
|
||||
size={18}
|
||||
style={{ color: isWholeWord ? 'var(--color-primary)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ToolBar>
|
||||
</InputWrapper>
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
)
|
||||
{searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
|
||||
)}
|
||||
@@ -407,7 +409,6 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
position: fixed;
|
||||
@@ -421,6 +422,7 @@ const SearchBarContainer = styled.div`
|
||||
justify-content: center;
|
||||
background-color: var(--color-background);
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
`
|
||||
|
||||
const Placeholder = styled.div`
|
||||
@@ -477,10 +479,6 @@ const SearchResultsPlaceholder = styled.span`
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const NoResults = styled.span`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const SearchResultCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
17
src/renderer/src/components/CustomSelect.tsx
Normal file
17
src/renderer/src/components/CustomSelect.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { includeKeywords } from '@renderer/utils/search'
|
||||
import { Select, SelectProps } from 'antd'
|
||||
|
||||
/**
|
||||
* 自定义 Select,使用增强的搜索 filter
|
||||
*/
|
||||
const CustomSelect = ({ ref, ...props }: SelectProps & { ref?: React.RefObject<any | null> }) => {
|
||||
return <Select ref={ref} filterOption={enhancedFilterOption} {...props} />
|
||||
}
|
||||
|
||||
CustomSelect.displayName = 'CustomSelect'
|
||||
|
||||
function enhancedFilterOption(input: string, option: any) {
|
||||
return includeKeywords(option.label, input)
|
||||
}
|
||||
|
||||
export default CustomSelect
|
||||
@@ -51,23 +51,27 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<VirtualList data={list} itemKey="id">
|
||||
{(item, index) => {
|
||||
const id = item.id || item
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
...listStyle,
|
||||
...provided.draggableProps.style,
|
||||
marginBottom: 8
|
||||
}}>
|
||||
{children(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
if (!item.disabled) {
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
...listStyle,
|
||||
...provided.draggableProps.style
|
||||
}}>
|
||||
{children(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
} else {
|
||||
return <div> {children(item, index)}</div>
|
||||
}
|
||||
}}
|
||||
</VirtualList>
|
||||
{provided.placeholder}
|
||||
|
||||
35
src/renderer/src/components/Icons/NarrowModeIcon.tsx
Normal file
35
src/renderer/src/components/Icons/NarrowModeIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
isNarrowMode: boolean
|
||||
}
|
||||
|
||||
const NarrowModeIcon: FC<Props> = ({ isNarrowMode }) => {
|
||||
return (
|
||||
<Container $isNarrowMode={isNarrowMode}>
|
||||
<Line />
|
||||
<Line />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $isNarrowMode: boolean }>`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid var(--color-text-2);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: ${({ $isNarrowMode }) => ($isNarrowMode ? 'space-evenly' : 'space-between')};
|
||||
padding: 2px;
|
||||
`
|
||||
|
||||
const Line = styled.div`
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background-color: var(--color-text-2);
|
||||
border-radius: 5px;
|
||||
`
|
||||
|
||||
export default NarrowModeIcon
|
||||
42
src/renderer/src/components/Icons/PanelIcons.tsx
Normal file
42
src/renderer/src/components/Icons/PanelIcons.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
interface PanelIconProps extends Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> {
|
||||
size?: number | string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
export const PanelLeftIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="lucide lucide-panel-left-icon lucide-panel-left"
|
||||
{...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
{expanded ? <path d="M10 7v10" strokeWidth={4} /> : <path d="M9 6v12" strokeWidth={2} />}
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PanelRightIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="lucide lucide-panel-right-icon lucide-panel-right"
|
||||
{...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
{expanded ? <path d="M14 7v10" strokeWidth={4} /> : <path d="M15 6v12" strokeWidth={2} />}
|
||||
</svg>
|
||||
)
|
||||
@@ -22,7 +22,7 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const Icon = styled.i`
|
||||
color: var(--color-link);
|
||||
color: var(--color-primary);
|
||||
font-size: 16px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
@@ -77,3 +77,18 @@ export function MdiLightbulbOn90(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpandWidth(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g>
|
||||
<path
|
||||
id="path"
|
||||
d="M0 25L0 175C0 179.41 3.58 183 8 183L12 183C16.41 183 20 179.41 20 175L20 25C20 20.58 16.41 17 12 17L8 17C3.58 17 0 20.58 0 25ZM60.41 58.43L29.42 94.81C26.87 97.8 26.87 102.19 29.42 105.18L60.38 141.53C65.2 147.19 74.47 143.78 74.47 136.34L74.47 121.83C74.47 117.41 78.06 113.83 82.47 113.83L117.5 113.83C121.91 113.83 125.5 117.41 125.5 121.83L125.5 136.35C125.5 143.78 134.76 147.2 139.58 141.54L170.57 105.18C173.12 102.19 173.12 97.8 170.57 94.81L139.59 58.43C134.76 52.77 125.5 56.18 125.5 63.62L125.5 78.16C125.5 82.58 121.91 86.16 117.5 86.16L82.5 86.16C78.08 86.16 74.5 82.58 74.5 78.16L74.5 63.62C74.5 56.18 65.23 52.77 60.41 58.43ZM188 17L192 17C196.41 17 200 20.58 200 25L200 175C200 179.41 196.41 183 192 183L188 183C183.58 183 180 179.41 180 175L180 25C180 20.58 183.58 17 188 17Z"
|
||||
fill="currentColor"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const Icon = styled(GlobalOutlined)`
|
||||
color: var(--color-link);
|
||||
color: var(--color-primary);
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
27
src/renderer/src/components/Layout/AppLayout.tsx
Normal file
27
src/renderer/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MainSidebar from '@renderer/pages/home/MainSidebar/MainSidebar'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const AppLayout: FC<AppLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<HStack style={{ display: 'flex', flex: 1 }} id="app-layout">
|
||||
<MainSidebar />
|
||||
<ContentArea>{children}</ContentArea>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const ContentArea = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
`
|
||||
|
||||
export default AppLayout
|
||||
17
src/renderer/src/components/MaxContextCount.tsx
Normal file
17
src/renderer/src/components/MaxContextCount.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MAX_CONTEXT_COUNT } from '@renderer/config/constant'
|
||||
import { Infinity as InfinityIcon } from 'lucide-react'
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
type Props = {
|
||||
maxContext: number
|
||||
style?: CSSProperties
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function MaxContextCount({ maxContext, style, size = 14 }: Props) {
|
||||
return maxContext === MAX_CONTEXT_COUNT ? (
|
||||
<InfinityIcon size={size} style={style} aria-label="infinity" />
|
||||
) : (
|
||||
<span style={style}>{maxContext.toString()}</span>
|
||||
)
|
||||
}
|
||||
@@ -395,10 +395,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{
|
||||
marginLeft: 'var(--sidebar-width)',
|
||||
backgroundColor: window.root.style.background
|
||||
}}>
|
||||
style={{ backgroundColor: window.root.style.background }}>
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar
|
||||
@@ -418,7 +415,7 @@ const TitleContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${isMac ? '20px' : '10px'};
|
||||
padding-left: ${isMac ? '80px' : '10px'};
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -89,7 +89,7 @@ const WebviewContainer = memo(
|
||||
)
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: 'calc(100vw - var(--sidebar-width))',
|
||||
width: '100vw',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
display: 'inline-flex'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSystemAgents } from '@renderer/pages/agents'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
@@ -24,7 +23,7 @@ interface Props {
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const { agents: userAgents } = useAgents()
|
||||
const { assistants: userAgents } = useAssistants()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { assistants, addAssistant } = useAssistants()
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from
|
||||
import { Trash } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { isLlmProvider, useApiKeys } from './hook'
|
||||
import ApiKeyItem from './item'
|
||||
@@ -87,7 +88,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
: keys
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListContainer>
|
||||
{/* Keys 列表 */}
|
||||
<Card
|
||||
size="small"
|
||||
@@ -122,7 +123,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Flex align="center" justify="space-between" style={{ marginTop: '0.5rem' }}>
|
||||
<Flex dir="row" align="center" justify="space-between" style={{ marginTop: 15 }}>
|
||||
{/* 帮助文本 */}
|
||||
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
|
||||
|
||||
@@ -166,7 +167,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</>
|
||||
</ListContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,3 +223,8 @@ export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ListContainer = styled.div`
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
`
|
||||
|
||||
@@ -14,14 +14,7 @@ interface Props {
|
||||
position: 'left' | 'right'
|
||||
}
|
||||
|
||||
const FloatingSidebar: FC<Props> = ({
|
||||
children,
|
||||
activeAssistant,
|
||||
setActiveAssistant,
|
||||
activeTopic,
|
||||
setActiveTopic,
|
||||
position = 'left'
|
||||
}) => {
|
||||
const FloatingSidebar: FC<Props> = ({ children, activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
@@ -45,16 +38,15 @@ const FloatingSidebar: FC<Props> = ({
|
||||
const content = (
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<HomeTabs
|
||||
tab="assistants"
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position={position}
|
||||
forceToSeeAllTab={true}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
maxHeight: maxHeight
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -82,6 +74,9 @@ const FloatingSidebar: FC<Props> = ({
|
||||
|
||||
const PopoverContent = styled.div<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
&.ant-popover-inner-content {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
export default FloatingSidebar
|
||||
|
||||
375
src/renderer/src/components/Popups/MessageSettingsPopup.tsx
Normal file
375
src/renderer/src/components/Popups/MessageSettingsPopup.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setCodeCollapsible,
|
||||
setCodeEditor,
|
||||
setCodeExecution,
|
||||
setCodePreview,
|
||||
setCodeShowLineNumbers,
|
||||
setCodeWrappable,
|
||||
setFontSize,
|
||||
setMathEngine,
|
||||
setMessageFont,
|
||||
setMessageNavigation,
|
||||
setMessageStyle,
|
||||
setMultiModelMessageStyle,
|
||||
setShowMessageDivider,
|
||||
setThoughtAutoCollapse
|
||||
} from '@renderer/store/settings'
|
||||
import { CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types'
|
||||
import { Col, InputNumber, Modal, Row, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Selector from '../Selector'
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
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 { messageStyle, fontSize } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { themeNames } = useCodeStyle()
|
||||
|
||||
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const {
|
||||
showMessageDivider,
|
||||
messageFont,
|
||||
codeShowLineNumbers,
|
||||
codeCollapsible,
|
||||
codeWrappable,
|
||||
codeEditor,
|
||||
codePreview,
|
||||
codeExecution,
|
||||
mathEngine,
|
||||
multiModelMessageStyle,
|
||||
thoughtAutoCollapse,
|
||||
messageNavigation
|
||||
} = useSettings()
|
||||
|
||||
const codeStyle = useMemo(() => {
|
||||
return codeEditor.enabled
|
||||
? theme === ThemeMode.light
|
||||
? codeEditor.themeLight
|
||||
: codeEditor.themeDark
|
||||
: theme === ThemeMode.light
|
||||
? codePreview.themeLight
|
||||
: codePreview.themeDark
|
||||
}, [
|
||||
codeEditor.enabled,
|
||||
codeEditor.themeLight,
|
||||
codeEditor.themeDark,
|
||||
theme,
|
||||
codePreview.themeLight,
|
||||
codePreview.themeDark
|
||||
])
|
||||
|
||||
const onCodeStyleChange = useCallback(
|
||||
(value: CodeStyleVarious) => {
|
||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||
const action = codeEditor.enabled ? setCodeEditor : setCodePreview
|
||||
dispatch(action({ [field]: value }))
|
||||
},
|
||||
[dispatch, theme, codeEditor.enabled]
|
||||
)
|
||||
|
||||
const onOk = () => {
|
||||
resolve(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
resolve(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
MessageSettingsPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
footer={null}
|
||||
centered
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.divider')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showMessageDivider}
|
||||
onChange={(checked) => dispatch(setShowMessageDivider(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.use_serif_font')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={messageFont === 'serif'}
|
||||
onChange={(checked) => dispatch(setMessageFont(checked ? 'serif' : 'system'))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.thought_auto_collapse')}
|
||||
<Tooltip title={t('chat.settings.thought_auto_collapse.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={thoughtAutoCollapse}
|
||||
onChange={(checked) => dispatch(setThoughtAutoCollapse(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.style')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={messageStyle}
|
||||
onChange={(value) => dispatch(setMessageStyle(value as 'plain' | 'bubble'))}
|
||||
options={[
|
||||
{ label: t('message.message.style.plain'), value: 'plain' },
|
||||
{ label: t('message.message.style.bubble'), value: 'bubble' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.multi_model_style')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={multiModelMessageStyle}
|
||||
onChange={(value) =>
|
||||
dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid'))
|
||||
}
|
||||
options={[
|
||||
{ label: t('message.message.multi_model_style.fold'), value: 'fold' },
|
||||
{ label: t('message.message.multi_model_style.vertical'), value: 'vertical' },
|
||||
{ label: t('message.message.multi_model_style.horizontal'), value: 'horizontal' },
|
||||
{ label: t('message.message.multi_model_style.grid'), value: 'grid' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.navigation')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={messageNavigation}
|
||||
onChange={(value) => dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))}
|
||||
options={[
|
||||
{ label: t('settings.messages.navigation.none'), value: 'none' },
|
||||
{ label: t('settings.messages.navigation.buttons'), value: 'buttons' },
|
||||
{ label: t('settings.messages.navigation.anchor'), value: 'anchor' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={mathEngine}
|
||||
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
|
||||
options={[
|
||||
{ label: 'KaTeX', value: 'KaTeX' },
|
||||
{ label: 'MathJax', value: 'MathJax' },
|
||||
{ label: t('settings.messages.math_engine.none'), value: 'none' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.font_size.title')}</SettingRowTitleSmall>
|
||||
</SettingRow>
|
||||
<Row align="middle" gutter={10}>
|
||||
<Col span={24}>
|
||||
<Slider
|
||||
value={fontSizeValue}
|
||||
onChange={(value) => setFontSizeValue(value)}
|
||||
onChangeComplete={(value) => dispatch(setFontSize(value))}
|
||||
min={12}
|
||||
max={22}
|
||||
step={1}
|
||||
marks={{
|
||||
12: <span style={{ fontSize: '12px' }}>A</span>,
|
||||
14: <span style={{ fontSize: '14px' }}>{t('common.default')}</span>,
|
||||
22: <span style={{ fontSize: '18px' }}>A</span>
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</SettingGroup>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={codeStyle}
|
||||
onChange={(value) => onCodeStyleChange(value as CodeStyleVarious)}
|
||||
options={themeNames.map((theme) => ({ label: theme, value: theme }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.title')}
|
||||
<Tooltip title={t('chat.settings.code_execution.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeExecution.enabled}
|
||||
onChange={(checked) => dispatch(setCodeExecution({ enabled: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
{codeExecution.enabled && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.timeout_minutes')}
|
||||
<Tooltip title={t('chat.settings.code_execution.timeout_minutes.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
value={codeExecution.timeoutMinutes}
|
||||
onChange={(value) => dispatch(setCodeExecution({ timeoutMinutes: value ?? 1 }))}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.title')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.enabled}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ enabled: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
{codeEditor.enabled && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.highlight_active_line')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.highlightActiveLine}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ highlightActiveLine: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.fold_gutter')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.foldGutter}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ foldGutter: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.autocompletion')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.autocompletion}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ autocompletion: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.keymap')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.keymap}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ keymap: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.show_line_numbers')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeShowLineNumbers}
|
||||
onChange={(checked) => dispatch(setCodeShowLineNumbers(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_collapsible')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeCollapsible}
|
||||
onChange={(checked) => dispatch(setCodeCollapsible(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
|
||||
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingRowTitleSmall = styled(SettingRowTitle)`
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const SettingGroup = styled.div<{ theme?: ThemeMode }>`
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
`
|
||||
|
||||
const TopViewKey = 'MessageSettingsPopup'
|
||||
|
||||
export default class MessageSettingsPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(<PopupContainer {...props} resolve={resolve} />, TopViewKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Center } from '@renderer/components/Layout'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import App from '@renderer/pages/apps/App'
|
||||
import { Popover } from 'antd'
|
||||
import { Empty } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Scrollbar from '../Scrollbar'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const MinAppsPopover: FC<Props> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { minapps } = useMinapps()
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 100)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMaxHeight(window.innerHeight - 100)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const content = (
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<AppsContainer>
|
||||
{minapps.map((app) => (
|
||||
<App key={app.id} app={app} onClick={handleClose} size={50} />
|
||||
))}
|
||||
{isEmpty(minapps) && (
|
||||
<Center>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
)}
|
||||
</AppsContainer>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
content={content}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
styles={{ body: { padding: 25 } }}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const AppsContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(90px, 1fr));
|
||||
gap: 18px;
|
||||
`
|
||||
|
||||
export default MinAppsPopover
|
||||
353
src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
Normal file
353
src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
analyzeMessageContent,
|
||||
CONTENT_TYPES,
|
||||
ContentType,
|
||||
MessageContentStats,
|
||||
processMessageContent
|
||||
} from '@renderer/utils/knowledge'
|
||||
import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd'
|
||||
import { Check, CircleHelp } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
// 内容类型配置
|
||||
const CONTENT_TYPE_CONFIG = {
|
||||
[CONTENT_TYPES.TEXT]: {
|
||||
label: 'chat.save.knowledge.content.maintext.title',
|
||||
description: 'chat.save.knowledge.content.maintext.description'
|
||||
},
|
||||
[CONTENT_TYPES.CODE]: {
|
||||
label: 'chat.save.knowledge.content.code.title',
|
||||
description: 'chat.save.knowledge.content.code.description'
|
||||
},
|
||||
[CONTENT_TYPES.THINKING]: {
|
||||
label: 'chat.save.knowledge.content.thinking.title',
|
||||
description: 'chat.save.knowledge.content.thinking.description'
|
||||
},
|
||||
[CONTENT_TYPES.TOOL_USE]: {
|
||||
label: 'chat.save.knowledge.content.tool_use.title',
|
||||
description: 'chat.save.knowledge.content.tool_use.description'
|
||||
},
|
||||
[CONTENT_TYPES.CITATION]: {
|
||||
label: 'chat.save.knowledge.content.citation.title',
|
||||
description: 'chat.save.knowledge.content.citation.description'
|
||||
},
|
||||
[CONTENT_TYPES.TRANSLATION]: {
|
||||
label: 'chat.save.knowledge.content.translation.title',
|
||||
description: 'chat.save.knowledge.content.translation.description'
|
||||
},
|
||||
[CONTENT_TYPES.ERROR]: {
|
||||
label: 'chat.save.knowledge.content.error.title',
|
||||
description: 'chat.save.knowledge.content.error.description'
|
||||
},
|
||||
[CONTENT_TYPES.FILE]: {
|
||||
label: 'chat.save.knowledge.content.file.title',
|
||||
description: 'chat.save.knowledge.content.file.description'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Tag 颜色常量
|
||||
const TAG_COLORS = {
|
||||
SELECTED: '#008001',
|
||||
UNSELECTED: '#8c8c8c'
|
||||
} as const
|
||||
|
||||
interface ContentTypeOption {
|
||||
type: ContentType
|
||||
label: string
|
||||
count: number
|
||||
enabled: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ShowParams {
|
||||
message: Message
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
success: boolean
|
||||
savedCount: number
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: SaveResult | null) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ message, title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedBaseId, setSelectedBaseId] = useState<string>()
|
||||
const [selectedTypes, setSelectedTypes] = useState<ContentType[]>([])
|
||||
const [hasInitialized, setHasInitialized] = useState(false)
|
||||
const { bases } = useKnowledgeBases()
|
||||
const { addNote, addFiles } = useKnowledge(selectedBaseId || '')
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 分析消息内容统计
|
||||
const contentStats = useMemo(() => analyzeMessageContent(message), [message])
|
||||
|
||||
// 生成内容类型选项(只显示有内容的类型)
|
||||
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
|
||||
return Object.entries(CONTENT_TYPE_CONFIG)
|
||||
.map(([type, config]) => {
|
||||
const contentType = type as ContentType
|
||||
const count = contentStats[contentType as keyof MessageContentStats] || 0
|
||||
return {
|
||||
type: contentType,
|
||||
count,
|
||||
enabled: count > 0,
|
||||
label: t(config.label),
|
||||
description: t(config.description)
|
||||
}
|
||||
})
|
||||
.filter((option) => option.enabled) // 只显示有内容的类型
|
||||
}, [contentStats, t])
|
||||
|
||||
// 知识库选项
|
||||
const knowledgeBaseOptions = useMemo(
|
||||
() =>
|
||||
bases.map((base) => ({
|
||||
label: base.name,
|
||||
value: base.id,
|
||||
disabled: !base.version // 如果知识库没有配置好就禁用
|
||||
})),
|
||||
[bases]
|
||||
)
|
||||
|
||||
// 合并状态计算
|
||||
const formState = useMemo(() => {
|
||||
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
|
||||
const hasContent = contentTypeOptions.length > 0
|
||||
const selectedCount = contentTypeOptions
|
||||
.filter((option) => selectedTypes.includes(option.type))
|
||||
.reduce((sum, option) => sum + option.count, 0)
|
||||
|
||||
return {
|
||||
hasValidBase,
|
||||
hasContent,
|
||||
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
|
||||
selectedCount,
|
||||
hasNoSelection: selectedTypes.length === 0 && hasContent
|
||||
}
|
||||
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
|
||||
|
||||
// 默认选择第一个可用的知识库
|
||||
useEffect(() => {
|
||||
if (!selectedBaseId) {
|
||||
const firstAvailableBase = bases.find((base) => base.version)
|
||||
if (firstAvailableBase) {
|
||||
setSelectedBaseId(firstAvailableBase.id)
|
||||
}
|
||||
}
|
||||
}, [bases, selectedBaseId])
|
||||
|
||||
// 默认选择所有可用的内容类型(仅在初始化时)
|
||||
useEffect(() => {
|
||||
if (!hasInitialized && contentTypeOptions.length > 0) {
|
||||
const availableTypes = contentTypeOptions.map((option) => option.type)
|
||||
setSelectedTypes(availableTypes)
|
||||
setHasInitialized(true)
|
||||
}
|
||||
}, [contentTypeOptions, hasInitialized])
|
||||
|
||||
// 计算UI状态
|
||||
const uiState = useMemo(() => {
|
||||
if (!formState.hasContent) {
|
||||
return { type: 'empty', message: t('chat.save.knowledge.empty.no_content') }
|
||||
}
|
||||
if (bases.length === 0) {
|
||||
return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') }
|
||||
}
|
||||
return { type: 'form' }
|
||||
}, [formState.hasContent, bases.length, t])
|
||||
|
||||
// 处理内容类型选择切换
|
||||
const handleContentTypeToggle = (type: ContentType) => {
|
||||
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
if (!formState.canSubmit) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
let savedCount = 0
|
||||
|
||||
try {
|
||||
const result = processMessageContent(message, selectedTypes)
|
||||
|
||||
// 保存文本内容
|
||||
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
|
||||
await addNote(result.text)
|
||||
savedCount++
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
|
||||
addFiles(result.files)
|
||||
savedCount += result.files.length
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
resolve({ success: true, savedCount })
|
||||
} catch (error) {
|
||||
Logger.error('[SaveToKnowledgePopup] save failed:', error)
|
||||
window.message.error(t('chat.save.knowledge.error.save_failed'))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => (
|
||||
<EmptyContainer>
|
||||
<Text type="secondary">{uiState.message}</Text>
|
||||
</EmptyContainer>
|
||||
)
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={t('chat.save.knowledge.select.base.title')}
|
||||
help={!formState.hasValidBase && selectedBaseId ? t('chat.save.knowledge.error.invalid_base') : undefined}
|
||||
validateStatus={!formState.hasValidBase && selectedBaseId ? 'error' : undefined}>
|
||||
<Select
|
||||
value={selectedBaseId}
|
||||
onChange={setSelectedBaseId}
|
||||
options={knowledgeBaseOptions}
|
||||
placeholder={t('chat.save.knowledge.select.base.placeholder')}
|
||||
showSearch
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('chat.save.knowledge.select.content.title')}>
|
||||
<Flex gap={8} style={{ flexDirection: 'column' }}>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<ContentTypeItem
|
||||
key={option.type}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={() => handleContentTypeToggle(option.type)}>
|
||||
<Flex align="center" gap={8}>
|
||||
<CustomTag
|
||||
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
|
||||
size={12}>
|
||||
{option.count}
|
||||
</CustomTag>
|
||||
<span>{option.label}</span>
|
||||
<Tooltip title={option.description} mouseLeaveDelay={0}>
|
||||
<CircleHelp size={16} style={{ cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
|
||||
</ContentTypeItem>
|
||||
))}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{formState.selectedCount > 0 && (
|
||||
<InfoContainer>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('chat.save.knowledge.select.content.tip', { count: formState.selectedCount })}
|
||||
</Text>
|
||||
</InfoContainer>
|
||||
)}
|
||||
|
||||
{formState.hasNoSelection && (
|
||||
<InfoContainer>
|
||||
<Text type="warning" style={{ fontSize: '12px' }}>
|
||||
{t('chat.save.knowledge.error.no_content_selected')}
|
||||
</Text>
|
||||
</InfoContainer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('chat.save.knowledge.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
destroyOnClose
|
||||
centered
|
||||
width={500}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{
|
||||
loading,
|
||||
disabled: !formState.canSubmit
|
||||
}}>
|
||||
{uiState.type === 'empty' ? renderEmptyState() : renderFormContent()}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'SaveToKnowledgePopup'
|
||||
|
||||
export default class SaveToKnowledgePopup {
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
static show(props: ShowParams): Promise<SaveResult | null> {
|
||||
return new Promise<SaveResult | null>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(result) => {
|
||||
resolve(result)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
`
|
||||
|
||||
const ContentTypeItem = styled(Flex)`
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const InfoContainer = styled.div`
|
||||
background: var(--color-background-soft);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 16px;
|
||||
`
|
||||
24
src/renderer/src/components/Popups/SettingsPopup.tsx
Normal file
24
src/renderer/src/components/Popups/SettingsPopup.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface SettingsPopupShowParams {
|
||||
defaultTab?:
|
||||
| 'provider'
|
||||
| 'model'
|
||||
| 'tool'
|
||||
| 'general'
|
||||
| 'display'
|
||||
| 'shortcut'
|
||||
| 'quickAssistant'
|
||||
| 'selectionAssistant'
|
||||
| 'data'
|
||||
| 'about'
|
||||
| 'quickPhrase'
|
||||
}
|
||||
|
||||
export default class SettingsPopup {
|
||||
static hide() {
|
||||
// Settings window is now independent, user can close it manually
|
||||
}
|
||||
|
||||
static show(props: SettingsPopupShowParams = {}) {
|
||||
return window.api.showSettingsWindow({ defaultTab: props.defaultTab })
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,17 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
const onOk = () => {
|
||||
resolve(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
resolve(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
TemplatePopup.hide = onCancel
|
||||
@@ -51,16 +53,7 @@ export default class TemplatePopup {
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
TopView.show(<PopupContainer {...props} resolve={resolve} />, TopViewKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
|
||||
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 添加更新item选中状态的方法
|
||||
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
|
||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
}, [])
|
||||
|
||||
const open = useCallback((options: QuickPanelOpenOptions) => {
|
||||
if (clearTimer.current) {
|
||||
clearTimeout(clearTimer.current)
|
||||
@@ -77,6 +82,7 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
updateItemSelection,
|
||||
|
||||
isVisible,
|
||||
symbol,
|
||||
@@ -90,7 +96,21 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
beforeAction,
|
||||
afterAction
|
||||
}),
|
||||
[open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction]
|
||||
[
|
||||
open,
|
||||
close,
|
||||
updateItemSelection,
|
||||
isVisible,
|
||||
symbol,
|
||||
list,
|
||||
title,
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
]
|
||||
)
|
||||
|
||||
return <QuickPanelContext value={value}>{children}</QuickPanelContext>
|
||||
|
||||
@@ -52,6 +52,7 @@ export type QuickPanelListItem = {
|
||||
export interface QuickPanelContextType {
|
||||
readonly open: (options: QuickPanelOpenOptions) => void
|
||||
readonly close: (action?: QuickPanelCloseAction) => void
|
||||
readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void
|
||||
readonly isVisible: boolean
|
||||
readonly symbol: string
|
||||
readonly list: QuickPanelListItem[]
|
||||
|
||||
@@ -50,7 +50,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const [isMouseOver, setIsMouseOver] = useState(false)
|
||||
|
||||
const scrollTriggerRef = useRef<QuickPanelScrollTrigger>('initial')
|
||||
const [_index, setIndex] = useState(ctx.defaultIndex)
|
||||
const [_index, setIndex] = useState(-1)
|
||||
const index = useDeferredValue(_index)
|
||||
const [historyPanel, setHistoryPanel] = useState<QuickPanelOpenOptions[]>([])
|
||||
|
||||
@@ -62,6 +62,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
|
||||
// 处理搜索,过滤列表
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
@@ -104,7 +108,24 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
}
|
||||
})
|
||||
|
||||
setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1)
|
||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
|
||||
if (isSearchChanged || isSymbolChanged) {
|
||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
if (prevIndex >= newList.length) {
|
||||
return newList.length > 0 ? newList.length - 1 : -1
|
||||
}
|
||||
return prevIndex
|
||||
})
|
||||
}
|
||||
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
return newList
|
||||
}, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText])
|
||||
@@ -168,12 +189,33 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
(item: QuickPanelListItem, action?: QuickPanelCloseAction) => {
|
||||
if (item.disabled) return
|
||||
|
||||
// 在多选模式下,先更新选中状态
|
||||
if (ctx.multiple && !item.isMenu) {
|
||||
const newSelectedState = !item.isSelected
|
||||
ctx.updateItemSelection(item, newSelectedState)
|
||||
|
||||
// 创建更新后的item对象用于回调
|
||||
const updatedItem = { ...item, isSelected: newSelectedState }
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
action,
|
||||
item: updatedItem,
|
||||
searchText: searchText,
|
||||
multiple: ctx.multiple
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
item?.action?.(quickPanelCallBackOptions)
|
||||
ctx.afterAction?.(quickPanelCallBackOptions)
|
||||
return
|
||||
}
|
||||
|
||||
const quickPanelCallBackOptions: QuickPanelCallBackOptions = {
|
||||
symbol: ctx.symbol,
|
||||
action,
|
||||
item,
|
||||
searchText: searchText,
|
||||
multiple: isAssistiveKeyPressed
|
||||
multiple: ctx.multiple
|
||||
}
|
||||
|
||||
ctx.beforeAction?.(quickPanelCallBackOptions)
|
||||
@@ -200,11 +242,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.multiple && isAssistiveKeyPressed) return
|
||||
// 多选模式下不关闭面板
|
||||
if (ctx.multiple) return
|
||||
|
||||
handleClose(action)
|
||||
},
|
||||
[ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index]
|
||||
[ctx, searchText, handleClose, clearSearchText, index]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -294,12 +337,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
if (isAssistiveKeyPressed) {
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
|
||||
const newIndex = prev - ctx.pageSize
|
||||
if (prev === 0) return list.length - 1
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
} else {
|
||||
setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1))
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? list.length - 1 : -1
|
||||
return prev > 0 ? prev - 1 : list.length - 1
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
@@ -307,18 +354,23 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
if (isAssistiveKeyPressed) {
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? 0 : -1
|
||||
const newIndex = prev + ctx.pageSize
|
||||
if (prev + 1 === list.length) return 0
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
} else {
|
||||
setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0))
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? 0 : -1
|
||||
return prev < list.length - 1 ? prev + 1 : 0
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'PageUp':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? Math.max(0, list.length - ctx.pageSize) : -1
|
||||
const newIndex = prev - ctx.pageSize
|
||||
return newIndex < 0 ? 0 : newIndex
|
||||
})
|
||||
@@ -327,6 +379,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
case 'PageDown':
|
||||
scrollTriggerRef.current = 'keyboard'
|
||||
setIndex((prev) => {
|
||||
if (prev === -1) return list.length > 0 ? Math.min(ctx.pageSize - 1, list.length - 1) : -1
|
||||
const newIndex = prev + ctx.pageSize
|
||||
return newIndex >= list.length ? list.length - 1 : newIndex
|
||||
})
|
||||
@@ -421,10 +474,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
(): VirtualizedRowData => ({
|
||||
list,
|
||||
focusedIndex: index,
|
||||
handleItemAction,
|
||||
setIndex
|
||||
handleItemAction
|
||||
}),
|
||||
[list, index, handleItemAction, setIndex]
|
||||
[list, index, handleItemAction]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -487,15 +539,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
<Flex align="center" gap={4}>
|
||||
↩︎ {t('settings.quickPanel.confirm')}
|
||||
</Flex>
|
||||
|
||||
{ctx.multiple && (
|
||||
<Flex align="center" gap={4}>
|
||||
<span style={{ color: isAssistiveKeyPressed ? 'var(--color-primary)' : 'var(--color-text-3)' }}>
|
||||
{ASSISTIVE_KEY}
|
||||
</span>
|
||||
+ ↩︎ {t('settings.quickPanel.multiple')}
|
||||
</Flex>
|
||||
)}
|
||||
</QuickPanelFooterTips>
|
||||
</QuickPanelFooter>
|
||||
</QuickPanelBody>
|
||||
@@ -507,7 +550,6 @@ interface VirtualizedRowData {
|
||||
list: QuickPanelListItem[]
|
||||
focusedIndex: number
|
||||
handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void
|
||||
setIndex: (index: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,7 +557,7 @@ interface VirtualizedRowData {
|
||||
*/
|
||||
const VirtualizedRow = React.memo(
|
||||
({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => {
|
||||
const { list, focusedIndex, handleItemAction, setIndex } = data
|
||||
const { list, focusedIndex, handleItemAction } = data
|
||||
const item = list[index]
|
||||
if (!item) return null
|
||||
|
||||
@@ -531,8 +573,7 @@ const VirtualizedRow = React.memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleItemAction(item, 'click')
|
||||
}}
|
||||
onMouseEnter={() => setIndex(index)}>
|
||||
}}>
|
||||
<QuickPanelItemLeft>
|
||||
<QuickPanelItemIcon>{item.icon}</QuickPanelItemIcon>
|
||||
<QuickPanelItemLabel>{item.label}</QuickPanelItemLabel>
|
||||
@@ -651,11 +692,19 @@ const QuickPanelItem = styled.div`
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: var(--focused-color);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--selected-color);
|
||||
&.focused {
|
||||
background-color: var(--selected-color-dark);
|
||||
}
|
||||
&:hover:not(.disabled) {
|
||||
background-color: var(--selected-color-dark);
|
||||
}
|
||||
}
|
||||
&.focused {
|
||||
background-color: var(--focused-color);
|
||||
|
||||
237
src/renderer/src/components/Tabs/TabsContainer.tsx
Normal file
237
src/renderer/src/components/Tabs/TabsContainer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs'
|
||||
import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
Home,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
SquareTerminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
switch (tabId) {
|
||||
case 'home':
|
||||
return <Home size={14} />
|
||||
case 'agents':
|
||||
return <Sparkle size={14} />
|
||||
case 'translate':
|
||||
return <Languages size={14} />
|
||||
case 'paintings':
|
||||
return <Palette size={14} />
|
||||
case 'apps':
|
||||
return <LayoutGrid size={14} />
|
||||
case 'knowledge':
|
||||
return <FileSearch size={14} />
|
||||
case 'mcp':
|
||||
return <SquareTerminal size={14} />
|
||||
case 'files':
|
||||
return <Folder size={14} />
|
||||
case 'settings':
|
||||
return <Settings size={14} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const tabs = useAppSelector((state) => state.tabs.tabs)
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
return segments[1] // 获取第一个路径段作为 id
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
return !tabs.some((tab) => tab.id === getTabId(path))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const tabId = getTabId(location.pathname)
|
||||
const currentTab = tabs.find((tab) => tab.id === tabId)
|
||||
|
||||
if (!currentTab && shouldCreateTab(location.pathname)) {
|
||||
dispatch(
|
||||
addTab({
|
||||
id: tabId,
|
||||
path: location.pathname
|
||||
})
|
||||
)
|
||||
} else if (currentTab) {
|
||||
dispatch(setActiveTab(currentTab.id))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, location.pathname])
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
const tabToClose = tabs.find((tab) => tab.id === tabId)
|
||||
if (!tabToClose) return
|
||||
|
||||
if (tabs.length === 1) return
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
const lastTab = remainingTabs[remainingTabs.length - 1]
|
||||
navigate(lastTab.path)
|
||||
}
|
||||
|
||||
dispatch(removeTab(tabId))
|
||||
}
|
||||
|
||||
const handleAddTab = () => {
|
||||
navigate('/launchpad')
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabsBar $isFullscreen={isFullscreen}>
|
||||
{tabs.map((tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => navigate(tab.path)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{t(`title.${tab.id}`)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
<AddTabButton onClick={handleAddTab}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
</TabsBar>
|
||||
<TabContent>{children}</TabContent>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '80px' : '8px')};
|
||||
-webkit-app-region: drag;
|
||||
height: var(--navbar-height);
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
background: ${(props) => (props.active ? 'var(--color-background)' : 'transparent')};
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-app-region: none;
|
||||
min-width: 100px;
|
||||
transition: background 0.2s;
|
||||
.close-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => (props.active ? 'var(--color-background)' : 'var(--color-background-soft)')};
|
||||
.close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TabHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const TabIcon = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const TabTitle = styled.span`
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const CloseButton = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`
|
||||
|
||||
const AddTabButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const TabContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: calc(100vw - 12px);
|
||||
margin: 6px;
|
||||
margin-top: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export default TabsContainer
|
||||
@@ -77,9 +77,10 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('QuickPanelView', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('should focus on the first item after panel open', () => {
|
||||
it('should not focus on any item after panel open by default', () => {
|
||||
const list = createList(100)
|
||||
|
||||
render(
|
||||
@@ -134,11 +134,16 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
)
|
||||
|
||||
// 检查第一个 item 是否有 focused
|
||||
// 检查是否没有任何 focused item
|
||||
const panel = screen.getByTestId('quick-panel')
|
||||
const focused = panel.querySelectorAll('.focused')
|
||||
expect(focused.length).toBe(0)
|
||||
|
||||
// 检查第一个 item 存在但没有 focused 类
|
||||
const item1 = screen.getByText('Item 1')
|
||||
const focused = item1.closest('.focused')
|
||||
expect(focused).not.toBeNull()
|
||||
expect(item1).toBeInTheDocument()
|
||||
const focusedItem1 = item1.closest('.focused')
|
||||
expect(focusedItem1).toBeNull()
|
||||
})
|
||||
|
||||
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
|
||||
@@ -154,10 +159,11 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', expected: 'Item 1' }, // 从未选中状态按 ArrowDown 会选中第一个
|
||||
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会循环到最后一个
|
||||
{ key: 'ArrowUp', expected: 'Item 99' },
|
||||
{ key: 'ArrowDown', expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', expected: 'Item 1' }
|
||||
{ key: 'ArrowDown', expected: 'Item 1' } // 从最后一个按 ArrowDown 会循环到第一个
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
@@ -176,11 +182,11 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
|
||||
{ key: 'ArrowUp', expected: 'Item 100' },
|
||||
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
|
||||
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
|
||||
{ key: 'PageDown', expected: 'Item 100' }
|
||||
{ key: 'PageDown', expected: `Item ${PAGE_SIZE}` }, // 从未选中状态按 PageDown 会选中第 pageSize 个项目
|
||||
{ key: 'PageUp', expected: 'Item 1' }, // PageUp 会选中第一个
|
||||
{ key: 'ArrowUp', expected: 'Item 100' }, // 从第一个按 ArrowUp 会到最后一个
|
||||
{ key: 'PageDown', expected: 'Item 100' }, // 从最后一个按 PageDown 仍然是最后一个
|
||||
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` } // PageUp 会向上翻页,从索引99到92,对应Item 93
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
@@ -199,10 +205,11 @@ describe('QuickPanelView', () => {
|
||||
)
|
||||
|
||||
const keySequence = [
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }, // 从未选中状态按 Ctrl+ArrowDown 会选中第一个
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` }, // Ctrl+ArrowDown 会跳转 pageSize 个位置
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' }, // Ctrl+ArrowUp 会跳转回去
|
||||
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' }, // 从第一个位置再按 Ctrl+ArrowUp 会循环到最后
|
||||
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' } // 从最后位置按 Ctrl+ArrowDown 会循环到第一个
|
||||
]
|
||||
|
||||
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import type { FC, HTMLAttributes, PropsWithChildren } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Navbar: FC<Props> = ({ children, ...props }) => {
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
|
||||
return (
|
||||
<NavbarContainer {...props} style={{ backgroundColor }}>
|
||||
{children}
|
||||
</NavbarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
|
||||
return <NavbarLeftContainer {...props}>{children}</NavbarLeftContainer>
|
||||
return <NavbarContainer {...props}>{children}</NavbarContainer>
|
||||
}
|
||||
|
||||
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
|
||||
@@ -36,8 +25,10 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
|
||||
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
|
||||
const isFullscreen = useFullscreen()
|
||||
const { showAssistants } = useShowAssistants()
|
||||
|
||||
return (
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen} $showAssistants={showAssistants}>
|
||||
{children}
|
||||
</NavbarMainContainer>
|
||||
)
|
||||
@@ -49,28 +40,8 @@ const NavbarContainer = styled.div`
|
||||
flex-direction: row;
|
||||
min-height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
|
||||
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
|
||||
-webkit-app-region: drag;
|
||||
`
|
||||
|
||||
const NavbarLeftContainer = styled.div`
|
||||
min-width: var(--assistants-width);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
@@ -82,14 +53,45 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean; $showAssistants: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
justify-content: space-between;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 12px;
|
||||
padding-left: ${({ $showAssistants }) => (isMac && !$showAssistants ? '70px' : '10px')};
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
padding: 0 8px;
|
||||
font-weight: bold;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
// const rotateAnimation = keyframes`
|
||||
// from {
|
||||
// transform: rotate(-180deg);
|
||||
// }
|
||||
// to {
|
||||
// transform: rotate(0);
|
||||
// }
|
||||
// `
|
||||
|
||||
// const AnimatedButton = styled(Button)`
|
||||
// animation: ${rotateAnimation} 0.4s ease-out;
|
||||
// `
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
@@ -11,9 +9,8 @@ import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import {
|
||||
CircleHelp,
|
||||
FileSearch,
|
||||
@@ -35,7 +32,6 @@ import styled from 'styled-components'
|
||||
|
||||
import { DraggableList } from '../DraggableList'
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
import UserPopup from '../Popups/UserPopup'
|
||||
|
||||
const Sidebar: FC = () => {
|
||||
const { hideMinappPopup, openMinapp } = useMinappPopup()
|
||||
@@ -47,11 +43,8 @@ const Sidebar: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { theme, settedTheme, toggleTheme } = useTheme()
|
||||
const avatar = useAvatar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onEditUser = () => UserPopup.show()
|
||||
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
|
||||
const showPinnedApps = pinned.length > 0 && sidebarIcons.visible.includes('minapp')
|
||||
@@ -79,13 +72,6 @@ const Sidebar: FC = () => {
|
||||
$isFullscreen={isFullscreen}
|
||||
id="app-sidebar"
|
||||
style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={31} fontSize={18}>
|
||||
{avatar}
|
||||
</EmojiAvatar>
|
||||
) : (
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
)}
|
||||
<MainMenusContainer>
|
||||
<Menus onClick={hideMinappPopup}>
|
||||
<MainMenus />
|
||||
@@ -339,16 +325,6 @@ const Container = styled.div<{ $isFullscreen: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const AvatarImg = styled(Avatar)`
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: ${isMac ? '12px' : '12px'};
|
||||
margin-top: ${isMac ? '0px' : '2px'};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const MainMenusContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -33,3 +33,6 @@ export const THEME_COLOR_PRESETS = [
|
||||
'#0EA5E9', // Sky Blue
|
||||
'#0284C7' // Light Blue
|
||||
]
|
||||
|
||||
export const MAX_CONTEXT_COUNT = 100
|
||||
export const UNLIMITED_CONTEXT_COUNT = 100000
|
||||
|
||||
@@ -2487,7 +2487,7 @@ export function isGrokModel(model?: Model): boolean {
|
||||
return model.id.includes('grok')
|
||||
}
|
||||
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
export function isSupportedReasoningEffortGrokModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
@@ -2499,7 +2499,16 @@ export function isGrokReasoningModel(model?: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export const isSupportedReasoningEffortGrokModel = isGrokReasoningModel
|
||||
export function isGrokReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
if (isSupportedReasoningEffortGrokModel(model) || model.id.includes('grok-4')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
|
||||
@@ -47,7 +47,7 @@ As [role name], with [list skills], strictly adhering to [list constraints], usi
|
||||
`
|
||||
|
||||
export const SUMMARIZE_PROMPT =
|
||||
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks or other special symbols"
|
||||
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks, markdown language markers, or other special symbols"
|
||||
|
||||
// https://github.com/ItzCrazyKns/Perplexica/blob/master/src/lib/prompts/webSearch.ts
|
||||
export const SEARCH_SUMMARY_PROMPT = `
|
||||
|
||||
@@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
bash: 'shell',
|
||||
'objective-c++': 'objective-cpp',
|
||||
svg: 'xml',
|
||||
vab: 'vb'
|
||||
vab: 'vb',
|
||||
graphviz: 'dot'
|
||||
} as Record<string, string>
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface ThemeProviderProps extends PropsWithChildren {
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 用户设置的主题
|
||||
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
|
||||
const { theme: settedTheme, setTheme: setSettedTheme, transparentWindow } = useSettings()
|
||||
const [actualTheme, setActualTheme] = useState<ThemeMode>(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
|
||||
)
|
||||
@@ -56,7 +56,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
setActualTheme(actualTheme)
|
||||
})
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme, transparentWindow])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.setTheme(settedTheme)
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import SettingsPopup from '@renderer/components/Popups/SettingsPopup'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const NavigationHandler: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const showSettingsShortcutEnabled = useAppSelector(
|
||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
NavigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useHotkeys(
|
||||
'meta+, ! ctrl+,',
|
||||
function () {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
return
|
||||
}
|
||||
navigate('/settings/provider')
|
||||
SettingsPopup.show({ defaultTab: 'provider' })
|
||||
},
|
||||
{
|
||||
splitKey: '!',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addAgent, removeAgent, updateAgent, updateAgents, updateAgentSettings } from '@renderer/store/agents'
|
||||
import { Agent, AssistantSettings } from '@renderer/types'
|
||||
|
||||
export function useAgents() {
|
||||
const agents = useAppSelector((state) => state.agents.agents)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
agents,
|
||||
updateAgents: (agents: Agent[]) => dispatch(updateAgents(agents)),
|
||||
addAgent: (agent: Agent) => dispatch(addAgent(agent)),
|
||||
removeAgent: (id: string) => dispatch(removeAgent({ id }))
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgent(id: string) {
|
||||
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
agent,
|
||||
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
|
||||
updateAgentSettings: (settings: Partial<AssistantSettings>) => {
|
||||
dispatch(updateAgentSettings({ assistantId: agent.id, settings }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||
@@ -13,17 +11,14 @@ import { useEffect } from 'react'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import useFullScreenNotice from './useFullScreenNotice'
|
||||
import { useRuntime } from './useRuntime'
|
||||
import { useSettings } from './useSettings'
|
||||
import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const { proxyUrl, language, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
@@ -70,18 +65,6 @@ export function useAppInit() {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
|
||||
|
||||
if (minappShow) {
|
||||
window.root.style.background =
|
||||
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
|
||||
return
|
||||
}
|
||||
|
||||
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
|
||||
}, [windowStyle, minappShow, theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalAi) {
|
||||
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)
|
||||
|
||||
@@ -1,45 +1,56 @@
|
||||
import { db } from '@renderer/databases'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addAssistant,
|
||||
addTopic,
|
||||
removeAllTopics,
|
||||
createAssistantFromTemplate,
|
||||
removeAssistant,
|
||||
removeTopic,
|
||||
selectActiveAssistants,
|
||||
selectTemplates,
|
||||
setModel,
|
||||
updateAssistant,
|
||||
updateAssistants,
|
||||
updateAssistantSettings,
|
||||
updateDefaultAssistant,
|
||||
updateTopic,
|
||||
updateTopics
|
||||
updateDefaultAssistant
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { selectTopicsForAssistant, topicsActions } from '@renderer/store/topics'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { TopicManager } from './useTopic'
|
||||
|
||||
export function useAssistants() {
|
||||
const { assistants } = useAppSelector((state) => state.assistants)
|
||||
const assistants = useAppSelector(selectActiveAssistants)
|
||||
const templates = useAppSelector(selectTemplates)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const getAssistantById = useCallback((id: string) => assistants.find((a) => a.id === id), [assistants])
|
||||
|
||||
return {
|
||||
assistants,
|
||||
templates,
|
||||
getAssistantById,
|
||||
updateAssistants: (assistants: Assistant[]) => dispatch(updateAssistants(assistants)),
|
||||
addAssistant: (assistant: Assistant) => dispatch(addAssistant(assistant)),
|
||||
addAssistant: (assistant: Assistant) => {
|
||||
dispatch(addAssistant({ ...assistant, isTemplate: false }))
|
||||
dispatch(topicsActions.addDefaultTopic({ assistantId: assistant.id }))
|
||||
},
|
||||
addTemplate: (template: Assistant) => {
|
||||
dispatch(addAssistant({ ...template, isTemplate: true }))
|
||||
},
|
||||
removeAssistant: (id: string) => {
|
||||
dispatch(removeAssistant({ id }))
|
||||
const assistant = assistants.find((a) => a.id === id)
|
||||
const topics = assistant?.topics || []
|
||||
topics.forEach(({ id }) => TopicManager.removeTopic(id))
|
||||
// Remove all topics for this assistant
|
||||
dispatch(topicsActions.removeAllTopics({ assistantId: id }))
|
||||
},
|
||||
createAssistantFromTemplate: (templateId: string, assistantId: string) => {
|
||||
dispatch(createAssistantFromTemplate({ templateId, assistantId }))
|
||||
dispatch(topicsActions.addDefaultTopic({ assistantId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssistant(id: string) {
|
||||
const assistant = useAppSelector((state) => state.assistants.assistants.find((a) => a.id === id) as Assistant)
|
||||
const topics = useTopicsForAssistant(id)
|
||||
const dispatch = useAppDispatch()
|
||||
const { defaultModel } = useDefaultModel()
|
||||
|
||||
@@ -48,19 +59,18 @@ export function useAssistant(id: string) {
|
||||
throw new Error(`Assistant model is not set for assistant with name: ${assistant?.name ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
|
||||
const assistantWithModel = useMemo(() => ({ ...assistant, model, topics }), [assistant, model, topics])
|
||||
|
||||
return {
|
||||
assistant: assistantWithModel,
|
||||
model,
|
||||
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
|
||||
topics,
|
||||
addTopic: (topic: Topic) => dispatch(topicsActions.addTopic({ assistantId: id, topic })),
|
||||
removeTopic: (topic: Topic) => {
|
||||
TopicManager.removeTopic(topic.id)
|
||||
dispatch(removeTopic({ assistantId: assistant.id, topic }))
|
||||
dispatch(topicsActions.removeTopic({ assistantId: id, topicId: topic.id }))
|
||||
},
|
||||
moveTopic: (topic: Topic, toAssistant: Assistant) => {
|
||||
dispatch(addTopic({ assistantId: toAssistant.id, topic: { ...topic, assistantId: toAssistant.id } }))
|
||||
dispatch(removeTopic({ assistantId: assistant.id, topic }))
|
||||
dispatch(topicsActions.moveTopic({ fromAssistantId: id, toAssistantId: toAssistant.id, topicId: topic.id }))
|
||||
// update topic messages in database
|
||||
db.topics
|
||||
.where('id')
|
||||
@@ -74,9 +84,9 @@ export function useAssistant(id: string) {
|
||||
}
|
||||
})
|
||||
},
|
||||
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
|
||||
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
|
||||
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
|
||||
updateTopic: (topic: Topic) => dispatch(topicsActions.updateTopic({ assistantId: id, topic })),
|
||||
updateTopics: (topics: Topic[]) => dispatch(topicsActions.updateTopics({ assistantId: id, topics })),
|
||||
removeAllTopics: () => dispatch(topicsActions.removeAllTopics({ assistantId: id })),
|
||||
setModel: useCallback(
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
@@ -88,16 +98,19 @@ export function useAssistant(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function useTopicsForAssistant(assistantId: string) {
|
||||
return useAppSelector((state) => selectTopicsForAssistant(state, assistantId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认助手模板
|
||||
*/
|
||||
export function useDefaultAssistant() {
|
||||
const defaultAssistant = useAppSelector((state) => state.assistants.defaultAssistant)
|
||||
const dispatch = useAppDispatch()
|
||||
const memoizedTopics = useMemo(() => [getDefaultTopic(defaultAssistant.id)], [defaultAssistant.id])
|
||||
|
||||
return {
|
||||
defaultAssistant: {
|
||||
...defaultAssistant,
|
||||
topics: memoizedTopics
|
||||
},
|
||||
defaultAssistant,
|
||||
updateDefaultAssistant: (assistant: Assistant) => dispatch(updateDefaultAssistant({ assistant }))
|
||||
}
|
||||
}
|
||||
|
||||
92
src/renderer/src/hooks/useChat.tsx
Normal file
92
src/renderer/src/hooks/useChat.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { createContext, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useTopicsForAssistant } from './useAssistant'
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
interface ChatContextType {
|
||||
activeAssistant: Assistant
|
||||
activeTopic: Topic
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | null>(null)
|
||||
|
||||
export const ChatProvider = ({ children }) => {
|
||||
const assistants = useAppSelector((state) => state.assistants.assistants)
|
||||
const [activeAssistant, setActiveAssistantBase] = useState<Assistant>(assistants[0])
|
||||
const topics = useTopicsForAssistant(activeAssistant.id)
|
||||
const [activeTopic, setActiveTopic] = useState<Topic>(topics[0])
|
||||
const { clickAssistantToShowTopic } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// 包装setActiveAssistant以添加导航逻辑
|
||||
const setActiveAssistant = useCallback(
|
||||
(assistant: Assistant) => {
|
||||
setActiveAssistantBase(assistant)
|
||||
// 如果当前不在聊天页面,导航到聊天页面
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/')
|
||||
}
|
||||
},
|
||||
[setActiveAssistantBase, location.pathname, navigate]
|
||||
)
|
||||
|
||||
// 当 topics 变化时,如果当前 activeTopic 不在 topics 中,设置第一个 topic
|
||||
useEffect(() => {
|
||||
if (!topics.find((topic) => topic.id === activeTopic?.id)) {
|
||||
const firstTopic = topics[0]
|
||||
firstTopic && setActiveTopic(firstTopic)
|
||||
}
|
||||
}, [topics, activeTopic?.id])
|
||||
|
||||
// 当 activeTopic 变化时加载消息
|
||||
useEffect(() => {
|
||||
if (activeTopic) {
|
||||
dispatch(loadTopicMessagesThunk(activeTopic.id))
|
||||
EventEmitter.emit(EVENT_NAMES.CHANGE_TOPIC, activeTopic)
|
||||
}
|
||||
}, [activeTopic, dispatch])
|
||||
|
||||
// 处理点击助手显示话题侧边栏
|
||||
useEffect(() => {
|
||||
if (clickAssistantToShowTopic) {
|
||||
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
||||
}
|
||||
}, [clickAssistantToShowTopic, activeAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
EventEmitter.on(EVENT_NAMES.SET_ASSISTANT, setActiveAssistant),
|
||||
EventEmitter.on(EVENT_NAMES.SET_TOPIC, setActiveTopic)
|
||||
]
|
||||
return () => subscriptions.forEach((subscription) => subscription())
|
||||
}, [setActiveAssistant])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeAssistant,
|
||||
activeTopic,
|
||||
setActiveAssistant,
|
||||
setActiveTopic
|
||||
}),
|
||||
[activeAssistant, activeTopic, setActiveAssistant]
|
||||
)
|
||||
|
||||
return <ChatContext value={value}>{children}</ChatContext>
|
||||
}
|
||||
|
||||
export const useChat = () => {
|
||||
const context = use(ChatContext)
|
||||
if (!context) {
|
||||
throw new Error('useChat must be used within ChatProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
|
||||
import { setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -27,10 +27,6 @@ export const useChatContext = (activeTopic: Topic) => {
|
||||
return () => unsubscribe()
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setActiveTopic(activeTopic))
|
||||
}, [dispatch, activeTopic])
|
||||
|
||||
const handleToggleMultiSelectMode = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch(toggleMultiSelectMode(value))
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { useAgents } from './useAgents'
|
||||
import { useAssistants } from './useAssistant'
|
||||
|
||||
export const useKnowledge = (baseId: string) => {
|
||||
@@ -145,7 +144,8 @@ export const useKnowledge = (baseId: string) => {
|
||||
}
|
||||
}
|
||||
if (item.type === 'file' && typeof item.content === 'object') {
|
||||
await window.api.file.deleteDir(item.content.id)
|
||||
// name: eg. text.pdf
|
||||
await window.api.file.delete(item.content.name)
|
||||
}
|
||||
}
|
||||
// 刷新项目
|
||||
@@ -294,7 +294,6 @@ export const useKnowledgeBases = () => {
|
||||
const dispatch = useDispatch()
|
||||
const bases = useSelector((state: RootState) => state.knowledge.bases)
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
const { agents, updateAgents } = useAgents()
|
||||
|
||||
const addKnowledgeBase = (base: KnowledgeBase) => {
|
||||
dispatch(addBase(base))
|
||||
@@ -318,19 +317,7 @@ export const useKnowledgeBases = () => {
|
||||
return assistant
|
||||
})
|
||||
|
||||
// remove agent knowledge_base
|
||||
const _agents = agents.map((agent) => {
|
||||
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
||||
return {
|
||||
...agent,
|
||||
knowledge_bases: agent.knowledge_bases.filter((kb) => kb.id !== baseId)
|
||||
}
|
||||
}
|
||||
return agent
|
||||
})
|
||||
|
||||
updateAssistants(_assistants)
|
||||
updateAgents(_agents)
|
||||
}
|
||||
|
||||
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user