Merge branch 'main' into feat/async-translate

This commit is contained in:
自由的世界人 2025-07-10 15:16:04 +08:00 committed by GitHub
commit 88f0596a1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
149 changed files with 21992 additions and 15840 deletions

View File

@ -1,9 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

2
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,2 @@
# ignore #7923 eol change and code formatting
4ac8a388347ff35f34de42c3ef4a2f81f03fb3b1

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
* text=auto eol=lf
/.yarn/** linguist-vendored
/.yarn/releases/* binary

View File

@ -73,4 +73,4 @@ body:
id: additional
attributes:
label: 附加信息
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接

View File

@ -73,4 +73,4 @@ body:
id: additional
attributes:
label: Additional Information
description: Any other information that could help us better understand your question, including screenshots or relevant links
description: Any other information that could help us better understand your question, including screenshots or relevant links

View File

@ -9,115 +9,115 @@ labels:
# skips and removes
- name: skip all
content:
regexes: "[Ss]kip (?:[Aa]ll |)[Ll]abels?"
regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?'
- name: remove all
content:
regexes: "[Rr]emove (?:[Aa]ll |)[Ll]abels?"
regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?'
- name: skip kind/bug
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: remove kind/bug
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: skip kind/enhancement
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: remove kind/enhancement
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: skip kind/question
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: remove kind/question
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: skip area/Connectivity
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: remove area/Connectivity
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: skip area/UI/UX
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: remove area/UI/UX
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: skip kind/documentation
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: remove kind/documentation
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: skip client:linux
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: remove client:linux
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: skip client:mac
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: remove client:mac
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: skip client:win
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: remove client:win
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: skip sig/Assistant
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: remove sig/Assistant
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: skip sig/Data
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: remove sig/Data
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: skip sig/MCP
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: remove sig/MCP
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: skip sig/RAG
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: remove sig/RAG
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: skip lgtm
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: remove lgtm
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: skip License
content:
regexes: "[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)"
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)'
- name: remove License
content:
regexes: "[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)"
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)'
# `Dev Team`
- name: Dev Team
@ -129,7 +129,7 @@ labels:
# Area labels
- name: area/Connectivity
content: area/Connectivity
regexes: "代理|[Pp]roxy"
regexes: '代理|[Pp]roxy'
skip-if:
- skip all
- skip area/Connectivity
@ -139,7 +139,7 @@ labels:
- name: area/UI/UX
content: area/UI/UX
regexes: "界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]"
regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]'
skip-if:
- skip all
- skip area/UI/UX
@ -150,7 +150,7 @@ labels:
# Kind labels
- name: kind/documentation
content: kind/documentation
regexes: "文档|教程|[Dd]oc(s|umentation)|[Rr]eadme"
regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme'
skip-if:
- skip all
- skip kind/documentation
@ -161,7 +161,7 @@ labels:
# Client labels
- name: client:linux
content: client:linux
regexes: "(?:[Ll]inux|[Uu]buntu|[Dd]ebian)"
regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)'
skip-if:
- skip all
- skip client:linux
@ -171,7 +171,7 @@ labels:
- name: client:mac
content: client:mac
regexes: "(?:[Mm]ac|[Mm]acOS|[Oo]SX)"
regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)'
skip-if:
- skip all
- skip client:mac
@ -181,7 +181,7 @@ labels:
- name: client:win
content: client:win
regexes: "(?:[Ww]in|[Ww]indows)"
regexes: '(?:[Ww]in|[Ww]indows)'
skip-if:
- skip all
- skip client:win
@ -192,7 +192,7 @@ labels:
# SIG labels
- name: sig/Assistant
content: sig/Assistant
regexes: "快捷助手|[Aa]ssistant"
regexes: '快捷助手|[Aa]ssistant'
skip-if:
- skip all
- skip sig/Assistant
@ -202,7 +202,7 @@ labels:
- name: sig/Data
content: sig/Data
regexes: "[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源"
regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源'
skip-if:
- skip all
- skip sig/Data
@ -212,7 +212,7 @@ labels:
- name: sig/MCP
content: sig/MCP
regexes: "[Mm][Cc][Pp]"
regexes: '[Mm][Cc][Pp]'
skip-if:
- skip all
- skip sig/MCP
@ -222,7 +222,7 @@ labels:
- name: sig/RAG
content: sig/RAG
regexes: "知识库|[Rr][Aa][Gg]"
regexes: '知识库|[Rr][Aa][Gg]'
skip-if:
- skip all
- skip sig/RAG
@ -233,7 +233,7 @@ labels:
# Other labels
- name: lgtm
content: lgtm
regexes: "(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)"
regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)'
skip-if:
- skip all
- skip lgtm
@ -243,7 +243,7 @@ labels:
- name: License
content: License
regexes: "(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)"
regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)'
skip-if:
- skip all
- skip License

View File

@ -1,4 +1,4 @@
name: "Issue Checker"
name: 'Issue Checker'
on:
issues:
@ -19,7 +19,7 @@ jobs:
steps:
- uses: MaaAssistantArknights/issue-checker@v1.14
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
repo-token: '${{ secrets.GITHUB_TOKEN }}'
configuration-path: .github/issue-checker.yml
not-before: 2022-08-05T00:00:00Z
include-title: 1
include-title: 1

View File

@ -1,8 +1,8 @@
name: "Stale Issue Management"
name: 'Stale Issue Management'
on:
schedule:
- cron: "0 0 * * *"
- cron: '0 0 * * *'
workflow_dispatch:
env:
@ -24,18 +24,18 @@ jobs:
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: "needs-more-info"
only-labels: 'needs-more-info'
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: 0 # Close immediately after stale
stale-issue-label: "inactive"
close-issue-label: "closed:no-response"
days-before-close: 0 # Close immediately after stale
stale-issue-label: 'inactive'
close-issue-label: 'closed:no-response'
stale-issue-message: |
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
It will be closed now due to lack of additional information.
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
operations-per-run: 50
exempt-issue-labels: "pending, Dev Team"
exempt-issue-labels: 'pending, Dev Team'
days-before-pr-stale: -1
days-before-pr-close: -1
@ -45,11 +45,11 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: ${{ env.daysBeforeClose }}
stale-issue-label: "inactive"
stale-issue-label: 'inactive'
stale-issue-message: |
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
exempt-issue-labels: "pending, Dev Team, kind/enhancement"
exempt-issue-labels: 'pending, Dev Team, kind/enhancement'
days-before-pr-stale: -1 # Completely disable stalling for PRs
days-before-pr-close: -1 # Completely disable closing for PRs

View File

@ -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
@ -117,4 +120,4 @@ jobs:
makeLatest: false
tag: ${{ steps.get-tag.outputs.tag }}
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

File diff suppressed because it is too large Load Diff

View File

@ -117,9 +117,9 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
划词助手:支持 macOS 系统
文档处理:增加 MinerU、Doc2xMistral 等服务商支持
知识库:新的知识库界面,增加扫描版 PDF 支持
OCRmacOS 增加系统 OCR 支持
服务商:支持一键添加服务商,新增 PH8 大模型开放平台, 支持 PPIO OAuth 登录
修复Linux下数据目录移动问题
服务商:新增 NewAPI 服务商支持
绘图:新增 NewAPI 绘图服务商支持
备份:支持 s3 兼容存储备份
服务商:支持多个密钥管理,支持配置自定义请求头
设置:支持禁用硬件加速
其他:性能优化和错误改进

View File

@ -26,7 +26,7 @@ export default defineConfig([
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@eslint-react/no-prop-types': 'error',
'prettier/prettier': ['error', { endOfLine: 'auto' }]
'prettier/prettier': ['error']
}
},
// Configuration for ensuring compatibility with the original ESLint(8.x) rules

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.4.8",
"version": "1.4.9",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -55,7 +55,7 @@
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"prepare": "husky"
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.840.0",
@ -63,6 +63,8 @@
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"iconv-lite": "^0.6.3",
"jschardet": "^3.1.4",
"jsdom": "26.1.0",
"macos-release": "^3.4.0",
"node-stream-zip": "^1.15.0",
@ -105,7 +107,7 @@
"@langchain/community": "^0.3.36",
"@langchain/ollama": "^0.2.1",
"@mistralai/mistralai": "^1.6.0",
"@modelcontextprotocol/sdk": "^1.11.4",
"@modelcontextprotocol/sdk": "^1.12.3",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@playwright/test": "^1.52.0",

View File

@ -74,6 +74,8 @@ export enum IpcChannel {
Mcp_ServersChanged = 'mcp:servers-changed',
Mcp_ServersUpdated = 'mcp:servers-updated',
Mcp_CheckConnectivity = 'mcp:check-connectivity',
Mcp_SetProgress = 'mcp:set-progress',
Mcp_AbortTool = 'mcp:abort-tool',
// Python
Python_Execute = 'python:execute',
@ -165,6 +167,11 @@ export enum IpcChannel {
Backup_CheckConnection = 'backup:checkConnection',
Backup_CreateDirectory = 'backup:createDirectory',
Backup_DeleteWebdavFile = 'backup:deleteWebdavFile',
Backup_BackupToLocalDir = 'backup:backupToLocalDir',
Backup_RestoreFromLocalBackup = 'backup:restoreFromLocalBackup',
Backup_ListLocalBackupFiles = 'backup:listLocalBackupFiles',
Backup_DeleteLocalBackupFile = 'backup:deleteLocalBackupFile',
Backup_SetLocalBackupDir = 'backup:setLocalBackupDir',
Backup_BackupToS3 = 'backup:backupToS3',
Backup_RestoreFromS3 = 'backup:restoreFromS3',
Backup_ListS3Files = 'backup:listS3Files',

View File

@ -12,6 +12,7 @@ import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webC
import log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
@ -114,12 +115,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
// launch on boot
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin })
}
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, isLaunchOnBoot: boolean) => {
appService.setAppLaunchOnBoot(isLaunchOnBoot)
})
// launch to tray
@ -368,6 +365,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir)
ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup)
ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles)
ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile)
ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir)
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
@ -499,6 +501,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
ipcMain.handle(IpcChannel.Mcp_SetProgress, (_, progress: number) => {
mainWindow.webContents.send('mcp-progress', progress)
})
// Register Python execution handler
ipcMain.handle(

View File

@ -1,8 +1,7 @@
import * as fs from 'node:fs'
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
import { readTextFileWithAutoEncoding } from '@main/utils/file'
import { LoaderReturn } from '@shared/config/types'
import { FileMetadata, KnowledgeBaseParams } from '@types'
import Logger from 'electron-log'
@ -115,7 +114,7 @@ export async function addFileLoader(
// HTML类型处理
loaderReturn = await ragApplication.addLoader(
new WebLoader({
urlOrContent: fs.readFileSync(file.path, 'utf-8'),
urlOrContent: readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@ -125,7 +124,7 @@ export async function addFileLoader(
case 'json':
try {
jsonObject = JSON.parse(fs.readFileSync(file.path, 'utf-8'))
jsonObject = JSON.parse(readTextFileWithAutoEncoding(file.path))
} catch (error) {
jsonParsed = false
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
@ -141,7 +140,7 @@ export async function addFileLoader(
// 如果是其他文本类型且尚未读取文件,则读取文件
loaderReturn = await ragApplication.addLoader(
new TextLoader({
text: fs.readFileSync(file.path, 'utf-8'),
text: readTextFileWithAutoEncoding(file.path),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,

View File

@ -0,0 +1,81 @@
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { app } from 'electron'
import log from 'electron-log'
import fs from 'fs'
import os from 'os'
import path from 'path'
export class AppService {
private static instance: AppService
private constructor() {
// Private constructor to prevent direct instantiation
}
public static getInstance(): AppService {
if (!AppService.instance) {
AppService.instance = new AppService()
}
return AppService.instance
}
public async setAppLaunchOnBoot(isLaunchOnBoot: boolean): Promise<void> {
// Set login item settings for windows and mac
// linux is not supported because it requires more file operations
if (isWin || isMac) {
app.setLoginItemSettings({ openAtLogin: isLaunchOnBoot })
} else if (isLinux) {
try {
const autostartDir = path.join(os.homedir(), '.config', 'autostart')
const desktopFile = path.join(autostartDir, isDev ? 'cherry-studio-dev.desktop' : 'cherry-studio.desktop')
if (isLaunchOnBoot) {
// Ensure autostart directory exists
try {
await fs.promises.access(autostartDir)
} catch {
await fs.promises.mkdir(autostartDir, { recursive: true })
}
// Get executable path
let executablePath = app.getPath('exe')
if (process.env.APPIMAGE) {
// For AppImage packaged apps, use APPIMAGE environment variable
executablePath = process.env.APPIMAGE
}
// Create desktop file content
const desktopContent = `[Desktop Entry]
Type=Application
Name=Cherry Studio
Comment=A powerful AI assistant for producer.
Exec=${executablePath}
Icon=cherrystudio
Terminal=false
StartupNotify=false
Categories=Development;Utility;
X-GNOME-Autostart-enabled=true
Hidden=false`
// Write desktop file
await fs.promises.writeFile(desktopFile, desktopContent)
log.info('Created autostart desktop file for Linux')
} else {
// Remove desktop file
try {
await fs.promises.access(desktopFile)
await fs.promises.unlink(desktopFile)
log.info('Removed autostart desktop file for Linux')
} catch {
// File doesn't exist, no need to remove
}
}
} catch (error) {
log.error('Failed to set launch on boot for Linux:', error)
}
}
}
}
// Default export as singleton instance
export default AppService.getInstance()

View File

@ -27,6 +27,11 @@ class BackupManager {
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
this.listLocalBackupFiles = this.listLocalBackupFiles.bind(this)
this.deleteLocalBackupFile = this.deleteLocalBackupFile.bind(this)
this.backupToLocalDir = this.backupToLocalDir.bind(this)
this.restoreFromLocalBackup = this.restoreFromLocalBackup.bind(this)
this.setLocalBackupDir = this.setLocalBackupDir.bind(this)
this.backupToS3 = this.backupToS3.bind(this)
this.restoreFromS3 = this.restoreFromS3.bind(this)
this.listS3Files = this.listS3Files.bind(this)
@ -477,6 +482,28 @@ class BackupManager {
}
}
async backupToLocalDir(
_: Electron.IpcMainInvokeEvent,
data: string,
fileName: string,
localConfig: {
localBackupDir: string
skipBackupFile: boolean
}
) {
try {
const backupDir = localConfig.localBackupDir
// Create backup directory if it doesn't exist
await fs.ensureDir(backupDir)
const backupedFilePath = await this.backup(_, fileName, data, backupDir, localConfig.skipBackupFile)
return backupedFilePath
} catch (error) {
Logger.error('[BackupManager] Local backup failed:', error)
throw error
}
}
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
const os = require('os')
const deviceName = os.hostname ? os.hostname() : 'device'
@ -504,6 +531,75 @@ class BackupManager {
}
}
async restoreFromLocalBackup(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
try {
const backupDir = localBackupDir
const backupPath = path.join(backupDir, fileName)
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup file not found: ${backupPath}`)
}
return await this.restore(_, backupPath)
} catch (error) {
Logger.error('[BackupManager] Local restore failed:', error)
throw error
}
}
async listLocalBackupFiles(_: Electron.IpcMainInvokeEvent, localBackupDir: string) {
try {
const files = await fs.readdir(localBackupDir)
const result: Array<{ fileName: string; modifiedTime: string; size: number }> = []
for (const file of files) {
const filePath = path.join(localBackupDir, file)
const stat = await fs.stat(filePath)
if (stat.isFile() && file.endsWith('.zip')) {
result.push({
fileName: file,
modifiedTime: stat.mtime.toISOString(),
size: stat.size
})
}
}
// Sort by modified time, newest first
return result.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error) {
Logger.error('[BackupManager] List local backup files failed:', error)
throw error
}
}
async deleteLocalBackupFile(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
try {
const filePath = path.join(localBackupDir, fileName)
if (!fs.existsSync(filePath)) {
throw new Error(`Backup file not found: ${filePath}`)
}
await fs.remove(filePath)
return true
} catch (error) {
Logger.error('[BackupManager] Delete local backup file failed:', error)
throw error
}
}
async setLocalBackupDir(_: Electron.IpcMainInvokeEvent, dirPath: string) {
try {
// Check if directory exists
await fs.ensureDir(dirPath)
return true
} catch (error) {
Logger.error('[BackupManager] Set local backup directory failed:', error)
throw error
}
}
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
const filename = s3Config.fileName || 'cherry-studio.backup.zip'

View File

@ -1,4 +1,4 @@
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import { documentExts, imageExts, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import * as crypto from 'crypto'
@ -188,6 +188,8 @@ class FileStorage {
count: 1
}
logger.info('[FileStorage] File uploaded:', fileMetadata)
return fileMetadata
}
@ -256,7 +258,13 @@ class FileStorage {
}
}
return fs.readFileSync(filePath, 'utf8')
try {
const result = readTextFileWithAutoEncoding(filePath)
return result
} catch (error) {
logger.error(error)
return 'failed to read file'
}
}
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {

View File

@ -28,6 +28,7 @@ import { app } from 'electron'
import Logger from 'electron-log'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
import { v4 as uuidv4 } from 'uuid'
import { CacheService } from './CacheService'
import { CallBackServer } from './mcp/oauth/callback'
@ -71,6 +72,7 @@ function withCache<T extends unknown[], R>(
class McpService {
private clients: Map<string, Client> = new Map()
private pendingClients: Map<string, Promise<Client>> = new Map()
private activeToolCalls: Map<string, AbortController> = new Map()
constructor() {
this.initClient = this.initClient.bind(this)
@ -84,6 +86,7 @@ class McpService {
this.removeServer = this.removeServer.bind(this)
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
this.abortTool = this.abortTool.bind(this)
this.cleanup = this.cleanup.bind(this)
}
@ -455,10 +458,14 @@ class McpService {
*/
public async callTool(
_: Electron.IpcMainInvokeEvent,
{ server, name, args }: { server: MCPServer; name: string; args: any }
{ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }
): Promise<MCPCallToolResponse> {
const toolCallId = callId || uuidv4()
const abortController = new AbortController()
this.activeToolCalls.set(toolCallId, abortController)
try {
Logger.info('[MCP] Calling:', server.name, name, args)
Logger.info('[MCP] Calling:', server.name, name, args, 'callId:', toolCallId)
if (typeof args === 'string') {
try {
args = JSON.parse(args)
@ -468,12 +475,19 @@ class McpService {
}
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
timeout: server.timeout ? server.timeout * 1000 : 60000 // Default timeout of 1 minute
onprogress: (process) => {
console.log('[MCP] Progress:', process.progress / (process.total || 1))
window.api.mcp.setProgress(process.progress / (process.total || 1))
},
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute
signal: this.activeToolCalls.get(toolCallId)?.signal
})
return result as MCPCallToolResponse
} catch (error) {
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
}
}
@ -664,6 +678,20 @@ class McpService {
delete env.http_proxy
delete env.https_proxy
}
// 实现 abortTool 方法
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
const activeToolCall = this.activeToolCalls.get(callId)
if (activeToolCall) {
activeToolCall.abort()
this.activeToolCalls.delete(callId)
Logger.info(`[MCP] Aborted tool call: ${callId}`)
return true
} else {
Logger.warn(`[MCP] No active tool call found for callId: ${callId}`)
return false
}
}
}
export default new McpService()

View File

@ -1,48 +1,48 @@
import { IpcChannel } from '@shared/IpcChannel'
import { ThemeMode } from '@types'
import { BrowserWindow, nativeTheme } from 'electron'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
import { configManager } from './ConfigManager'
class ThemeService {
private theme: ThemeMode = ThemeMode.system
constructor() {
this.theme = configManager.getTheme()
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
nativeTheme.themeSource = this.theme
} else {
// 兼容旧版本
configManager.setTheme(ThemeMode.system)
nativeTheme.themeSource = ThemeMode.system
}
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
}
themeUpdatadHandler() {
BrowserWindow.getAllWindows().forEach((win) => {
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
try {
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
} catch (error) {
// don't throw error if setTitleBarOverlay failed
// Because it may be called with some windows have some title bar
}
}
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
})
}
setTheme(theme: ThemeMode) {
if (theme === this.theme) {
return
}
this.theme = theme
nativeTheme.themeSource = theme
configManager.setTheme(theme)
}
}
export const themeService = new ThemeService()
import { IpcChannel } from '@shared/IpcChannel'
import { ThemeMode } from '@types'
import { BrowserWindow, nativeTheme } from 'electron'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
import { configManager } from './ConfigManager'
class ThemeService {
private theme: ThemeMode = ThemeMode.system
constructor() {
this.theme = configManager.getTheme()
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
nativeTheme.themeSource = this.theme
} else {
// 兼容旧版本
configManager.setTheme(ThemeMode.system)
nativeTheme.themeSource = ThemeMode.system
}
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
}
themeUpdatadHandler() {
BrowserWindow.getAllWindows().forEach((win) => {
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
try {
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
} catch (error) {
// don't throw error if setTitleBarOverlay failed
// Because it may be called with some windows have some title bar
}
}
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
})
}
setTheme(theme: ThemeMode) {
if (theme === this.theme) {
return
}
this.theme = theme
nativeTheme.themeSource = theme
configManager.setTheme(theme)
}
}
export const themeService = new ThemeService()

View File

@ -1,3 +1,4 @@
import { isMac } from '@main/constant'
import Logger from 'electron-log'
import { windowService } from '../WindowService'
@ -33,8 +34,13 @@ export async function handleProvidersProtocolUrl(url: URL) {
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
) {
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
if (isMac) {
windowService.showMainWindow()
}
} else {
setTimeout(() => {
Logger.info('handleProvidersProtocolUrl timeout', { data, version })
handleProvidersProtocolUrl(url)
}, 1000)
}

View File

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

View File

@ -3,8 +3,10 @@ import os from 'node:os'
import path from 'node:path'
import { FileTypes } from '@types'
import iconv from 'iconv-lite'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { detectEncoding, readTextFileWithAutoEncoding } from '../file'
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
// Mock dependencies
@ -241,4 +243,104 @@ describe('file', () => {
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
})
})
// 在 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', () => {
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)
expect(result).toBe(content)
})
it('should try to fix bad detected encoding', () => {
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)
expect(result).toBe(content)
})
})
})

View File

@ -6,6 +6,9 @@ import { isLinux, isPortable } from '@main/constant'
import { audioExts, documentExts, imageExts, 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 { v4 as uuidv4 } from 'uuid'
export function initAppDataDir() {
@ -202,3 +205,57 @@ export function getCacheDir() {
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)
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')
}
return content
}

View File

@ -1,26 +1,26 @@
import { BrowserWindow } from 'electron'
import { configManager } from '../services/ConfigManager'
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
if (reset) {
wins.forEach((win) => {
win.webContents.setZoomFactor(1)
})
configManager.setZoomFactor(1)
return
}
if (delta === 0) {
return
}
const currentZoom = configManager.getZoomFactor()
const newZoom = Number((currentZoom + delta).toFixed(1))
if (newZoom >= 0.5 && newZoom <= 2.0) {
wins.forEach((win) => {
win.webContents.setZoomFactor(newZoom)
})
configManager.setZoomFactor(newZoom)
}
}
import { BrowserWindow } from 'electron'
import { configManager } from '../services/ConfigManager'
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
if (reset) {
wins.forEach((win) => {
win.webContents.setZoomFactor(1)
})
configManager.setZoomFactor(1)
return
}
if (delta === 0) {
return
}
const currentZoom = configManager.getZoomFactor()
const newZoom = Number((currentZoom + delta).toFixed(1))
if (newZoom >= 0.5 && newZoom <= 2.0) {
wins.forEach((win) => {
win.webContents.setZoomFactor(newZoom)
})
configManager.setZoomFactor(newZoom)
}
}

View File

@ -88,6 +88,18 @@ const api = {
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
backupToLocalDir: (
data: string,
fileName: string,
localConfig: { localBackupDir?: string; skipBackupFile?: boolean }
) => ipcRenderer.invoke(IpcChannel.Backup_BackupToLocalDir, data, fileName, localConfig),
restoreFromLocalBackup: (fileName: string, localBackupDir?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromLocalBackup, fileName, localBackupDir),
listLocalBackupFiles: (localBackupDir?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_ListLocalBackupFiles, localBackupDir),
deleteLocalBackupFile: (fileName: string, localBackupDir?: string) =>
ipcRenderer.invoke(IpcChannel.Backup_DeleteLocalBackupFile, fileName, localBackupDir),
setLocalBackupDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.Backup_SetLocalBackupDir, dirPath),
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
@ -216,8 +228,8 @@ const api = {
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
callTool: ({ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args, callId }),
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
@ -225,7 +237,9 @@ const api = {
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server),
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress)
},
python: {
execute: (script: string, context?: Record<string, any>, timeout?: number) =>

View File

@ -1,46 +1,45 @@
<!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' 'unsafe-inline' *; 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>
<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' 'unsafe-inline' *; 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>
html,
body {
margin: 0;
}
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: flex;
}
#spinner {
position: fixed;
width: 100vw;
height: 100vh;
flex-direction: row;
justify-content: center;
align-items: center;
display: flex;
}
#spinner img {
width: 100px;
border-radius: 50px;
}
</style>
</head>
#spinner img {
width: 100px;
border-radius: 50px;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script>
console.time('init')
</script>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/entryPoint.tsx"></script>
</body>
</html>
<body>
<div id="root"></div>
<div id="spinner">
<img src="/src/assets/images/logo.png" />
</div>
<script>
console.time('init')
</script>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/entryPoint.tsx"></script>
</body>
</html>

View File

@ -1,24 +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>
<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>
<style>
html,
body {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
</body>
</html>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
</body>
</html>

View File

@ -1,41 +1,39 @@
<!doctype html>
<html lang="zh-CN">
<head>
<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:" />
<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 Selection Assistant</title>
</head>
</head>
<body>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
</style>
</body>
</html>
</body>
</html>

View File

@ -1,46 +1,43 @@
<!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 Selection Toolbar</title>
</head>
<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 Selection Toolbar</title>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0 !important;
background-color: transparent !important;
background-image: none !important;
}
</head>
body {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
width: 100vw !important;
height: 100vh !important;
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0 !important;
background-color: transparent !important;
background-image: none !important;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}
body {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
width: 100vw !important;
height: 100vh !important;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
margin: 0 !important;
padding: 0 !important;
width: max-content !important;
height: fit-content !important;
}
</style>
</body>
</html>
#root {
margin: 0 !important;
padding: 0 !important;
width: max-content !important;
height: fit-content !important;
}
</style>
</body>
</html>

View File

@ -47,10 +47,9 @@ export class ApiClientFactory {
// 然后检查标准的provider type
switch (provider.type) {
case 'openai':
case 'azure-openai':
console.log(`[ApiClientFactory] Creating OpenAIApiClient for provider: ${provider.id}`)
instance = new OpenAIAPIClient(provider) as BaseApiClient
break
case 'azure-openai':
case 'openai-response':
instance = new OpenAIResponseAPIClient(provider) as BaseApiClient
break

View File

@ -106,7 +106,7 @@ export class NewAPIClient extends BaseApiClient {
return client
}
if (model.endpoint_type === 'openai') {
if (model.endpoint_type === 'openai' || model.endpoint_type === 'image-generation') {
const client = this.clients.get('openai')
if (!client || !this.isValidClient(client)) {
throw new Error('Failed to get openai client')

View File

@ -2,6 +2,7 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
import {
isOpenAIChatCompletionOnlyModel,
isOpenAILLMModel,
isSupportedReasoningEffortOpenAIModel,
isVisionModel
} from '@renderer/config/models'
@ -64,10 +65,10 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
*
*/
public getClient(model: Model) {
if (isOpenAIChatCompletionOnlyModel(model)) {
return this.client
} else {
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
return this
} else {
return this.client
}
}

View File

@ -75,7 +75,8 @@ export default class AiProvider {
} else {
// Existing logic for other models
if (!params.enableReasoning) {
builder.remove(ThinkingTagExtractionMiddlewareName)
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
// builder.remove(ThinkingTagExtractionMiddlewareName)
builder.remove(ThinkChunkMiddlewareName)
}
// 注意用client判断会导致typescript类型收窄

View File

@ -67,7 +67,12 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
const streamWithAbortHandler = (result.stream as ReadableStream<Chunk>).pipeThrough(
new TransformStream<Chunk, Chunk | ErrorChunk>({
transform(chunk, controller) {
// 检查 abort 状态
// 如果已经收到错误块,不再检查 abort 状态
if (chunk.type === ChunkType.ERROR) {
controller.enqueue(chunk)
return
}
if (abortSignal?.aborted) {
// 转换为 ErrorChunk
const errorChunk: ErrorChunk = {

View File

@ -136,7 +136,6 @@ function extractAndAccumulateUsageMetrics(ctx: CompletionsContext, chunk: Generi
Logger.debug(`[${MIDDLEWARE_NAME}] First token timestamp: ${ctx._internal.customState.firstTokenTimestamp}`)
}
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
Logger.debug(`[${MIDDLEWARE_NAME}] LLM_RESPONSE_COMPLETE chunk received:`, ctx._internal)
// 从LLM_RESPONSE_COMPLETE chunk中提取usage数据
if (chunk.response?.usage) {
accumulateUsage(ctx._internal.observer.usage, chunk.response.usage)

View File

@ -89,6 +89,11 @@ function createToolHandlingTransform(
let hasToolUseResponses = false
let streamEnded = false
// 存储已执行的工具结果
const executedToolResults: SdkMessageParam[] = []
const executedToolCalls: SdkToolCall[] = []
const executionPromises: Promise<void>[] = []
return new TransformStream({
async transform(chunk: GenericChunk, controller) {
try {
@ -98,22 +103,64 @@ function createToolHandlingTransform(
// 1. 处理Function Call方式的工具调用
if (createdChunk.tool_calls && createdChunk.tool_calls.length > 0) {
toolCalls.push(...createdChunk.tool_calls)
hasToolCalls = true
for (const toolCall of createdChunk.tool_calls) {
toolCalls.push(toolCall)
const executionPromise = (async () => {
try {
const result = await executeToolCalls(
ctx,
[toolCall],
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
// 缓存执行结果
executedToolResults.push(...result.toolResults)
executedToolCalls.push(...result.confirmedToolCalls)
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool call asynchronously:`, error)
}
})()
executionPromises.push(executionPromise)
}
}
// 2. 处理Tool Use方式的工具调用
if (createdChunk.tool_use_responses && createdChunk.tool_use_responses.length > 0) {
toolUseResponses.push(...createdChunk.tool_use_responses)
hasToolUseResponses = true
for (const toolUseResponse of createdChunk.tool_use_responses) {
toolUseResponses.push(toolUseResponse)
const executionPromise = (async () => {
try {
const result = await executeToolUseResponses(
ctx,
[toolUseResponse], // 单个执行
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
// 缓存执行结果
executedToolResults.push(...result.toolResults)
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool use response asynchronously:`, error)
// 错误时不影响其他工具的执行
}
})()
executionPromises.push(executionPromise)
}
}
// 不转发MCP工具进展chunks避免重复处理
return
} else {
controller.enqueue(chunk)
}
// 转发其他所有chunk
controller.enqueue(chunk)
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error processing chunk:`, error)
controller.error(error)
@ -121,43 +168,33 @@ function createToolHandlingTransform(
},
async flush(controller) {
const shouldExecuteToolCalls = hasToolCalls && toolCalls.length > 0
const shouldExecuteToolUseResponses = hasToolUseResponses && toolUseResponses.length > 0
if (!streamEnded && (shouldExecuteToolCalls || shouldExecuteToolUseResponses)) {
// 在流结束时等待所有异步工具执行完成,然后进行递归调用
if (!streamEnded && (hasToolCalls || hasToolUseResponses)) {
streamEnded = true
try {
let toolResult: SdkMessageParam[] = []
if (shouldExecuteToolCalls) {
toolResult = await executeToolCalls(
ctx,
toolCalls,
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
} else if (shouldExecuteToolUseResponses) {
toolResult = await executeToolUseResponses(
ctx,
toolUseResponses,
mcpTools,
allToolResponses,
currentParams.onChunk,
currentParams.assistant.model!
)
}
if (toolResult.length > 0) {
await Promise.all(executionPromises)
if (executedToolResults.length > 0) {
const output = ctx._internal.toolProcessingState?.output
const newParams = buildParamsWithToolResults(
ctx,
currentParams,
output,
executedToolResults,
executedToolCalls
)
// 在递归调用前通知UI开始新的LLM响应处理
if (currentParams.onChunk) {
currentParams.onChunk({
type: ChunkType.LLM_RESPONSE_CREATED
})
}
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
await executeWithToolHandling(newParams, depth + 1)
}
} catch (error) {
console.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
Logger.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
controller.error(error)
} finally {
hasToolCalls = false
@ -178,8 +215,7 @@ async function executeToolCalls(
allToolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
model: Model
): Promise<SdkMessageParam[]> {
// 转换为MCPToolResponse格式
): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> {
const mcpToolResponses: ToolCallResponse[] = toolCalls
.map((toolCall) => {
const mcpTool = ctx.apiClientInstance.convertSdkToolCallToMcp(toolCall, mcpTools)
@ -192,11 +228,11 @@ async function executeToolCalls(
if (mcpToolResponses.length === 0) {
console.warn(`🔧 [${MIDDLEWARE_NAME}] No valid MCP tool responses to execute`)
return []
return { toolResults: [], confirmedToolCalls: [] }
}
// 使用现有的parseAndCallTools函数执行工具
const toolResults = await parseAndCallTools(
const { toolResults, confirmedToolResponses } = await parseAndCallTools(
mcpToolResponses,
allToolResponses,
onChunk,
@ -204,10 +240,25 @@ async function executeToolCalls(
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
},
model,
mcpTools
mcpTools,
ctx._internal?.flowControl?.abortSignal
)
return toolResults
// 找出已确认工具对应的原始toolCalls
const confirmedToolCalls = toolCalls.filter((toolCall) => {
return confirmedToolResponses.find((confirmed) => {
// 根据不同的ID字段匹配原始toolCall
return (
('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 ||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id)
)
})
})
return { toolResults, confirmedToolCalls }
}
/**
@ -221,9 +272,9 @@ async function executeToolUseResponses(
allToolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
model: Model
): Promise<SdkMessageParam[]> {
): Promise<{ toolResults: SdkMessageParam[] }> {
// 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse
const toolResults = await parseAndCallTools(
const { toolResults } = await parseAndCallTools(
toolUseResponses,
allToolResponses,
onChunk,
@ -231,10 +282,11 @@ async function executeToolUseResponses(
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
},
model,
mcpTools
mcpTools,
ctx._internal?.flowControl?.abortSignal
)
return toolResults
return { toolResults }
}
/**
@ -245,7 +297,7 @@ function buildParamsWithToolResults(
currentParams: CompletionsParams,
output: SdkRawOutput | string | undefined,
toolResults: SdkMessageParam[],
toolCalls: SdkToolCall[]
confirmedToolCalls: SdkToolCall[]
): CompletionsParams {
// 获取当前已经转换好的reqMessages如果没有则使用原始messages
const currentReqMessages = getCurrentReqMessages(ctx)
@ -253,7 +305,7 @@ function buildParamsWithToolResults(
const apiClient = ctx.apiClientInstance
// 从回复中构建助手消息
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, confirmedToolCalls)
if (output && ctx._internal.toolProcessingState) {
ctx._internal.toolProcessingState.output = undefined

View File

@ -22,7 +22,8 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
* 1. <tool_use></tool_use>
* 2. ToolUseResponse
* 3. MCP_TOOL_CREATED chunk McpToolChunkMiddleware
* 4. 使
* 4. tool_use
* 5. 使
*
* McpToolChunkMiddleware
*/
@ -32,13 +33,10 @@ export const ToolUseExtractionMiddleware: CompletionsMiddleware =
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
const mcpTools = params.mcpTools || []
// 如果没有工具,直接调用下一个中间件
if (!mcpTools || mcpTools.length === 0) return next(ctx, params)
// 调用下游中间件
const result = await next(ctx, params)
// 响应后处理:处理工具使用标签提取
if (result.stream) {
const resultFromUpstream = result.stream as ReadableStream<GenericChunk>
@ -60,7 +58,9 @@ function createToolUseExtractionTransform(
_ctx: CompletionsContext,
mcpTools: MCPTool[]
): TransformStream<GenericChunk, GenericChunk> {
const tagExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
const toolUseExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
let hasAnyToolUse = false
let toolCounter = 0
return new TransformStream({
async transform(chunk: GenericChunk, controller) {
@ -68,30 +68,37 @@ function createToolUseExtractionTransform(
// 处理文本内容,检测工具使用标签
if (chunk.type === ChunkType.TEXT_DELTA) {
const textChunk = chunk as TextDeltaChunk
const extractionResults = tagExtractor.processText(textChunk.text)
for (const result of extractionResults) {
// 处理 tool_use 标签
const toolUseResults = toolUseExtractor.processText(textChunk.text)
for (const result of toolUseResults) {
if (result.complete && result.tagContentExtracted) {
// 提取到完整的工具使用内容,解析并转换为 SDK ToolCall 格式
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools)
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools, toolCounter)
toolCounter += toolUseResponses.length
if (toolUseResponses.length > 0) {
// 生成 MCP_TOOL_CREATED chunk,复用现有的处理流程
// 生成 MCP_TOOL_CREATED chunk
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
type: ChunkType.MCP_TOOL_CREATED,
tool_use_responses: toolUseResponses
}
controller.enqueue(mcpToolCreatedChunk)
// 标记已有工具调用
hasAnyToolUse = true
}
} else if (!result.isTagContent && result.content) {
// 发送标签外的正常文本内容
const cleanTextChunk: TextDeltaChunk = {
...textChunk,
text: result.content
if (!hasAnyToolUse) {
const cleanTextChunk: TextDeltaChunk = {
...textChunk,
text: result.content
}
controller.enqueue(cleanTextChunk)
}
controller.enqueue(cleanTextChunk)
}
// 注意标签内的内容不会作为TEXT_DELTA转发,避免重复显示
// tool_use 标签内的内容不转发,避免重复显示
}
return
}
@ -105,16 +112,17 @@ function createToolUseExtractionTransform(
},
async flush(controller) {
// 检查是否有未完成的标签内容
const finalResult = tagExtractor.finalize()
if (finalResult && finalResult.tagContentExtracted) {
const toolUseResponses = parseToolUse(finalResult.tagContentExtracted, mcpTools)
// 检查是否有未完成的 tool_use 标签内容
const finalToolUseResult = toolUseExtractor.finalize()
if (finalToolUseResult && finalToolUseResult.tagContentExtracted) {
const toolUseResponses = parseToolUse(finalToolUseResult.tagContentExtracted, mcpTools, toolCounter)
if (toolUseResponses.length > 0) {
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
type: ChunkType.MCP_TOOL_CREATED,
tool_use_responses: toolUseResponses
}
controller.enqueue(mcpToolCreatedChunk)
hasAnyToolUse = true
}
}
}

View File

@ -1,13 +1,13 @@
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range:
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
src: url('TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
/* 国旗字体样式类 */
.country-flag-font {
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
}
@font-face {
font-family: 'Twemoji Country Flags';
unicode-range:
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
src: url('TwemojiCountryFlags.woff2') format('woff2');
font-display: swap;
}
/* 国旗字体样式类 */
.country-flag-font {
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
}

View File

@ -1,20 +1,20 @@
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}
// Windows系统专用字体配置
body[os='windows'] {
--font-family:
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
:root {
--font-family:
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-family-serif:
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
}
// Windows系统专用字体配置
body[os='windows'] {
--font-family:
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

View File

@ -139,7 +139,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;

View File

@ -1,8 +1,10 @@
.markdown {
color: var(--color-text);
line-height: 1.6;
line-height: 2;
user-select: text;
word-break: break-word;
letter-spacing: 0.02em;
word-spacing: 0.05em;
h1:first-child,
h2:first-child,
@ -19,12 +21,14 @@
h4,
h5,
h6 {
margin: 1em 0 1em 0;
margin: 2em 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 +57,9 @@
}
p {
margin: 1em 0;
margin: 1.3em 0;
white-space: pre-wrap;
text-align: justify;
&:last-child {
margin-bottom: 5px;
@ -108,6 +113,7 @@
li code {
background: var(--color-background-mute);
padding: 3px 5px;
margin: 0 2px;
border-radius: 5px;
word-break: keep-all;
white-space: pre;
@ -148,16 +154,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,8 +191,13 @@
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 {

View File

@ -9,6 +9,7 @@ import { debounce } from 'lodash'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
@ -150,7 +151,8 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
{
'--gutter-width': `${gutterDigits}ch`,
fontSize: `${fontSize - 1}px`,
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined,
overflowY: shouldCollapse ? 'auto' : 'hidden'
} as React.CSSProperties
}>
<div
@ -195,9 +197,49 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
CodePreview.displayName = 'CodePreview'
/**
* tokens
*/
function completeLineTokens(themedTokens: ThemedToken[], rawLine: string): ThemedToken[] {
// 如果出现空行,补一个空格保证行高
if (rawLine.length === 0) {
return [
{
content: ' ',
offset: 0,
color: 'inherit',
bgColor: 'inherit',
htmlStyle: {
opacity: '0.35'
}
}
]
}
const themedContent = themedTokens.map((token) => token.content).join('')
const extraContent = rawLine.slice(themedContent.length)
// 已有内容已经全部高亮,直接返回
if (!extraContent) return themedTokens
// 补全剩余内容
return [
...themedTokens,
{
content: extraContent,
offset: themedContent.length,
color: 'inherit',
bgColor: 'inherit',
htmlStyle: {
opacity: '0.35'
}
}
]
}
interface VirtualizedRowData {
rawLine: string
tokenLine?: any[]
tokenLine?: ThemedToken[]
showLineNumbers: boolean
}
@ -210,17 +252,11 @@ const VirtualizedRow = memo(
<div className="line">
{showLineNumbers && <span className="line-number">{index + 1}</span>}
<span className="line-content">
{tokenLine ? (
// 渲染高亮后的内容
tokenLine.map((token, tokenIndex) => (
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))
) : (
// 渲染原始内容
<span className="line-content-raw">{rawLine || ' '}</span>
)}
{completeLineTokens(tokenLine ?? [], rawLine).map((token, tokenIndex) => (
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))}
</span>
</div>
)
@ -234,7 +270,7 @@ const ScrollContainer = styled.div<{
$lineHeight?: number
}>`
display: block;
overflow: auto;
overflow-x: auto;
position: relative;
border-radius: inherit;
padding: 0.5em 1em;
@ -264,10 +300,6 @@ const ScrollContainer = styled.div<{
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
}
}
.line-content-raw {
opacity: 0.35;
}
}
`

View File

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

View File

@ -0,0 +1,436 @@
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>
{isStreaming && (
<StreamingIndicator>
<ClipLoader size={16} color="currentColor" />
<StreamingText>{t('html_artifacts.generating')}</StreamingText>
</StreamingIndicator>
)}
</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: 4px 8px;
background: var(--color-background-mute);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
width: fit-content;
`
const StreamingIndicator = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-status-warning);
border: 1px solid var(--color-status-warning);
border-radius: 8px;
color: var(--color-text);
font-size: 12px;
opacity: 0.9;
[theme-mode='light'] & {
background: #fef3c7;
border-color: #fbbf24;
color: #92400e;
}
`
const StreamingText = styled.div`
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
`
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

View 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

View File

@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodePreview from './CodePreview'
import HtmlArtifacts from './HtmlArtifacts'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import StatusBar from './StatusBar'
@ -45,6 +45,7 @@ interface Props {
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
@ -228,19 +229,16 @@ 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>
)
@ -292,6 +290,7 @@ const SplitViewWrapper = styled.div`
&:not(:has(+ [class*='Container'])) {
border-radius: 0 0 8px 8px;
overflow: hidden;
}
`

View File

@ -42,6 +42,7 @@ interface Props {
extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
style?: React.CSSProperties
editable?: boolean
}
/**
@ -62,7 +63,8 @@ const CodeEditor = ({
maxHeight,
options,
extensions,
style
style,
editable = true
}: Props) => {
const {
fontSize,
@ -190,7 +192,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}

View File

@ -208,8 +208,6 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
inputEl.focus()
inputEl.select()
search()
CSS.highlights.clear()
setSearchCompleted(SearchCompletedState.NotSearched)
})
} else {
requestAnimationFrame(() => {

View File

@ -0,0 +1,2 @@
export { default as DraggableList } from './list'
export { default as DraggableVirtualList } from './virtual-list'

View File

@ -23,7 +23,7 @@ interface Props<T> {
droppableProps?: Partial<DroppableProps>
}
const DragableList: FC<Props<any>> = ({
const DraggableList: FC<Props<any>> = ({
children,
list,
style,
@ -78,4 +78,4 @@ const DragableList: FC<Props<any>> = ({
)
}
export default DragableList
export default DraggableList

View File

@ -0,0 +1,212 @@
import {
DragDropContext,
Draggable,
Droppable,
DroppableProps,
DropResult,
OnDragEndResponder,
OnDragStartResponder,
ResponderProvided
} from '@hello-pangea/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { droppableReorder } from '@renderer/utils'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useRef } from 'react'
/**
* Props DraggableVirtualList
*
* @template T
* @property {string} [className] class
* @property {React.CSSProperties} [style]
* @property {React.CSSProperties} [itemStyle]
* @property {React.CSSProperties} [itemContainerStyle]
* @property {Partial<DroppableProps>} [droppableProps] Droppable
* @property {(list: T[]) => void} onUpdate
* @property {OnDragStartResponder} [onDragStart]
* @property {OnDragEndResponder} [onDragEnd]
* @property {T[]} list
* @property {(index: number) => Key} [itemKey] key使 index
* @property {number} [overscan=5]
* @property {(item: T, index: number) => React.ReactNode} children
*/
interface DraggableVirtualListProps<T> {
ref?: React.Ref<HTMLDivElement>
className?: string
style?: React.CSSProperties
itemStyle?: React.CSSProperties
itemContainerStyle?: React.CSSProperties
droppableProps?: Partial<DroppableProps>
onUpdate: (list: T[]) => void
onDragStart?: OnDragStartResponder
onDragEnd?: OnDragEndResponder
list: T[]
itemKey?: (index: number) => Key
overscan?: number
children: (item: T, index: number) => React.ReactNode
}
/**
*
* -
* @template T
* @param {DraggableVirtualListProps<T>} props
* @returns {React.ReactElement}
*/
function DraggableVirtualList<T>({
ref,
className,
style,
itemStyle,
itemContainerStyle,
droppableProps,
onDragStart,
onUpdate,
onDragEnd,
list,
itemKey,
overscan = 5,
children
}: DraggableVirtualListProps<T>): React.ReactElement {
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided)
if (result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
onUpdate(reorderAgents)
}
}
// 虚拟列表滚动容器的 ref
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: list.length,
getScrollElement: useCallback(() => parentRef.current, []),
getItemKey: itemKey,
estimateSize: useCallback(() => 50, []),
overscan
})
return (
<div ref={ref} className={`${className} draggable-virtual-list`} style={{ height: '100%', ...style }}>
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
<Droppable
droppableId="droppable"
mode="virtual"
renderClone={(provided, _snapshot, rubric) => {
const item = list[rubric.source.index]
return (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={{
...itemStyle,
...provided.draggableProps.style
}}>
{item && children(item, rubric.source.index)}
</div>
)
}}
{...droppableProps}>
{(provided) => {
// 让 dnd 和虚拟列表共享同一个滚动容器
const setRefs = (el: HTMLDivElement | null) => {
provided.innerRef(el)
parentRef.current = el
}
return (
<Scrollbar
ref={setRefs}
{...provided.droppableProps}
className="virtual-scroller"
style={{
height: '100%',
width: '100%',
overflowY: 'auto',
position: 'relative'
}}>
<div
className="virtual-list"
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<VirtualRow
key={virtualItem.key}
virtualItem={virtualItem}
list={list}
itemStyle={itemStyle}
itemContainerStyle={itemContainerStyle}
virtualizer={virtualizer}
children={children}
/>
))}
</div>
</Scrollbar>
)
}}
</Droppable>
</DragDropContext>
</div>
)
}
/**
*
*/
const VirtualRow = memo(({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer }: any) => {
const item = list[virtualItem.index]
const draggableId = String(virtualItem.key)
return (
<Draggable
key={`draggable_${draggableId}_${virtualItem.index}`}
draggableId={draggableId}
index={virtualItem.index}>
{(provided) => {
const setDragRefs = (el: HTMLElement | null) => {
provided.innerRef(el)
virtualizer.measureElement(el)
}
const dndStyle = provided.draggableProps.style
const virtualizerTransform = `translateY(${virtualItem.start}px)`
// dnd 的 transform 负责拖拽时的位移和让位动画,
// virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置,
// 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。
const combinedTransform = dndStyle?.transform
? `${dndStyle.transform} ${virtualizerTransform}`
: virtualizerTransform
return (
<div
{...provided.draggableProps}
ref={setDragRefs}
className="draggable-item"
data-index={virtualItem.index}
style={{
...itemContainerStyle,
...dndStyle,
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: combinedTransform
}}>
<div {...provided.dragHandleProps} className="draggable-content" style={{ ...itemStyle }}>
{item && children(item, virtualItem.index)}
</div>
</div>
)
}}
</Draggable>
)
})
export default DraggableVirtualList

View File

@ -0,0 +1,255 @@
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { restoreFromLocalBackup } from '@renderer/services/BackupService'
import { formatFileSize } from '@renderer/utils'
import { Button, message, Modal, Table, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
interface LocalBackupManagerProps {
visible: boolean
onClose: () => void
localBackupDir?: string
restoreMethod?: (fileName: string) => Promise<void>
}
export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMethod }: LocalBackupManagerProps) {
const { t } = useTranslation()
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [loading, setLoading] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [deleting, setDeleting] = useState(false)
const [restoring, setRestoring] = useState(false)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 5,
total: 0
})
const fetchBackupFiles = useCallback(async () => {
if (!localBackupDir) {
return
}
setLoading(true)
try {
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
setBackupFiles(files)
setPagination((prev) => ({
...prev,
total: files.length
}))
} catch (error: any) {
message.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`)
} finally {
setLoading(false)
}
}, [localBackupDir, t])
useEffect(() => {
if (visible) {
fetchBackupFiles()
setSelectedRowKeys([])
setPagination((prev) => ({
...prev,
current: 1
}))
}
}, [visible, fetchBackupFiles])
const handleTableChange = (pagination: any) => {
setPagination(pagination)
}
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning(t('settings.data.local.backup.manager.select.files.delete'))
return
}
if (!localBackupDir) {
return
}
window.modal.confirm({
title: t('settings.data.local.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.local.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
// Delete selected files one by one
for (const key of selectedRowKeys) {
await window.api.backup.deleteLocalBackupFile(key.toString(), localBackupDir)
}
message.success(
t('settings.data.local.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
)
setSelectedRowKeys([])
await fetchBackupFiles()
} catch (error: any) {
message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
}
})
}
const handleDeleteSingle = async (fileName: string) => {
if (!localBackupDir) {
return
}
window.modal.confirm({
title: t('settings.data.local.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.local.backup.manager.delete.confirm.single', { fileName }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
await window.api.backup.deleteLocalBackupFile(fileName, localBackupDir)
message.success(t('settings.data.local.backup.manager.delete.success.single'))
await fetchBackupFiles()
} catch (error: any) {
message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
} finally {
setDeleting(false)
}
}
})
}
const handleRestore = async (fileName: string) => {
if (!localBackupDir) {
return
}
window.modal.confirm({
title: t('settings.data.local.restore.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.local.restore.confirm.content'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await (restoreMethod || restoreFromLocalBackup)(fileName)
message.success(t('settings.data.local.backup.manager.restore.success'))
onClose() // Close the modal
} catch (error: any) {
message.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`)
} finally {
setRestoring(false)
}
}
})
}
const columns = [
{
title: t('settings.data.local.backup.manager.columns.fileName'),
dataIndex: 'fileName',
key: 'fileName',
ellipsis: {
showTitle: false
},
render: (fileName: string) => (
<Tooltip placement="topLeft" title={fileName}>
{fileName}
</Tooltip>
)
},
{
title: t('settings.data.local.backup.manager.columns.modifiedTime'),
dataIndex: 'modifiedTime',
key: 'modifiedTime',
width: 180,
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
},
{
title: t('settings.data.local.backup.manager.columns.size'),
dataIndex: 'size',
key: 'size',
width: 120,
render: (size: number) => formatFileSize(size)
},
{
title: t('settings.data.local.backup.manager.columns.actions'),
key: 'action',
width: 160,
render: (_: any, record: BackupFile) => (
<>
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
{t('settings.data.local.backup.manager.restore.text')}
</Button>
<Button
type="link"
danger
onClick={() => handleDeleteSingle(record.fileName)}
disabled={deleting || restoring}>
{t('settings.data.local.backup.manager.delete.text')}
</Button>
</>
)
}
]
const rowSelection = {
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[]) => {
setSelectedRowKeys(selectedRowKeys)
}
}
return (
<Modal
title={t('settings.data.local.backup.manager.title')}
open={visible}
onCancel={onClose}
width={800}
centered
transitionName="animation-move-down"
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
{t('settings.data.local.backup.manager.refresh')}
</Button>,
<Button
key="delete"
danger
icon={<DeleteOutlined />}
onClick={handleDeleteSelected}
disabled={selectedRowKeys.length === 0 || deleting}
loading={deleting}>
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
</Button>,
<Button key="close" onClick={onClose}>
{t('common.close')}
</Button>
]}>
<Table
rowKey="fileName"
columns={columns}
dataSource={backupFiles}
rowSelection={rowSelection}
pagination={pagination}
loading={loading}
onChange={handleTableChange}
size="middle"
/>
</Modal>
)
}

View File

@ -0,0 +1,98 @@
import { backupToLocalDir } from '@renderer/services/BackupService'
import { Button, Input, Modal } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface LocalBackupModalProps {
isModalVisible: boolean
handleBackup: () => void
handleCancel: () => void
backuping: boolean
customFileName: string
setCustomFileName: (value: string) => void
}
export function LocalBackupModal({
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName
}: LocalBackupModalProps) {
const { t } = useTranslation()
return (
<Modal
title={t('settings.data.local.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
footer={[
<Button key="back" onClick={handleCancel}>
{t('common.cancel')}
</Button>,
<Button key="submit" type="primary" loading={backuping} onClick={handleBackup}>
{t('common.confirm')}
</Button>
]}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.local.backup.modal.filename.placeholder')}
/>
</Modal>
)
}
// Hook for backup modal
export function useLocalBackupModal(localBackupDir: string | undefined) {
const [isModalVisible, setIsModalVisible] = useState(false)
const [backuping, setBackuping] = useState(false)
const [customFileName, setCustomFileName] = useState('')
const handleCancel = () => {
setIsModalVisible(false)
}
const showBackupModal = useCallback(async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const hostname = await window.api.system.getHostname()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}, [])
const handleBackup = async () => {
if (!localBackupDir) {
setIsModalVisible(false)
return
}
setBackuping(true)
try {
await backupToLocalDir({
showMessage: true,
customFileName
})
setIsModalVisible(false)
} catch (error) {
console.error('[LocalBackupModal] Backup failed:', error)
} finally {
setBackuping(false)
}
}
return {
isModalVisible,
handleBackup,
handleCancel,
backuping,
customFileName,
setCustomFileName,
showBackupModal
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
ref?: React.RefObject<HTMLDivElement | null>
ref?: React.Ref<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}

View File

@ -3,7 +3,7 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DragableList from '../DragableList'
import { DraggableList } from '../DraggableList'
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
@ -49,7 +49,7 @@ declare global {
}
}
describe('DragableList', () => {
describe('DraggableList', () => {
describe('rendering', () => {
it('should render all list items', () => {
const list = [
@ -58,9 +58,9 @@ describe('DragableList', () => {
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
const items = screen.getAllByTestId('item')
expect(items.length).toBe(3)
@ -74,9 +74,9 @@ describe('DragableList', () => {
const style = { background: 'red' }
const listStyle = { color: 'blue' }
render(
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
<DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list')
@ -85,9 +85,9 @@ describe('DragableList', () => {
it('should render nothing when list is empty', () => {
render(
<DragableList list={[]} onUpdate={() => {}}>
<DraggableList list={[]} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 虚拟列表存在但无内容
const items = screen.queryAllByTestId('item')
@ -106,9 +106,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
@ -128,9 +128,9 @@ describe('DragableList', () => {
const onDragEnd = vi.fn()
render(
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
<DraggableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 先手动调用 onDragStart
@ -150,9 +150,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 模拟拖拽到自身
@ -168,9 +168,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 拖拽自身
@ -188,9 +188,9 @@ describe('DragableList', () => {
// 不传 onDragStart/onDragEnd
expect(() => {
render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
}).not.toThrow()
@ -201,9 +201,9 @@ describe('DragableList', () => {
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item}</div>}
</DragableList>
</DraggableList>
)
// 拖拽第0项到第2项
@ -222,9 +222,9 @@ describe('DragableList', () => {
]
render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// placeholder 应该在初始渲染时就存在
@ -240,9 +240,9 @@ describe('DragableList', () => {
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
<DraggableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
// 拖拽第2项到第0项
@ -272,9 +272,9 @@ describe('DragableList', () => {
{ id: 'c', name: 'C' }
]
const { container } = render(
<DragableList list={list} onUpdate={() => {}}>
<DraggableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
</DraggableList>
)
expect(container).toMatchSnapshot()
})

View File

@ -0,0 +1,164 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DraggableVirtualList from '../DraggableList/virtual-list'
// Mock 依赖项
vi.mock('@hello-pangea/dnd', () => ({
__esModule: true,
DragDropContext: ({ children, onDragEnd, onDragStart }) => {
// 挂载到 window 以便测试用例直接调用
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
onDragEnd?.(result, provided)
}
window.triggerOnDragStart = (result = { source: { index: 0 } }, provided = {}) => {
onDragStart?.(result, provided)
}
return <div data-testid="drag-drop-context">{children}</div>
},
Droppable: ({ children, renderClone }) => (
<div data-testid="droppable">
{/* 模拟 renderClone 的调用 */}
{renderClone &&
renderClone({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {}, { source: { index: 0 } })}
{children({ droppableProps: {}, innerRef: vi.fn() })}
</div>
),
Draggable: ({ children, draggableId, index }) => (
<div data-testid={`draggable-${draggableId}-${index}`}>
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {})}
</div>
)
}))
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }) => ({
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 50,
size: 50
})),
getTotalSize: () => count * 50,
measureElement: vi.fn()
})
}))
vi.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: ({ children }) => <div data-testid="auto-sizer">{children({ height: 500, width: 300 })}</div>
}))
vi.mock('@renderer/components/Scrollbar', () => ({
__esModule: true,
default: ({ ref, children, ...props }) => (
<div ref={ref} {...props} data-testid="scrollbar">
{children}
</div>
)
}))
declare global {
interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void
triggerOnDragStart: (result?: any, provided?: any) => void
}
}
describe('DraggableVirtualList', () => {
const sampleList = [
{ id: 'a', name: 'Item A' },
{ id: 'b', name: 'Item B' },
{ id: 'c', name: 'Item C' }
]
describe('rendering', () => {
it('should render all list items provided', () => {
render(
<DraggableVirtualList list={sampleList} onUpdate={() => {}}>
{(item) => <div data-testid="test-item">{item.name}</div>}
</DraggableVirtualList>
)
const items = screen.getAllByTestId('test-item')
// 我们的 mock 中renderClone 会渲染一个额外的 item
expect(items.length).toBe(sampleList.length + 1)
expect(items[0]).toHaveTextContent('Item A')
expect(items[1]).toHaveTextContent('Item A')
expect(items[2]).toHaveTextContent('Item B')
expect(items[3]).toHaveTextContent('Item C')
})
it('should render nothing when the list is empty', () => {
render(
<DraggableVirtualList list={[]} onUpdate={() => {}}>
{/* @ts-ignore test*/}
{(item) => <div data-testid="test-item">{item.name}</div>}
</DraggableVirtualList>
)
const items = screen.queryAllByTestId('test-item')
expect(items.length).toBe(0)
})
})
describe('drag and drop', () => {
it('should call onUpdate with the new order after a drag operation', () => {
const onUpdate = vi.fn()
render(
<DraggableVirtualList list={sampleList} onUpdate={onUpdate}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } })
const expectedOrder = [sampleList[1], sampleList[2], sampleList[0]] // B, C, A
expect(onUpdate).toHaveBeenCalledWith(expectedOrder)
})
it('should call onDragStart and onDragEnd callbacks', () => {
const onDragStart = vi.fn()
const onDragEnd = vi.fn()
render(
<DraggableVirtualList list={sampleList} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
window.triggerOnDragStart()
expect(onDragStart).toHaveBeenCalledTimes(1)
window.triggerOnDragEnd()
expect(onDragEnd).toHaveBeenCalledTimes(1)
})
it('should not call onUpdate if destination is not defined', () => {
const onUpdate = vi.fn()
render(
<DraggableVirtualList list={sampleList} onUpdate={onUpdate}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: null })
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('snapshot', () => {
it('should match snapshot with custom styles', () => {
const { container } = render(
<DraggableVirtualList
list={sampleList}
onUpdate={() => {}}
className="custom-class"
style={{ border: '1px solid red' }}
itemStyle={{ background: 'blue' }}>
{(item) => <div>{item.name}</div>}
</DraggableVirtualList>
)
expect(container).toMatchSnapshot()
})
})
})

View File

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

View File

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DragableList > snapshot > should match snapshot 1`] = `
exports[`DraggableList > snapshot > should match snapshot 1`] = `
<div>
<div
data-testid="drag-drop-context"

View File

@ -0,0 +1,91 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DraggableVirtualList > snapshot > should match snapshot with custom styles 1`] = `
<div>
<div
class="custom-class draggable-virtual-list"
style="height: 100%; border: 1px solid red;"
>
<div
data-testid="drag-drop-context"
>
<div
data-testid="droppable"
>
<div
style="background: blue;"
>
<div>
Item A
</div>
</div>
<div
class="virtual-scroller"
data-testid="scrollbar"
style="height: 100%; width: 100%; overflow-y: auto; position: relative;"
>
<div
class="virtual-list"
style="height: 150px; width: 100%; position: relative;"
>
<div
data-testid="draggable-0-0"
>
<div
class="draggable-item"
data-index="0"
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(0px);"
>
<div
class="draggable-content"
style="background: blue;"
>
<div>
Item A
</div>
</div>
</div>
</div>
<div
data-testid="draggable-1-1"
>
<div
class="draggable-item"
data-index="1"
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(50px);"
>
<div
class="draggable-content"
style="background: blue;"
>
<div>
Item B
</div>
</div>
</div>
</div>
<div
data-testid="draggable-2-2"
>
<div
class="draggable-item"
data-index="2"
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(100px);"
>
<div
class="draggable-content"
style="background: blue;"
>
<div>
Item C
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -35,7 +35,7 @@ import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import DragableList from '../DragableList'
import { DraggableList } from '../DraggableList'
import MinAppIcon from '../Icons/MinAppIcon'
import UserPopup from '../Popups/UserPopup'
@ -292,7 +292,7 @@ const PinnedApps: FC = () => {
const { openMinappKeepAlive } = useMinappPopup()
return (
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
<DraggableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
{(app) => {
const menuItems: MenuProps['items'] = [
{
@ -320,7 +320,7 @@ const PinnedApps: FC = () => {
</Tooltip>
)
}}
</DragableList>
</DraggableList>
)
}

View File

@ -0,0 +1,10 @@
import { EndpointType } from '@renderer/types'
export const endpointTypeOptions: { label: string; value: EndpointType }[] = [
{ value: 'openai', label: 'endpoint_type.openai' },
{ value: 'openai-response', label: 'endpoint_type.openai-response' },
{ value: 'anthropic', label: 'endpoint_type.anthropic' },
{ value: 'gemini', label: 'endpoint_type.gemini' },
{ value: 'image-generation', label: 'endpoint_type.image-generation' },
{ value: 'jina-rerank', label: 'endpoint_type.jina-rerank' }
]

View File

@ -208,7 +208,7 @@ export const isDedicatedImageGenerationModel = (model: Model): boolean =>
DEDICATED_IMAGE_MODELS.filter((m) => model.id.includes(m)).length > 0
// Text to image models
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus/i
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|image|gpt-image/i
// Reasoning models
export const REASONING_REGEX =

View File

@ -357,9 +357,10 @@ export async function upgradeToV8(tx: Transaction): Promise<void> {
}
Logger.log('originPair: %o', originPair)
newPair = [langMap[originPair[0]], langMap[originPair[1]]]
if (!newPair[0] || !newPair[1]) {
if (!originPair || !originPair[0] || !originPair[1]) {
newPair = defaultPair
} else {
newPair = [langMap[originPair[0]], langMap[originPair[1]]]
}
Logger.log('DB migration to version 8: %o', { newSource, newTarget, newPair })

View File

@ -1,4 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'
import NavigationService from '@renderer/services/NavigationService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
@ -8,8 +9,11 @@ import { IpcChannel } from '@shared/IpcChannel'
window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
store.dispatch(setMCPServers(servers))
})
window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
store.dispatch(addMCPServer(server))
NavigationService.navigate?.('/settings/mcp')
NavigationService.navigate?.('/settings/mcp/settings', { state: { server } })
})
const selectMcpServers = (state) => state.mcp.servers

View File

@ -11,6 +11,8 @@ export function usePaintings() {
const upscale = useAppSelector((state) => state.paintings.upscale)
const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings)
const tokenFluxPaintings = useAppSelector((state) => state.paintings.tokenFluxPaintings)
const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate)
const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit)
const dispatch = useAppDispatch()
return {
@ -24,6 +26,10 @@ export function usePaintings() {
upscale,
tokenFluxPaintings
},
newApiPaintings: {
openai_image_generate,
openai_image_edit
},
addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => {
dispatch(addPainting({ namespace, painting }))
return painting

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import KeyvStorage from '@kangfenmao/keyv-storage'
import { startAutoSync } from './services/BackupService'
import { startAutoSync, startLocalBackupAutoSync } from './services/BackupService'
import { startNutstoreAutoSync } from './services/NutstoreService'
import storeSyncService from './services/StoreSyncService'
import store from './store'
@ -12,7 +12,7 @@ function initKeyv() {
function initAutoSync() {
setTimeout(() => {
const { webdavAutoSync, s3 } = store.getState().settings
const { webdavAutoSync, localBackupAutoSync, s3 } = store.getState().settings
const { nutstoreAutoSync } = store.getState().nutstore
if (webdavAutoSync || (s3 && s3.autoSync)) {
startAutoSync()
@ -20,6 +20,9 @@ function initAutoSync() {
if (nutstoreAutoSync) {
startNutstoreAutoSync()
}
if (localBackupAutoSync) {
startLocalBackupAutoSync()
}
}, 8000)
}

View File

@ -1,5 +1,5 @@
import { MenuOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import { Box, HStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
@ -43,7 +43,7 @@ const PopupContainer: React.FC = () => {
centered>
<Container>
{agents.length > 0 && (
<DragableList list={agents} onUpdate={updateAgents}>
<DraggableList list={agents} onUpdate={updateAgents}>
{(item) => (
<AgentItem>
<Box mr={8}>
@ -54,7 +54,7 @@ const PopupContainer: React.FC = () => {
</HStack>
</AgentItem>
)}
</DragableList>
</DraggableList>
)}
{agents.length === 0 && <Empty description="" />}
</Container>

View File

@ -39,6 +39,12 @@ const MiniAppSettings: FC = () => {
updateDisabledMinapps([])
}, [updateDisabledMinapps, updateMinapps])
const handleSwapMinApps = useCallback(() => {
const temp = visibleMiniApps
setVisibleMiniApps(disabledMiniApps)
setDisabledMiniApps(temp)
}, [disabledMiniApps, visibleMiniApps])
// 恢复默认缓存数量
const handleResetCacheLimit = useCallback(() => {
dispatch(setMaxKeepAliveMinapps(DEFAULT_MAX_KEEPALIVE))
@ -77,9 +83,10 @@ const MiniAppSettings: FC = () => {
<SettingTitle
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{t('settings.miniapps.display_title')}</span>
<ResetButtonWrapper>
<ButtonWrapper>
<Button onClick={handleSwapMinApps}>{t('common.swap')}</Button>
<Button onClick={handleResetMinApps}>{t('common.reset')}</Button>
</ResetButtonWrapper>
</ButtonWrapper>
</SettingTitle>
<BorderedContainer>
<MiniAppIconsManager
@ -219,10 +226,11 @@ const ResetButton = styled.button`
}
`
const ResetButtonWrapper = styled.div`
const ButtonWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
`
// 新增: 带边框的容器组件

View File

@ -240,7 +240,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setText('')
setFiles([])
setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0)
setTimeout(() => resizeTextArea(true), 0)
setExpend(false)
} catch (error) {
console.error('Failed to send message:', error)
@ -864,7 +864,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
onInput={onInput}
disabled={searching}
onPaste={(e) => onPaste(e.nativeEvent)}
onClick={() => searching && dispatch(setSearching(false))}
onClick={() => {
searching && dispatch(setSearching(false))
quickPanel.close()
}}
/>
<DragHandle onMouseDown={handleDragStart}>
<HolderOutlined />

View File

@ -65,7 +65,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
title: t('chat.input.knowledge_base'),
list: baseItems,
symbol: '#',
multiple: true,
multiple: false,
afterAction({ item }) {
item.isSelected = !item.isSelected
}

View File

@ -183,12 +183,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
label: t('common.close'),
description: t('settings.mcp.disable.description'),
icon: <CircleX />,
isSelected: !(assistant.mcpServers && assistant.mcpServers.length > 0),
action: () => updateMcpEnabled(false)
isSelected: false,
action: () => {
updateMcpEnabled(false)
quickPanel.close()
}
})
return newList
}, [activedMcpServers, t, assistant.mcpServers, assistantMcpServers, navigate, updateMcpEnabled])
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
const openQuickPanel = useCallback(() => {
quickPanel.open({

View File

@ -6,10 +6,9 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { Assistant, WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Tooltip } from 'antd'
import { CircleX, Globe, Settings } from 'lucide-react'
import { Globe } from 'lucide-react'
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
export interface WebSearchButtonRef {
openQuickPanel: () => void
@ -23,11 +22,12 @@ interface Props {
const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const quickPanel = useQuickPanel()
const { providers } = useWebSearchProviders()
const { updateAssistant } = useAssistant(assistant.id)
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
const updateSelectedWebSearchProvider = useCallback(
(providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题会导致关闭快捷面板卡顿
@ -78,42 +78,41 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
})
}
items.push({
label: t('chat.input.web_search.settings'),
icon: <Settings />,
action: () => navigate('/settings/tool/websearch')
})
items.unshift({
label: t('common.close'),
description: t('chat.input.web_search.no_web_search.description'),
icon: <CircleX />,
isSelected: !assistant.enableWebSearch && !assistant.webSearchProviderId,
action: () => {
updateSelectedWebSearchProvider(undefined)
}
})
return items
}, [
assistant.model,
assistant.enableWebSearch,
assistant.webSearchProviderId,
assistant.model,
assistant?.webSearchProviderId,
providers,
t,
updateSelectedWebSearchProvider,
updateSelectedWebSearchBuiltin,
navigate
updateSelectedWebSearchProvider
])
const openQuickPanel = useCallback(() => {
if (assistant.webSearchProviderId) {
return updateSelectedWebSearchProvider(undefined)
}
if (assistant.enableWebSearch) {
return updateSelectedWebSearchBuiltin()
}
quickPanel.open({
title: t('chat.input.web_search'),
list: providerItems,
symbol: '?',
pageSize: 9
})
}, [quickPanel, providerItems, t])
}, [
assistant.webSearchProviderId,
assistant.enableWebSearch,
quickPanel,
t,
providerItems,
updateSelectedWebSearchProvider,
updateSelectedWebSearchBuiltin
])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '?') {
@ -128,13 +127,12 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
}))
return (
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
<Tooltip placement="top" title={enableWebSearch ? t('common.close') : t('chat.input.web_search')} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<Globe
size={18}
style={{
color:
assistant?.webSearchProviderId || assistant.enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
color: enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
}}
/>
</ToolbarButton>

View File

@ -17,7 +17,11 @@ vi.mock('@renderer/hooks/useSettings', () => ({
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation()
useTranslation: () => mockUseTranslation(),
initReactI18next: {
type: '3rdParty',
init: vi.fn()
}
}))
// Mock services

View File

@ -47,7 +47,7 @@ const MessageItem: FC<Props> = ({
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { messageFont, fontSize } = useSettings()
const { messageFont, fontSize, messageStyle } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
const messageContainerRef = useRef<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
@ -127,6 +127,8 @@ const MessageItem: FC<Props> = ({
)
}
const showHeader = messageStyle === 'plain' || isAssistantMessage
return (
<MessageContainer
key={message.id}
@ -136,14 +138,15 @@ const MessageItem: FC<Props> = ({
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}>
<MessageHeader
message={message}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
index={index}
topic={topic}
/>
{showHeader && (
<MessageHeader
message={message}
assistant={assistant}
model={model}
key={getModelUniqId(model)}
topic={topic}
/>
)}
{isEditing && (
<MessageEditor
message={message}
@ -167,7 +170,7 @@ const MessageItem: FC<Props> = ({
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (
<MessageFooter className="MessageFooter">
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage}>
<MessageMenubar
message={message}
assistant={assistant}
@ -224,12 +227,12 @@ const MessageContentContainer = styled(Scrollbar)`
overflow-y: auto;
`
const MessageFooter = styled.div`
const MessageFooter = styled.div<{ $isLastMessage: boolean }>`
display: flex;
flex-direction: row;
justify-content: space-between;
flex-direction: ${({ $isLastMessage }) => ($isLastMessage ? 'row-reverse' : 'row')};
align-items: center;
gap: 20px;
justify-content: space-between;
gap: 10px;
margin-left: 46px;
margin-top: 2px;
`

View File

@ -43,7 +43,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
const model = assistant.model || assistant.defaultModel
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { pasteLongTextThreshold, fontSize, sendMessageShortcut, enableSpellCheck } = useSettings()
const { t } = useTranslation()
const textareaRef = useRef<TextAreaRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
@ -75,14 +75,14 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
supportExts,
setFiles,
undefined, // 不需要setText
pasteLongTextAsFile,
false, // 不需要 pasteLongTextAsFile
pasteLongTextThreshold,
undefined, // 不需要text
resizeTextArea,
t
)
},
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
[model, pasteLongTextThreshold, resizeTextArea, supportExts, t]
)
// 添加全局粘贴事件处理
@ -256,71 +256,72 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
}, [couldAddImageFile, couldAddTextFile])
return (
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (
<Textarea
className={classNames('editing-message', isFileDragging && 'file-dragging')}
key={block.id}
ref={textareaRef}
variant="borderless"
value={block.content}
onChange={(e) => {
handleTextChange(block.id, e.target.value)
resizeTextArea()
}}
onKeyDown={(e) => handleKeyDown(e, block.id)}
autoFocus
spellCheck={enableSpellCheck}
onPaste={(e) => onPaste(e.nativeEvent)}
onFocus={() => {
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('messageEditor')
}}
onContextMenu={(e) => {
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
e.stopPropagation()
}}
style={{
fontSize,
padding: '0px 15px 8px 15px'
}}>
<TranslateButton onTranslated={onTranslated} />
</Textarea>
))}
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
files.length > 0) && (
<FileBlocksContainer>
{editedBlocks
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
.map(
(block) =>
block.file && (
<CustomTag
key={block.id}
icon={getFileIcon(block.file.ext)}
color="#37a5aa"
closable
onClose={() => handleFileRemove(block.id)}>
<FileNameRender file={block.file} />
</CustomTag>
)
)}
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
<>
<EditorContainer className="message-editor" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
{editedBlocks
.filter((block) => block.type === MessageBlockType.MAIN_TEXT)
.map((block) => (
<Textarea
className={classNames('editing-message', isFileDragging && 'file-dragging')}
key={block.id}
ref={textareaRef}
variant="borderless"
value={block.content}
onChange={(e) => {
handleTextChange(block.id, e.target.value)
resizeTextArea()
}}
onKeyDown={(e) => handleKeyDown(e, block.id)}
autoFocus
spellCheck={enableSpellCheck}
onPaste={(e) => onPaste(e.nativeEvent)}
onFocus={() => {
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('messageEditor')
}}
onContextMenu={(e) => {
// 阻止事件冒泡,避免触发全局的 Electron contextMenu
e.stopPropagation()
}}
style={{
fontSize,
padding: '0px 15px 8px 15px'
}}>
<TranslateButton onTranslated={onTranslated} />
</Textarea>
))}
</FileBlocksContainer>
)}
{(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
files.length > 0) && (
<FileBlocksContainer>
{editedBlocks
.filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
.map(
(block) =>
block.file && (
<CustomTag
key={block.id}
icon={getFileIcon(block.file.ext)}
color="#37a5aa"
closable
onClose={() => handleFileRemove(block.id)}>
<FileNameRender file={block.file} />
</CustomTag>
)
)}
{files.map((file) => (
<CustomTag
key={file.id}
icon={getFileIcon(file.ext)}
color="#37a5aa"
closable
onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
<FileNameRender file={file} />
</CustomTag>
))}
</FileBlocksContainer>
)}
</EditorContainer>
<ActionBar>
<ActionBarLeft>
{isUserMessage && (
@ -355,17 +356,17 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
)}
</ActionBarRight>
</ActionBar>
</EditorContainer>
</>
)
}
const EditorContainer = styled.div`
padding: 8px 0;
padding: 18px 0;
padding-bottom: 5px;
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
border-radius: 15px;
margin-top: 5px;
margin-bottom: 10px;
margin-top: 18px;
background-color: var(--color-background-opacity);
width: 100%;

View File

@ -18,13 +18,10 @@ import { FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import MessageTokens from './MessageTokens'
interface Props {
message: Message
assistant: Assistant
model?: Model
index: number | undefined
topic: Topic
}
@ -33,7 +30,7 @@ const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => {
return modelId ? getModelLogo(modelId) : undefined
}
const MessageHeader: FC<Props> = memo(({ assistant, model, message, index, topic }) => {
const MessageHeader: FC<Props> = memo(({ assistant, model, message, topic }) => {
const avatar = useAvatar()
const { theme } = useTheme()
const { userName, sidebarIcons } = useSettings()
@ -61,11 +58,9 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, index, topic
const isAssistantMessage = message.role === 'assistant'
const showMinappIcon = sidebarIcons.visible.includes('minapp')
const { showTokens } = useSettings()
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
const isLastMessage = index === 0
const showMiniApp = useCallback(() => {
showMinappIcon && model?.provider && openMinappById(model.provider)
@ -110,8 +105,6 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, index, topic
</UserName>
<InfoWrap className="message-header-info-wrap">
<MessageTime>{dayjs(message?.updatedAt ?? message.createdAt).format('MM/DD HH:mm')}</MessageTime>
{showTokens && <DividerContainer style={{ color: 'var(--color-text-3)' }}> | </DividerContainer>}
<MessageTokens message={message} isLastMessage={isLastMessage} />
</InfoWrap>
</UserWrap>
{isMultiSelectMode && (
@ -133,6 +126,7 @@ const Container = styled.div`
align-items: center;
gap: 10px;
position: relative;
margin-bottom: 8px;
`
const UserWrap = styled.div`
@ -149,12 +143,6 @@ const InfoWrap = styled.div`
gap: 4px;
`
const DividerContainer = styled.div`
font-size: 10px;
color: var(--color-text-3);
margin: 0 2px;
`
const UserName = styled.div<{ isBubbleStyle?: boolean; theme?: string }>`
font-size: 14px;
font-weight: 600;

View File

@ -49,6 +49,8 @@ import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import MessageTokens from './MessageTokens'
interface Props {
message: Message
assistant: Assistant
@ -398,172 +400,180 @@ const MessageMenubar: FC<Props> = (props) => {
const softHoverBg = isBubbleStyle && !isLastMessage
const showMessageTokens = isBubbleStyle ? isAssistantMessage : true
return (
<MenusBar className={classNames({ menubar: true, show: isLastMessage })}>
{message.role === 'user' && (
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton
className="message-action-button"
onClick={() => handleResendUserMessage()}
$softHoverBg={isBubbleStyle}>
<SyncOutlined />
</ActionButton>
</Tooltip>
)}
{message.role === 'user' && (
<Tooltip title={t('common.edit')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onEdit} $softHoverBg={softHoverBg}>
<EditOutlined />
</ActionButton>
</Tooltip>
)}
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onCopy} $softHoverBg={softHoverBg}>
{!copied && <Copy size={16} />}
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
{isAssistantMessage && (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={onRegenerate}
onOpenChange={(open) => open && setShowRegenerateTooltip(false)}>
<Tooltip
title={t('common.regenerate')}
mouseEnterDelay={0.8}
open={showRegenerateTooltip}
onOpenChange={setShowRegenerateTooltip}>
<ActionButton className="message-action-button" $softHoverBg={softHoverBg}>
<RefreshCw size={16} />
<>
{showMessageTokens && <MessageTokens message={message} />}
<MenusBar className={classNames({ menubar: true, show: isLastMessage })}>
{message.role === 'user' && (
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton
className="message-action-button"
onClick={() => handleResendUserMessage()}
$softHoverBg={isBubbleStyle}>
<SyncOutlined />
</ActionButton>
</Tooltip>
</Popconfirm>
)}
{isAssistantMessage && (
<Tooltip title={t('message.mention.title')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
<AtSign size={16} />
)}
{message.role === 'user' && (
<Tooltip title={t('common.edit')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onEdit} $softHoverBg={softHoverBg}>
<EditOutlined />
</ActionButton>
</Tooltip>
)}
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onCopy} $softHoverBg={softHoverBg}>
{!copied && <Copy size={16} />}
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
)}
{!isUserMessage && (
<Dropdown
menu={{
style: {
maxHeight: 250,
overflowY: 'auto',
backgroundClip: 'border-box'
},
items: [
...translateLanguageOptions.map((item) => ({
label: item.emoji + ' ' + item.label(),
key: item.langCode,
onClick: () => handleTranslate(item)
})),
...(hasTranslationBlocks
? [
{ type: 'divider' as const },
{
label: '📋 ' + t('common.copy'),
key: 'translate-copy',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
{isAssistantMessage && (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={onRegenerate}
onOpenChange={(open) => open && setShowRegenerateTooltip(false)}>
<Tooltip
title={t('common.regenerate')}
mouseEnterDelay={0.8}
open={showRegenerateTooltip}
onOpenChange={setShowRegenerateTooltip}>
<ActionButton className="message-action-button" $softHoverBg={softHoverBg}>
<RefreshCw size={16} />
</ActionButton>
</Tooltip>
</Popconfirm>
)}
{isAssistantMessage && (
<Tooltip title={t('message.mention.title')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
<AtSign size={16} />
</ActionButton>
</Tooltip>
)}
{!isUserMessage && (
<Dropdown
menu={{
style: {
maxHeight: 250,
overflowY: 'auto',
backgroundClip: 'border-box'
},
items: [
...translateLanguageOptions.map((item) => ({
label: item.emoji + ' ' + item.label(),
key: item.langCode,
onClick: () => handleTranslate(item)
})),
...(hasTranslationBlocks
? [
{ type: 'divider' as const },
{
label: '📋 ' + t('common.copy'),
key: 'translate-copy',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
if (translationBlocks.length > 0) {
const translationContent = translationBlocks
.map((block) => block?.content || '')
.join('\n\n')
.trim()
if (translationBlocks.length > 0) {
const translationContent = translationBlocks
.map((block) => block?.content || '')
.join('\n\n')
.trim()
if (translationContent) {
navigator.clipboard.writeText(translationContent)
window.message.success({ content: t('translate.copied'), key: 'translate-copy' })
} else {
window.message.warning({ content: t('translate.empty'), key: 'translate-copy' })
if (translationContent) {
navigator.clipboard.writeText(translationContent)
window.message.success({ content: t('translate.copied'), key: 'translate-copy' })
} else {
window.message.warning({ content: t('translate.empty'), key: 'translate-copy' })
}
}
}
},
{
label: '✖ ' + t('translate.close'),
key: 'translate-close',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
.map((block) => block?.id)
if (translationBlocks.length > 0) {
translationBlocks.forEach((blockId) => {
if (blockId) removeMessageBlock(message.id, blockId)
})
window.message.success({ content: t('translate.closed'), key: 'translate-close' })
}
}
}
},
{
label: '✖ ' + t('translate.close'),
key: 'translate-close',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
.map((block) => block?.id)
if (translationBlocks.length > 0) {
translationBlocks.forEach((blockId) => {
if (blockId) removeMessageBlock(message.id, blockId)
})
window.message.success({ content: t('translate.closed'), key: 'translate-close' })
}
}
}
]
: [])
],
onClick: (e) => e.domEvent.stopPropagation()
}}
trigger={['click']}
placement="top"
arrow>
<Tooltip title={t('chat.translate')} mouseEnterDelay={1.2}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Languages size={16} />
]
: [])
],
onClick: (e) => e.domEvent.stopPropagation()
}}
trigger={['click']}
placement="top"
arrow>
<Tooltip title={t('chat.translate')} mouseEnterDelay={1.2}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Languages size={16} />
</ActionButton>
</Tooltip>
</Dropdown>
)}
{isAssistantMessage && isGrouped && (
<Tooltip title={t('chat.message.useful')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onUseful} $softHoverBg={softHoverBg}>
{message.useful ? (
<ThumbsUp size={17.5} fill="var(--color-primary)" strokeWidth={0} />
) : (
<ThumbsUp size={16} />
)}
</ActionButton>
</Tooltip>
</Dropdown>
)}
{isAssistantMessage && isGrouped && (
<Tooltip title={t('chat.message.useful')} mouseEnterDelay={0.8}>
<ActionButton className="message-action-button" onClick={onUseful} $softHoverBg={softHoverBg}>
{message.useful ? (
<ThumbsUp size={17.5} fill="var(--color-primary)" strokeWidth={0} />
) : (
<ThumbsUp size={16} />
)}
</ActionButton>
</Tooltip>
)}
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}
onConfirm={() => deleteMessage(message.id)}>
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()} $softHoverBg={softHoverBg}>
<Tooltip
title={t('common.delete')}
mouseEnterDelay={1}
open={showDeleteTooltip}
onOpenChange={setShowDeleteTooltip}>
<Trash size={16} />
</Tooltip>
</ActionButton>
</Popconfirm>
{!isUserMessage && (
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
trigger={['click']}
placement="topRight">
)}
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}
onConfirm={() => deleteMessage(message.id)}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Menu size={19} />
<Tooltip
title={t('common.delete')}
mouseEnterDelay={1}
open={showDeleteTooltip}
onOpenChange={setShowDeleteTooltip}>
<Trash size={16} />
</Tooltip>
</ActionButton>
</Dropdown>
)}
</MenusBar>
</Popconfirm>
{!isUserMessage && (
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
trigger={['click']}
placement="topRight">
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Menu size={19} />
</ActionButton>
</Dropdown>
)}
</MenusBar>
</>
)
}
@ -572,7 +582,8 @@ const MenusBar = styled.div`
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 6px;
gap: 8px;
margin-top: 5px;
`
const ActionButton = styled.div<{ $softHoverBg?: boolean }>`
@ -582,8 +593,8 @@ const ActionButton = styled.div<{ $softHoverBg?: boolean }>`
flex-direction: row;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
width: 26px;
height: 26px;
transition: all 0.2s ease;
&:hover {
background-color: ${(props) =>

View File

@ -11,7 +11,7 @@ interface MessageTokensProps {
isLastMessage?: boolean
}
const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
const { showTokens } = useSettings()
// const { generating } = useRuntime()
const locateMessage = () => {
@ -106,4 +106,4 @@ const MessageMetadata = styled.div`
}
`
export default MessgeTokens
export default MessageTokens

View File

@ -1,8 +1,12 @@
import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
import { CheckOutlined, CloseOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { Collapse, message as antdMessage, Modal, Tabs, Tooltip } from 'antd'
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { message } from 'antd'
import Logger from 'electron-log/renderer'
import { PauseCircle } from 'lucide-react'
import { FC, memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -14,12 +18,24 @@ interface Props {
const MessageTools: FC<Props> = ({ block }) => {
const [activeKeys, setActiveKeys] = useState<string[]>([])
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null)
const { t } = useTranslation()
const { messageFont, fontSize } = useSettings()
const toolResponse = block.metadata?.rawMcpToolResponse
const { id, tool, status, response } = toolResponse!
const isPending = status === 'pending'
const isInvoking = status === 'invoking'
const isDone = status === 'done'
const argsString = useMemo(() => {
if (toolResponse?.arguments) {
return JSON.stringify(toolResponse.arguments, null, 2)
}
return 'No arguments'
}, [toolResponse])
const resultString = useMemo(() => {
try {
return JSON.stringify(
@ -50,13 +66,34 @@ const MessageTools: FC<Props> = ({ block }) => {
setActiveKeys(Array.isArray(keys) ? keys : [keys])
}
const handleConfirmTool = () => {
confirmToolAction(id)
}
const handleCancelTool = () => {
cancelToolAction(id)
}
const handleAbortTool = async () => {
if (toolResponse?.id) {
try {
const success = await window.api.mcp.abortTool(toolResponse.id)
if (success) {
message.success({ content: t('message.tools.aborted'), key: 'abort-tool' })
} else {
message.error({ content: t('message.tools.abort_failed'), key: 'abort-tool' })
}
} catch (error) {
Logger.error('Failed to abort tool:', error)
message.error({ content: t('message.tools.abort_failed'), key: 'abort-tool' })
}
}
}
// Format tool responses for collapse items
const getCollapseItems = () => {
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
const { id, tool, status, response } = toolResponse
const isInvoking = status === 'invoking'
const isDone = status === 'done'
const hasError = isDone && response?.isError === true
const hasError = response?.isError === true
const result = {
params: toolResponse.arguments,
response: toolResponse.response
@ -68,34 +105,93 @@ const MessageTools: FC<Props> = ({ block }) => {
<MessageTitleLabel>
<TitleContent>
<ToolName>{tool.name}</ToolName>
<StatusIndicator $isInvoking={isInvoking} $hasError={hasError}>
{isInvoking
? t('message.tools.invoking')
: hasError
? t('message.tools.error')
: t('message.tools.completed')}
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
{isDone && !hasError && <CheckOutlined style={{ marginLeft: 6 }} />}
{hasError && <WarningOutlined style={{ marginLeft: 6 }} />}
<StatusIndicator status={status} hasError={hasError}>
{(() => {
switch (status) {
case 'pending':
return (
<>
{t('message.tools.pending')}
<LoadingOutlined spin style={{ marginLeft: 6 }} />
</>
)
case 'invoking':
return (
<>
{t('message.tools.invoking')}
<LoadingOutlined spin style={{ marginLeft: 6 }} />
</>
)
case 'cancelled':
return (
<>
{t('message.tools.cancelled')}
<CloseOutlined style={{ marginLeft: 6 }} />
</>
)
case 'done':
if (hasError) {
return (
<>
{t('message.tools.error')}
<WarningOutlined style={{ marginLeft: 6 }} />
</>
)
} else {
return (
<>
{t('message.tools.completed')}
<CheckOutlined style={{ marginLeft: 6 }} />
</>
)
}
default:
return ''
}
})()}
</StatusIndicator>
</TitleContent>
<ActionButtonsContainer>
{isDone && response && (
{isPending && (
<>
<Tooltip title={t('common.expand')} mouseEnterDelay={0.5}>
<Tooltip title={t('common.cancel')} mouseEnterDelay={0.3}>
<ActionButton
className="message-action-button"
onClick={(e) => {
e.stopPropagation()
setExpandedResponse({
content: JSON.stringify(response, null, 2),
title: tool.name
})
handleCancelTool()
}}
aria-label={t('common.expand')}>
<ExpandOutlined />
aria-label={t('common.cancel')}>
<CloseOutlined style={{ fontSize: '14px' }} />
</ActionButton>
</Tooltip>
<Tooltip title={t('common.confirm')} mouseEnterDelay={0.3}>
<ActionButton
className="confirm-button"
onClick={(e) => {
e.stopPropagation()
handleConfirmTool()
}}
aria-label={t('common.confirm')}>
<CheckOutlined style={{ fontSize: '14px' }} />
</ActionButton>
</Tooltip>
</>
)}
{isInvoking && toolResponse?.id && (
<Tooltip title={t('chat.input.pause')} mouseEnterDelay={0.3}>
<ActionButton
className="abort-button"
onClick={(e) => {
e.stopPropagation()
handleAbortTool()
}}
aria-label={t('chat.input.pause')}>
<PauseCircle color="var(--color-error)" size={14} />
</ActionButton>
</Tooltip>
)}
{isDone && response && (
<>
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
<ActionButton
className="message-action-button"
@ -113,98 +209,38 @@ const MessageTools: FC<Props> = ({ block }) => {
</ActionButtonsContainer>
</MessageTitleLabel>
),
children: isDone && result && (
<ToolResponseContainer
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize: '12px'
}}>
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
</ToolResponseContainer>
)
children:
isDone && result ? (
<ToolResponseContainer
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
</ToolResponseContainer>
) : argsString ? (
<>
<ToolResponseContainer>
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={argsString} />
</ToolResponseContainer>
</>
) : null
})
return items
}
const renderPreview = (content: string) => {
if (!content) return null
try {
const parsedResult = JSON.parse(content)
switch (parsedResult.content[0]?.type) {
case 'text':
return <PreviewBlock>{parsedResult.content[0].text}</PreviewBlock>
default:
return <PreviewBlock>{content}</PreviewBlock>
}
} catch (e) {
console.error('failed to render the preview of mcp results:', e)
return <PreviewBlock>{content}</PreviewBlock>
}
}
return (
<>
<ToolContainer>
<CollapseContainer
activeKey={activeKeys}
size="small"
onChange={handleCollapseChange}
className="message-tools-container"
items={getCollapseItems()}
expandIcon={({ isActive }) => (
<CollapsibleIcon className={`iconfont ${isActive ? 'icon-chevron-down' : 'icon-chevron-right'}`} />
)}
expandIconPosition="end"
/>
<Modal
title={expandedResponse?.title}
open={!!expandedResponse}
onCancel={() => setExpandedResponse(null)}
footer={null}
width="80%"
centered
transitionName="animation-move-down"
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
{expandedResponse && (
<ExpandedResponseContainer
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<Tabs
tabBarExtraContent={
<ActionButton
className="copy-expanded-button"
onClick={() => {
navigator.clipboard.writeText(
typeof expandedResponse.content === 'string'
? expandedResponse.content
: JSON.stringify(expandedResponse.content, null, 2)
)
antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' })
}}
aria-label={t('common.copy')}>
<i className="iconfont icon-copy"></i>
</ActionButton>
}
items={[
{
key: 'preview',
label: t('message.tools.preview'),
children: <CollapsedContent isExpanded={true} resultString={resultString} />
},
{
key: 'raw',
label: t('message.tools.raw'),
children: renderPreview(expandedResponse.content)
}
]}
/>
</ExpandedResponseContainer>
)}
</Modal>
</>
</ToolContainer>
)
}
@ -230,15 +266,25 @@ const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ i
}
const CollapseContainer = styled(Collapse)`
margin-top: 10px;
margin-bottom: 12px;
border-radius: 8px;
border: none;
overflow: hidden;
.ant-collapse-header {
background-color: var(--color-bg-2);
transition: background-color 0.2s;
display: flex;
align-items: center;
.ant-collapse-expand-icon {
height: 100% !important;
}
.ant-collapse-arrow {
height: 28px !important;
svg {
width: 14px;
height: 14px;
}
}
&:hover {
background-color: var(--color-bg-3);
}
@ -249,6 +295,15 @@ const CollapseContainer = styled(Collapse)`
}
`
const ToolContainer = styled.div`
margin-top: 10px;
margin-bottom: 12px;
border: 1px solid var(--color-border);
background-color: var(--color-bg-2);
border-radius: 8px;
overflow: hidden;
`
const MarkdownContainer = styled.div`
& pre {
background: transparent !important;
@ -267,6 +322,7 @@ const MessageTitleLabel = styled.div`
min-height: 26px;
gap: 10px;
padding: 0;
margin-left: 4px;
`
const TitleContent = styled.div`
@ -282,18 +338,27 @@ const ToolName = styled.span`
font-size: 13px;
`
const StatusIndicator = styled.span<{ $isInvoking: boolean; $hasError?: boolean }>`
const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>`
color: ${(props) => {
if (props.$hasError) return 'var(--color-error, #ff4d4f)'
if (props.$isInvoking) return 'var(--color-primary)'
return 'var(--color-success, #52c41a)'
switch (props.status) {
case 'pending':
return 'var(--color-text-2)'
case 'invoking':
return 'var(--color-primary)'
case 'cancelled':
return 'var(--color-error, #ff4d4f)' // Assuming cancelled should also be an error color
case 'done':
return props.hasError ? 'var(--color-error, #ff4d4f)' : 'var(--color-success, #52c41a)'
default:
return 'var(--color-text)'
}
}};
font-size: 11px;
display: flex;
align-items: center;
opacity: 0.85;
border-left: 1px solid var(--color-border);
padding-left: 8px;
padding-left: 12px;
`
const ActionButtonsContainer = styled.div`
@ -307,18 +372,30 @@ const ActionButton = styled.button`
border: none;
color: var(--color-text-2);
cursor: pointer;
padding: 4px 8px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: all 0.2s;
border-radius: 4px;
gap: 4px;
min-width: 28px;
height: 28px;
&:hover {
opacity: 1;
color: var(--color-text);
background-color: var(--color-bg-1);
background-color: var(--color-bg-3);
}
&.confirm-button {
color: var(--color-primary);
&:hover {
background-color: var(--color-primary-bg);
color: var(--color-primary);
}
}
&:focus-visible {
@ -332,12 +409,6 @@ const ActionButton = styled.button`
}
`
const CollapsibleIcon = styled.i`
color: var(--color-text-2);
font-size: 12px;
transition: transform 0.2s;
`
const ToolResponseContainer = styled.div`
border-radius: 0 0 4px 4px;
overflow: auto;
@ -346,35 +417,4 @@ const ToolResponseContainer = styled.div`
position: relative;
`
const PreviewBlock = styled.div`
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
user-select: text;
`
const ExpandedResponseContainer = styled.div`
background: var(--color-bg-1);
border-radius: 8px;
padding: 16px;
position: relative;
.copy-expanded-button {
position: absolute;
top: 10px;
right: 10px;
background-color: var(--color-bg-2);
border-radius: 4px;
z-index: 1;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
}
`
export default memo(MessageTools)

View File

@ -1,7 +1,6 @@
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import FloatingSidebar from '@renderer/components/Popups/FloatingSidebar'
import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isMac } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
@ -16,7 +15,7 @@ import { setNarrowMode } from '@renderer/store/settings'
import { Assistant, Topic } from '@renderer/types'
import { Tooltip } from 'antd'
import { t } from 'i18next'
import { LayoutGrid, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { FC, useCallback, useState } from 'react'
import styled from 'styled-components'
@ -35,7 +34,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { assistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const isFullscreen = useFullscreen()
const { topicPosition, sidebarIcons, narrowMode } = useSettings()
const { topicPosition, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
const dispatch = useAppDispatch()
const [sidebarHideCooldown, setSidebarHideCooldown] = useState(false)
@ -145,15 +144,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
<i className="iconfont icon-icon-adaptive-width"></i>
</NarrowIcon>
</Tooltip>
{sidebarIcons.visible.includes('minapp') && (
<MinAppsPopover>
<Tooltip title={t('minapp.title')} mouseEnterDelay={0.8}>
<NarrowIcon>
<LayoutGrid size={18} />
</NarrowIcon>
</Tooltip>
</MinAppsPopover>
)}
{topicPosition === 'right' && !showTopics && !sidebarHideCooldown && (
<FloatingSidebar
activeAssistant={assistant}

View File

@ -1,5 +1,5 @@
import { DownOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableList } from '@renderer/components/DraggableList'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
@ -92,7 +92,7 @@ const Assistants: FC<AssistantsTabProps> = ({
)}
{!collapsedTags[group.tag] && (
<div>
<DragableList
<DraggableList
list={group.assistants}
onUpdate={(newList) => handleGroupReorder(group.tag, newList)}
onDragStart={() => setDragging(true)}
@ -111,7 +111,7 @@ const Assistants: FC<AssistantsTabProps> = ({
handleSortByChange={handleSortByChange}
/>
)}
</DragableList>
</DraggableList>
</div>
)}
</TagsContainer>
@ -129,7 +129,7 @@ const Assistants: FC<AssistantsTabProps> = ({
return (
<Container className="assistants-tab" ref={containerRef}>
<DragableList
<DraggableList
list={assistants}
onUpdate={updateAssistants}
onDragStart={() => setDragging(true)}
@ -148,7 +148,7 @@ const Assistants: FC<AssistantsTabProps> = ({
handleSortByChange={handleSortByChange}
/>
)}
</DragableList>
</DraggableList>
{!dragging && (
<AssistantAddItem onClick={onCreateAssistant}>
<AssistantName>
@ -167,6 +167,7 @@ const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 10px;
margin-top: 3px;
`
const TagsContainer = styled.div`

View File

@ -41,7 +41,6 @@ import {
setRenderInputMessageAsMarkdown,
setShowInputEstimatedTokens,
setShowPrompt,
setShowTokens,
setShowTranslateConfirm,
setThoughtAutoCollapse
} from '@renderer/store/settings'
@ -102,8 +101,7 @@ const SettingsTab: FC<Props> = (props) => {
messageNavigation,
enableQuickPanelTriggers,
enableBackspaceDeleteModel,
showTranslateConfirm,
showTokens
showTranslateConfirm
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@ -300,11 +298,6 @@ const SettingsTab: FC<Props> = (props) => {
<Switch size="small" checked={showPrompt} onChange={(checked) => dispatch(setShowPrompt(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.tokens')}</SettingRowTitleSmall>
<Switch size="small" checked={showTokens} onChange={(checked) => dispatch(setShowTokens(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.use_serif_font')}</SettingRowTitleSmall>
<Switch

View File

@ -9,11 +9,10 @@ import {
QuestionCircleOutlined,
UploadOutlined
} from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { DraggableVirtualList as DraggableList } from '@renderer/components/DraggableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime'
@ -447,92 +446,86 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
}, [assistant.topics, pinTopicsToTop])
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<Container className="topics-tab">
<DragableList list={sortedTopics} onUpdate={updateTopics}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
<DraggableList
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ padding: '13px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
return (
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={isActive ? 'active' : ''}
onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
<TopicNameContainer>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</TopicName>
{!topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={isActive ? 'active' : ''}
onClick={() => onSwitchTopic(topic)}
style={{ borderRadius }}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
<TopicNameContainer>
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</TopicName>
{!topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
title={
<div>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
) : (
<CloseOutlined />
)}
</MenuButton>
</Tooltip>
)}
{topic.pinned && (
<MenuButton className="pin">
<PushpinOutlined />
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
) : (
<CloseOutlined />
)}
</MenuButton>
)}
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
</Tooltip>
)}
{showTopicTime && (
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
{topic.pinned && (
<MenuButton className="pin">
<PushpinOutlined />
</MenuButton>
)}
</TopicListItem>
)
}}
</DragableList>
<div style={{ minHeight: '10px' }}></div>
</Container>
</Dropdown>
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
)}
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
</TopicListItem>
</Dropdown>
)
}}
</DraggableList>
)
}
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 10px;
`
const TopicListItem = styled.div`
padding: 7px 12px;
border-radius: var(--list-item-border-radius);

Some files were not shown because too many files have changed in this diff Show More