Compare commits

..

13 Commits

Author SHA1 Message Date
suyao 2cd7f91fff fix: update zod import to use namespace import 2025-10-14 15:33:57 +08:00
suyao ad7a043fb3 revert type 2025-10-14 15:12:43 +08:00
suyao 7db628702d Merge branch 'main' into feat/mcp-runjs 2025-10-14 15:09:42 +08:00
suyao 44d2cb345f return 2025-10-09 08:50:46 +08:00
suyao 49eec68434 done 2025-10-09 08:46:07 +08:00
icarus d5ae3e6edc Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/mcp-runjs 2025-10-09 00:27:30 +08:00
icarus 6eedcc29ba feat(i18n): add untranslated strings for multiple languages
Add placeholder strings marked with "[to be translated]" in Japanese, Russian, Portuguese, Greek, Spanish and French translation files. These include new message placeholders and auto-rename functionality for notes.
2025-10-09 00:20:39 +08:00
suyao 71d35eddf7 Add JavaScript execution safety limits and improve error handling
- Add 1MB code size limit to prevent memory issues
- Improve timeout error handling with proper cleanup logging
- Remove host environment exposure and fix file descriptor leaks in worker
2025-10-08 07:11:39 +08:00
suyao 9f1c8f2c17 fix: more accurate 2025-10-08 07:08:19 +08:00
suyao 651e9a529e Add JavaScript execution support to code blocks
- Register new IPC channel and handler for JavaScript code execution
- Extend code block execution to support JavaScript, TypeScript, and JS languages
- Add JavaScript service with sandboxed execution using quickJs
- Update UI to show JavaScript execution option alongside Python
2025-10-08 07:07:13 +08:00
suyao f68f6e9896 chore: i18n 2025-10-08 06:56:21 +08:00
suyao 2dcc68da87 chore: yarn lock 2025-10-08 06:54:14 +08:00
suyao 1db259cd3e Add JavaScript MCP server with QuickJS WASM sandbox execution
- Implement JsServer MCP server with `run_javascript_code` tool for secure code execution
- Add JsService worker management with timeout handling and error formatting
- Include QuickJS WASM binary and integrate with WASI for sandboxed execution
- Update UI with i18n support and JSON result formatting improvements
2025-10-08 06:48:14 +08:00
163 changed files with 2348 additions and 4572 deletions
@@ -1,44 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class {
}
});
} else if (value.item.type === "message") {
- controller.enqueue({
- type: "text-end",
- id: value.item.id
- });
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
+ if (currentTextId) {
+ controller.enqueue({
+ type: "text-end",
+ id: currentTextId
+ });
+ }
currentTextId = null;
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id];
diff --git a/dist/index.mjs b/dist/index.mjs
index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class {
}
});
} else if (value.item.type === "message") {
- controller.enqueue({
- type: "text-end",
- id: value.item.id
- });
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
+ if (currentTextId) {
+ controller.enqueue({
+ type: "text-end",
+ id: currentTextId
+ });
+ }
currentTextId = null;
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id];
+14 -54
View File
@@ -125,61 +125,21 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
<!--LANG:en--> What's New in v1.6.3
What's New in v1.7.0-beta.2
New Features: Features:
- Session Settings: Manage session-specific settings and model configurations independently - Notes: Add spell-check control, automatic table line wrapping, export functionality, and LLM-based renaming
- Notes Full-Text Search: Search across all notes with match highlighting - UI: Expand topic rename clickable area, add middle-click tab closing, remove redundant scrollbars, fix message menubar overflow
- Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only) - Editor: Add read-only extension support, make TextFilePreview read-only but copyable
- Intel OV OCR: Hardware-accelerated OCR using Intel NPU - Models: Update support for DeepSeek v3.2, Claude 4.5, GLM 4.6, Gemini regex, and vision models
- Auto-start API Server: Automatically starts when agents exist - Code Tools: Add GitHub Copilot CLI integration
Improvements:
- Agent model selection now requires explicit user choice
- Added Mistral AI provider support
- Added NewAPI generic provider support
- Improved navbar layout consistency across different modes
- Enhanced chat component responsiveness
- Better code block display on small screens
- Updated OVMS to 2025.3 official release
- Added Greek language support
Bug Fixes: Bug Fixes:
- Fixed GitHub Copilot gpt-5-codex streaming issues - Fix migration for missing providers
- Fixed assistant creation failures - Fix forked topic retaining old name after rename
- Fixed translate auto-copy functionality - Restore first token latency reporting in metrics
- Fixed miniapps external link opening - Fix UI scrollbar and overflow issues
- Fixed message layout and overflow issues
- Fixed API key parsing to preserve spaces
- Fixed agent display in different navbar layouts
<!--LANG:zh-CN--> Technical Updates:
v1.7.0-beta.2 新特性 - Upgrade to Electron 37.6.0
- Update dependencies across packages
新功能:
- 会话设置:独立管理会话特定的设置和模型配置
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
- 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区)
- Intel OV OCR:使用 Intel NPU 的硬件加速 OCR
- 自动启动 API 服务器:当存在 Agent 时自动启动
改进:
- Agent 模型选择现在需要用户显式选择
- 添加 Mistral AI 提供商支持
- 添加 NewAPI 通用提供商支持
- 改进不同模式下的导航栏布局一致性
- 增强聊天组件响应式设计
- 优化小屏幕代码块显示
- 更新 OVMS 至 2025.3 正式版
- 添加希腊语支持
问题修复:
- 修复 GitHub Copilot gpt-5-codex 流式传输问题
- 修复助手创建失败
- 修复翻译自动复制功能
- 修复小程序外部链接打开
- 修复消息布局和溢出问题
- 修复 API 密钥解析以保留空格
- 修复不同导航栏布局中的 Agent 显示
<!--LANG:END-->
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig([
eslint.configs.recommended, eslint.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
eslintReact.configs['recommended-typescript'], eslintReact.configs['recommended-typescript'],
reactHooks.configs.flat.recommended, reactHooks.configs['recommended-latest'],
{ {
plugins: { plugins: {
'simple-import-sort': simpleImportSort, 'simple-import-sort': simpleImportSort,
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.7.0-beta.2", "version": "1.7.0-alpha.5",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -83,7 +83,6 @@
"@libsql/win32-x64-msvc": "^0.4.7", "@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7",
"express": "^5.1.0",
"font-list": "^2.0.0", "font-list": "^2.0.0",
"graceful-fs": "^4.2.11", "graceful-fs": "^4.2.11",
"jsdom": "26.1.0", "jsdom": "26.1.0",
@@ -93,7 +92,6 @@
"selection-hook": "^1.0.12", "selection-hook": "^1.0.12",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"turndown": "7.2.0" "turndown": "7.2.0"
}, },
@@ -154,7 +152,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch", "@opeoginni/github-copilot-openai-compatible": "0.1.18",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
@@ -261,9 +259,10 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0", "eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0", "eslint-plugin-oxlint": "^1.15.0",
"eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"express": "^5.1.0",
"express-validator": "^7.2.1", "express-validator": "^7.2.1",
"fast-diff": "^1.3.0", "fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0", "fast-xml-parser": "^5.2.0",
@@ -304,8 +303,8 @@
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"playwright": "^1.52.0", "playwright": "^1.52.0",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"react": "^19.2.0", "react": "^19.0.0",
"react-dom": "^19.2.0", "react-dom": "^19.0.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hotkeys-hook": "^4.6.1", "react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
@@ -337,6 +336,7 @@
"string-width": "^7.2.0", "string-width": "^7.2.0",
"striptags": "^3.2.0", "striptags": "^3.2.0",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"swagger-ui-express": "^5.0.1",
"swr": "^2.3.6", "swr": "^2.3.6",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tar": "^7.4.3", "tar": "^7.4.3",
+3 -2
View File
@@ -96,6 +96,9 @@ export enum IpcChannel {
AgentMessage_PersistExchange = 'agent-message:persist-exchange', AgentMessage_PersistExchange = 'agent-message:persist-exchange',
AgentMessage_GetHistory = 'agent-message:get-history', AgentMessage_GetHistory = 'agent-message:get-history',
// JavaScript
Js_Execute = 'js:execute',
//copilot //copilot
Copilot_GetAuthMessage = 'copilot:get-auth-message', Copilot_GetAuthMessage = 'copilot:get-auth-message',
Copilot_GetCopilotToken = 'copilot:get-copilot-token', Copilot_GetCopilotToken = 'copilot:get-copilot-token',
@@ -317,7 +320,6 @@ export enum IpcChannel {
ApiServer_Stop = 'api-server:stop', ApiServer_Stop = 'api-server:stop',
ApiServer_Restart = 'api-server:restart', ApiServer_Restart = 'api-server:restart',
ApiServer_GetStatus = 'api-server:get-status', ApiServer_GetStatus = 'api-server:get-status',
// NOTE: This api is not be used.
ApiServer_GetConfig = 'api-server:get-config', ApiServer_GetConfig = 'api-server:get-config',
// Anthropic OAuth // Anthropic OAuth
@@ -337,7 +339,6 @@ export enum IpcChannel {
// OCR // OCR
OCR_ocr = 'ocr:ocr', OCR_ocr = 'ocr:ocr',
OCR_ListProviders = 'ocr:list-providers',
// OVMS // OVMS
Ovms_AddModel = 'ovms:add-model', Ovms_AddModel = 'ovms:add-model',
+74 -160
View File
@@ -5,171 +5,105 @@ const { execSync } = require('child_process')
const { downloadWithPowerShell } = require('./download') const { downloadWithPowerShell } = require('./download')
// Base URL for downloading OVMS binaries // Base URL for downloading OVMS binaries
const OVMS_RELEASE_BASE_URL = const OVMS_PKG_NAME = 'ovms250911.zip'
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip' const OVMS_RELEASE_BASE_URL = [`https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/${OVMS_PKG_NAME}`]
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
/** /**
* error code: * Downloads and extracts the OVMS binary for the specified platform
* 101: Unsupported CPU (not Intel Ultra)
* 102: Unsupported platform (not Windows)
* 103: Download failed
* 104: Installation failed
* 105: Failed to create ovdnd.exe
* 106: Failed to create run.bat
* 110: Cleanup of old installation failed
*/ */
async function downloadOvmsBinary() {
/** // Create output directory structure - OVMS goes into its own subdirectory
* Clean old OVMS installation if it exists
*/
function cleanOldOvmsInstallation() {
console.log('Cleaning the existing OVMS installation...')
const csDir = path.join(os.homedir(), '.cherrystudio') const csDir = path.join(os.homedir(), '.cherrystudio')
// Ensure directories exist
fs.mkdirSync(csDir, { recursive: true })
const csOvmsDir = path.join(csDir, 'ovms') const csOvmsDir = path.join(csDir, 'ovms')
// Delete existing OVMS directory if it exists
if (fs.existsSync(csOvmsDir)) { if (fs.existsSync(csOvmsDir)) {
try { fs.rmSync(csOvmsDir, { recursive: true })
fs.rmSync(csOvmsDir, { recursive: true })
} catch (error) {
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
return 110
}
} }
return 0
}
/**
* Install OVMS Base package
*/
async function installOvmsBase() {
// Download the base package
const tempdir = os.tmpdir() const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms.zip') const tempFilename = path.join(tempdir, 'ovms.zip')
try { // Try each URL until one succeeds
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`) let downloadSuccess = false
let lastError = null
// Try PowerShell download first, fallback to Node.js download if it fails for (let i = 0; i < OVMS_RELEASE_BASE_URL.length; i++) {
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename) const downloadUrl = OVMS_RELEASE_BASE_URL[i]
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`) console.log(`Attempting download from URL ${i + 1}/${OVMS_RELEASE_BASE_URL.length}: ${downloadUrl}`)
} catch (error) {
console.error(`Download OVMS Base failed: ${error.message}`) try {
fs.unlinkSync(tempFilename) console.log(`Downloading OVMS from ${downloadUrl} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(downloadUrl, tempFilename)
// If we get here, download was successful
downloadSuccess = true
console.log(`Successfully downloaded from: ${downloadUrl}`)
break
} catch (error) {
console.warn(`Download failed from ${downloadUrl}: ${error.message}`)
lastError = error
// Clean up failed download file if it exists
if (fs.existsSync(tempFilename)) {
try {
fs.unlinkSync(tempFilename)
} catch (cleanupError) {
console.warn(`Failed to clean up temporary file: ${cleanupError.message}`)
}
}
// Continue to next URL if this one failed
if (i < OVMS_RELEASE_BASE_URL.length - 1) {
console.log(`Trying next URL...`)
}
}
}
// Check if any download succeeded
if (!downloadSuccess) {
console.error(`All download URLs failed. Last error: ${lastError?.message || 'Unknown error'}`)
return 103 return 103
} }
// unzip the base package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
fs.mkdirSync(csOvmsDir, { recursive: true })
try { try {
console.log(`Extracting to ${csDir}...`)
// Use tar.exe to extract the ZIP file // Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Base to ${csOvmsDir}...`) console.log(`Extracting OVMS to ${csDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' }) execSync(`tar -xf ${tempFilename} -C ${csDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`) console.log(`OVMS extracted to ${csDir}`)
// Clean up temporary file // Clean up temporary file
fs.unlinkSync(tempFilename) fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`) console.log(`Installation directory: ${csDir}`)
} catch (error) { } catch (error) {
console.error(`Error installing OVMS: ${error.message}`) console.error(`Error installing OVMS: ${error.message}`)
fs.unlinkSync(tempFilename) if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
// Check if ovmsDir is empty and remove it if so
try {
const ovmsDir = path.join(csDir, 'ovms')
const files = fs.readdirSync(ovmsDir)
if (files.length === 0) {
fs.rmSync(ovmsDir, { recursive: true })
console.log(`Removed empty directory: ${ovmsDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
return 105
}
return 104 return 104
} }
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
// copy ovms.exe to ovdnd.exe
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
console.log('Copied ovms.exe to ovdnd.exe')
} catch (error) {
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
return 105
}
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
// del %USERPROFILE%\.cherrystudio\ovms_log.log
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
try {
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
fs.appendFileSync(runBatPath, '\r\n')
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
fs.appendFileSync(
runBatPath,
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
)
console.log(`Created run.bat at: ${runBatPath}`)
} catch (error) {
console.error(`Error creating run.bat: ${error.message}`)
return 106
}
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
console.log(`Created config file: ${configJsonPath}`)
return 0
}
/**
* Install OVMS Extra package
*/
async function installOvmsExtra() {
// Download the extra package
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
try {
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
// Try PowerShell download first, fallback to Node.js download if it fails
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
} catch (error) {
console.error(`Download OVMS Extra failed: ${error.message}`)
fs.unlinkSync(tempFilename)
return 103
}
// unzip the extra package to the target directory
const csDir = path.join(os.homedir(), '.cherrystudio')
const csOvmsDir = path.join(csDir, 'ovms')
try {
// Use tar.exe to extract the ZIP file
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
console.log(`OVMS extracted to ${csOvmsDir}`)
// Clean up temporary file
fs.unlinkSync(tempFilename)
console.log(`Installation directory: ${csDir}`)
} catch (error) {
console.error(`Error installing OVMS Extra: ${error.message}`)
fs.unlinkSync(tempFilename)
return 104
}
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
try {
const files = fs.readdirSync(patchDir)
files.forEach((file) => {
const srcPath = path.join(patchDir, file)
const destPath = path.join(csOvmsBinDir, file)
fs.copyFileSync(srcPath, destPath)
console.log(`Applied patch file: ${file}`)
})
} catch (error) {
console.error(`Error applying OVMS patch: ${error.message}`)
}
return 0 return 0
} }
@@ -224,27 +158,7 @@ async function installOvms() {
return 102 return 102
} }
// Clean old installation if it exists return await downloadOvmsBinary()
const cleanupCode = cleanOldOvmsInstallation()
if (cleanupCode !== 0) {
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
return cleanupCode
}
const installBaseCode = await installOvmsBase()
if (installBaseCode !== 0) {
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
cleanOldOvmsInstallation()
return installBaseCode
}
const installExtraCode = await installOvmsExtra()
if (installExtraCode !== 0) {
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
return installExtraCode
}
return 0
} }
// Run the installation // Run the installation
Binary file not shown.
+3 -14
View File
@@ -1,8 +1,7 @@
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { loggerService } from '@logger'
import { agentService } from '../services/agents' import { agentService } from '../services/agents'
import { loggerService } from '../services/LoggerService'
import { app } from './app' import { app } from './app'
import { config } from './config' import { config } from './config'
@@ -16,17 +15,11 @@ export class ApiServer {
private server: ReturnType<typeof createServer> | null = null private server: ReturnType<typeof createServer> | null = null
async start(): Promise<void> { async start(): Promise<void> {
if (this.server && this.server.listening) { if (this.server) {
logger.warn('Server already running') logger.warn('Server already running')
return return
} }
// Clean up any failed server instance
if (this.server && !this.server.listening) {
logger.warn('Cleaning up failed server instance')
this.server = null
}
// Load config // Load config
const { port, host } = await config.load() const { port, host } = await config.load()
@@ -46,11 +39,7 @@ export class ApiServer {
resolve() resolve()
}) })
this.server!.on('error', (error) => { this.server!.on('error', reject)
// Clean up the server instance if listen fails
this.server = null
reject(error)
})
}) })
} }
+14 -19
View File
@@ -1,13 +1,6 @@
import { isEmpty } from 'lodash'
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
import { loggerService } from '../../services/LoggerService' import { loggerService } from '../../services/LoggerService'
import { import { getAvailableProviders, listAllAvailableModels, transformModelToOpenAI } from '../utils'
getAvailableProviders,
getProviderAnthropicModelChecker,
listAllAvailableModels,
transformModelToOpenAI
} from '../utils'
const logger = loggerService.withContext('ModelsService') const logger = loggerService.withContext('ModelsService')
@@ -23,7 +16,9 @@ export class ModelsService {
let providers = await getAvailableProviders() let providers = await getAvailableProviders()
if (filter.providerType === 'anthropic') { if (filter.providerType === 'anthropic') {
providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim())) providers = providers.filter(
(p) => p.type === 'anthropic' || (p.anthropicApiHost !== undefined && p.anthropicApiHost.trim() !== '')
)
} }
const models = await listAllAvailableModels(providers) const models = await listAllAvailableModels(providers)
@@ -32,18 +27,18 @@ export class ModelsService {
for (const model of models) { for (const model of models) {
const provider = providers.find((p) => p.id === model.provider) const provider = providers.find((p) => p.id === model.provider)
logger.debug(`Processing model ${model.id}`) logger.debug(`Processing model ${model.id} from provider ${model.provider}`, {
if (!provider) { isAnthropicModel: provider?.isAnthropicModel
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`) })
if (
!provider ||
(filter.providerType === 'anthropic' && provider.isAnthropicModel && !provider.isAnthropicModel(model))
) {
continue continue
} }
// Special case: For "aihubmix", it should be covered by above condition, but just in case
if (filter.providerType === 'anthropic') { if (provider.id === 'aihubmix' && filter.providerType === 'anthropic' && !model.id.includes('claude')) {
const checker = getProviderAnthropicModelChecker(provider.id) continue
if (!checker(model)) {
logger.debug(`Skipping model ${model.id} from ${model.provider}. Reason: Not an Anthropic model.`)
continue
}
} }
const openAIModel = transformModelToOpenAI(model, provider) const openAIModel = transformModelToOpenAI(model, provider)
-13
View File
@@ -279,16 +279,3 @@ export function validateProvider(provider: Provider): boolean {
return false return false
} }
} }
export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => {
switch (providerId) {
case 'cherryin':
case 'new-api':
return (m: Model) => m.endpoint_type === 'anthropic'
case 'aihubmix':
return (m: Model) => m.id.includes('claude')
default:
// allow all models when checker not configured
return () => true
}
}
+2 -17
View File
@@ -159,26 +159,11 @@ if (!app.requestSingleInstanceLock()) {
logger.error('Failed to initialize Agent service:', error) logger.error('Failed to initialize Agent service:', error)
} }
// Start API server if enabled or if agents exist // Start API server if enabled
try { try {
const config = await apiServerService.getCurrentConfig() const config = await apiServerService.getCurrentConfig()
logger.info('API server config:', config) logger.info('API server config:', config)
if (config.enabled) {
// Check if there are any agents
let shouldStart = config.enabled
if (!shouldStart) {
try {
const { total } = await agentService.listAgents({ limit: 1 })
if (total > 0) {
shouldStart = true
logger.info(`Detected ${total} agent(s), auto-starting API server`)
}
} catch (error: any) {
logger.warn('Failed to check agent count:', error)
}
}
if (shouldStart) {
await apiServerService.start() await apiServerService.start()
} }
} catch (error: any) { } catch (error: any) {
+6 -1
View File
@@ -37,6 +37,7 @@ import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService' import { ExportService } from './services/ExportService'
import { fileStorage as fileManager } from './services/FileStorage' import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService' import FileService from './services/FileSystemService'
import { jsService } from './services/JsService'
import KnowledgeService from './services/KnowledgeService' import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService' import mcpService from './services/MCPService'
import MemoryService from './services/memory/MemoryService' import MemoryService from './services/memory/MemoryService'
@@ -741,6 +742,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
} }
) )
// Register JavaScript execution handler
ipcMain.handle(IpcChannel.Js_Execute, async (_, code: string, timeout?: number) => {
return await jsService.executeScript(code, { timeout })
})
ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name))
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name)) ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js')) ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
@@ -875,7 +881,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) => ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
ocrService.ocr(file, provider) ocrService.ocr(file, provider)
) )
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS // OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) => ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
-473
View File
@@ -1,473 +0,0 @@
/**
* DiDi MCP Server Implementation
*
* Based on official DiDi MCP API capabilities.
* API Documentation: https://mcp.didichuxing.com/api?tap=api
*
* Provides ride-hailing services including map search, price estimation,
* order management, and driver tracking.
*
* Note: Only available in Mainland China.
*/
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const logger = loggerService.withContext('DiDiMCPServer')
export class DiDiMcpServer {
private _server: Server
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
private apiKey: string
constructor(apiKey?: string) {
this._server = new Server(
{
name: 'didi-mcp-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
// Get API key from parameter or environment variables
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
if (!this.apiKey) {
logger.warn('DIDI_API_KEY environment variable is not set')
}
this.setupRequestHandlers()
}
get server(): Server {
return this._server
}
private setupRequestHandlers() {
// List available tools
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'maps_textsearch',
description: 'Search for POI locations based on keywords and city',
inputSchema: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'Query city'
},
keywords: {
type: 'string',
description: 'Search keywords'
},
location: {
type: 'string',
description: 'Location coordinates, format: longitude,latitude'
}
},
required: ['keywords', 'city']
}
},
{
name: 'taxi_cancel_order',
description: 'Cancel a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation or query results'
},
reason: {
type: 'string',
description:
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
}
},
required: ['order_id']
}
},
{
name: 'taxi_create_order',
description: 'Create taxi order directly via API without opening any app interface',
inputSchema: {
type: 'object',
properties: {
caller_car_phone: {
type: 'string',
description: 'Caller phone number (optional)'
},
estimate_trace_id: {
type: 'string',
description: 'Estimation trace ID from estimation results'
},
product_category: {
type: 'string',
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
}
},
required: ['product_category', 'estimate_trace_id']
}
},
{
name: 'taxi_estimate',
description: 'Get available ride-hailing vehicle types and fare estimates',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
from_name: {
type: 'string',
description: 'Departure location name'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
},
to_name: {
type: 'string',
description: 'Destination name'
}
},
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
}
},
{
name: 'taxi_generate_ride_app_link',
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
inputSchema: {
type: 'object',
properties: {
from_lat: {
type: 'string',
description: 'Departure latitude, must be from map tools'
},
from_lng: {
type: 'string',
description: 'Departure longitude, must be from map tools'
},
product_category: {
type: 'string',
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
},
to_lat: {
type: 'string',
description: 'Destination latitude, must be from map tools'
},
to_lng: {
type: 'string',
description: 'Destination longitude, must be from map tools'
}
},
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
}
},
{
name: 'taxi_get_driver_location',
description: 'Get real-time driver location for a taxi order',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Taxi order ID'
}
},
required: ['order_id']
}
},
{
name: 'taxi_query_order',
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
inputSchema: {
type: 'object',
properties: {
order_id: {
type: 'string',
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
}
}
}
}
]
}
})
// Handle tool calls
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'maps_textsearch':
return await this.handleMapsTextSearch(args)
case 'taxi_cancel_order':
return await this.handleTaxiCancelOrder(args)
case 'taxi_create_order':
return await this.handleTaxiCreateOrder(args)
case 'taxi_estimate':
return await this.handleTaxiEstimate(args)
case 'taxi_generate_ride_app_link':
return await this.handleTaxiGenerateRideAppLink(args)
case 'taxi_get_driver_location':
return await this.handleTaxiGetDriverLocation(args)
case 'taxi_query_order':
return await this.handleTaxiQueryOrder(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
logger.error(`Error calling tool ${name}:`, error as Error)
throw error
}
})
}
private async handleMapsTextSearch(args: any) {
const { city, keywords, location } = args
const params = {
name: 'maps_textsearch',
arguments: {
keywords,
city,
...(location && { location })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Maps text search error:', error as Error)
throw error
}
}
private async handleTaxiCancelOrder(args: any) {
const { order_id, reason } = args
const params = {
name: 'taxi_cancel_order',
arguments: {
order_id,
...(reason && { reason })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi cancel order error:', error as Error)
throw error
}
}
private async handleTaxiCreateOrder(args: any) {
const { caller_car_phone, estimate_trace_id, product_category } = args
const params = {
name: 'taxi_create_order',
arguments: {
product_category,
estimate_trace_id,
...(caller_car_phone && { caller_car_phone })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi create order error:', error as Error)
throw error
}
}
private async handleTaxiEstimate(args: any) {
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
const params = {
name: 'taxi_estimate',
arguments: {
from_lng,
from_lat,
from_name,
to_lng,
to_lat,
to_name
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi estimate error:', error as Error)
throw error
}
}
private async handleTaxiGenerateRideAppLink(args: any) {
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
const params = {
name: 'taxi_generate_ride_app_link',
arguments: {
from_lng,
from_lat,
to_lng,
to_lat,
...(product_category && { product_category })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi generate ride app link error:', error as Error)
throw error
}
}
private async handleTaxiGetDriverLocation(args: any) {
const { order_id } = args
const params = {
name: 'taxi_get_driver_location',
arguments: {
order_id
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi get driver location error:', error as Error)
throw error
}
}
private async handleTaxiQueryOrder(args: any) {
const { order_id } = args
const params = {
name: 'taxi_query_order',
arguments: {
...(order_id && { order_id })
}
}
try {
const response = await this.makeRequest('tools/call', params)
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2)
}
]
}
} catch (error) {
logger.error('Taxi query order error:', error as Error)
throw error
}
}
private async makeRequest(method: string, params: any): Promise<any> {
const requestData = {
jsonrpc: '2.0',
method: method,
id: Date.now(),
...(Object.keys(params).length > 0 && { params })
}
// API key is passed as URL parameter
const url = `${this.baseUrl}?key=${this.apiKey}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
}
return data.result
}
}
export default DiDiMcpServer
+3 -4
View File
@@ -3,10 +3,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types' import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search' import BraveSearchServer from './brave-search'
import DiDiMcpServer from './didi-mcp'
import DifyKnowledgeServer from './dify-knowledge' import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch' import FetchServer from './fetch'
import FileSystemServer from './filesystem' import FileSystemServer from './filesystem'
import JsServer from './js'
import MemoryServer from './memory' import MemoryServer from './memory'
import PythonServer from './python' import PythonServer from './python'
import ThinkingServer from './sequentialthinking' import ThinkingServer from './sequentialthinking'
@@ -43,9 +43,8 @@ export function createInMemoryMCPServer(
case BuiltinMCPServerNames.python: { case BuiltinMCPServerNames.python: {
return new PythonServer().server return new PythonServer().server
} }
case BuiltinMCPServerNames.didiMCP: { case BuiltinMCPServerNames.js: {
const apiKey = envs.DIDI_API_KEY return new JsServer().server
return new DiDiMcpServer(apiKey).server
} }
default: default:
throw new Error(`Unknown in-memory MCP server: ${name}`) throw new Error(`Unknown in-memory MCP server: ${name}`)
+139
View File
@@ -0,0 +1,139 @@
// port from https://github.com/jlucaso1/mcp-javascript-sandbox
import { loggerService } from '@logger'
import { jsService } from '@main/services/JsService'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types'
import * as z from 'zod'
const TOOL_NAME = 'run_javascript_code'
const DEFAULT_TIMEOUT = 60_000
export const RequestPayloadSchema = z.object({
javascript_code: z.string().min(1).describe('The JavaScript code to execute in the sandbox.'),
timeout: z
.number()
.int()
.positive()
.max(5 * 60_000)
.optional()
.describe('Execution timeout in milliseconds (default 60000, max 300000).')
})
const logger = loggerService.withContext('MCPServer:JavaScript')
function formatExecutionResult(result: {
stdout: string
stderr: string
error?: string | undefined
exitCode: number
}) {
let combinedOutput = ''
if (result.stdout) {
combinedOutput += result.stdout
}
if (result.stderr) {
combinedOutput += `--- stderr ---\n${result.stderr}\n--- stderr ---\n`
}
if (result.error) {
combinedOutput += `--- Execution Error ---\n${result.error}\n--- Execution Error ---\n`
}
const isError = Boolean(result.error) || Boolean(result.stderr?.trim()) || result.exitCode !== 0
return {
combinedOutput: combinedOutput.trim(),
isError
}
}
class JsServer {
public server: Server
constructor() {
this.server = new Server(
{
name: 'MCP QuickJS Runner',
version: '1.0.0',
description: 'An MCP server that provides a tool to execute JavaScript code in a QuickJS WASM sandbox.'
},
{
capabilities: {
resources: {},
tools: {}
}
}
)
this.setupHandlers()
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: TOOL_NAME,
description:
'Executes the provided JavaScript code in a secure WASM sandbox (QuickJS). Returns stdout and stderr. Non-zero exit code indicates an error.',
inputSchema: z.toJSONSchema(RequestPayloadSchema)
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
if (name !== TOOL_NAME) {
return {
content: [{ type: 'text', text: `Tool not found: ${name}` }],
isError: true
}
}
const parseResult = RequestPayloadSchema.safeParse(args)
if (!parseResult.success) {
return {
content: [{ type: 'text', text: `Invalid arguments: ${parseResult.error.message}` }],
isError: true
}
}
const { javascript_code, timeout } = parseResult.data
try {
logger.debug('Executing JavaScript code via JsService')
const result = await jsService.executeScript(javascript_code, {
timeout: timeout ?? DEFAULT_TIMEOUT
})
const { combinedOutput, isError } = formatExecutionResult(result)
return {
content: [
{
type: 'text',
text: combinedOutput
}
],
isError
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error(`JavaScript execution failed: ${message}`)
return {
content: [
{
type: 'text',
text: `Server error during tool execution: ${message}`
}
],
isError: true
}
}
})
}
}
export default JsServer
+5 -11
View File
@@ -1,11 +1,5 @@
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { import { ApiServerConfig } from '@types'
ApiServerConfig,
GetApiServerStatusResult,
RestartApiServerStatusResult,
StartApiServerStatusResult,
StopApiServerStatusResult
} from '@types'
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { apiServer } from '../apiServer' import { apiServer } from '../apiServer'
@@ -58,7 +52,7 @@ export class ApiServerService {
registerIpcHandlers(): void { registerIpcHandlers(): void {
// API Server // API Server
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => { ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
try { try {
await this.start() await this.start()
return { success: true } return { success: true }
@@ -67,7 +61,7 @@ export class ApiServerService {
} }
}) })
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => { ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
try { try {
await this.stop() await this.stop()
return { success: true } return { success: true }
@@ -76,7 +70,7 @@ export class ApiServerService {
} }
}) })
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => { ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
try { try {
await this.restart() await this.restart()
return { success: true } return { success: true }
@@ -85,7 +79,7 @@ export class ApiServerService {
} }
}) })
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => { ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
try { try {
const config = await this.getCurrentConfig() const config = await this.getCurrentConfig()
return { return {
+115
View File
@@ -0,0 +1,115 @@
import { loggerService } from '@logger'
import type { JsExecutionResult } from './workers/JsWorker'
// oxlint-disable-next-line default
import createJsWorker from './workers/JsWorker?nodeWorker'
interface ExecuteScriptOptions {
timeout?: number
}
type WorkerResponse =
| {
success: true
result: JsExecutionResult
}
| {
success: false
error: string
}
const DEFAULT_TIMEOUT = 60_000
const logger = loggerService.withContext('JsService')
export class JsService {
private static instance: JsService | null = null
private constructor() {}
public static getInstance(): JsService {
if (!JsService.instance) {
JsService.instance = new JsService()
}
return JsService.instance
}
public async executeScript(code: string, options: ExecuteScriptOptions = {}): Promise<JsExecutionResult> {
const { timeout = DEFAULT_TIMEOUT } = options
if (!code || typeof code !== 'string') {
throw new Error('JavaScript code must be a non-empty string')
}
// Limit code size to 1MB to prevent memory issues
const MAX_CODE_SIZE = 1_000_000
if (code.length > MAX_CODE_SIZE) {
throw new Error(`JavaScript code exceeds maximum size of ${MAX_CODE_SIZE / 1_000_000}MB`)
}
return new Promise<JsExecutionResult>((resolve, reject) => {
const worker = createJsWorker({
workerData: { code },
argv: [],
trackUnmanagedFds: false
})
let settled = false
let timeoutId: NodeJS.Timeout | null = null
const cleanup = async () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
try {
await worker.terminate()
} catch {
// ignore termination errors
}
}
const settleSuccess = async (result: JsExecutionResult) => {
if (settled) return
settled = true
await cleanup()
resolve(result)
}
const settleError = async (error: Error) => {
if (settled) return
settled = true
await cleanup()
reject(error)
}
worker.once('message', async (message: WorkerResponse) => {
if (message.success) {
await settleSuccess(message.result)
} else {
await settleError(new Error(message.error))
}
})
worker.once('error', async (error) => {
logger.error(`JsWorker error: ${error instanceof Error ? error.message : String(error)}`)
await settleError(error instanceof Error ? error : new Error(String(error)))
})
worker.once('exit', async (exitCode) => {
if (!settled && exitCode !== 0) {
await settleError(new Error(`JsWorker exited with code ${exitCode}`))
}
})
timeoutId = setTimeout(() => {
logger.warn(`JavaScript execution timed out after ${timeout}ms`)
settleError(new Error('JavaScript execution timed out')).catch((err) => {
logger.error('Error during timeout cleanup:', err instanceof Error ? err : new Error(String(err)))
})
}, timeout)
})
}
}
export const jsService = JsService.getInstance()
+3 -8
View File
@@ -60,20 +60,15 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
if (!isFindShortcut && !isEscape && !isEnter) { if (!isFindShortcut && !isEscape && !isEnter) {
return return
} }
// Prevent default to override the guest page's native find dialog
// and keep shortcuts routed to our custom search overlay
event.preventDefault()
const host = contents.hostWebContents const host = contents.hostWebContents
if (!host || host.isDestroyed()) { if (!host || host.isDestroyed()) {
return return
} }
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
host.send(IpcChannel.Webview_SearchHotkey, { host.send(IpcChannel.Webview_SearchHotkey, {
webviewId: contents.id, webviewId: contents.id,
key, key,
-7
View File
@@ -2,7 +2,6 @@ import { loggerService } from '@logger'
import { isLinux } from '@main/constant' import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { ovOcrService } from './builtin/OvOcrService'
import { ppocrService } from './builtin/PpocrService' import { ppocrService } from './builtin/PpocrService'
import { systemOcrService } from './builtin/SystemOcrService' import { systemOcrService } from './builtin/SystemOcrService'
import { tesseractService } from './builtin/TesseractService' import { tesseractService } from './builtin/TesseractService'
@@ -23,10 +22,6 @@ export class OcrService {
this.registry.delete(providerId) this.registry.delete(providerId)
} }
public listProviderIds(): string[] {
return Array.from(this.registry.keys())
}
public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> { public async ocr(file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> {
const handler = this.registry.get(provider.id) const handler = this.registry.get(provider.id)
if (!handler) { if (!handler) {
@@ -44,5 +39,3 @@ ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(t
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) !isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService)) ocrService.register(BuiltinOcrProviderIds.paddleocr, ppocrService.ocr.bind(ppocrService))
ovOcrService.isAvailable() && ocrService.register(BuiltinOcrProviderIds.ovocr, ovOcrService.ocr.bind(ovOcrService))
@@ -1,128 +0,0 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { exec } from 'child_process'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { promisify } from 'util'
import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
export class OvOcrService extends OcrBaseService {
constructor() {
super()
}
public isAvailable(): boolean {
return (
isWin &&
os.cpus()[0].model.toLowerCase().includes('intel') &&
os.cpus()[0].model.toLowerCase().includes('ultra') &&
fs.existsSync(PATH_BAT_FILE)
)
}
private getOvOcrPath(): string {
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
}
private getImgDir(): string {
return path.join(this.getOvOcrPath(), 'img')
}
private getOutputDir(): string {
return path.join(this.getOvOcrPath(), 'output')
}
private async clearDirectory(dirPath: string): Promise<void> {
if (fs.existsSync(dirPath)) {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
await this.clearDirectory(filePath)
await fs.promises.rmdir(filePath)
} else {
await fs.promises.unlink(filePath)
}
}
} else {
// If the directory does not exist, create it
await fs.promises.mkdir(dirPath, { recursive: true })
}
}
private async copyFileToImgDir(sourceFilePath: string, targetFileName: string): Promise<void> {
const imgDir = this.getImgDir()
const targetFilePath = path.join(imgDir, targetFileName)
await fs.promises.copyFile(sourceFilePath, targetFilePath)
}
private async runOcrBatch(): Promise<void> {
const ovOcrPath = this.getOvOcrPath()
try {
// Execute run.bat in the ov-ocr directory
await execAsync(`"${PATH_BAT_FILE}"`, {
cwd: ovOcrPath,
timeout: 60000 // 60 second timeout
})
} catch (error) {
logger.error(`Error running ovocr batch: ${error}`)
throw new Error(`Failed to run OCR batch: ${error}`)
}
}
private async ocrImage(filePath: string, options?: OcrOvConfig): Promise<OcrResult> {
logger.info(`OV OCR called on ${filePath} with options ${JSON.stringify(options)}`)
try {
// 1. Clear img directory and output directory
await this.clearDirectory(this.getImgDir())
await this.clearDirectory(this.getOutputDir())
// 2. Copy file to img directory
const fileName = path.basename(filePath)
await this.copyFileToImgDir(filePath, fileName)
logger.info(`File copied to img directory: ${fileName}`)
// 3. Run run.bat
logger.info('Running OV OCR batch process...')
await this.runOcrBatch()
// 4. Check that output/[basename].txt file exists
const baseNameWithoutExt = path.basename(fileName, path.extname(fileName))
const outputFilePath = path.join(this.getOutputDir(), `${baseNameWithoutExt}.txt`)
if (!fs.existsSync(outputFilePath)) {
throw new Error(`OV OCR output file not found at: ${outputFilePath}`)
}
// 5. Read output/[basename].txt file content
const ocrText = await fs.promises.readFile(outputFilePath, 'utf-8')
logger.info(`OV OCR text extracted: ${ocrText.substring(0, 100)}...`)
// 6. Return result
return { text: ocrText }
} catch (error) {
logger.error(`Error during OV OCR process: ${error}`)
throw error
}
}
public ocr = async (file: SupportedOcrFile, options?: OcrOvConfig): Promise<OcrResult> => {
if (isImageFileMetadata(file)) {
return this.ocrImage(file.path, options)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const ovOcrService = new OvOcrService()
+118
View File
@@ -0,0 +1,118 @@
import { mkdtemp, open, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WASI } from 'node:wasi'
import { parentPort, workerData } from 'node:worker_threads'
import loadWasm from '../../../../resources/wasm/qjs-wasi.wasm?loader'
interface WorkerPayload {
code: string
}
export interface JsExecutionResult {
stdout: string
stderr: string
error?: string
exitCode: number
}
if (!parentPort) {
throw new Error('JsWorker requires a parent port')
}
async function runQuickJsInSandbox(jsCode: string): Promise<JsExecutionResult> {
let tempDir: string | undefined
let stdoutHandle: Awaited<ReturnType<typeof open>> | undefined
let stderrHandle: Awaited<ReturnType<typeof open>> | undefined
let stdoutPath: string | undefined
let stderrPath: string | undefined
try {
tempDir = await mkdtemp(join(tmpdir(), 'quickjs-wasi-'))
stdoutPath = join(tempDir, 'stdout.log')
stderrPath = join(tempDir, 'stderr.log')
stdoutHandle = await open(stdoutPath, 'w')
stderrHandle = await open(stderrPath, 'w')
const wasi = new WASI({
version: 'preview1',
args: ['qjs', '-e', jsCode],
env: {}, // Empty environment for security - don't expose host env vars
stdin: 0,
stdout: stdoutHandle.fd,
stderr: stderrHandle.fd,
returnOnExit: true
})
const instance = await loadWasm(wasi.getImportObject() as WebAssembly.Imports)
let exitCode = 0
try {
exitCode = wasi.start(instance)
} catch (wasiError: any) {
return {
stdout: '',
stderr: `WASI start error: ${wasiError?.message ?? String(wasiError)}`,
error: `Sandbox execution failed during start: ${wasiError?.message ?? String(wasiError)}`,
exitCode: -1
}
}
// Close handles before reading files to prevent descriptor leak
const _stdoutHandle = stdoutHandle
stdoutHandle = undefined
await _stdoutHandle.close()
const _stderrHandle = stderrHandle
stderrHandle = undefined
await _stderrHandle.close()
const capturedStdout = await readFile(stdoutPath, 'utf8')
const capturedStderr = await readFile(stderrPath, 'utf8')
let executionError: string | undefined
if (exitCode !== 0) {
executionError = `QuickJS process exited with code ${exitCode}. Check stderr for details.`
}
return {
stdout: capturedStdout,
stderr: capturedStderr,
error: executionError,
exitCode
}
} catch (error: any) {
return {
stdout: '',
stderr: '',
error: `Sandbox setup or execution failed: ${error?.message ?? String(error)}`,
exitCode: -1
}
} finally {
if (stdoutHandle) await stdoutHandle.close()
if (stderrHandle) await stderrHandle.close()
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
}
}
}
async function execute(code: string) {
return runQuickJsInSandbox(code)
}
const payload = workerData as WorkerPayload | undefined
if (!payload?.code || typeof payload.code !== 'string') {
parentPort.postMessage({ success: false, error: 'JavaScript code must be provided to the worker' })
} else {
execute(payload.code)
.then((result) => {
parentPort?.postMessage({ success: true, result })
})
.catch((error: any) => {
const errorMessage = error instanceof Error ? error.message : String(error)
parentPort?.postMessage({ success: false, error: errorMessage })
})
}
+4 -12
View File
@@ -12,7 +12,6 @@ import {
FileListResponse, FileListResponse,
FileMetadata, FileMetadata,
FileUploadResponse, FileUploadResponse,
GetApiServerStatusResult,
KnowledgeBaseParams, KnowledgeBaseParams,
KnowledgeItem, KnowledgeItem,
KnowledgeSearchResult, KnowledgeSearchResult,
@@ -23,11 +22,8 @@ import {
OcrProvider, OcrProvider,
OcrResult, OcrResult,
Provider, Provider,
RestartApiServerStatusResult,
S3Config, S3Config,
Shortcut, Shortcut,
StartApiServerStatusResult,
StopApiServerStatusResult,
SupportedOcrFile, SupportedOcrFile,
ThemeMode, ThemeMode,
WebDavConfig WebDavConfig
@@ -349,6 +345,9 @@ const api = {
execute: (script: string, context?: Record<string, any>, timeout?: number) => execute: (script: string, context?: Record<string, any>, timeout?: number) =>
ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout) ipcRenderer.invoke(IpcChannel.Python_Execute, script, context, timeout)
}, },
js: {
execute: (code: string, timeout?: number) => ipcRenderer.invoke(IpcChannel.Js_Execute, code, timeout)
},
shell: { shell: {
openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options) openExternal: (url: string, options?: Electron.OpenExternalOptions) => shell.openExternal(url, options)
}, },
@@ -480,8 +479,7 @@ const api = {
}, },
ocr: { ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> => ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider), ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
listProviders: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.OCR_ListProviders)
}, },
cherryai: { cherryai: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) => generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
@@ -501,12 +499,6 @@ const api = {
ipcRenderer.removeListener(channel, listener) ipcRenderer.removeListener(channel, listener)
} }
} }
},
apiServer: {
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
} }
} }
@@ -6,11 +6,9 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk' import { Chunk, ChunkType } from '@renderer/types/chunk'
import { ProviderSpecificError } from '@renderer/types/provider-specific-error'
import { formatErrorMessage } from '@renderer/utils/error'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types' import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import { AISDKError, type TextStreamPart, type ToolSet } from 'ai' import type { TextStreamPart, ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk' import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -357,14 +355,7 @@ export class AiSdkToChunkAdapter {
case 'error': case 'error':
this.onChunk({ this.onChunk({
type: ChunkType.ERROR, type: ChunkType.ERROR,
error: error: chunk.error as Record<string, any>
chunk.error instanceof AISDKError
? chunk.error
: new ProviderSpecificError({
message: formatErrorMessage(chunk.error),
provider: 'unknown',
cause: chunk.error
})
}) })
break break
+4 -2
View File
@@ -83,8 +83,10 @@ export default class ModernAiProvider {
throw new Error('Model is required for completions. Please use constructor with model parameter.') throw new Error('Model is required for completions. Please use constructor with model parameter.')
} }
// 每次请求时重新生成配置以确保API key轮换生效 // 确保配置存在
this.config = providerToAiSdkConfig(this.actualProvider, this.model) if (!this.config) {
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
}
// 准备特殊配置 // 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config) await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -70,19 +70,13 @@ export abstract class BaseApiClient<
{ {
public provider: Provider public provider: Provider
protected host: string protected host: string
protected apiKey: string
protected sdkInstance?: TSdkInstance protected sdkInstance?: TSdkInstance
constructor(provider: Provider) { constructor(provider: Provider) {
this.provider = provider this.provider = provider
this.host = this.getBaseURL() this.host = this.getBaseURL()
} this.apiKey = this.getApiKey()
/**
* Get the current API key with rotation support
* This getter ensures API keys rotate on each access when multiple keys are configured
*/
protected get apiKey(): string {
return this.getApiKey()
} }
/** /**
@@ -1,5 +1,4 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
import { import {
isClaudeReasoningModel, isClaudeReasoningModel,
isOpenAIReasoningModel, isOpenAIReasoningModel,
@@ -167,8 +166,7 @@ export abstract class OpenAIBaseClient<
baseURL: this.getBaseURL(), baseURL: this.getBaseURL(),
defaultHeaders: { defaultHeaders: {
...this.defaultHeaders(), ...this.defaultHeaders(),
...this.provider.extra_headers, ...this.provider.extra_headers
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
} }
}) as TSdkInstance }) as TSdkInstance
} }
@@ -4,8 +4,6 @@ import type { MCPTool, Message, Model, Provider } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk' import type { Chunk } from '@renderer/types/chunk'
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai' import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
import { noThinkMiddleware } from './noThinkMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder') const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
/** /**
@@ -188,14 +186,6 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
// 其他provider的通用处理 // 其他provider的通用处理
break break
} }
// OVMS+MCP's specific middleware
if (config.provider.id === 'ovms' && config.mcpTools && config.mcpTools.length > 0) {
builder.add({
name: 'no-think',
middleware: noThinkMiddleware()
})
}
} }
/** /**
@@ -1,52 +0,0 @@
import { loggerService } from '@logger'
import { LanguageModelMiddleware } from 'ai'
const logger = loggerService.withContext('noThinkMiddleware')
/**
* No Think Middleware
* Automatically appends ' /no_think' string to the end of user messages for the provider
* This prevents the model from generating unnecessary thinking process and returns results directly
* @returns LanguageModelMiddleware
*/
export function noThinkMiddleware(): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
const transformedParams = { ...params }
// Process messages in prompt
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
transformedParams.prompt = transformedParams.prompt.map((message) => {
// Only process user messages
if (message.role === 'user') {
// Process content array
if (Array.isArray(message.content)) {
const lastContent = message.content[message.content.length - 1]
// If the last content is text type, append ' /no_think'
if (lastContent && lastContent.type === 'text' && typeof lastContent.text === 'string') {
// Avoid duplicate additions
if (!lastContent.text.endsWith('/no_think')) {
logger.debug('Adding /no_think to user message')
return {
...message,
content: [
...message.content.slice(0, -1),
{
...lastContent,
text: lastContent.text + ' /no_think'
}
]
}
}
}
}
}
return message
})
}
return transformedParams
}
}
}
@@ -63,14 +63,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
// return createVertexProvider(provider) // return createVertexProvider(provider)
// } // }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (isSystemProvider(provider)) { if (isSystemProvider(provider)) {
if (provider.id === 'aihubmix') { if (provider.id === 'aihubmix') {
return aihubmixProviderCreator(model, provider) return aihubmixProviderCreator(model, provider)
} }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
if (provider.id === 'vertexai') { if (provider.id === 'vertexai') {
return vertexAnthropicProviderCreator(model, provider) return vertexAnthropicProviderCreator(model, provider)
} }
@@ -55,14 +55,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createPerplexity', creatorFunctionName: 'createPerplexity',
supportsImageGeneration: false, supportsImageGeneration: false,
aliases: ['perplexity'] aliases: ['perplexity']
},
{
id: 'mistral',
name: 'Mistral',
import: () => import('@ai-sdk/mistral'),
creatorFunctionName: 'createMistral',
supportsImageGeneration: false,
aliases: ['mistral']
} }
] as const ] as const
+2 -4
View File
@@ -9,8 +9,6 @@ import {
CreateAgentRequest, CreateAgentRequest,
CreateAgentResponse, CreateAgentResponse,
CreateAgentResponseSchema, CreateAgentResponseSchema,
CreateAgentSessionResponse,
CreateAgentSessionResponseSchema,
CreateSessionForm, CreateSessionForm,
CreateSessionRequest, CreateSessionRequest,
GetAgentResponse, GetAgentResponse,
@@ -173,12 +171,12 @@ export class AgentApiClient {
} }
} }
public async createSession(agentId: string, session: CreateSessionForm): Promise<CreateAgentSessionResponse> { public async createSession(agentId: string, session: CreateSessionForm): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).base const url = this.getSessionPaths(agentId).base
try { try {
const payload = session satisfies CreateSessionRequest const payload = session satisfies CreateSessionRequest
const response = await this.axios.post(url, payload) const response = await this.axios.post(url, payload)
const data = CreateAgentSessionResponseSchema.parse(response.data) const data = GetAgentSessionResponseSchema.parse(response.data)
return data return data
} catch (error) { } catch (error) {
throw processError(error, 'Failed to add session.') throw processError(error, 'Failed to add session.')
@@ -87,7 +87,8 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const [tools, setTools] = useState<ActionTool[]>([]) const [tools, setTools] = useState<ActionTool[]>([])
const isExecutable = useMemo(() => { const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python' const executableLanguages = ['python', 'py', 'javascript', 'js']
return codeExecution.enabled && executableLanguages.includes(language.toLowerCase())
}, [codeExecution.enabled, language]) }, [codeExecution.enabled, language])
const sourceViewRef = useRef<CodeEditorHandles>(null) const sourceViewRef = useRef<CodeEditorHandles>(null)
@@ -152,21 +153,49 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
setIsRunning(true) setIsRunning(true)
setExecutionResult(null) setExecutionResult(null)
pyodideService const isPython = ['python', 'py'].includes(language.toLowerCase())
.runScript(children, {}, codeExecution.timeoutMinutes * 60000) const isJavaScript = ['javascript', 'js'].includes(language.toLowerCase())
.then((result) => {
setExecutionResult(result) if (isPython) {
}) pyodideService
.catch((error) => { .runScript(children, {}, codeExecution.timeoutMinutes * 60000)
logger.error('Unexpected error:', error) .then((result) => {
setExecutionResult({ setExecutionResult(result)
text: `Unexpected error: ${error.message || 'Unknown error'}`
}) })
}) .catch((error) => {
.finally(() => { logger.error('Unexpected error:', error)
setIsRunning(false) setExecutionResult({
}) text: `Unexpected error: ${error.message || 'Unknown error'}`
}, [children, codeExecution.timeoutMinutes]) })
})
.finally(() => {
setIsRunning(false)
})
} else if (isJavaScript) {
window.api.js
.execute(children, codeExecution.timeoutMinutes * 60000)
.then((result) => {
if (result.error) {
setExecutionResult({
text: `Error: ${result.error}\n${result.stderr || ''}`
})
} else {
setExecutionResult({
text: result.stdout || (result.stderr ? `stderr: ${result.stderr}` : 'Execution completed')
})
}
})
.catch((error) => {
logger.error('Unexpected error:', error)
setExecutionResult({
text: `Unexpected error: ${error.message || 'Unknown error'}`
})
})
.finally(() => {
setIsRunning(false)
})
}
}, [children, codeExecution.timeoutMinutes, language])
const showPreviewTools = useMemo(() => { const showPreviewTools = useMemo(() => {
return viewMode !== 'source' && hasSpecialView return viewMode !== 'source' && hasSpecialView
@@ -326,7 +355,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
* CodeViewer * CodeViewer
* toolbar title * toolbar title
*/ */
min-width: 35ch; min-width: 45ch;
.code-toolbar { .code-toolbar {
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
@@ -2,7 +2,7 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemir
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { Extension, keymap } from '@uiw/react-codemirror' import { Extension, keymap } from '@uiw/react-codemirror'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { getNormalizedExtension } from './utils' import { getNormalizedExtension } from './utils'
@@ -203,80 +203,3 @@ export function useHeightListener({ onHeightChange }: UseHeightListenerProps) {
}) })
}, [onHeightChange]) }, [onHeightChange])
} }
interface UseScrollToLineOptions {
highlight?: boolean
}
export function useScrollToLine(editorViewRef: React.MutableRefObject<EditorView | null>) {
const findLineElement = useCallback((view: EditorView, position: number): HTMLElement | null => {
const domAtPos = view.domAtPos(position)
let node: Node | null = domAtPos.node
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement
}
while (node) {
if (node instanceof HTMLElement && node.classList.contains('cm-line')) {
return node
}
node = node.parentElement
}
return null
}, [])
const highlightLine = useCallback((view: EditorView, element: HTMLElement) => {
const previousHighlight = view.dom.querySelector('.animation-locate-highlight') as HTMLElement | null
if (previousHighlight) {
previousHighlight.classList.remove('animation-locate-highlight')
}
element.classList.add('animation-locate-highlight')
const handleAnimationEnd = () => {
element.classList.remove('animation-locate-highlight')
element.removeEventListener('animationend', handleAnimationEnd)
}
element.addEventListener('animationend', handleAnimationEnd)
}, [])
return useCallback(
(lineNumber: number, options?: UseScrollToLineOptions) => {
const view = editorViewRef.current
if (!view) return
const targetLine = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
const lineElement = findLineElement(view, targetLine.from)
if (lineElement) {
lineElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
if (options?.highlight) {
requestAnimationFrame(() => highlightLine(view, lineElement))
}
return
}
view.dispatch({
effects: EditorView.scrollIntoView(targetLine.from, {
y: 'start'
})
})
if (!options?.highlight) {
return
}
setTimeout(() => {
const fallbackElement = findLineElement(view, targetLine.from)
if (fallbackElement) {
highlightLine(view, fallbackElement)
}
}, 200)
},
[editorViewRef, findLineElement, highlightLine]
)
}
@@ -5,14 +5,13 @@ import diff from 'fast-diff'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { memo } from 'react' import { memo } from 'react'
import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks' import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks'
// 标记非用户编辑的变更 // 标记非用户编辑的变更
const External = Annotation.define<boolean>() const External = Annotation.define<boolean>()
export interface CodeEditorHandles { export interface CodeEditorHandles {
save?: () => void save?: () => void
scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void
} }
export interface CodeEditorProps { export interface CodeEditorProps {
@@ -182,11 +181,8 @@ const CodeEditor = ({
].flat() ].flat()
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
const scrollToLine = useScrollToLine(editorViewRef)
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
save: handleSave, save: handleSave
scrollToLine
})) }))
return ( return (
@@ -1,48 +0,0 @@
import { FC, memo, useMemo } from 'react'
interface HighlightTextProps {
text: string
keyword: string
caseSensitive?: boolean
className?: string
}
/**
* Text highlighting component that marks keyword matches
*/
const HighlightText: FC<HighlightTextProps> = ({ text, keyword, caseSensitive = false, className }) => {
const highlightedText = useMemo(() => {
if (!keyword || !text) {
return <span>{text}</span>
}
// Escape regex special characters
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(`(${escapedKeyword})`, flags)
// Split text by keyword matches
const parts = text.split(regex)
return (
<>
{parts.map((part, index) => {
// Check if part matches keyword
const isMatch = regex.test(part)
regex.lastIndex = 0 // Reset regex state
if (isMatch) {
return <mark key={index}>{part}</mark>
}
return <span key={index}>{part}</span>
})}
</>
)
}, [text, keyword, caseSensitive])
const combinedClassName = className ? `ant-typography ${className}` : 'ant-typography'
return <span className={combinedClassName}>{highlightedText}</span>
}
export default memo(HighlightText)
@@ -25,7 +25,7 @@ const WebviewContainer = memo(
onNavigateCallback: (appid: string, url: string) => void onNavigateCallback: (appid: string, url: string) => void
}) => { }) => {
const webviewRef = useRef<WebviewTag | null>(null) const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck, minappsOpenLinkExternal } = useSettings() const { enableSpellCheck } = useSettings()
const setRef = (appid: string) => { const setRef = (appid: string) => {
onSetRefCallback(appid, null) onSetRefCallback(appid, null)
@@ -76,8 +76,6 @@ const WebviewContainer = memo(
const webviewId = webviewRef.current?.getWebContentsId() const webviewId = webviewRef.current?.getWebContentsId()
if (webviewId) { if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck) window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
// Set link opening behavior for this webview
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
} }
} }
@@ -106,22 +104,6 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url]) }, [appid, url])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return
try {
const webviewId = webviewRef.current.getWebContentsId()
if (webviewId) {
window.api?.webview?.setSpellCheckEnabled?.(webviewId, enableSpellCheck)
window.api?.webview?.setOpenLinkExternal?.(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView may not be ready yet, settings will be applied in dom-ready event
logger.debug(`WebView ${appid} not ready for settings update`)
}
}, [appid, minappsOpenLinkExternal, enableSpellCheck])
const WebviewStyle: React.CSSProperties = { const WebviewStyle: React.CSSProperties = {
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -1,5 +1,6 @@
import { import {
Button, Button,
cn,
Form, Form,
Input, Input,
Modal, Modal,
@@ -16,7 +17,7 @@ import {
import { loggerService } from '@logger' import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared' import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png' import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { agentModelFilter, getModelLogo } from '@renderer/config/models' import { getModelLogo } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes' import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents' import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels' import { useApiModels } from '@renderer/hooks/agents/useModels'
@@ -33,7 +34,7 @@ import {
UpdateAgentForm UpdateAgentForm
} from '@renderer/types' } from '@renderer/types'
import { AlertTriangleIcon } from 'lucide-react' import { AlertTriangleIcon } from 'lucide-react'
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../../ErrorBoundary' import { ErrorBoundary } from '../../ErrorBoundary'
@@ -56,30 +57,43 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
name: existing?.name ?? 'Claude Code', name: existing?.name ?? 'Claude Code',
description: existing?.description, description: existing?.description,
instructions: existing?.instructions, instructions: existing?.instructions,
model: existing?.model ?? '', model: existing?.model ?? 'claude-4-sonnet',
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [], accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [],
allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [], allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [],
mcps: existing?.mcps ? [...existing.mcps] : [], mcps: existing?.mcps ? [...existing.mcps] : [],
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {}) configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
}) })
type Props = { interface BaseProps {
agent?: AgentWithTools agent?: AgentWithTools
}
interface TriggerProps extends BaseProps {
trigger: { content: ReactNode; className?: string }
isOpen?: never
onClose?: never
}
interface StateProps extends BaseProps {
trigger?: never
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
} }
type Props = TriggerProps | StateProps
/** /**
* Modal component for creating or editing an agent. * Modal component for creating or editing an agent.
* *
* Either trigger or isOpen and onClose is given. * Either trigger or isOpen and onClose is given.
* @param agent - Optional agent entity for editing mode. * @param agent - Optional agent entity for editing mode.
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
* @param isOpen - Optional controlled modal open state. From useDisclosure. * @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure. * @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing * @returns Modal component for agent creation/editing
*/ */
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose }) => { export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose }) const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation() const { t } = useTranslation()
const loadingRef = useRef(false) const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer() // const { setTimeoutTimer } = useTimer()
@@ -231,23 +245,14 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
const modelOptions = useMemo(() => { const modelOptions = useMemo(() => {
// mocked data. not final version // mocked data. not final version
return (models ?? []) return (models ?? []).map((model) => ({
.filter((m) => type: 'model',
agentModelFilter({ key: model.id,
id: m.id, label: model.name,
provider: m.provider || '', avatar: getModelLogo(model.id),
name: m.name, providerId: model.provider,
group: '' providerName: model.provider_name
}) })) satisfies ModelOption[]
)
.map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models]) }, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => { const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
@@ -345,6 +350,23 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
return ( return (
<ErrorBoundary> <ErrorBoundary>
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
encapsulated component. This is because the Modal component needs to bind the onOpen
event handler to the Button for proper focus management.
Or just use external isOpen/onOpen/onClose to control modal state.
*/}
{trigger && (
<div
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
className={cn('w-full', trigger.className)}>
{trigger.content}
</div>
)}
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
@@ -98,7 +98,7 @@ export const SessionModal: React.FC<Props> = ({
const loadingRef = useRef(false) const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer() // const { setTimeoutTimer } = useTimer()
const { createSession } = useSessions(agentId) const { createSession } = useSessions(agentId)
const { updateSession } = useUpdateSession(agentId) const updateSession = useUpdateSession(agentId)
const { agent } = useAgent(agentId) const { agent } = useAgent(agentId)
const isEditing = (session?: AgentSessionEntity) => session !== undefined const isEditing = (session?: AgentSessionEntity) => session !== undefined
@@ -1,2 +0,0 @@
// Attribute used to store the original source line number in markdown editors
export const MARKDOWN_SOURCE_LINE_ATTR = 'data-source-line'
@@ -1,6 +1,5 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch' import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import DragHandle from '@tiptap/extension-drag-handle-react' import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react' import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
@@ -30,156 +29,6 @@ import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor' import { useRichEditor } from './useRichEditor'
const logger = loggerService.withContext('RichEditor') const logger = loggerService.withContext('RichEditor')
/**
* Find element by line number with fallback strategies:
* 1. Exact line + content match
* 2. Exact line match
* 3. Closest line <= target
*/
function findElementByLine(editorDom: HTMLElement, lineNumber: number, lineContent?: string): HTMLElement | null {
const allElements = Array.from(editorDom.querySelectorAll(`[${MARKDOWN_SOURCE_LINE_ATTR}]`)) as HTMLElement[]
if (allElements.length === 0) {
logger.warn('No elements with data-source-line attribute found')
return null
}
const exactMatches = editorDom.querySelectorAll(
`[${MARKDOWN_SOURCE_LINE_ATTR}="${lineNumber}"]`
) as NodeListOf<HTMLElement>
// Strategy 1: Exact line + content match
if (exactMatches.length > 1 && lineContent) {
for (const match of Array.from(exactMatches)) {
if (match.textContent?.includes(lineContent)) {
return match
}
}
}
// Strategy 2: Exact line match
if (exactMatches.length > 0) {
return exactMatches[0]
}
// Strategy 3: Closest line <= target
let closestElement: HTMLElement | null = null
let closestLine = 0
for (const el of allElements) {
const sourceLine = parseInt(el.getAttribute(MARKDOWN_SOURCE_LINE_ATTR) || '0', 10)
if (sourceLine <= lineNumber && sourceLine > closestLine) {
closestLine = sourceLine
closestElement = el
}
}
return closestElement
}
/**
* Create fixed-position highlight overlay at element location
* with boundary detection to prevent overflow and toolbar overlap
*/
function createHighlightOverlay(element: HTMLElement, container: HTMLElement): void {
try {
// Remove previous overlay
const previousOverlay = document.body.querySelector('.highlight-overlay')
if (previousOverlay) {
previousOverlay.remove()
}
const editorWrapper = container.closest('.rich-editor-wrapper')
// Create overlay at element position
const rect = element.getBoundingClientRect()
const overlay = document.createElement('div')
overlay.className = 'highlight-overlay animation-locate-highlight'
overlay.style.position = 'fixed'
overlay.style.left = `${rect.left}px`
overlay.style.top = `${rect.top}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
overlay.style.pointerEvents = 'none'
overlay.style.zIndex = '9999'
overlay.style.borderRadius = '4px'
document.body.appendChild(overlay)
// Update overlay position and visibility on scroll
const updatePosition = () => {
const newRect = element.getBoundingClientRect()
const newContainerRect = container.getBoundingClientRect()
// Update position
overlay.style.left = `${newRect.left}px`
overlay.style.top = `${newRect.top}px`
overlay.style.width = `${newRect.width}px`
overlay.style.height = `${newRect.height}px`
// Get current toolbar bottom (it might change)
const currentToolbar = editorWrapper?.querySelector('[class*="ToolbarWrapper"]')
const currentToolbarRect = currentToolbar?.getBoundingClientRect()
const currentToolbarBottom = currentToolbarRect ? currentToolbarRect.bottom : newContainerRect.top
// Check if overlay is within visible bounds
const overlayTop = newRect.top
const overlayBottom = newRect.bottom
const visibleTop = currentToolbarBottom // Don't overlap toolbar
const visibleBottom = newContainerRect.bottom
// Hide overlay if any part is outside the visible container area
if (overlayTop < visibleTop || overlayBottom > visibleBottom) {
overlay.style.opacity = '0'
overlay.style.visibility = 'hidden'
} else {
overlay.style.opacity = '1'
overlay.style.visibility = 'visible'
}
}
container.addEventListener('scroll', updatePosition)
// Auto-remove after animation
const handleAnimationEnd = () => {
overlay.remove()
container.removeEventListener('scroll', updatePosition)
overlay.removeEventListener('animationend', handleAnimationEnd)
}
overlay.addEventListener('animationend', handleAnimationEnd)
} catch (error) {
logger.error('Failed to create highlight overlay:', error as Error)
}
}
/**
* Scroll to element and show highlight after scroll completes
*/
function scrollAndHighlight(element: HTMLElement, container: HTMLElement): void {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
let scrollTimeout: NodeJS.Timeout
const handleScroll = () => {
clearTimeout(scrollTimeout)
scrollTimeout = setTimeout(() => {
container.removeEventListener('scroll', handleScroll)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}, 150)
}
container.addEventListener('scroll', handleScroll)
// Fallback: if element already in view (no scroll happens)
setTimeout(() => {
const initialScrollTop = container.scrollTop
setTimeout(() => {
if (Math.abs(container.scrollTop - initialScrollTop) < 1) {
container.removeEventListener('scroll', handleScroll)
clearTimeout(scrollTimeout)
requestAnimationFrame(() => createHighlightOverlay(element, container))
}
}, 200)
}, 50)
}
const RichEditor = ({ const RichEditor = ({
ref, ref,
initialContent = '', initialContent = '',
@@ -523,22 +372,6 @@ const RichEditor = ({
scrollContainerRef.current.scrollTop = value scrollContainerRef.current.scrollTop = value
} }
}, },
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => {
if (!editor || !scrollContainerRef.current) return
try {
const element = findElementByLine(editor.view.dom, lineNumber, options?.lineContent)
if (!element) return
if (options?.highlight) {
scrollAndHighlight(element, scrollContainerRef.current)
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' })
}
} catch (error) {
logger.error('Failed in scrollToLine:', error as Error)
}
},
// Dynamic command management // Dynamic command management
registerCommand, registerCommand,
registerToolbarCommand, registerToolbarCommand,
@@ -111,8 +111,6 @@ export interface RichEditorRef {
getScrollTop: () => number getScrollTop: () => number
/** Set scrollTop of the editor scroll container */ /** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void setScrollTop: (value: number) => void
/** Scroll to specific line number in markdown */
scrollToLine: (lineNumber: number, options?: { highlight?: boolean; lineContent?: string }) => void
// Dynamic command management // Dynamic command management
/** Register a new command/toolbar item */ /** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void registerCommand: (cmd: Command) => void
@@ -2,7 +2,6 @@ import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus' import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { MARKDOWN_SOURCE_LINE_ATTR } from '@renderer/components/RichEditor/constants'
import type { FormattingState } from '@renderer/components/RichEditor/types' import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { import {
@@ -12,7 +11,6 @@ import {
markdownToPreviewText markdownToPreviewText
} from '@renderer/utils/markdownConverter' } from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list' import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics' import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention' import Mention from '@tiptap/extension-mention'
@@ -38,31 +36,6 @@ import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers
const logger = loggerService.withContext('useRichEditor') const logger = loggerService.withContext('useRichEditor')
// Create extension to preserve data-source-line attribute
const SourceLineAttribute = Extension.create({
name: 'sourceLineAttribute',
addGlobalAttributes() {
return [
{
types: ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'listItem', 'horizontalRule'],
attributes: {
dataSourceLine: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute(MARKDOWN_SOURCE_LINE_ATTR)
return value
},
renderHTML: (attributes) => {
if (!attributes.dataSourceLine) return {}
return { [MARKDOWN_SOURCE_LINE_ATTR]: attributes.dataSourceLine }
}
}
}
}
]
}
})
export interface UseRichEditorOptions { export interface UseRichEditorOptions {
/** Initial markdown content */ /** Initial markdown content */
initialContent?: string initialContent?: string
@@ -223,7 +196,6 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
// TipTap editor extensions // TipTap editor extensions
const extensions = useMemo( const extensions = useMemo(
() => [ () => [
SourceLineAttribute,
StarterKit.configure({ StarterKit.configure({
heading: { heading: {
levels: [1, 2, 3, 4, 5, 6] levels: [1, 2, 3, 4, 5, 6]
+2 -2
View File
@@ -67,14 +67,14 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
flex-direction: row; flex-direction: row;
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'}; min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
max-height: var(--navbar-height); max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0}; margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
padding-left: ${({ $isFullScreen }) => padding-left: ${({ $isFullScreen }) =>
isMac ? ($isFullScreen ? 'var(--sidebar-width)' : 'env(titlebar-area-x)') : 0}; isMac ? ($isFullScreen ? 'var(--sidebar-width)' : 'env(titlebar-area-x)') : 0};
-webkit-app-region: drag; -webkit-app-region: drag;
` `
const NavbarLeftContainer = styled.div` const NavbarLeftContainer = styled.div`
/* min-width: ${isMac ? 'calc(var(--assistants-width) - 20px)' : 'var(--assistants-width)'}; */ min-width: var(--assistants-width);
padding: 0 10px; padding: 0 10px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -430,12 +430,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
} }
], ],
anthropic: [ anthropic: [
{
id: 'claude-haiku-4-5-20251001',
provider: 'anthropic',
name: 'Claude Haiku 4.5',
group: 'Claude 4.5'
},
{ {
id: 'claude-sonnet-4-5-20250929', id: 'claude-sonnet-4-5-20250929',
provider: 'anthropic', provider: 'anthropic',
+3 -5
View File
@@ -335,8 +335,7 @@ export function isClaudeReasoningModel(model?: Model): boolean {
modelId.includes('claude-3-7-sonnet') || modelId.includes('claude-3-7-sonnet') ||
modelId.includes('claude-3.7-sonnet') || modelId.includes('claude-3.7-sonnet') ||
modelId.includes('claude-sonnet-4') || modelId.includes('claude-sonnet-4') ||
modelId.includes('claude-opus-4') || modelId.includes('claude-opus-4')
modelId.includes('claude-haiku-4')
) )
} }
@@ -494,9 +493,8 @@ export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> =
'qwen3-(?!max).*$': { min: 1024, max: 38_912 }, 'qwen3-(?!max).*$': { min: 1024, max: 38_912 },
// Claude models // Claude models
'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64_000 }, 'claude-3[.-]7.*sonnet.*$': { min: 1024, max: 64000 },
'claude-(:?haiku|sonnet)-4.*$': { min: 1024, max: 64_000 }, 'claude-(:?sonnet|opus)-4.*$': { min: 1024, max: 32000 }
'claude-opus-4-1.*$': { min: 1024, max: 32_000 }
} }
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => { export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {
+1 -6
View File
@@ -1,4 +1,3 @@
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { getLowerBaseModelName } from '@renderer/utils' import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai' import OpenAI from 'openai'
@@ -6,7 +5,7 @@ import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts' import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts'
import { getWebSearchTools } from '../tools' import { getWebSearchTools } from '../tools'
import { isOpenAIReasoningModel } from './reasoning' import { isOpenAIReasoningModel } from './reasoning'
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision' import { isGenerateImageModel, isVisionModel } from './vision'
import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch' import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch'
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
@@ -247,7 +246,3 @@ export const isOpenAIOpenWeightModel = (model: Model) => {
// zhipu 视觉推理模型用这组 special token 标记推理结果 // zhipu 视觉推理模型用这组 special token 标记推理结果
export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const
export const agentModelFilter = (model: Model): boolean => {
return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model)
}
-1
View File
@@ -15,7 +15,6 @@ const visionAllowedModels = [
'gemini-(flash|pro|flash-lite)-latest', 'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp', 'gemini-exp',
'claude-3', 'claude-3',
'claude-haiku-4',
'claude-sonnet-4', 'claude-sonnet-4',
'claude-opus-4', 'claude-opus-4',
'vision', 'vision',
+1 -1
View File
@@ -7,7 +7,7 @@ import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision' import { isPureGenerateImageModel, isTextToImageModel } from './vision'
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp( export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`, `\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
'i' 'i'
) )
+1 -15
View File
@@ -1,7 +1,6 @@
import { import {
BuiltinOcrProvider, BuiltinOcrProvider,
BuiltinOcrProviderId, BuiltinOcrProviderId,
OcrOvProvider,
OcrPpocrProvider, OcrPpocrProvider,
OcrProviderCapability, OcrProviderCapability,
OcrSystemProvider, OcrSystemProvider,
@@ -51,23 +50,10 @@ const ppocrOcr: OcrPpocrProvider = {
} }
} as const } as const
const ovOcr: OcrOvProvider = {
id: 'ovocr',
name: 'Intel OV(NPU) OCR',
config: {
langs: isWin ? ['en-us', 'zh-cn'] : undefined
},
capabilities: {
image: true
// pdf: true
}
} as const satisfies OcrOvProvider
export const BUILTIN_OCR_PROVIDERS_MAP = { export const BUILTIN_OCR_PROVIDERS_MAP = {
tesseract, tesseract,
system: systemOcr, system: systemOcr,
paddleocr: ppocrOcr, paddleocr: ppocrOcr
ovocr: ovOcr
} as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider> } as const satisfies Record<BuiltinOcrProviderId, BuiltinOcrProvider>
export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP) export const BUILTIN_OCR_PROVIDERS: BuiltinOcrProvider[] = Object.values(BUILTIN_OCR_PROVIDERS_MAP)
+4 -3
View File
@@ -58,6 +58,7 @@ import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import { import {
AtLeast, AtLeast,
isSystemProvider, isSystemProvider,
Model,
OpenAIServiceTiers, OpenAIServiceTiers,
Provider, Provider,
ProviderType, ProviderType,
@@ -87,7 +88,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai', type: 'openai',
apiKey: '', apiKey: '',
apiHost: 'https://open.cherryin.net', apiHost: 'https://open.cherryin.net',
anthropicApiHost: 'https://open.cherryin.net',
models: [], models: [],
isSystem: true, isSystem: true,
enabled: true enabled: true
@@ -109,6 +109,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
apiKey: '', apiKey: '',
apiHost: 'https://aihubmix.com', apiHost: 'https://aihubmix.com',
anthropicApiHost: 'https://aihubmix.com/anthropic', anthropicApiHost: 'https://aihubmix.com/anthropic',
isAnthropicModel: (m: Model) => m.id.includes('claude'),
models: SYSTEM_MODELS.aihubmix, models: SYSTEM_MODELS.aihubmix,
isSystem: true, isSystem: true,
enabled: false enabled: false
@@ -288,7 +289,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
'new-api': { 'new-api': {
id: 'new-api', id: 'new-api',
name: 'New API', name: 'New API',
type: 'new-api', type: 'openai',
apiKey: '', apiKey: '',
apiHost: 'http://localhost:3000', apiHost: 'http://localhost:3000',
anthropicApiHost: 'http://localhost:3000', anthropicApiHost: 'http://localhost:3000',
@@ -1431,5 +1432,5 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
} }
export const isNewApiProvider = (provider: Provider) => { export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api' return ['new-api', 'cherryin'].includes(provider.id)
} }
-4
View File
@@ -1,4 +0,0 @@
export type UpdateAgentBaseOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}
@@ -1,8 +0,0 @@
import { useRuntime } from '../useRuntime'
import { useAgent } from './useAgent'
export const useActiveAgent = () => {
const { chat } = useRuntime()
const { activeAgentId } = chat
return useAgent(activeAgentId)
}
@@ -1,9 +0,0 @@
import { useRuntime } from '../useRuntime'
import { useSession } from './useSession'
export const useActiveSession = () => {
const { chat } = useRuntime()
const { activeSessionIdMap, activeAgentId } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
return useSession(activeAgentId, activeSessionId)
}
+3 -13
View File
@@ -1,28 +1,18 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr' import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
export const useAgent = (id: string | null) => { export const useAgent = (id: string | null) => {
const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null const key = id ? client.agentPaths.withId(id) : null
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => { const fetcher = useCallback(async () => {
if (!id) { if (!id || id === 'fake') {
throw new Error(t('agent.get.error.null_id')) return null
}
if (!apiServerConfig.enabled) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
} }
const result = await client.getAgent(id) const result = await client.getAgent(id)
return result return result
}, [apiServerConfig.enabled, apiServerRunning, client, id, t]) }, [client, id])
const { data, error, isLoading } = useSWR(key, id ? fetcher : null) const { data, error, isLoading } = useSWR(key, id ? fetcher : null)
return { return {
@@ -17,7 +17,7 @@ export const useAgentSessionInitializer = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const client = useAgentClient() const client = useAgentClient()
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeAgentId, activeSessionIdMap } = chat const { activeAgentId, activeSessionId } = chat
/** /**
* Initialize session for the given agent by loading its sessions * Initialize session for the given agent by loading its sessions
@@ -25,11 +25,11 @@ export const useAgentSessionInitializer = () => {
*/ */
const initializeAgentSession = useCallback( const initializeAgentSession = useCallback(
async (agentId: string) => { async (agentId: string) => {
if (!agentId) return if (!agentId || agentId === 'fake') return
try { try {
// Check if this agent already has an active session // Check if this agent already has an active session
const currentSessionId = activeSessionIdMap[agentId] const currentSessionId = activeSessionId[agentId]
if (currentSessionId) { if (currentSessionId) {
// Session already exists, just switch to session view // Session already exists, just switch to session view
dispatch(setActiveTopicOrSessionAction('session')) dispatch(setActiveTopicOrSessionAction('session'))
@@ -58,21 +58,21 @@ export const useAgentSessionInitializer = () => {
dispatch(setActiveTopicOrSessionAction('session')) dispatch(setActiveTopicOrSessionAction('session'))
} }
}, },
[client, dispatch, activeSessionIdMap] [client, dispatch, activeSessionId]
) )
/** /**
* Auto-initialize when activeAgentId changes * Auto-initialize when activeAgentId changes
*/ */
useEffect(() => { useEffect(() => {
if (activeAgentId) { if (activeAgentId && activeAgentId !== 'fake') {
// Check if we need to initialize this agent's session // Check if we need to initialize this agent's session
const hasActiveSession = activeSessionIdMap[activeAgentId] const hasActiveSession = activeSessionId[activeAgentId]
if (!hasActiveSession) { if (!hasActiveSession) {
initializeAgentSession(activeAgentId) initializeAgentSession(activeAgentId)
} }
} }
}, [activeAgentId, activeSessionIdMap, initializeAgentSession]) }, [activeAgentId, activeSessionId, initializeAgentSession])
return { return {
initializeAgentSession initializeAgentSession
+1 -10
View File
@@ -6,7 +6,6 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr' import useSWR from 'swr'
import { useApiServer } from '../useApiServer'
import { useRuntime } from '../useRuntime' import { useRuntime } from '../useRuntime'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
@@ -24,19 +23,11 @@ export const useAgents = () => {
const { t } = useTranslation() const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const key = client.agentPaths.base const key = client.agentPaths.base
const { apiServerConfig, apiServerRunning } = useApiServer()
const fetcher = useCallback(async () => { const fetcher = useCallback(async () => {
// API server will start on startup if enabled OR there are agents
if (!apiServerConfig.enabled && !apiServerRunning) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
}
const result = await client.listAgents() const result = await client.listAgents()
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior. // NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data return result.data
}, [apiServerConfig.enabled, apiServerRunning, client, t]) }, [client])
const { data, error, isLoading, mutate } = useSWR(key, fetcher) const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeAgentId } = chat const { activeAgentId } = chat
+19 -9
View File
@@ -1,24 +1,21 @@
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk' import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { UpdateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { useEffect, useMemo } from 'react' import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSWR from 'swr' import useSWR from 'swr'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
import { useUpdateSession } from './useUpdateSession'
export const useSession = (agentId: string | null, sessionId: string | null) => { export const useSession = (agentId: string, sessionId: string) => {
const { t } = useTranslation() const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const key = agentId && sessionId ? client.getSessionPaths(agentId).withId(sessionId) : null const key = client.getSessionPaths(agentId).withId(sessionId)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const sessionTopicId = useMemo(() => (sessionId ? buildAgentSessionTopicId(sessionId) : null), [sessionId]) const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
const { updateSession } = useUpdateSession(agentId)
const fetcher = async () => { const fetcher = async () => {
if (!agentId) throw new Error(t('agent.get.error.null_id'))
if (!sessionId) throw new Error(t('agent.session.get.error.null_id'))
const data = await client.getSession(agentId, sessionId) const data = await client.getSession(agentId, sessionId)
return data return data
} }
@@ -27,13 +24,26 @@ export const useSession = (agentId: string | null, sessionId: string | null) =>
// Use loadTopicMessagesThunk to load messages (with caching mechanism) // Use loadTopicMessagesThunk to load messages (with caching mechanism)
// This ensures messages are preserved when switching between sessions/tabs // This ensures messages are preserved when switching between sessions/tabs
useEffect(() => { useEffect(() => {
if (sessionTopicId) { if (sessionId) {
// loadTopicMessagesThunk will check if messages already exist in Redux // loadTopicMessagesThunk will check if messages already exist in Redux
// and skip loading if they do (unless forceReload is true) // and skip loading if they do (unless forceReload is true)
dispatch(loadTopicMessagesThunk(sessionTopicId)) dispatch(loadTopicMessagesThunk(sessionTopicId))
} }
}, [dispatch, sessionId, sessionTopicId]) }, [dispatch, sessionId, sessionTopicId])
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate(result)
} catch (error) {
window.toast.error(t('agent.session.update.error.failed'))
}
},
[agentId, client, mutate, t]
)
return { return {
session: data, session: data,
error, error,
+9 -13
View File
@@ -1,4 +1,4 @@
import { CreateAgentSessionResponse, CreateSessionForm, GetAgentSessionResponse } from '@renderer/types' import { CreateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error' import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -6,50 +6,46 @@ import useSWR from 'swr'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
export const useSessions = (agentId: string | null) => { export const useSessions = (agentId: string) => {
const { t } = useTranslation() const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const key = agentId ? client.getSessionPaths(agentId).base : null const key = client.getSessionPaths(agentId).base
const fetcher = async () => { const fetcher = async () => {
if (!agentId) throw new Error('No active agent.')
const data = await client.listSessions(agentId) const data = await client.listSessions(agentId)
return data.data return data.data
} }
const { data, error, isLoading, mutate } = useSWR(key, fetcher) const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const createSession = useCallback( const createSession = useCallback(
async (form: CreateSessionForm): Promise<CreateAgentSessionResponse | null> => { async (form: CreateSessionForm) => {
if (!agentId) return null
try { try {
const result = await client.createSession(agentId, form) const result = await client.createSession(agentId, form)
await mutate((prev) => [result, ...(prev ?? [])], { revalidate: false }) await mutate((prev) => [...(prev ?? []), result], { revalidate: false })
return result return result
} catch (error) { } catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed'))) window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed')))
return null return undefined
} }
}, },
[agentId, client, mutate, t] [agentId, client, mutate, t]
) )
// TODO: including messages field
const getSession = useCallback( const getSession = useCallback(
async (id: string): Promise<GetAgentSessionResponse | null> => { async (id: string) => {
if (!agentId) return null
try { try {
const result = await client.getSession(agentId, id) const result = await client.getSession(agentId, id)
mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session))) mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session)))
return result
} catch (error) { } catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed'))) window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed')))
return null
} }
}, },
[agentId, client, mutate, t] [agentId, client, mutate, t]
) )
const deleteSession = useCallback( const deleteSession = useCallback(
async (id: string): Promise<boolean> => { async (id: string) => {
if (!agentId) return false if (!agentId) return false
try { try {
await client.deleteSession(agentId, id) await client.deleteSession(agentId, id)
@@ -4,16 +4,20 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mutate } from 'swr' import { mutate } from 'swr'
import { UpdateAgentBaseOptions } from './types'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
export type UpdateAgentOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}
export const useUpdateAgent = () => { export const useUpdateAgent = () => {
const { t } = useTranslation() const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const listKey = client.agentPaths.base const listKey = client.agentPaths.base
const updateAgent = useCallback( const updateAgent = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => { async (form: UpdateAgentForm, options?: UpdateAgentOptions) => {
try { try {
const itemKey = client.agentPaths.withId(form.id) const itemKey = client.agentPaths.withId(form.id)
// may change to optimistic update // may change to optimistic update
@@ -31,7 +35,7 @@ export const useUpdateAgent = () => {
) )
const updateModel = useCallback( const updateModel = useCallback(
async (agentId: string, modelId: string, options?: UpdateAgentBaseOptions) => { async (agentId: string, modelId: string, options?: UpdateAgentOptions) => {
updateAgent({ id: agentId, model: modelId }, options) updateAgent({ id: agentId, model: modelId }, options)
}, },
[updateAgent] [updateAgent]
@@ -1,21 +1,19 @@
import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types' import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error' import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mutate } from 'swr' import { mutate } from 'swr'
import { UpdateAgentBaseOptions } from './types'
import { useAgentClient } from './useAgentClient' import { useAgentClient } from './useAgentClient'
export const useUpdateSession = (agentId: string | null) => { export const useUpdateSession = (agentId: string) => {
const { t } = useTranslation() const { t } = useTranslation()
const client = useAgentClient() const client = useAgentClient()
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const updateSession = useCallback( const updateSession = useCallback(
async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => { async (form: UpdateSessionForm) => {
if (!agentId) return
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const sessionId = form.id const sessionId = form.id
try { try {
const itemKey = paths.withId(sessionId) const itemKey = paths.withId(sessionId)
@@ -26,29 +24,13 @@ export const useUpdateSession = (agentId: string | null) => {
(prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? [] (prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? []
) )
mutate(itemKey, result) mutate(itemKey, result)
if (options?.showSuccessToast ?? true) { window.toast.success(t('common.update_success'))
window.toast.success(t('common.update_success'))
}
} catch (error) { } catch (error) {
window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) }) window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
} }
}, },
[agentId, client, t] [agentId, client, listKey, paths, t]
) )
const updateModel = useCallback( return updateSession
async (sessionId: string, modelId: string, options?: UpdateAgentBaseOptions) => {
if (!agentId) return
return updateSession(
{
id: sessionId,
model: modelId
},
options
)
},
[agentId, updateSession]
)
return { updateSession, updateModel }
} }
-112
View File
@@ -1,112 +0,0 @@
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useApiServer')
export const useApiServer = () => {
const { t } = useTranslation()
// FIXME: We currently store two copies of the config data in both the renderer and the main processes,
// which carries the risk of data inconsistency. This should be modified so that the main process stores
// the data, and the renderer retrieves it.
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
const dispatch = useAppDispatch()
// Optimistic initial state.
const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled)
const [apiServerLoading, setApiServerLoading] = useState(true)
const setApiServerEnabled = useCallback(
(enabled: boolean) => {
dispatch(setApiServerEnabledAction(enabled))
},
[dispatch]
)
// API Server functions
const checkApiServerStatus = useCallback(async () => {
setApiServerLoading(true)
try {
const status = await window.api.apiServer.getStatus()
setApiServerRunning(status.running)
} catch (error: any) {
logger.error('Failed to check API server status:', error)
} finally {
setApiServerLoading(false)
}
}, [])
const startApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.start()
if (result.success) {
setApiServerRunning(true)
window.toast.success(t('apiServer.messages.startSuccess'))
} else {
window.toast.error(t('apiServer.messages.startError') + result.error)
}
} catch (error: any) {
window.toast.error(t('apiServer.messages.startError') + (error.message || error))
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
const stopApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.stop()
if (result.success) {
setApiServerRunning(false)
window.toast.success(t('apiServer.messages.stopSuccess'))
} else {
window.toast.error(t('apiServer.messages.stopError') + result.error)
}
} catch (error: any) {
window.toast.error(t('apiServer.messages.stopError') + (error.message || error))
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
const restartApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.restart()
if (result.success) {
await checkApiServerStatus()
window.toast.success(t('apiServer.messages.restartSuccess'))
} else {
window.toast.error(t('apiServer.messages.restartError') + result.error)
}
} catch (error) {
window.toast.error(t('apiServer.messages.restartFailed') + (error as Error).message)
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, checkApiServerStatus, t])
useEffect(() => {
checkApiServerStatus()
}, [checkApiServerStatus])
return {
apiServerConfig,
apiServerRunning,
apiServerLoading,
startApiServer,
stopApiServer,
restartApiServer,
checkApiServerStatus,
setApiServerEnabled
}
}
@@ -1,5 +1,4 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import IntelLogo from '@renderer/assets/images/providers/intel.png'
import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png' import PaddleocrLogo from '@renderer/assets/images/providers/paddleocr.png'
import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png' import TesseractLogo from '@renderer/assets/images/providers/Tesseract.js.png'
import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' import { BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr'
@@ -84,8 +83,6 @@ export const useOcrProviders = () => {
return <MonitorIcon size={size} /> return <MonitorIcon size={size} />
case 'paddleocr': case 'paddleocr':
return <Avatar size={size} src={PaddleocrLogo} /> return <Avatar size={size} src={PaddleocrLogo} />
case 'ovocr':
return <Avatar size={size} src={IntelLogo} />
} }
} }
return <FileQuestionMarkIcon size={size} /> return <FileQuestionMarkIcon size={size} />
+2 -4
View File
@@ -330,7 +330,7 @@ const builtInMcpDescriptionKeyMap: Record<BuiltinMCPServerName, string> = {
[BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem', [BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem',
[BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge', [BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge',
[BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python',
[BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp' [BuiltinMCPServerNames.js]: 'settings.mcp.builtinServersDescriptions.js'
} as const } as const
export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
@@ -340,14 +340,12 @@ export const getBuiltInMcpServerDescriptionLabel = (key: string): string => {
const builtinOcrProviderKeyMap = { const builtinOcrProviderKeyMap = {
system: 'ocr.builtin.system', system: 'ocr.builtin.system',
tesseract: '', tesseract: '',
paddleocr: '', paddleocr: ''
ovocr: ''
} as const satisfies Record<BuiltinOcrProviderId, string> } as const satisfies Record<BuiltinOcrProviderId, string>
export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => { export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
if (key === 'tesseract') return 'Tesseract' if (key === 'tesseract') return 'Tesseract'
else if (key == 'paddleocr') return 'PaddleOCR' else if (key == 'paddleocr') return 'PaddleOCR'
else if (key == 'ovocr') return 'Intel OV(NPU) OCR'
else return getLabel(builtinOcrProviderKeyMap, key) else return getLabel(builtinOcrProviderKeyMap, key)
} }
+6 -25
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Failed to get the agent.", "failed": "Failed to get the agent."
"null_id": "Agent ID is null."
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "Failed to list agents." "failed": "Failed to list agents."
} }
}, },
"server": {
"error": {
"not_running": "The API server is enabled but not running properly."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Add directory", "add": "Add directory",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Failed to get the session", "failed": "Failed to get the session"
"null_id": "Session ID is null"
} }
}, },
"label_one": "Session", "label_one": "Session",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "API Key copied to clipboard", "apiKeyCopied": "API Key copied to clipboard",
"apiKeyRegenerated": "API Key regenerated", "apiKeyRegenerated": "API Key regenerated",
"notEnabled": "The API Server is not enabled.",
"operationFailed": "API Server operation failed: ", "operationFailed": "API Server operation failed: ",
"restartError": "Failed to restart API Server: ", "restartError": "Failed to restart API Server: ",
"restartFailed": "API Server restart failed: ", "restartFailed": "API Server restart failed: ",
@@ -1959,14 +1951,6 @@
"rename": "Rename", "rename": "Rename",
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}", "rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
"save": "Save to Notes", "save": "Save to Notes",
"search": {
"both": "Name+Content",
"content": "Content",
"found_results": "Found {{count}} results (Name: {{nameCount}}, Content: {{contentCount}})",
"more_matches": "more matches",
"searching": "Searching...",
"show_less": "Show less"
},
"settings": { "settings": {
"data": { "data": {
"apply": "Apply", "apply": "Apply",
@@ -2051,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "Cannot delete built-in provider", "cannot_remove_builtin": "Cannot delete built-in provider",
"existing": "The provider already exists", "existing": "The provider already exists",
"get_providers": "Failed to get available providers",
"not_found": "OCR provider does not exist", "not_found": "OCR provider does not exist",
"update_failed": "Failed to update configuration" "update_failed": "Failed to update configuration"
}, },
@@ -2113,10 +2096,8 @@
"install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU", "install_code_101": "Only supports Intel(R) Core(TM) Ultra CPU",
"install_code_102": "Only supports Windows", "install_code_102": "Only supports Windows",
"install_code_103": "Download OVMS runtime failed", "install_code_103": "Download OVMS runtime failed",
"install_code_104": "Failed to install OVMS runtime", "install_code_104": "Uncompress OVMS runtime failed",
"install_code_105": "Failed to create ovdnd.exe", "install_code_105": "Clean OVMS runtime failed",
"install_code_106": "Failed to create run.bat",
"install_code_110": "Failed to clean old OVMS runtime",
"run": "Run OVMS failed:", "run": "Run OVMS failed:",
"stop": "Stop OVMS failed:" "stop": "Stop OVMS failed:"
}, },
@@ -3587,10 +3568,10 @@
"builtinServers": "Builtin Servers", "builtinServers": "Builtin Servers",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable", "brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable",
"didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable",
"dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key", "dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key",
"fetch": "MCP server for retrieving URL web content", "fetch": "MCP server for retrieving URL web content",
"filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.", "filesystem": "A Node.js server implementing the Model Context Protocol (MCP) for file system operations. Requires configuration of directories allowed for access.",
"js": "Execute JavaScript code in a secure sandbox environment. Run JavaScript using QuickJS, supporting most standard libraries and popular third-party libraries.",
"mcp_auto_install": "Automatically install MCP service (beta)", "mcp_auto_install": "Automatically install MCP service (beta)",
"memory": "Persistent memory implementation based on a local knowledge graph. This enables the model to remember user-related information across different conversations. Requires configuring the MEMORY_FILE_PATH environment variable.", "memory": "Persistent memory implementation based on a local knowledge graph. This enables the model to remember user-related information across different conversations. Requires configuring the MEMORY_FILE_PATH environment variable.",
"no": "No description", "no": "No description",
@@ -4093,7 +4074,7 @@
"api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.", "api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.",
"api_key": { "api_key": {
"label": "API Key", "label": "API Key",
"tip": "Use commas to separate multiple keys" "tip": "Multiple keys separated by commas or spaces"
}, },
"api_version": "API Version", "api_version": "API Version",
"aws-bedrock": { "aws-bedrock": {
+7 -26
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "获取智能体失败", "failed": "获取智能体失败"
"null_id": "智能体 ID 为空。"
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "获取智能体列表失败" "failed": "获取智能体列表失败"
} }
}, },
"server": {
"error": {
"not_running": "API 服务器已启用但未正常运行。"
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "添加目录", "add": "添加目录",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "获取会话失败", "failed": "获取会话失败"
"null_id": "会话 ID 为空"
} }
}, },
"label_one": "会话", "label_one": "会话",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "API 密钥已复制到剪贴板", "apiKeyCopied": "API 密钥已复制到剪贴板",
"apiKeyRegenerated": "API 密钥已重新生成", "apiKeyRegenerated": "API 密钥已重新生成",
"notEnabled": "API 服务器未启用。",
"operationFailed": "API 服务器操作失败:", "operationFailed": "API 服务器操作失败:",
"restartError": "重启 API 服务器失败:", "restartError": "重启 API 服务器失败:",
"restartFailed": "API 服务器重启失败:", "restartFailed": "API 服务器重启失败:",
@@ -1959,14 +1951,6 @@
"rename": "重命名", "rename": "重命名",
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}", "rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
"save": "保存到笔记", "save": "保存到笔记",
"search": {
"both": "名称+内容",
"content": "内容",
"found_results": "找到 {{count}} 个结果 (名称: {{nameCount}}, 内容: {{contentCount}})",
"more_matches": "个匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "应用", "apply": "应用",
@@ -2051,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "不能删除内置提供商", "cannot_remove_builtin": "不能删除内置提供商",
"existing": "提供商已存在", "existing": "提供商已存在",
"get_providers": "获取可用提供商失败",
"not_found": "OCR 提供商不存在", "not_found": "OCR 提供商不存在",
"update_failed": "更新配置失败" "update_failed": "更新配置失败"
}, },
@@ -2088,7 +2071,7 @@
"description": "<div><p>1. 下载 OV 模型.</p><p>2. 在 'Manager' 中添加模型.</p><p>仅支持 Windows!</p><p>OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>请参考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>", "description": "<div><p>1. 下载 OV 模型.</p><p>2. 在 'Manager' 中添加模型.</p><p>仅支持 Windows!</p><p>OVMS 安装路径: '%USERPROFILE%\\.cherrystudio\\ovms' .</p><p>请参考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
"download": { "download": {
"button": "下载", "button": "下载",
"error": "下载失败", "error": "选择失败",
"model_id": { "model_id": {
"label": "模型 ID", "label": "模型 ID",
"model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头", "model_id_pattern": "模型 ID 必须以 OpenVINO/ 开头",
@@ -2113,10 +2096,8 @@
"install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU", "install_code_101": "仅支持 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "仅支持 Windows", "install_code_102": "仅支持 Windows",
"install_code_103": "下载 OVMS runtime 失败", "install_code_103": "下载 OVMS runtime 失败",
"install_code_104": "安装 OVMS runtime 失败", "install_code_104": "解压 OVMS runtime 失败",
"install_code_105": "创建 ovdnd.exe 失败", "install_code_105": "清理 OVMS runtime 失败",
"install_code_106": "创建 run.bat 失败",
"install_code_110": "清理旧 OVMS runtime 失败",
"run": "运行 OVMS 失败:", "run": "运行 OVMS 失败:",
"stop": "停止 OVMS 失败:" "stop": "停止 OVMS 失败:"
}, },
@@ -3587,10 +3568,10 @@
"builtinServers": "内置服务器", "builtinServers": "内置服务器",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量", "brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量",
"didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量",
"dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key", "dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key",
"fetch": "用于获取 URL 网页内容的 MCP 服务器", "fetch": "用于获取 URL 网页内容的 MCP 服务器",
"filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录", "filesystem": "实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器。需要配置允许访问的目录",
"js": "在安全的沙盒环境中执行 JavaScript 代码。使用 quickJs 运行 JavaScript,支持大多数标准库和流行的第三方库",
"mcp_auto_install": "自动安装 MCP 服务(测试版)", "mcp_auto_install": "自动安装 MCP 服务(测试版)",
"memory": "基于本地知识图谱的持久性记忆基础实现。这使得模型能够在不同对话间记住用户的相关信息。需要配置 MEMORY_FILE_PATH 环境变量。", "memory": "基于本地知识图谱的持久性记忆基础实现。这使得模型能够在不同对话间记住用户的相关信息。需要配置 MEMORY_FILE_PATH 环境变量。",
"no": "无描述", "no": "无描述",
@@ -4093,7 +4074,7 @@
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。", "api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
"api_key": { "api_key": {
"label": "API 密钥", "label": "API 密钥",
"tip": "多个密钥使用逗号分隔" "tip": "多个密钥使用逗号或空格分隔"
}, },
"api_version": "API 版本", "api_version": "API 版本",
"aws-bedrock": { "aws-bedrock": {
+9 -28
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "無法取得代理程式。", "failed": "無法取得代理程式。"
"null_id": "代理程式 ID 為空。"
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "無法列出代理程式。" "failed": "無法列出代理程式。"
} }
}, },
"server": {
"error": {
"not_running": "API 伺服器已啟用,但運行不正常。"
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "新增目錄", "add": "新增目錄",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "無法取得工作階段", "failed": "無法取得工作階段"
"null_id": "工作階段 ID 為空"
} }
}, },
"label_one": "會議", "label_one": "會議",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "API 金鑰已複製到剪貼簿", "apiKeyCopied": "API 金鑰已複製到剪貼簿",
"apiKeyRegenerated": "API 金鑰已重新生成", "apiKeyRegenerated": "API 金鑰已重新生成",
"notEnabled": "API 伺服器未啟用。",
"operationFailed": "API 伺服器操作失敗:", "operationFailed": "API 伺服器操作失敗:",
"restartError": "重新啟動 API 伺服器失敗:", "restartError": "重新啟動 API 伺服器失敗:",
"restartFailed": "API 伺服器重新啟動失敗:", "restartFailed": "API 伺服器重新啟動失敗:",
@@ -1959,14 +1951,6 @@
"rename": "重命名", "rename": "重命名",
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}", "rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
"save": "儲存到筆記", "save": "儲存到筆記",
"search": {
"both": "名稱+內容",
"content": "內容",
"found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})",
"more_matches": "個匹配",
"searching": "搜索中...",
"show_less": "收起"
},
"settings": { "settings": {
"data": { "data": {
"apply": "應用", "apply": "應用",
@@ -2050,9 +2034,8 @@
"error": { "error": {
"provider": { "provider": {
"cannot_remove_builtin": "不能刪除內建提供者", "cannot_remove_builtin": "不能刪除內建提供者",
"existing": "提供已存在", "existing": "提供已存在",
"get_providers": "取得可用提供者失敗", "not_found": "OCR 提供商不存在",
"not_found": "OCR 提供者不存在",
"update_failed": "更新配置失敗" "update_failed": "更新配置失敗"
}, },
"unknown": "OCR過程發生錯誤" "unknown": "OCR過程發生錯誤"
@@ -2088,7 +2071,7 @@
"description": "<div><p>1. 下載 OV 模型。</p><p>2. 在 'Manager' 中新增模型。</p><p>僅支援 Windows</p><p>OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>請參考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>", "description": "<div><p>1. 下載 OV 模型。</p><p>2. 在 'Manager' 中新增模型。</p><p>僅支援 Windows</p><p>OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。</p><p>請參考 <a href=https://github.com/openvinotoolkit/model_server/blob/c55551763d02825829337b62c2dcef9339706f79/docs/deploying_server_baremetal.md>Intel OVMS 指南</a></p></dev>",
"download": { "download": {
"button": "下載", "button": "下載",
"error": "下載失敗", "error": "選擇失敗",
"model_id": { "model_id": {
"label": "模型 ID", "label": "模型 ID",
"model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭", "model_id_pattern": "模型 ID 必須以 OpenVINO/ 開頭",
@@ -2113,10 +2096,8 @@
"install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU", "install_code_101": "僅支援 Intel(R) Core(TM) Ultra CPU",
"install_code_102": "僅支援 Windows", "install_code_102": "僅支援 Windows",
"install_code_103": "下載 OVMS runtime 失敗", "install_code_103": "下載 OVMS runtime 失敗",
"install_code_104": "安裝 OVMS runtime 失敗", "install_code_104": "解壓 OVMS runtime 失敗",
"install_code_105": "創建 ovdnd.exe 失敗", "install_code_105": "清理 OVMS runtime 失敗",
"install_code_106": "創建 run.bat 失敗",
"install_code_110": "清理舊 OVMS runtime 失敗",
"run": "執行 OVMS 失敗:", "run": "執行 OVMS 失敗:",
"stop": "停止 OVMS 失敗:" "stop": "停止 OVMS 失敗:"
}, },
@@ -3587,10 +3568,10 @@
"builtinServers": "內置伺服器", "builtinServers": "內置伺服器",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數", "brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數",
"didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數",
"dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key", "dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key",
"fetch": "用於獲取 URL 網頁內容的 MCP 伺服器", "fetch": "用於獲取 URL 網頁內容的 MCP 伺服器",
"filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄", "filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄",
"js": "在安全的沙盒環境中執行 JavaScript 程式碼。使用 quickJs 執行 JavaScript,支援大多數標準函式庫和流行的第三方函式庫",
"mcp_auto_install": "自動安裝 MCP 服務(測試版)", "mcp_auto_install": "自動安裝 MCP 服務(測試版)",
"memory": "基於本地知識圖譜的持久性記憶基礎實現。這使得模型能夠在不同對話間記住使用者的相關資訊。需要配置 MEMORY_FILE_PATH 環境變數。", "memory": "基於本地知識圖譜的持久性記憶基礎實現。這使得模型能夠在不同對話間記住使用者的相關資訊。需要配置 MEMORY_FILE_PATH 環境變數。",
"no": "無描述", "no": "無描述",
@@ -4093,7 +4074,7 @@
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。", "api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
"api_key": { "api_key": {
"label": "API 金鑰", "label": "API 金鑰",
"tip": "多個金鑰使用逗號分隔" "tip": "多個金鑰使用逗號或空格分隔"
}, },
"api_version": "API 版本", "api_version": "API 版本",
"aws-bedrock": { "aws-bedrock": {
+2 -12
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Αποτυχία λήψης του πράκτορα.", "failed": "Αποτυχία λήψης του πράκτορα."
"null_id": "Το ID του πράκτορα είναι null."
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "Αποτυχία καταχώρησης πρακτόρων." "failed": "Αποτυχία καταχώρησης πρακτόρων."
} }
}, },
"server": {
"error": {
"not_running": "Ο διακομιστής API είναι ενεργοποιημένος αλλά δεν λειτουργεί σωστά."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Προσθήκη καταλόγου", "add": "Προσθήκη καταλόγου",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Αποτυχία λήψης της συνεδρίας", "failed": "Αποτυχία λήψης της συνεδρίας"
"null_id": "Το ID της συνεδρίας είναι null"
} }
}, },
"label_one": "Συνεδρία", "label_one": "Συνεδρία",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "Το κλειδί API αντιγράφηκε στο πρόχειρο", "apiKeyCopied": "Το κλειδί API αντιγράφηκε στο πρόχειρο",
"apiKeyRegenerated": "Το κλειδί API αναδημιουργήθηκε", "apiKeyRegenerated": "Το κλειδί API αναδημιουργήθηκε",
"notEnabled": "Ο διακομιστής API δεν είναι ενεργοποιημένος.",
"operationFailed": "Η λειτουργία του Διακομιστή API απέτυχε: ", "operationFailed": "Η λειτουργία του Διακομιστή API απέτυχε: ",
"restartError": "Αποτυχία επανεκκίνησης του Διακομιστή API: ", "restartError": "Αποτυχία επανεκκίνησης του Διακομιστή API: ",
"restartFailed": "Η επανεκκίνηση του Διακομιστή API απέτυχε: ", "restartFailed": "Η επανεκκίνηση του Διακομιστή API απέτυχε: ",
@@ -2043,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου", "cannot_remove_builtin": "Δεν είναι δυνατή η διαγραφή του ενσωματωμένου παρόχου",
"existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη", "existing": "Ο πάροχος υπηρεσιών υπάρχει ήδη",
"get_providers": "Αποτυχία λήψης διαθέσιμων παρόχων",
"not_found": "Ο πάροχος OCR δεν υπάρχει", "not_found": "Ο πάροχος OCR δεν υπάρχει",
"update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης" "update_failed": "Αποτυχία ενημέρωσης της διαμόρφωσης"
}, },
@@ -3577,7 +3568,6 @@
"builtinServers": "Ενσωματωμένοι Διακομιστές", "builtinServers": "Ενσωματωμένοι Διακομιστές",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY", "brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY",
"didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY",
"dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify", "dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify",
"fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL", "fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL",
"filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους", "filesystem": "Εξυπηρετητής Node.js για το πρωτόκολλο περιβάλλοντος μοντέλου (MCP) που εφαρμόζει λειτουργίες συστήματος αρχείων. Απαιτείται διαμόρφωση για την επιτροπή πρόσβασης σε καταλόγους",
+2 -12
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "No se pudo obtener el agente.", "failed": "No se pudo obtener el agente."
"null_id": "El ID del agente es nulo."
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "Error al listar agentes." "failed": "Error al listar agentes."
} }
}, },
"server": {
"error": {
"not_running": "El servidor de API está habilitado pero no funciona correctamente."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Agregar directorio", "add": "Agregar directorio",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Error al obtener la sesión", "failed": "Error al obtener la sesión"
"null_id": "El ID de sesión es nulo"
} }
}, },
"label_one": "Sesión", "label_one": "Sesión",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "Clave API copiada al portapapeles", "apiKeyCopied": "Clave API copiada al portapapeles",
"apiKeyRegenerated": "Clave API regenerada", "apiKeyRegenerated": "Clave API regenerada",
"notEnabled": "El servidor de API no está habilitado.",
"operationFailed": "Falló la operación del Servidor API: ", "operationFailed": "Falló la operación del Servidor API: ",
"restartError": "Error al reiniciar el Servidor API: ", "restartError": "Error al reiniciar el Servidor API: ",
"restartFailed": "Falló el reinicio del Servidor API: ", "restartFailed": "Falló el reinicio del Servidor API: ",
@@ -2043,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "No se puede eliminar el proveedor integrado", "cannot_remove_builtin": "No se puede eliminar el proveedor integrado",
"existing": "El proveedor ya existe", "existing": "El proveedor ya existe",
"get_providers": "Error al obtener proveedores disponibles",
"not_found": "El proveedor de OCR no existe", "not_found": "El proveedor de OCR no existe",
"update_failed": "Actualización de la configuración fallida" "update_failed": "Actualización de la configuración fallida"
}, },
@@ -3577,7 +3568,6 @@
"builtinServers": "Servidores integrados", "builtinServers": "Servidores integrados",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY", "brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY",
"didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY",
"dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.", "dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.",
"fetch": "Servidor MCP para obtener el contenido de la página web de una URL", "fetch": "Servidor MCP para obtener el contenido de la página web de una URL",
"filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso", "filesystem": "Servidor Node.js que implementa el protocolo de contexto del modelo (MCP) para operaciones del sistema de archivos. Requiere configuración del directorio permitido para el acceso",
+2 -12
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Échec de l'obtention de l'agent.", "failed": "Échec de l'obtention de l'agent."
"null_id": "L'ID de l'agent est nul."
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "Échec de la liste des agents." "failed": "Échec de la liste des agents."
} }
}, },
"server": {
"error": {
"not_running": "Le serveur API est activé mais ne fonctionne pas correctement."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Ajouter un répertoire", "add": "Ajouter un répertoire",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Échec de l'obtention de la session", "failed": "Échec de l'obtention de la session"
"null_id": "L'ID de session est nul"
} }
}, },
"label_one": "Session", "label_one": "Session",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "Clé API copiée dans le presse-papiers", "apiKeyCopied": "Clé API copiée dans le presse-papiers",
"apiKeyRegenerated": "Clé API régénérée", "apiKeyRegenerated": "Clé API régénérée",
"notEnabled": "Le serveur API n'est pas activé.",
"operationFailed": "Opération du Serveur API échouée : ", "operationFailed": "Opération du Serveur API échouée : ",
"restartError": "Échec du redémarrage du Serveur API : ", "restartError": "Échec du redémarrage du Serveur API : ",
"restartFailed": "Redémarrage du Serveur API échoué : ", "restartFailed": "Redémarrage du Serveur API échoué : ",
@@ -2043,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré", "cannot_remove_builtin": "Impossible de supprimer le fournisseur intégré",
"existing": "Le fournisseur existe déjà", "existing": "Le fournisseur existe déjà",
"get_providers": "Échec de l'obtention des fournisseurs disponibles",
"not_found": "Le fournisseur OCR n'existe pas", "not_found": "Le fournisseur OCR n'existe pas",
"update_failed": "Échec de la mise à jour de la configuration" "update_failed": "Échec de la mise à jour de la configuration"
}, },
@@ -3577,7 +3568,6 @@
"builtinServers": "Serveurs intégrés", "builtinServers": "Serveurs intégrés",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY", "brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY",
"didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY",
"dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify", "dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify",
"fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL", "fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL",
"filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.", "filesystem": "Serveur Node.js implémentant le protocole de contexte de modèle (MCP) pour les opérations de système de fichiers. Nécessite une configuration des répertoires autorisés à être accédés.",
+2 -12
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "エージェントの取得に失敗しました。", "failed": "エージェントの取得に失敗しました。"
"null_id": "エージェント ID が null です。"
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "エージェントの一覧取得に失敗しました。" "failed": "エージェントの一覧取得に失敗しました。"
} }
}, },
"server": {
"error": {
"not_running": "APIサーバーは有効になっていますが、正常に動作していません。"
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "ディレクトリを追加", "add": "ディレクトリを追加",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "セッションの取得に失敗しました", "failed": "セッションの取得に失敗しました"
"null_id": "セッション ID が null です"
} }
}, },
"label_one": "セッション", "label_one": "セッション",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "API キーがクリップボードにコピーされました", "apiKeyCopied": "API キーがクリップボードにコピーされました",
"apiKeyRegenerated": "API キーが再生成されました", "apiKeyRegenerated": "API キーが再生成されました",
"notEnabled": "APIサーバーが有効になっていません。",
"operationFailed": "API サーバーの操作に失敗しました:", "operationFailed": "API サーバーの操作に失敗しました:",
"restartError": "API サーバーの再起動に失敗しました:", "restartError": "API サーバーの再起動に失敗しました:",
"restartFailed": "API サーバーの再起動に失敗しました:", "restartFailed": "API サーバーの再起動に失敗しました:",
@@ -2043,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "組み込みプロバイダーは削除できません", "cannot_remove_builtin": "組み込みプロバイダーは削除できません",
"existing": "プロバイダーはすでに存在します", "existing": "プロバイダーはすでに存在します",
"get_providers": "利用可能なプロバイダーの取得に失敗しました",
"not_found": "OCRプロバイダーが存在しません", "not_found": "OCRプロバイダーが存在しません",
"update_failed": "更新構成に失敗しました" "update_failed": "更新構成に失敗しました"
}, },
@@ -3577,7 +3568,6 @@
"builtinServers": "組み込みサーバー", "builtinServers": "組み込みサーバー",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です", "brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です",
"didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です",
"dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。", "dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。",
"fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー", "fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー",
"filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です", "filesystem": "Node.jsサーバーによるファイルシステム操作を実現するモデルコンテキストプロトコル(MCP)。アクセスを許可するディレクトリの設定が必要です",
+2 -12
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Falha ao obter o agente.", "failed": "Falha ao obter o agente."
"null_id": "O ID do agente é nulo."
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "Falha ao listar agentes." "failed": "Falha ao listar agentes."
} }
}, },
"server": {
"error": {
"not_running": "O servidor de API está habilitado, mas não está funcionando corretamente."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Adicionar diretório", "add": "Adicionar diretório",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Falha ao obter a sessão", "failed": "Falha ao obter a sessão"
"null_id": "O ID da sessão é nulo"
} }
}, },
"label_one": "Sessão", "label_one": "Sessão",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "Chave API copiada para a área de transferência", "apiKeyCopied": "Chave API copiada para a área de transferência",
"apiKeyRegenerated": "Chave API regenerada", "apiKeyRegenerated": "Chave API regenerada",
"notEnabled": "O Servidor de API não está habilitado.",
"operationFailed": "Operação do Servidor API falhou: ", "operationFailed": "Operação do Servidor API falhou: ",
"restartError": "Falha ao reiniciar o Servidor API: ", "restartError": "Falha ao reiniciar o Servidor API: ",
"restartFailed": "Reinício do Servidor API falhou: ", "restartFailed": "Reinício do Servidor API falhou: ",
@@ -2043,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "Não é possível excluir o provedor integrado", "cannot_remove_builtin": "Não é possível excluir o provedor integrado",
"existing": "O provedor já existe", "existing": "O provedor já existe",
"get_providers": "Falha ao obter provedores disponíveis",
"not_found": "O provedor OCR não existe", "not_found": "O provedor OCR não existe",
"update_failed": "Falha ao atualizar a configuração" "update_failed": "Falha ao atualizar a configuração"
}, },
@@ -3577,7 +3568,6 @@
"builtinServers": "Servidores integrados", "builtinServers": "Servidores integrados",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY", "brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY",
"didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY",
"dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify", "dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify",
"fetch": "servidor MCP para obter o conteúdo da página web do URL", "fetch": "servidor MCP para obter o conteúdo da página web do URL",
"filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso", "filesystem": "Servidor Node.js do protocolo de contexto de modelo (MCP) para implementar operações de sistema de ficheiros. Requer configuração do diretório permitido para acesso",
+2 -12
View File
@@ -22,8 +22,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Не удалось получить агента.", "failed": "Не удалось получить агента."
"null_id": "ID агента равен null."
} }
}, },
"list": { "list": {
@@ -31,11 +30,6 @@
"failed": "Не удалось получить список агентов." "failed": "Не удалось получить список агентов."
} }
}, },
"server": {
"error": {
"not_running": "API-сервер включен, но работает неправильно."
}
},
"session": { "session": {
"accessible_paths": { "accessible_paths": {
"add": "Добавить каталог", "add": "Добавить каталог",
@@ -74,8 +68,7 @@
}, },
"get": { "get": {
"error": { "error": {
"failed": "Не удалось получить сеанс", "failed": "Не удалось получить сеанс"
"null_id": "ID сессии равен null"
} }
}, },
"label_one": "Сессия", "label_one": "Сессия",
@@ -244,7 +237,6 @@
"messages": { "messages": {
"apiKeyCopied": "API ключ скопирован в буфер обмена", "apiKeyCopied": "API ключ скопирован в буфер обмена",
"apiKeyRegenerated": "API ключ перегенерирован", "apiKeyRegenerated": "API ключ перегенерирован",
"notEnabled": "API-сервер не включен.",
"operationFailed": "Операция API сервера не удалась: ", "operationFailed": "Операция API сервера не удалась: ",
"restartError": "Не удалось перезапустить API сервер: ", "restartError": "Не удалось перезапустить API сервер: ",
"restartFailed": "Перезапуск API сервера не удался: ", "restartFailed": "Перезапуск API сервера не удался: ",
@@ -2043,7 +2035,6 @@
"provider": { "provider": {
"cannot_remove_builtin": "Не удается удалить встроенного поставщика", "cannot_remove_builtin": "Не удается удалить встроенного поставщика",
"existing": "Поставщик уже существует", "existing": "Поставщик уже существует",
"get_providers": "Не удалось получить доступных поставщиков",
"not_found": "Поставщик OCR отсутствует", "not_found": "Поставщик OCR отсутствует",
"update_failed": "Обновление конфигурации не удалось" "update_failed": "Обновление конфигурации не удалось"
}, },
@@ -3577,7 +3568,6 @@
"builtinServers": "Встроенные серверы", "builtinServers": "Встроенные серверы",
"builtinServersDescriptions": { "builtinServersDescriptions": {
"brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY", "brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY",
"didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY",
"dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify", "dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify",
"fetch": "MCP-сервер для получения содержимого веб-страниц по URL", "fetch": "MCP-сервер для получения содержимого веб-страниц по URL",
"filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ", "filesystem": "Node.js-сервер протокола контекста модели (MCP) для реализации операций файловой системы. Требуется настройка каталогов, к которым разрешён доступ",
+67 -81
View File
@@ -49,8 +49,7 @@ const Chat: FC<Props> = (props) => {
const { isTopNavbar } = useNavbarPosition() const { isTopNavbar } = useNavbarPosition()
const chatMaxWidth = useChatMaxWidth() const chatMaxWidth = useChatMaxWidth()
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat const { activeTopicOrSession, activeAgentId, activeSessionId } = chat
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
const { apiServer } = useSettings() const { apiServer } = useSettings()
const mainRef = React.useRef<HTMLDivElement>(null) const mainRef = React.useRef<HTMLDivElement>(null)
@@ -140,13 +139,16 @@ const Chat: FC<Props> = (props) => {
firstUpdateOrNoFirstUpdateHandler() firstUpdateOrNoFirstUpdateHandler()
} }
const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))' const mainHeight = isTopNavbar
? 'calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px)'
: 'calc(100vh - var(--navbar-height))'
const SessionMessages = useMemo(() => { const SessionMessages = useMemo(() => {
if (activeAgentId === null) { if (activeAgentId === null) {
return () => <div> Active Agent ID is invalid.</div> return () => <div> Active Agent ID is invalid.</div>
} }
if (!activeSessionId) { const sessionId = activeSessionId[activeAgentId]
if (!sessionId) {
return () => <div> Active Session ID is invalid.</div> return () => <div> Active Session ID is invalid.</div>
} }
if (!apiServer.enabled) { if (!apiServer.enabled) {
@@ -156,17 +158,18 @@ const Chat: FC<Props> = (props) => {
</div> </div>
) )
} }
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} /> return () => <AgentSessionMessages agentId={activeAgentId} sessionId={sessionId} />
}, [activeAgentId, activeSessionId, apiServer.enabled, t]) }, [activeAgentId, activeSessionId, apiServer.enabled, t])
const SessionInputBar = useMemo(() => { const SessionInputBar = useMemo(() => {
if (activeAgentId === null) { if (activeAgentId === null) {
return () => <div> Active Agent ID is invalid.</div> return () => <div> Active Agent ID is invalid.</div>
} }
if (!activeSessionId) { const sessionId = activeSessionId[activeAgentId]
if (!sessionId) {
return () => <div> Active Session ID is invalid.</div> return () => <div> Active Session ID is invalid.</div>
} }
return () => <AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} /> return () => <AgentSessionInputbar agentId={activeAgentId} sessionId={sessionId} />
}, [activeAgentId, activeSessionId]) }, [activeAgentId, activeSessionId])
// TODO: more info // TODO: more info
@@ -190,84 +193,68 @@ const Chat: FC<Props> = (props) => {
</div> </div>
) )
}, []) }, [])
return ( return (
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}> <Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
{isTopNavbar && (
<ChatNavbar
activeAssistant={props.assistant}
activeTopic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
setActiveAssistant={props.setActiveAssistant}
position="left"
/>
)}
<HStack> <HStack>
<motion.div <Main
animate={{ ref={mainRef}
marginRight: topicPosition === 'right' && showTopics ? 'var(--assistants-width)' : 0 id="chat-main"
}} vertical
transition={{ duration: 0.3, ease: 'easeInOut' }} flex={1}
style={{ flex: 1, display: 'flex', minWidth: 0 }}> justify="space-between"
<Main style={{ maxWidth: chatMaxWidth, height: mainHeight }}>
ref={mainRef} <QuickPanelProvider>
id="chat-main" {activeTopicOrSession === 'topic' && (
vertical <>
flex={1} <Messages
justify="space-between" key={props.activeTopic.id}
style={{ maxWidth: chatMaxWidth, height: mainHeight }}> assistant={assistant}
<QuickPanelProvider> topic={props.activeTopic}
<ChatNavbar setActiveTopic={props.setActiveTopic}
activeAssistant={props.assistant} onComponentUpdate={messagesComponentUpdateHandler}
activeTopic={props.activeTopic} onFirstUpdate={messagesComponentFirstUpdateHandler}
setActiveTopic={props.setActiveTopic} />
setActiveAssistant={props.setActiveAssistant} <ContentSearch
position="left" ref={contentSearchRef}
/> searchTarget={mainRef as React.RefObject<HTMLElement>}
<div filter={contentSearchFilter}
className="flex flex-1 flex-col justify-between" includeUser={filterIncludeUser}
style={{ height: `calc(${mainHeight} - var(--navbar-height))` }}> onIncludeUserChange={userOutlinedItemClickHandler}
{activeTopicOrSession === 'topic' && ( />
<> {messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Messages <Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
key={props.activeTopic.id} </>
assistant={assistant} )}
topic={props.activeTopic} {activeTopicOrSession === 'session' && !activeAgentId && <AgentInvalid />}
setActiveTopic={props.setActiveTopic} {activeTopicOrSession === 'session' && activeAgentId && !activeSessionId[activeAgentId] && (
onComponentUpdate={messagesComponentUpdateHandler} <SessionInvalid />
onFirstUpdate={messagesComponentFirstUpdateHandler} )}
/> {activeTopicOrSession === 'session' && activeAgentId && activeSessionId[activeAgentId] && (
<ContentSearch <>
ref={contentSearchRef} <SessionMessages />
searchTarget={mainRef as React.RefObject<HTMLElement>} <SessionInputBar />
filter={contentSearchFilter} </>
includeUser={filterIncludeUser} )}
onIncludeUserChange={userOutlinedItemClickHandler} {isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
/> </QuickPanelProvider>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />} </Main>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</>
)}
{activeTopicOrSession === 'session' && !activeAgentId && <AgentInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
<>
<SessionMessages />
<SessionInputBar />
</>
)}
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</div>
</QuickPanelProvider>
</Main>
</motion.div>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{topicPosition === 'right' && showTopics && ( {topicPosition === 'right' && showTopics && (
<motion.div <motion.div
key="right-tabs" initial={{ width: 0, opacity: 0 }}
initial={{ x: 'var(--assistants-width)' }} animate={{ width: 'auto', opacity: 1 }}
animate={{ x: 0 }} exit={{ width: 0, opacity: 0 }}
exit={{ x: 'var(--assistants-width)' }}
transition={{ duration: 0.3, ease: 'easeInOut' }} transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ style={{ overflow: 'hidden' }}>
position: 'absolute',
right: 0,
top: isTopNavbar ? 0 : 'calc(var(--navbar-height) + 1px)',
width: 'var(--assistants-width)',
height: '100%',
zIndex: 10
}}>
<Tabs <Tabs
activeAssistant={assistant} activeAssistant={assistant}
activeTopic={props.activeTopic} activeTopic={props.activeTopic}
@@ -285,14 +272,13 @@ const Chat: FC<Props> = (props) => {
export const useChatMaxWidth = () => { export const useChatMaxWidth = () => {
const { showTopics, topicPosition } = useSettings() const { showTopics, topicPosition } = useSettings()
const { isLeftNavbar, isTopNavbar } = useNavbarPosition() const { isLeftNavbar } = useNavbarPosition()
const { showAssistants } = useShowAssistants() const { showAssistants } = useShowAssistants()
const showRightTopics = showTopics && topicPosition === 'right' const showRightTopics = showTopics && topicPosition === 'right'
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
const minusBorderWidth = isTopNavbar ? (showTopics ? '- 12px' : '- 6px') : ''
const sidebarWidth = isLeftNavbar ? '- var(--sidebar-width)' : '' const sidebarWidth = isLeftNavbar ? '- var(--sidebar-width)' : ''
return `calc(100vw ${sidebarWidth} ${minusAssistantsWidth} ${minusRightTopicsWidth} ${minusBorderWidth})` return `calc(100vw ${sidebarWidth} ${minusAssistantsWidth} ${minusRightTopicsWidth})`
} }
const Container = styled.div` const Container = styled.div`
+146 -37
View File
@@ -1,24 +1,34 @@
import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react'
import { NavbarHeader } from '@renderer/components/app/Navbar' import { NavbarHeader } from '@renderer/components/app/Navbar'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup' import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setNarrowMode } from '@renderer/store/settings' import { setNarrowMode } from '@renderer/store/settings'
import { Assistant, Topic } from '@renderer/types' import { ApiModel, Assistant, PermissionMode, Topic } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react' import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { FC } from 'react' import React, { FC, ReactNode, useCallback } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { AgentSettingsPopup } from '../settings/AgentSettings'
import { AgentLabel } from '../settings/AgentSettings/shared'
import AssistantsDrawer from './components/AssistantsDrawer' import AssistantsDrawer from './components/AssistantsDrawer'
import ChatNavbarContent from './components/ChatNavbarContent' import SelectAgentModelButton from './components/SelectAgentModelButton'
import SelectModelButton from './components/SelectModelButton'
import UpdateAppButton from './components/UpdateAppButton' import UpdateAppButton from './components/UpdateAppButton'
interface Props { interface Props {
@@ -34,8 +44,12 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showAssistants, toggleShowAssistants } = useShowAssistants() const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { topicPosition, narrowMode } = useSettings() const { topicPosition, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics() const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat
const sessionId = activeAgentId ? (chat.activeSessionId[activeAgentId] ?? null) : null
const { agent } = useAgent(activeAgentId)
const { updateModel } = useUpdateAgent()
useShortcut('toggle_show_assistants', toggleShowAssistants) useShortcut('toggle_show_assistants', toggleShowAssistants)
@@ -65,25 +79,25 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
}) })
} }
// const handleUpdateModel = useCallback( const handleUpdateModel = useCallback(
// async (model: ApiModel) => { async (model: ApiModel) => {
// if (!activeSession || !activeAgent) return if (!agent) return
// return updateModel(activeSession.id, model.id, { showSuccessToast: false }) return updateModel(agent.id, model.id, { showSuccessToast: false })
// }, },
// [activeAgent, activeSession, updateModel] [agent, updateModel]
// ) )
return ( return (
<NavbarHeader className="home-navbar" style={{ height: 'var(--navbar-height)' }}> <NavbarHeader className="home-navbar">
<div className="flex h-full min-w-0 flex-1 shrink items-center overflow-auto"> <div className="flex min-w-0 flex-1 shrink items-center overflow-auto">
{isTopNavbar && showAssistants && ( {showAssistants && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}> <Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={toggleShowAssistants}> <NavbarIcon onClick={toggleShowAssistants}>
<PanelLeftClose size={18} /> <PanelLeftClose size={18} />
</NavbarIcon> </NavbarIcon>
</Tooltip> </Tooltip>
)} )}
{isTopNavbar && !showAssistants && ( {!showAssistants && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}> <Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => toggleShowAssistants()} style={{ marginRight: 8 }}> <NavbarIcon onClick={() => toggleShowAssistants()} style={{ marginRight: 8 }}>
<PanelRightClose size={18} /> <PanelRightClose size={18} />
@@ -91,44 +105,71 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</Tooltip> </Tooltip>
)} )}
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{!showAssistants && isTopNavbar && ( {!showAssistants && (
<motion.div <motion.div
initial={{ width: 0, opacity: 0 }} initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }} animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }} exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}> transition={{ duration: 0.3, ease: 'easeInOut' }}>
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 5 }}> <NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
<Menu size={18} /> <Menu size={18} />
</NavbarIcon> </NavbarIcon>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<ChatNavbarContent assistant={assistant} /> {activeTopicOrSession === 'topic' && <SelectModelButton assistant={assistant} />}
{activeTopicOrSession === 'session' && agent && (
<HorizontalScrollContainer>
<Breadcrumbs
classNames={{
base: 'flex',
list: 'flex-nowrap'
}}>
<BreadcrumbItem
onPress={() => AgentSettingsPopup.show({ agentId: agent.id })}
classNames={{
base: 'self-stretch',
item: 'h-full'
}}>
<Chip size="md" variant="light" className="h-full transition-background hover:bg-foreground-100">
<AgentLabel
agent={agent}
classNames={{ name: 'max-w-50 font-bold text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
/>
</Chip>
</BreadcrumbItem>
<BreadcrumbItem>
<SelectAgentModelButton agent={agent} onSelect={handleUpdateModel} />
</BreadcrumbItem>
{activeAgentId && sessionId && (
<BreadcrumbItem>
<SessionWorkspaceMeta agentId={activeAgentId} sessionId={sessionId} />
</BreadcrumbItem>
)}
</Breadcrumbs>
</HorizontalScrollContainer>
)}
</div> </div>
<HStack alignItems="center" gap={8}> <HStack alignItems="center" gap={8}>
{isTopNavbar && <UpdateAppButton />} <UpdateAppButton />
{isTopNavbar && ( <Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}> <NarrowIcon onClick={handleNarrowModeToggle}>
<NarrowIcon onClick={handleNarrowModeToggle}> <i className="iconfont icon-icon-adaptive-width"></i>
<i className="iconfont icon-icon-adaptive-width"></i> </NarrowIcon>
</NarrowIcon> </Tooltip>
</Tooltip> <Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
)} <NavbarIcon onClick={() => SearchPopup.show()}>
{isTopNavbar && ( <Search size={18} />
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}> </NavbarIcon>
<NavbarIcon onClick={() => SearchPopup.show()}> </Tooltip>
<Search size={18} /> {topicPosition === 'right' && !showTopics && (
</NavbarIcon>
</Tooltip>
)}
{isTopNavbar && topicPosition === 'right' && !showTopics && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}> <Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}> <NavbarIcon onClick={toggleShowTopics}>
<PanelLeftClose size={18} /> <PanelLeftClose size={18} />
</NavbarIcon> </NavbarIcon>
</Tooltip> </Tooltip>
)} )}
{isTopNavbar && topicPosition === 'right' && showTopics && ( {topicPosition === 'right' && showTopics && (
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={2}> <Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={2}>
<NavbarIcon onClick={toggleShowTopics}> <NavbarIcon onClick={toggleShowTopics}>
<PanelRightClose size={18} /> <PanelRightClose size={18} />
@@ -140,6 +181,74 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
) )
} }
const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agentId, sessionId }) => {
const { agent } = useAgent(agentId)
const { session } = useSession(agentId, sessionId)
if (!session || !agent) {
return null
}
const firstAccessiblePath = session.accessible_paths?.[0]
const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode
const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode)
const permissionModeLabel = permissionModeCard
? t(permissionModeCard.titleKey, permissionModeCard.titleFallback)
: permissionMode
const infoItems: ReactNode[] = []
const InfoTag = ({
text,
className,
onClick
}: {
text: string
className?: string
classNames?: {}
onClick?: (e: React.MouseEvent) => void
}) => (
<div
className={cn(
'rounded-medium border border-default-200 px-2 py-1 text-foreground-500 text-xs dark:text-foreground-400',
onClick !== undefined ? 'cursor-pointer' : undefined,
className
)}
title={text}
onClick={onClick}>
<span className="block truncate">{text}</span>
</div>
)
// infoItems.push(<InfoTag key="name" text={agent.name ?? ''} className="max-w-60" />)
if (firstAccessiblePath) {
infoItems.push(
<InfoTag
key="path"
text={firstAccessiblePath}
className="max-w-60 transition-colors hover:border-primary hover:text-primary"
onClick={() => {
window.api.file
.openPath(firstAccessiblePath)
.catch((e) =>
window.toast.error(
formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath }))
)
)
}}
/>
)
}
infoItems.push(<InfoTag key="permission-mode" text={permissionModeLabel} className="max-w-50" />)
if (infoItems.length === 0) {
return null
}
return <div className="ml-2 flex items-center gap-2">{infoItems}</div>
}
export const NavbarIcon = styled.div` export const NavbarIcon = styled.div`
-webkit-app-region: none; -webkit-app-region: none;
border-radius: 8px; border-radius: 8px;
+1 -2
View File
@@ -117,7 +117,7 @@ const HomePage: FC = () => {
type: 'chat' type: 'chat'
}) })
} else if (activeTopicOrSession === 'topic') { } else if (activeTopicOrSession === 'topic') {
dispatch(setActiveAgentId(null)) dispatch(setActiveAgentId('fake'))
} }
}, [activeTopicOrSession, dispatch, setActiveAssistant]) }, [activeTopicOrSession, dispatch, setActiveAssistant])
@@ -130,7 +130,6 @@ const HomePage: FC = () => {
setActiveTopic={setActiveTopic} setActiveTopic={setActiveTopic}
setActiveAssistant={setActiveAssistant} setActiveAssistant={setActiveAssistant}
position="left" position="left"
activeTopicOrSession={activeTopicOrSession}
/> />
)} )}
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}> <ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>
@@ -262,9 +262,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
value={text} value={text}
onChange={onChange} onChange={onChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder_without_triggers', { placeholder={t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })}
key: getSendMessageShortcutLabel(sendMessageShortcut)
})}
autoFocus autoFocus
variant="borderless" variant="borderless"
spellCheck={enableSpellCheck} spellCheck={enableSpellCheck}
@@ -1,6 +1,5 @@
import { Button } from '@heroui/button' import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer' import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label' import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
import { getProviderById } from '@renderer/services/ProviderService' import { getProviderById } from '@renderer/services/ProviderService'
@@ -36,7 +35,7 @@ import {
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage' import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error' import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { Alert as AntdAlert, Modal } from 'antd' import { Alert as AntdAlert, Modal } from 'antd'
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
@@ -306,36 +305,14 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染 // 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => { const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { highlightCode } = useCodeStyle()
const [highlightedString, setHighlightedString] = useState('')
const cause = error.cause const cause = error.cause
useEffect(() => {
const highlight = async () => {
try {
const result = await highlightCode(JSON.stringify(JSON.parse(cause || '{}'), null, 2), 'json')
setHighlightedString(result)
} catch {
setHighlightedString(cause || '')
}
}
const timer = setTimeout(highlight, 0)
return () => clearTimeout(timer)
}, [highlightCode, cause])
return ( return (
<> <>
<BuiltinError error={error} /> <BuiltinError error={error} />
{cause && ( {cause && (
<ErrorDetailItem> <ErrorDetailItem>
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel> <ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
<ErrorDetailValue> <ErrorDetailValue>{error.cause}</ErrorDetailValue>
<div
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: highlightedString }}
/>
</ErrorDetailValue>
</ErrorDetailItem> </ErrorDetailItem>
)} )}
</> </>
@@ -337,18 +337,29 @@ const GroupContainer = styled.div`
const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>` const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }>`
width: 100%; width: 100%;
display: grid; display: grid;
overflow-y: visible;
gap: 16px; gap: 16px;
&.horizontal { &.horizontal {
padding-bottom: 4px; padding-bottom: 4px;
grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr)); grid-template-columns: repeat(${({ $count }) => $count}, minmax(420px, 1fr));
overflow-y: hidden;
overflow-x: auto; overflow-x: auto;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
border-radius: var(--scrollbar-thumb-radius);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-thumb-hover);
}
} }
&.fold, &.fold,
&.vertical { &.vertical {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 8px; gap: 8px;
overflow: hidden;
} }
&.grid { &.grid {
grid-template-columns: repeat( grid-template-columns: repeat(
@@ -356,11 +367,15 @@ const GridContainer = styled(Scrollbar)<{ $count: number; $gridColumns: number }
minmax(0, 1fr) minmax(0, 1fr)
); );
grid-template-rows: auto; grid-template-rows: auto;
overflow-y: auto;
overflow-x: hidden;
} }
&.multi-select-mode { &.multi-select-mode {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px; gap: 10px;
overflow-y: auto;
overflow-x: hidden;
.grid { .grid {
height: auto; height: auto;
} }
@@ -386,7 +401,7 @@ interface MessageWrapperProps {
const MessageWrapper = styled.div<MessageWrapperProps>` const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal { &.horizontal {
padding: 1px; padding: 1px;
overflow-y: auto; /* overflow-y: auto; */
.message { .message {
height: 100%; height: 100%;
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
@@ -408,7 +423,7 @@ const MessageWrapper = styled.div<MessageWrapperProps>`
&.grid { &.grid {
display: block; display: block;
height: 300px; height: 300px;
overflow-y: hidden; overflow: hidden;
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
@@ -535,7 +535,30 @@ const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ i
} }
const highlight = async () => { const highlight = async () => {
const result = await highlightCode(resultString, 'json') // 处理转义字符
let processedString = resultString
try {
// 尝试解析字符串以处理可能的转义
const parsed = JSON.parse(resultString)
if (typeof parsed === 'string') {
// 如果解析后是字符串,再次尝试解析(处理双重转义)
try {
const doubleParsed = JSON.parse(parsed)
processedString = JSON.stringify(doubleParsed, null, 2)
} catch {
// 不是有效的 JSON,使用解析后的字符串
processedString = parsed
}
} else {
// 重新格式化 JSON
processedString = JSON.stringify(parsed, null, 2)
}
} catch {
// 解析失败,使用原始字符串
processedString = resultString
}
const result = await highlightCode(processedString, 'json')
setStyledResult(result) setStyledResult(result)
} }
+12 -15
View File
@@ -1,7 +1,8 @@
import { Navbar, NavbarCenter, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup' import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isLinux, isWin } from '@renderer/config/constant' import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useRuntime' import { modelGenerating } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShortcut } from '@renderer/hooks/useShortcuts'
@@ -18,6 +19,7 @@ import { FC } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import AssistantsDrawer from './components/AssistantsDrawer' import AssistantsDrawer from './components/AssistantsDrawer'
import SelectModelButton from './components/SelectModelButton'
import UpdateAppButton from './components/UpdateAppButton' import UpdateAppButton from './components/UpdateAppButton'
interface Props { interface Props {
@@ -26,16 +28,10 @@ interface Props {
setActiveTopic: (topic: Topic) => void setActiveTopic: (topic: Topic) => void
setActiveAssistant: (assistant: Assistant) => void setActiveAssistant: (assistant: Assistant) => void
position: 'left' | 'right' position: 'left' | 'right'
activeTopicOrSession?: 'topic' | 'session'
} }
const HeaderNavbar: FC<Props> = ({ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
activeAssistant, const { assistant } = useAssistant(activeAssistant.id)
setActiveAssistant,
activeTopic,
setActiveTopic,
activeTopicOrSession
}) => {
const { showAssistants, toggleShowAssistants } = useShowAssistants() const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { topicPosition, narrowMode } = useSettings() const { topicPosition, narrowMode } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics() const { showTopics, toggleShowTopics } = useShowTopics()
@@ -95,7 +91,7 @@ const HeaderNavbar: FC<Props> = ({
justifyContent: 'flex-start', justifyContent: 'flex-start',
borderRight: 'none', borderRight: 'none',
paddingLeft: 0, paddingLeft: 0,
paddingRight: 0, paddingRight: 10,
minWidth: 'auto' minWidth: 'auto'
}}> }}>
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}> <Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
@@ -117,14 +113,15 @@ const HeaderNavbar: FC<Props> = ({
</AnimatePresence> </AnimatePresence>
</NavbarLeft> </NavbarLeft>
)} )}
<NavbarCenter></NavbarCenter> <HStack alignItems="center" gap={6} ml={!isMac ? 16 : 0}>
<SelectModelButton assistant={assistant} />
</HStack>
<NavbarRight <NavbarRight
style={{ style={{
justifyContent: 'flex-end', justifyContent: 'flex-end',
flex: activeTopicOrSession === 'topic' ? 1 : 'none', flex: 1,
position: 'relative', position: 'relative',
paddingRight: isWin || isLinux ? '144px' : '15px', paddingRight: isWin || isLinux ? '144px' : '15px'
minWidth: activeTopicOrSession === 'topic' ? '' : 'auto'
}} }}
className="home-navbar-right"> className="home-navbar-right">
<HStack alignItems="center" gap={6}> <HStack alignItems="center" gap={6}>
@@ -0,0 +1,40 @@
import { Button, Divider } from '@heroui/react'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { AgentSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
import AgentEssentialSettings from '@renderer/pages/settings/AgentSettings/AgentEssentialSettings'
import { GetAgentResponse } from '@renderer/types/agent'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
agent: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>['updateAgent']
}
const AgentSettingsTab: FC<Props> = ({ agent, update }) => {
const { t } = useTranslation()
const onMoreSetting = () => {
if (agent?.id) {
AgentSettingsPopup.show({ agentId: agent.id! })
}
}
if (!agent) {
return null
}
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<AgentEssentialSettings agent={agent} update={update} showModelSetting={false} />
<AdvancedSettings agentBase={agent} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onPress={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</div>
)
}
export default AgentSettingsTab
@@ -1,17 +1,16 @@
import { Alert, Spinner } from '@heroui/react' import { Alert, Spinner } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/agents/useAgents' import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useAssistants } from '@renderer/hooks/useAssistant' import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore' import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags' import { useTags } from '@renderer/hooks/useTags'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime' import { addIknowAction } from '@renderer/store/runtime'
import { Assistant, AssistantsSortType } from '@renderer/types' import { Assistant, AssistantsSortType } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { FC, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -36,8 +35,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation() const { t } = useTranslation()
const { apiServerConfig, apiServerRunning } = useApiServer() const { apiServer } = useSettings()
const apiServerEnabled = apiServerConfig.enabled
const { iknow, chat } = useRuntime() const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -57,7 +55,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({ const { unifiedItems, handleUnifiedListReorder } = useUnifiedItems({
agents, agents,
assistants, assistants,
apiServerEnabled, apiServerEnabled: apiServer.enabled,
agentsLoading, agentsLoading,
agentsError, agentsError,
updateAssistants updateAssistants
@@ -74,12 +72,18 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
unifiedItems, unifiedItems,
assistants, assistants,
agents, agents,
apiServerEnabled, apiServerEnabled: apiServer.enabled,
agentsLoading, agentsLoading,
agentsError, agentsError,
updateAssistants updateAssistants
}) })
useEffect(() => {
if (!agentsLoading && agents.length > 0 && !activeAgentId && apiServer.enabled) {
setActiveAgentId(agents[0].id)
}
}, [agentsLoading, agents, activeAgentId, setActiveAgentId, apiServer.enabled])
const onDeleteAssistant = useCallback( const onDeleteAssistant = useCallback(
(assistant: Assistant) => { (assistant: Assistant) => {
const remaining = assistants.filter((a) => a.id !== assistant.id) const remaining = assistants.filter((a) => a.id !== assistant.id)
@@ -101,7 +105,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
return ( return (
<Container className="assistants-tab" ref={containerRef}> <Container className="assistants-tab" ref={containerRef}>
{!apiServerConfig.enabled && !apiServerRunning && !iknow[ALERT_KEY] && ( {!apiServer.enabled && !iknow[ALERT_KEY] && (
<Alert <Alert
color="warning" color="warning"
title={t('agent.warning.enable_server')} title={t('agent.warning.enable_server')}
@@ -109,22 +113,11 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
onClose={() => { onClose={() => {
dispatch(addIknowAction(ALERT_KEY)) dispatch(addIknowAction(ALERT_KEY))
}} }}
className="mb-2"
/> />
)} )}
{agentsLoading && <Spinner />} {agentsLoading && <Spinner />}
{apiServerConfig.enabled && !apiServerRunning && ( {apiServer.enabled && agentsError && <Alert color="danger" title={t('agent.list.error.failed')} />}
<Alert color="danger" title={t('agent.server.error.not_running')} isClosable className="mb-2" />
)}
{apiServerRunning && agentsError && (
<Alert
color="danger"
title={t('agent.list.error.failed')}
description={getErrorMessage(agentsError)}
className="mb-2"
/>
)}
{assistantsTabSortType === 'tags' ? ( {assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups <UnifiedTagGroups
@@ -180,7 +173,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const Container = styled(Scrollbar)` const Container = styled(Scrollbar)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 10px; padding: 10px;
` `
export default AssistantsTab export default AssistantsTab
@@ -1,43 +0,0 @@
import { Button, Divider } from '@heroui/react'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
import EssentialSettings from '@renderer/pages/settings/AgentSettings/EssentialSettings'
import { GetAgentSessionResponse } from '@renderer/types'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
session: GetAgentSessionResponse | undefined | null
update: ReturnType<typeof useUpdateSession>['updateSession']
}
const SessionSettingsTab: FC<Props> = ({ session, update }) => {
const { t } = useTranslation()
const onMoreSetting = () => {
if (session?.id) {
SessionSettingsPopup.show({
agentId: session.agent_id,
sessionId: session.id
})
}
}
if (!session) {
return null
}
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<EssentialSettings agentBase={session} update={update} showModelSetting={false} />
<AdvancedSettings agentBase={session} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onPress={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</div>
)
}
export default SessionSettingsTab
@@ -14,6 +14,7 @@ const SessionsTab: FC<SessionsTabProps> = () => {
const { activeAgentId } = chat const { activeAgentId } = chat
const { t } = useTranslation() const { t } = useTranslation()
const { apiServer } = useSettings() const { apiServer } = useSettings()
const { topicPosition, navbarPosition } = useSettings()
if (!apiServer.enabled) { if (!apiServer.enabled) {
return ( return (
@@ -33,7 +34,15 @@ const SessionsTab: FC<SessionsTabProps> = () => {
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div className={cn('overflow-hidden', 'h-full')}> <motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
className={cn(
'overflow-hidden',
topicPosition === 'right' && navbarPosition === 'top' ? 'rounded-l-2xl border-t border-b border-l' : undefined
)}>
<Sessions agentId={activeAgentId} /> <Sessions agentId={activeAgentId} />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
@@ -44,16 +44,9 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
<AgentLabel agent={agent} /> <AgentLabel agent={agent} />
</AgentNameWrapper> </AgentNameWrapper>
</AssistantNameRow> </AssistantNameRow>
{isActive && ( <MenuButton>
<MenuButton> {isActive ? <SessionCount>{sessions.length}</SessionCount> : <Bot size={14} className="text-primary" />}
<SessionCount>{sessions.length}</SessionCount> </MenuButton>
</MenuButton>
)}
{!isActive && (
<BotIcon>
<Bot size={16} className="text-primary" />
</BotIcon>
)}
</Container> </Container>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
@@ -117,16 +110,6 @@ export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ cla
/> />
) )
export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'absolute top-[8px] right-[12px] flex flex-row items-center justify-center rounded-full text-[14px] text-[var(--color-text)]',
className
)}
{...props}
/>
)
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => ( export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div <div
className={cn( className={cn(
@@ -0,0 +1,71 @@
import { Alert, Button, Spinner } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
import { Plus } from 'lucide-react'
import { FC, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AgentItem from './AgentItem'
interface AssistantsTabProps {}
export const Agents: FC<AssistantsTabProps> = () => {
const { agents, deleteAgent, isLoading, error } = useAgents()
const { t } = useTranslation()
const { chat } = useRuntime()
const { activeAgentId } = chat
const { initializeAgentSession } = useAgentSessionInitializer()
const dispatch = useAppDispatch()
const setActiveAgentId = useCallback(
async (id: string) => {
dispatch(setActiveAgentIdAction(id))
// Initialize the session for this agent
await initializeAgentSession(id)
},
[dispatch, initializeAgentSession]
)
useEffect(() => {
if (!isLoading && agents.length > 0 && !activeAgentId) {
setActiveAgentId(agents[0].id)
}
}, [isLoading, agents, activeAgentId, setActiveAgentId])
return (
<>
{isLoading && <Spinner />}
{error && <Alert color="danger" title={t('agent.list.error.failed')} />}
{!isLoading &&
!error &&
agents.map((agent) => (
<AgentItem
key={agent.id}
agent={agent}
isActive={agent.id === activeAgentId}
onDelete={() => deleteAgent(agent.id)}
onPress={() => {
setActiveAgentId(agent.id)
}}
/>
))}
<AgentModal
trigger={{
content: (
<Button
onPress={(e) => e.continuePropagation()}
startContent={<Plus size={16} className="mr-1 shrink-0 translate-x-[-2px]" />}
className="w-full justify-start bg-transparent text-foreground-500 hover:bg-[var(--color-list-item)]">
{t('agent.add.title')}
</Button>
)
}}
/>
</>
)
}
@@ -0,0 +1,208 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons'
import { Button } from '@heroui/react'
import { DraggableList } from '@renderer/components/DraggableList'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { Tooltip } from 'antd'
import { Plus } from 'lucide-react'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AssistantItem from './AssistantItem'
import { SectionName } from './SectionName'
interface AssistantsProps {
activeAssistant: Assistant
setActiveAssistant: (assistant: Assistant) => void
onCreateAssistant: () => void
onCreateDefaultAssistant: () => void
}
const Assistants: FC<AssistantsProps> = ({
activeAssistant,
setActiveAssistant,
onCreateAssistant,
onCreateDefaultAssistant
}) => {
const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants()
const [dragging, setDragging] = useState(false)
const { addAssistantPreset } = useAssistantPresets()
const { t } = useTranslation()
const { getGroupedAssistants, collapsedTags, toggleTagCollapse } = useTags()
const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType()
const onDelete = useCallback(
(assistant: Assistant) => {
const remaining = assistants.filter((a) => a.id !== assistant.id)
if (assistant.id === activeAssistant?.id) {
const newActive = remaining[remaining.length - 1]
newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant()
}
removeAssistant(assistant.id)
},
[activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant]
)
const handleSortByChange = useCallback(
(sortType: AssistantsSortType) => {
setAssistantsTabSortType(sortType)
},
[setAssistantsTabSortType]
)
const handleGroupReorder = useCallback(
(tag: string, newGroupList: Assistant[]) => {
let insertIndex = 0
const newGlobal = assistants.map((a) => {
const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')]
if (tags.includes(tag)) {
const replaced = newGroupList[insertIndex]
insertIndex += 1
return replaced
}
return a
})
updateAssistants(newGlobal)
},
[assistants, t, updateAssistants]
)
const renderAddAssistantButton = useMemo(() => {
return (
<Button
onPress={onCreateAssistant}
className="w-full justify-start bg-transparent text-foreground-500 hover:bg-[var(--color-list-item)]">
<Plus size={16} style={{ marginRight: 4, flexShrink: 0 }} />
{t('chat.add.assistant.title')}
</Button>
)
}, [onCreateAssistant, t])
if (assistantsTabSortType === 'tags') {
return (
<>
<SectionName name={t('common.assistant_other')} />
<div style={{ marginBottom: '8px' }}>
{getGroupedAssistants.map((group) => (
<TagsContainer key={group.tag}>
{group.tag !== t('assistants.tags.untagged') && (
<GroupTitle onClick={() => toggleTagCollapse(group.tag)}>
<Tooltip title={group.tag}>
<GroupTitleName>
{collapsedTags[group.tag] ? (
<RightOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
) : (
<DownOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
)}
{group.tag}
</GroupTitleName>
</Tooltip>
<GroupTitleDivider />
</GroupTitle>
)}
{!collapsedTags[group.tag] && (
<div>
<DraggableList
list={group.assistants}
onUpdate={(newList) => handleGroupReorder(group.tag, newList)}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => (
<AssistantItem
key={assistant.id}
assistant={assistant}
isActive={assistant.id === activeAssistant.id}
sortBy={assistantsTabSortType}
onSwitch={setActiveAssistant}
onDelete={onDelete}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
/>
)}
</DraggableList>
</div>
)}
</TagsContainer>
))}
{renderAddAssistantButton}
</div>
</>
)
}
return (
<div>
<SectionName name={t('common.assistant_other')} />
<DraggableList
list={assistants}
onUpdate={updateAssistants}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => (
<AssistantItem
key={assistant.id}
assistant={assistant}
isActive={assistant.id === activeAssistant.id}
sortBy={assistantsTabSortType}
onSwitch={setActiveAssistant}
onDelete={onDelete}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
/>
)}
</DraggableList>
{!dragging && renderAddAssistantButton}
<div style={{ minHeight: 10 }}></div>
</div>
)
}
// 样式组件
const TagsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const GroupTitle = styled.div`
color: var(--color-text-2);
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 24px;
margin: 5px 0;
`
const GroupTitleName = styled.div`
max-width: 50%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
padding: 0 4px;
color: var(--color-text);
font-size: 13px;
line-height: 24px;
margin-right: 5px;
display: flex;
`
const GroupTitleDivider = styled.div`
flex: 1;
border-top: 1px solid var(--color-border);
`
export default Assistants
@@ -1,3 +1,4 @@
import { Button, cn, Input, Tooltip } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
@@ -10,19 +11,9 @@ import { SessionLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage' import { newMessagesActions } from '@renderer/store/newMessage'
import { AgentSessionEntity } from '@renderer/types' import { AgentSessionEntity } from '@renderer/types'
import { import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@renderer/ui/context-menu'
import { classNames } from '@renderer/utils'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Tooltip } from 'antd' import { XIcon } from 'lucide-react'
import { MenuIcon, XIcon } from 'lucide-react'
import React, { FC, memo, startTransition, useEffect, useMemo, useState } from 'react' import React, { FC, memo, startTransition, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -33,15 +24,17 @@ interface SessionItemProps {
session: AgentSessionEntity session: AgentSessionEntity
// use external agentId as SSOT, instead of session.agent_id // use external agentId as SSOT, instead of session.agent_id
agentId: string agentId: string
isDisabled?: boolean
isLoading?: boolean
onDelete: () => void onDelete: () => void
onPress: () => void onPress: () => void
} }
const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress }) => { const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { chat } = useRuntime() const { chat } = useRuntime()
const { updateSession } = useUpdateSession(agentId) const updateSession = useUpdateSession(agentId)
const activeSessionId = chat.activeSessionIdMap[agentId] const activeSessionId = chat.activeSessionId[agentId]
const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false) const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false)
const { setTimeoutTimer } = useTimer() const { setTimeoutTimer } = useTimer()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -57,16 +50,16 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
const DeleteButton = () => { const DeleteButton = () => {
return ( return (
<Tooltip <Tooltip
placement="bottom" content={t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
mouseEnterDelay={0.7} classNames={{ content: 'text-xs' }}
mouseLeaveDelay={0} delay={500}
title={ closeDelay={0}>
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}> <div
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} role="button"
</div> className={cn(
}> 'mr-2 flex aspect-square h-6 w-6 items-center justify-center rounded-2xl',
<MenuButton isConfirmingDeletion ? 'hover:bg-danger-100' : 'hover:bg-foreground-300'
className="menu" )}
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (isConfirmingDeletion || e.ctrlKey || e.metaKey) { if (isConfirmingDeletion || e.ctrlKey || e.metaKey) {
@@ -85,11 +78,17 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
} }
}}> }}>
{isConfirmingDeletion ? ( {isConfirmingDeletion ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} /> <DeleteIcon
size={14}
className="opacity-0 transition-colors-opacity group-hover:text-danger group-hover:opacity-100"
/>
) : ( ) : (
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} /> <XIcon
size={14}
className={cn(isActive ? 'opacity-100' : 'opacity-0', 'group-hover:opacity-100', 'transition-opacity')}
/>
)} )}
</MenuButton> </div>
</Tooltip> </Tooltip>
) )
} }
@@ -107,44 +106,44 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
} }
}, [activeSessionId, dispatch, isFulfilled, session.id, sessionTopicId]) }, [activeSessionId, dispatch, isFulfilled, session.id, sessionTopicId])
const { topicPosition, setTopicPosition } = useSettings()
const singlealone = topicPosition === 'right'
return ( return (
<> <>
<ContextMenu modal={false}> <ContextMenu modal={false}>
<ContextMenuTrigger> <ContextMenuTrigger>
<SessionListItem <ButtonContainer
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} isDisabled={isDisabled}
onClick={isEditing ? undefined : onPress} isLoading={isLoading}
onPress={onPress}
isActive={isActive}
onDoubleClick={() => startEdit(session.name ?? '')} onDoubleClick={() => startEdit(session.name ?? '')}
title={session.name ?? session.id} className="group">
style={{ <SessionLabelContainer className="name h-full w-full pl-1" title={session.name ?? session.id}>
borderRadius: 'var(--list-item-border-radius)', {isPending && !isActive && <PendingIndicator />}
cursor: isEditing ? 'default' : 'pointer' {isFulfilled && !isActive && <FulfilledIndicator />}
}}> {isEditing && (
{isPending && !isActive && <PendingIndicator />} <Input
{isFulfilled && !isActive && <FulfilledIndicator />}
<SessionNameContainer>
{isEditing ? (
<SessionEditInput
ref={inputRef} ref={inputRef}
variant="bordered"
value={editValue} value={editValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)} onValueChange={handleValueChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onClick={(e: React.MouseEvent) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ opacity: isSaving ? 0.5 : 1 }} classNames={{
base: 'h-full',
mainWrapper: 'h-full',
inputWrapper: 'h-full min-h-0 px-1.5',
input: isSaving ? 'brightness-50' : undefined
}}
/> />
) : (
<>
<SessionName>
<SessionLabel session={session} />
</SessionName>
<DeleteButton />
</>
)} )}
</SessionNameContainer> {!isEditing && (
</SessionListItem> <div className="flex w-full items-center justify-between">
<SessionLabel session={session} />
<DeleteButton />
</div>
)}
</SessionLabelContainer>
</ButtonContainer>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem <ContextMenuItem
@@ -158,20 +157,6 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
<EditIcon size={14} /> <EditIcon size={14} />
{t('common.edit')} {t('common.edit')}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger className="gap-2">
<MenuIcon size={14} />
{t('settings.topic.position.label')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem key="left" onClick={() => setTopicPosition('left')}>
{t('settings.topic.position.left')}
</ContextMenuItem>
<ContextMenuItem key="right" onClick={() => setTopicPosition('right')}>
{t('settings.topic.position.right')}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem <ContextMenuItem
key="delete" key="delete"
className="text-danger" className="text-danger"
@@ -187,96 +172,38 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
) )
} }
const SessionListItem = styled.div` const ButtonContainer: React.FC<React.ComponentProps<typeof Button> & { isActive?: boolean }> = ({
padding: 7px 12px; isActive,
border-radius: var(--list-item-border-radius); className,
font-size: 13px; children,
display: flex; ...props
flex-direction: column; }) => {
justify-content: space-between; const { topicPosition } = useSettings()
cursor: pointer; const activeBg = topicPosition === 'left' ? 'bg-[var(--color-list-item)]' : 'bg-foreground-100'
width: calc(var(--assistants-width) - 20px); return (
margin-bottom: 8px; <Button
{...props}
variant="light"
className={cn(
'relative mb-2 flex h-9 flex-row justify-between p-0',
'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]',
'cursor-pointer',
isActive ? cn(activeBg, 'shadow-sm') : undefined,
className
)}>
{children}
</Button>
)
}
.menu { const SessionLabelContainer: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
opacity: 0; <div
color: var(--color-text-3); {...props}
} className={cn('text-[13px] text-[var(--color-text)]', 'flex flex-row items-center gap-2', className)}
/>
&:hover { )
background-color: var(--color-list-item-hover);
transition: background-color 0.1s;
.menu {
opacity: 1;
}
}
&.active {
background-color: var(--color-list-item);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
.menu {
opacity: 1;
&:hover {
color: var(--color-text-2);
}
}
}
&.singlealone {
border-radius: 0 !important;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
border-left: 2px solid var(--color-primary);
box-shadow: none;
}
}
`
const SessionNameContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
height: 20px;
justify-content: space-between;
`
const SessionName = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
position: relative;
`
const SessionEditInput = styled.input`
background: var(--color-background);
border: none;
color: var(--color-text-1);
font-size: 13px;
font-family: inherit;
padding: 2px 6px;
width: 100%;
outline: none;
padding: 0;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 20px;
min-height: 20px;
.anticon {
font-size: 12px;
}
`
const PendingIndicator = styled.div.attrs({ const PendingIndicator = styled.div.attrs({
className: 'animation-pulse' className: 'animation-pulse'
@@ -12,7 +12,7 @@ import {
} from '@renderer/store/runtime' } from '@renderer/store/runtime'
import { CreateSessionForm } from '@renderer/types' import { CreateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react' import { memo, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -30,7 +30,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { agent } = useAgent(agentId) const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId) const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeSessionIdMap } = chat const { activeSessionId, sessionWaiting } = chat
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const setActiveSessionId = useCallback( const setActiveSessionId = useCallback(
@@ -75,24 +75,24 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
[agentId, deleteSession, dispatch, sessions, t] [agentId, deleteSession, dispatch, sessions, t]
) )
const activeSessionId = activeSessionIdMap[agentId] const currentActiveSessionId = activeSessionId[agentId]
useEffect(() => { useEffect(() => {
if (!isLoading && sessions.length > 0 && !activeSessionId) { if (!isLoading && sessions.length > 0 && !currentActiveSessionId) {
setActiveSessionId(agentId, sessions[0].id) setActiveSessionId(agentId, sessions[0].id)
} }
}, [isLoading, sessions, activeSessionId, agentId, setActiveSessionId]) }, [isLoading, sessions, currentActiveSessionId, agentId, setActiveSessionId])
useEffect(() => { useEffect(() => {
if (activeSessionId) { if (currentActiveSessionId) {
dispatch( dispatch(
newMessagesActions.setTopicFulfilled({ newMessagesActions.setTopicFulfilled({
topicId: buildAgentSessionTopicId(activeSessionId), topicId: buildAgentSessionTopicId(currentActiveSessionId),
fulfilled: false fulfilled: false
}) })
) )
} }
}, [activeSessionId, dispatch]) }, [currentActiveSessionId, dispatch])
if (isLoading) { if (isLoading) {
return ( return (
@@ -109,30 +109,45 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
if (error) return <Alert color="danger" content={t('agent.session.get.error.failed')} /> if (error) return <Alert color="danger" content={t('agent.session.get.error.failed')} />
return ( return (
<div className="sessions-tab flex h-full w-full flex-col p-2"> <motion.div
<AddButton onPress={handleCreateSession} className="mb-2"> initial={{ opacity: 0 }}
{t('agent.session.add.title')} animate={{ opacity: 1 }}
</AddButton> transition={{ duration: 0.3 }}
{/* h-9 */} className="sessions-tab flex h-full w-full flex-col p-2">
<DynamicVirtualList <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
list={sessions} <AddButton onPress={handleCreateSession} className="mb-2">
estimateSize={() => 9 * 4} {t('agent.session.add.title')}
scrollerStyle={{ </AddButton>
// FIXME: This component only supports CSSProperties </motion.div>
overflowX: 'hidden' <AnimatePresence>
}} {/* h-9 */}
autoHideScrollbar> <DynamicVirtualList
{(session) => ( list={sessions}
<SessionItem estimateSize={() => 9 * 4}
key={session.id} scrollerStyle={{
session={session} // FIXME: This component only supports CSSProperties
agentId={agentId} overflowX: 'hidden'
onDelete={() => handleDeleteSession(session.id)} }}
onPress={() => setActiveSessionId(agentId, session.id)} autoHideScrollbar>
/> {(session) => (
)} <motion.div
</DynamicVirtualList> key={session.id}
</div> initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}>
<SessionItem
session={session}
agentId={agentId}
isDisabled={sessionWaiting[session.id]}
isLoading={sessionWaiting[session.id]}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
</motion.div>
)}
</DynamicVirtualList>
</AnimatePresence>
</motion.div>
) )
} }
@@ -288,7 +288,13 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
label: t('chat.topics.clear.title'), label: t('chat.topics.clear.title'),
key: 'clear-messages', key: 'clear-messages',
icon: <BrushCleaning size={14} />, icon: <BrushCleaning size={14} />,
onClick: () => onClearMessages(topic) async onClick() {
window.modal.confirm({
title: t('chat.input.clear.content'),
centered: true,
onOk: () => onClearMessages(topic)
})
}
}, },
{ {
label: t('settings.topic.position.label'), label: t('settings.topic.position.label'),
@@ -1,4 +1,4 @@
import { Button, Popover, PopoverContent, PopoverTrigger, useDisclosure } from '@heroui/react' import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal' import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { Bot, MessageSquare } from 'lucide-react' import { Bot, MessageSquare } from 'lucide-react'
import { FC, useState } from 'react' import { FC, useState } from 'react'
@@ -13,7 +13,7 @@ interface UnifiedAddButtonProps {
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => { const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isPopoverOpen, setIsPopoverOpen] = useState(false) const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const { isOpen: isAgentModalOpen, onOpen: onAgentModalOpen, onClose: onAgentModalClose } = useDisclosure() const [isAgentModalOpen, setIsAgentModalOpen] = useState(false)
const handleAddAssistant = () => { const handleAddAssistant = () => {
setIsPopoverOpen(false) setIsPopoverOpen(false)
@@ -22,7 +22,7 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
const handleAddAgent = () => { const handleAddAgent = () => {
setIsPopoverOpen(false) setIsPopoverOpen(false)
onAgentModalOpen() setIsAgentModalOpen(true)
} }
return ( return (
@@ -53,7 +53,7 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant }) => {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<AgentModal isOpen={isAgentModalOpen} onClose={onAgentModalClose} /> <AgentModal isOpen={isAgentModalOpen} onClose={() => setIsAgentModalOpen(false)} />
</div> </div>
) )
} }
+12 -23
View File
@@ -1,7 +1,6 @@
import { Alert, Skeleton } from '@heroui/react'
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup' import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession' import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
@@ -9,13 +8,13 @@ import { useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { Tab } from '@renderer/types/chat' import { Tab } from '@renderer/types/chat'
import { classNames, getErrorMessage, uuid } from '@renderer/utils' import { classNames, uuid } from '@renderer/utils'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import AgentSettingsTab from './AgentSettingsTab'
import Assistants from './AssistantsTab' import Assistants from './AssistantsTab'
import SessionSettingsTab from './SessionSettingsTab'
import Settings from './SettingsTab' import Settings from './SettingsTab'
import Topics from './TopicsTab' import Topics from './TopicsTab'
@@ -48,8 +47,8 @@ const HomeTabs: FC<Props> = ({
const { t } = useTranslation() const { t } = useTranslation()
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat const { activeTopicOrSession, activeAgentId } = chat
const { session, isLoading: isSessionLoading, error: sessionError } = useActiveSession() const { agent } = useAgent(activeAgentId)
const { updateSession } = useUpdateSession(activeAgentId) const { updateAgent } = useUpdateAgent()
const isSessionView = activeTopicOrSession === 'session' const isSessionView = activeTopicOrSession === 'session'
const isTopicView = activeTopicOrSession === 'topic' const isTopicView = activeTopicOrSession === 'topic'
@@ -126,7 +125,7 @@ const HomeTabs: FC<Props> = ({
</CustomTabs> </CustomTabs>
)} )}
{position === 'right' && topicPosition === 'right' && ( {position === 'right' && topicPosition === 'right' && isTopicView && (
<CustomTabs> <CustomTabs>
<TabItem active={tab === 'topic'} onClick={() => setTab('topic')}> <TabItem active={tab === 'topic'} onClick={() => setTab('topic')}>
{t('common.topics')} {t('common.topics')}
@@ -155,20 +154,7 @@ const HomeTabs: FC<Props> = ({
/> />
)} )}
{tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />} {tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isSessionView && !sessionError && ( {tab === 'settings' && isSessionView && <AgentSettingsTab agent={agent} update={updateAgent} />}
<Skeleton isLoaded={!isSessionLoading} className="h-full">
<SessionSettingsTab session={session} update={updateSession} />
</Skeleton>
)}
{tab === 'settings' && isSessionView && sessionError && (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<Alert
color="danger"
title={t('agent.session.get.error.failed')}
description={getErrorMessage(sessionError)}
/>
</div>
)}
</TabContent> </TabContent>
</Container> </Container>
) )
@@ -189,7 +175,10 @@ const Container = styled.div`
background-color: var(--color-background); background-color: var(--color-background);
} }
[navbar-position='top'] & { [navbar-position='top'] & {
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height) - 12px);
&.right {
height: calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px);
}
} }
overflow: hidden; overflow: hidden;
.collapsed { .collapsed {
@@ -1,147 +0,0 @@
import { BreadcrumbItem, Breadcrumbs, cn } from '@heroui/react'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { AgentEntity, AgentSessionEntity, ApiModel, Assistant } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { t } from 'i18next'
import { Folder } from 'lucide-react'
import { FC, ReactNode, useCallback } from 'react'
import { AgentSettingsPopup, SessionSettingsPopup } from '../../settings/AgentSettings'
import { AgentLabel, SessionLabel } from '../../settings/AgentSettings/shared'
import SelectAgentBaseModelButton from './SelectAgentBaseModelButton'
import SelectModelButton from './SelectModelButton'
interface Props {
assistant: Assistant
}
const ChatNavbarContent: FC<Props> = ({ assistant }) => {
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
const { agent: activeAgent } = useActiveAgent()
const { session: activeSession } = useActiveSession()
const { updateModel } = useUpdateSession(activeAgent?.id ?? null)
const handleUpdateModel = useCallback(
async (model: ApiModel) => {
if (!activeAgent || !activeSession) return
return updateModel(activeSession.id, model.id, { showSuccessToast: false })
},
[activeAgent, activeSession, updateModel]
)
return (
<>
{activeTopicOrSession === 'topic' && <SelectModelButton assistant={assistant} />}
{activeTopicOrSession === 'session' && activeAgent && (
<HorizontalScrollContainer className="ml-2 flex-initial">
<Breadcrumbs classNames={{ base: 'flex', list: 'flex-nowrap' }}>
<BreadcrumbItem
onPress={() => AgentSettingsPopup.show({ agentId: activeAgent.id })}
classNames={{ base: 'self-stretch', item: 'h-full' }}>
<AgentLabel
agent={activeAgent}
classNames={{ name: 'max-w-40 text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
/>
</BreadcrumbItem>
{activeSession && (
<BreadcrumbItem
onPress={() =>
SessionSettingsPopup.show({
agentId: activeAgent.id,
sessionId: activeSession.id
})
}
classNames={{ base: 'self-stretch', item: 'h-full' }}>
<SessionLabel session={activeSession} className="max-w-40 text-xs" />
</BreadcrumbItem>
)}
{activeSession && (
<BreadcrumbItem>
<SelectAgentBaseModelButton agentBase={activeSession} onSelect={handleUpdateModel} />
</BreadcrumbItem>
)}
{activeAgent && activeSession && (
<BreadcrumbItem>
<SessionWorkspaceMeta agent={activeAgent} session={activeSession} />
</BreadcrumbItem>
)}
</Breadcrumbs>
</HorizontalScrollContainer>
)}
</>
)
}
const SessionWorkspaceMeta: FC<{ agent: AgentEntity; session: AgentSessionEntity }> = ({ agent, session }) => {
if (!session || !agent) {
return null
}
const firstAccessiblePath = session.accessible_paths?.[0]
// const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode
// const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode)
// const permissionModeLabel = permissionModeCard
// ? t(permissionModeCard.titleKey, permissionModeCard.titleFallback)
// : permissionMode
const infoItems: ReactNode[] = []
const InfoTag = ({
text,
className,
onClick
}: {
text: string
className?: string
classNames?: {}
onClick?: (e: React.MouseEvent) => void
}) => (
<div
className={cn(
'flex items-center gap-1.5 text-foreground-500 text-xs dark:text-foreground-400',
onClick !== undefined ? 'cursor-pointer' : undefined,
className
)}
title={text}
onClick={onClick}>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="block truncate">{text}</span>
</div>
)
// infoItems.push(<InfoTag key="name" text={agent.name ?? ''} className="max-w-60" />)
if (firstAccessiblePath) {
infoItems.push(
<InfoTag
key="path"
text={firstAccessiblePath}
className="max-w-60 transition-colors hover:border-primary hover:text-primary"
onClick={() => {
window.api.file
.openPath(firstAccessiblePath)
.catch((e) =>
window.toast.error(
formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath }))
)
)
}}
/>
)
}
// infoItems.push(<InfoTag key="permission-mode" text={permissionModeLabel} className="max-w-50" />)
if (infoItems.length === 0) {
return null
}
return <div className="ml-2 flex items-center gap-2">{infoItems}</div>
}
export default ChatNavbarContent
@@ -1,10 +1,10 @@
import { Button } from '@heroui/react' import { Button } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup' import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup'
import { agentModelFilter } from '@renderer/config/models' import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { useApiModel } from '@renderer/hooks/agents/useModel' import { useApiModel } from '@renderer/hooks/agents/useModel'
import { getProviderNameById } from '@renderer/services/ProviderService' import { getProviderNameById } from '@renderer/services/ProviderService'
import { AgentBaseWithId, ApiModel, isAgentEntity } from '@renderer/types' import { AgentBaseWithId, ApiModel, isAgentEntity, Model } from '@renderer/types'
import { getModelFilterByAgentType } from '@renderer/utils/agentSession' import { getModelFilterByAgentType } from '@renderer/utils/agentSession'
import { apiModelAdapter } from '@renderer/utils/model' import { apiModelAdapter } from '@renderer/utils/model'
import { ChevronsUpDown } from 'lucide-react' import { ChevronsUpDown } from 'lucide-react'
@@ -12,21 +12,22 @@ import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface Props { interface Props {
agentBase: AgentBaseWithId agent: AgentBaseWithId
onSelect: (model: ApiModel) => Promise<void> onSelect: (model: ApiModel) => Promise<void>
isDisabled?: boolean isDisabled?: boolean
} }
const SelectAgentBaseModelButton: FC<Props> = ({ agentBase: agent, onSelect, isDisabled }) => { const SelectAgentModelButton: FC<Props> = ({ agent, onSelect, isDisabled }) => {
const { t } = useTranslation() const { t } = useTranslation()
const model = useApiModel({ id: agent?.model }) const model = useApiModel({ id: agent?.model })
const apiFilter = isAgentEntity(agent) ? getModelFilterByAgentType(agent.type) : undefined const apiFilter = isAgentEntity(agent) ? getModelFilterByAgentType(agent.type) : undefined
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model)
if (!agent) return null if (!agent) return null
const onSelectModel = async () => { const onSelectModel = async () => {
const selectedModel = await SelectApiModelPopup.show({ model, apiFilter: apiFilter, modelFilter: agentModelFilter }) const selectedModel = await SelectApiModelPopup.show({ model, apiFilter: apiFilter, modelFilter })
if (selectedModel && selectedModel.id !== agent.model) { if (selectedModel && selectedModel.id !== agent.model) {
onSelect(selectedModel) onSelect(selectedModel)
} }
@@ -38,12 +39,12 @@ const SelectAgentBaseModelButton: FC<Props> = ({ agentBase: agent, onSelect, isD
<Button <Button
size="sm" size="sm"
variant="light" variant="light"
className="nodrag h-[28px] rounded-2xl px-1" className="nodrag rounded-2xl px-1 py-3"
onPress={onSelectModel} onPress={onSelectModel}
isDisabled={isDisabled}> isDisabled={isDisabled}>
<div className="flex items-center gap-1.5 overflow-x-hidden"> <div className="flex items-center gap-1.5">
<ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={20} /> <ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={20} />
<span className="truncate text-[var(--color-text)]"> <span className="-mr-0.5 font-medium">
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''} {model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</span> </span>
</div> </div>
@@ -52,4 +53,4 @@ const SelectAgentBaseModelButton: FC<Props> = ({ agentBase: agent, onSelect, isD
) )
} }
export default SelectAgentBaseModelButton export default SelectAgentModelButton
@@ -87,7 +87,6 @@ const ButtonContent = styled.div`
const ModelName = styled.span` const ModelName = styled.span`
font-weight: 500; font-weight: 500;
margin-right: -2px; margin-right: -2px;
font-size: 12px;
` `
export default SelectModelButton export default SelectModelButton
+12 -22
View File
@@ -7,7 +7,7 @@ import TabsService from '@renderer/services/TabsService'
import { getWebviewLoaded, onWebviewStateChange, setWebviewLoaded } from '@renderer/utils/webviewStateManager' import { getWebviewLoaded, onWebviewStateChange, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Avatar } from 'antd' import { Avatar } from 'antd'
import { WebviewTag } from 'electron' import { WebviewTag } from 'electron'
import { FC, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import BeatLoader from 'react-spinners/BeatLoader' import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components' import styled from 'styled-components'
@@ -28,9 +28,7 @@ const MinAppPage: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
// Remember the initial navbar position when component mounts // Remember the initial navbar position when component mounts
// It's immutable state const initialIsTopNavbar = useRef<boolean>(isTopNavbar)
const [initialIsTopNavbar] = useState<boolean>(isTopNavbar)
const initialIsTopNavbarRef = useRef<boolean>(initialIsTopNavbar)
const hasRedirected = useRef<boolean>(false) const hasRedirected = useRef<boolean>(false)
// Initialize TabsService with cache reference // Initialize TabsService with cache reference
@@ -42,8 +40,8 @@ const MinAppPage: FC = () => {
// Debug: track navbar position changes // Debug: track navbar position changes
useEffect(() => { useEffect(() => {
if (initialIsTopNavbarRef.current !== isTopNavbar) { if (initialIsTopNavbar.current !== isTopNavbar) {
logger.debug(`NavBar position changed from ${initialIsTopNavbarRef.current} to ${isTopNavbar}`) logger.debug(`NavBar position changed from ${initialIsTopNavbar.current} to ${isTopNavbar}`)
} }
}, [isTopNavbar]) }, [isTopNavbar])
@@ -71,7 +69,7 @@ const MinAppPage: FC = () => {
// For sidebar navigation, redirect to apps list and open popup // For sidebar navigation, redirect to apps list and open popup
// Only check once and only if we haven't already redirected // Only check once and only if we haven't already redirected
if (!initialIsTopNavbarRef.current && !hasRedirected.current) { if (!initialIsTopNavbar.current && !hasRedirected.current) {
hasRedirected.current = true hasRedirected.current = true
navigate('/apps') navigate('/apps')
// Open popup after navigation // Open popup after navigation
@@ -82,20 +80,15 @@ const MinAppPage: FC = () => {
} }
// For top navbar mode, integrate with cache system // For top navbar mode, integrate with cache system
if (initialIsTopNavbarRef.current) { if (initialIsTopNavbar.current) {
// 无论是否已在缓存,都调用以确保 currentMinappId 同步到路由切换的新 appId // 无论是否已在缓存,都调用以确保 currentMinappId 同步到路由切换的新 appId
openMinappKeepAlive(app) openMinappKeepAlive(app)
} }
}, [app, navigate, openMinappKeepAlive]) }, [app, navigate, openMinappKeepAlive, initialIsTopNavbar])
// -------------- 新的 Tab Shell 逻辑 -------------- // -------------- 新的 Tab Shell 逻辑 --------------
// 注意:Hooks 必须在任何 return 之前调用,因此提前定义,并在内部判空 // 注意:Hooks 必须在任何 return 之前调用,因此提前定义,并在内部判空
const [webview, setWebview] = useState<WebviewTag | null>(null) const webviewRef = useRef<WebviewTag | null>(null)
const webviewRef = useRef<WebviewTag | null>(webview)
useEffect(() => {
webviewRef.current = webview
}, [webview])
const [isReady, setIsReady] = useState<boolean>(() => (app ? getWebviewLoaded(app.id) : false)) const [isReady, setIsReady] = useState<boolean>(() => (app ? getWebviewLoaded(app.id) : false))
const [currentUrl, setCurrentUrl] = useState<string | null>(app?.url ?? null) const [currentUrl, setCurrentUrl] = useState<string | null>(app?.url ?? null)
@@ -110,7 +103,7 @@ const MinAppPage: FC = () => {
if (webviewRef.current === el) return true // 已附着 if (webviewRef.current === el) return true // 已附着
setWebview(el) webviewRef.current = el
const handleInPageNav = (e: any) => setCurrentUrl(e.url) const handleInPageNav = (e: any) => setCurrentUrl(e.url)
el.addEventListener('did-navigate-in-page', handleInPageNav) el.addEventListener('did-navigate-in-page', handleInPageNav)
webviewCleanupRef.current = () => { webviewCleanupRef.current = () => {
@@ -144,10 +137,7 @@ const MinAppPage: FC = () => {
if (!app) return if (!app) return
if (getWebviewLoaded(app.id)) { if (getWebviewLoaded(app.id)) {
// 已经加载 // 已经加载
if (!isReady) if (!isReady) setIsReady(true)
startTransition(() => {
setIsReady(true)
})
return return
} }
let mounted = true let mounted = true
@@ -165,7 +155,7 @@ const MinAppPage: FC = () => {
}, [app, isReady]) }, [app, isReady])
// 如果条件不满足,提前返回(所有 hooks 已调用) // 如果条件不满足,提前返回(所有 hooks 已调用)
if (!app || !initialIsTopNavbar) { if (!app || !initialIsTopNavbar.current) {
return null return null
} }
@@ -195,7 +185,7 @@ const MinAppPage: FC = () => {
onOpenDevTools={handleOpenDevTools} onOpenDevTools={handleOpenDevTools}
/> />
</ToolbarWrapper> </ToolbarWrapper>
<WebviewSearch activeWebview={webview} isWebviewReady={isReady} appId={app.id} /> <WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
{!isReady && ( {!isReady && (
<LoadingMask> <LoadingMask>
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} /> <Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
@@ -8,7 +8,6 @@ import {
PushpinOutlined, PushpinOutlined,
ReloadOutlined ReloadOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { loggerService } from '@logger'
import { isDev } from '@renderer/config/constant' import { isDev } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useMinapps } from '@renderer/hooks/useMinapps' import { useMinapps } from '@renderer/hooks/useMinapps'
@@ -18,21 +17,11 @@ import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { WebviewTag } from 'electron' import { WebviewTag } from 'electron'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
const logger = loggerService.withContext('MinimalToolbar')
// Constants for timing delays
const WEBVIEW_CHECK_INITIAL_MS = 100 // Initial check interval
const WEBVIEW_CHECK_MAX_MS = 1000 // Maximum check interval (1 second)
const WEBVIEW_CHECK_MULTIPLIER = 2 // Exponential backoff multiplier
const WEBVIEW_CHECK_MAX_ATTEMPTS = 30 // Stop after ~30 seconds total
const NAVIGATION_UPDATE_DELAY_MS = 50
const NAVIGATION_COMPLETE_DELAY_MS = 100
interface Props { interface Props {
app: MinAppType app: MinAppType
webviewRef: React.RefObject<WebviewTag | null> webviewRef: React.RefObject<WebviewTag | null>
@@ -53,166 +42,27 @@ const MinimalToolbar: FC<Props> = ({ app, webviewRef, currentUrl, onReload, onOp
const isPinned = pinned.some((item) => item.id === app.id) const isPinned = pinned.some((item) => item.id === app.id)
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
// Ref to track navigation update timeout
const navigationUpdateTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Update navigation state // Update navigation state
const updateNavigationState = useCallback(() => { const updateNavigationState = useCallback(() => {
if (webviewRef.current) { if (webviewRef.current) {
try { setCanGoBack(webviewRef.current.canGoBack())
setCanGoBack(webviewRef.current.canGoBack()) setCanGoForward(webviewRef.current.canGoForward())
setCanGoForward(webviewRef.current.canGoForward())
} catch (error) {
logger.debug('WebView not ready for navigation state update', { appId: app.id })
setCanGoBack(false)
setCanGoForward(false)
}
} else {
setCanGoBack(false)
setCanGoForward(false)
} }
}, [app.id, webviewRef]) }, [webviewRef])
// Schedule navigation state update with debouncing
const scheduleNavigationUpdate = useCallback(
(delay: number) => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
navigationUpdateTimeoutRef.current = setTimeout(() => {
updateNavigationState()
navigationUpdateTimeoutRef.current = null
}, delay)
},
[updateNavigationState]
)
// Cleanup navigation timeout on unmount
useEffect(() => {
return () => {
if (navigationUpdateTimeoutRef.current) {
clearTimeout(navigationUpdateTimeoutRef.current)
}
}
}, [])
// Monitor webviewRef changes and update navigation state
useEffect(() => {
let checkTimeout: NodeJS.Timeout | null = null
let navigationListener: (() => void) | null = null
let listenersAttached = false
let currentInterval = WEBVIEW_CHECK_INITIAL_MS
let attemptCount = 0
const attachListeners = () => {
if (webviewRef.current && !listenersAttached) {
// Update state immediately
updateNavigationState()
// Add navigation event listeners
const handleNavigation = () => {
scheduleNavigationUpdate(NAVIGATION_UPDATE_DELAY_MS)
}
webviewRef.current.addEventListener('did-navigate', handleNavigation)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigation)
listenersAttached = true
navigationListener = () => {
if (webviewRef.current) {
webviewRef.current.removeEventListener('did-navigate', handleNavigation)
webviewRef.current.removeEventListener('did-navigate-in-page', handleNavigation)
}
listenersAttached = false
}
if (checkTimeout) {
clearTimeout(checkTimeout)
checkTimeout = null
}
logger.debug('Navigation listeners attached', { appId: app.id, attempts: attemptCount })
return true
}
return false
}
const scheduleCheck = () => {
checkTimeout = setTimeout(() => {
// Use requestAnimationFrame to avoid blocking the main thread
requestAnimationFrame(() => {
attemptCount++
if (!attachListeners()) {
// Stop checking after max attempts to prevent infinite loops
if (attemptCount >= WEBVIEW_CHECK_MAX_ATTEMPTS) {
logger.warn('WebView attachment timeout', {
appId: app.id,
attempts: attemptCount,
totalTimeMs: currentInterval * attemptCount
})
return
}
// Exponential backoff: double the interval up to the maximum
currentInterval = Math.min(currentInterval * WEBVIEW_CHECK_MULTIPLIER, WEBVIEW_CHECK_MAX_MS)
// Log only on first few attempts or when interval changes significantly
if (attemptCount <= 3 || attemptCount % 10 === 0) {
logger.debug('WebView not ready, scheduling next check', {
appId: app.id,
nextCheckMs: currentInterval,
attempt: attemptCount
})
}
scheduleCheck()
}
})
}, currentInterval)
}
// Check for webview attachment
if (!webviewRef.current) {
scheduleCheck()
} else {
attachListeners()
}
// Cleanup
return () => {
if (checkTimeout) clearTimeout(checkTimeout)
if (navigationListener) navigationListener()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [app.id, updateNavigationState, scheduleNavigationUpdate]) // webviewRef excluded as it's a ref object
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
if (webviewRef.current) { if (webviewRef.current && webviewRef.current.canGoBack()) {
try { webviewRef.current.goBack()
if (webviewRef.current.canGoBack()) { updateNavigationState()
webviewRef.current.goBack()
// Delay update to ensure navigation completes
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
}
} catch (error) {
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goBack' })
}
} }
}, [app.id, webviewRef, scheduleNavigationUpdate]) }, [webviewRef, updateNavigationState])
const handleGoForward = useCallback(() => { const handleGoForward = useCallback(() => {
if (webviewRef.current) { if (webviewRef.current && webviewRef.current.canGoForward()) {
try { webviewRef.current.goForward()
if (webviewRef.current.canGoForward()) { updateNavigationState()
webviewRef.current.goForward()
// Delay update to ensure navigation completes
scheduleNavigationUpdate(NAVIGATION_COMPLETE_DELAY_MS)
}
} catch (error) {
logger.debug('WebView not ready for navigation', { appId: app.id, action: 'goForward' })
}
} }
}, [app.id, webviewRef, scheduleNavigationUpdate]) }, [webviewRef, updateNavigationState])
const handleMinimize = useCallback(() => { const handleMinimize = useCallback(() => {
navigate('/apps') navigate('/apps')
@@ -8,14 +8,14 @@ import { useTranslation } from 'react-i18next'
type FoundInPageResult = Electron.FoundInPageResult type FoundInPageResult = Electron.FoundInPageResult
interface WebviewSearchProps { interface WebviewSearchProps {
activeWebview: WebviewTag | null webviewRef: React.RefObject<WebviewTag | null>
isWebviewReady: boolean isWebviewReady: boolean
appId: string appId: string
} }
const logger = loggerService.withContext('WebviewSearch') const logger = loggerService.withContext('WebviewSearch')
const WebviewSearch: FC<WebviewSearchProps> = ({ activeWebview, isWebviewReady, appId }) => { const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -25,6 +25,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ activeWebview, isWebviewReady,
const focusFrameRef = useRef<number | null>(null) const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId) const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null) const attachedWebviewRef = useRef<WebviewTag | null>(null)
const activeWebview = webviewRef.current ?? null
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
if (focusFrameRef.current !== null) { if (focusFrameRef.current !== null) {
@@ -80,7 +81,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ activeWebview, isWebviewReady,
) )
const getUsableWebview = useCallback(() => { const getUsableWebview = useCallback(() => {
const candidates = [activeWebview, attachedWebviewRef.current] const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) { for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate) const usable = ensureWebviewReady(candidate)
@@ -90,7 +91,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ activeWebview, isWebviewReady,
} }
return null return null
}, [ensureWebviewReady, activeWebview]) }, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => { const stopSearch = useCallback(() => {
const target = getUsableWebview() const target = getUsableWebview()
@@ -136,8 +136,9 @@ describe('WebviewSearch', () => {
it('opens the search overlay with keyboard shortcut', async () => { it('opens the search overlay with keyboard shortcut', async () => {
const { webview } = createWebviewMock() const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
@@ -148,8 +149,9 @@ describe('WebviewSearch', () => {
it('opens the search overlay when webview shortcut is forwarded', async () => { it('opens the search overlay when webview shortcut is forwarded', async () => {
const { webview } = createWebviewMock() const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => { await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled() expect(onFindShortcutMock).toHaveBeenCalled()
@@ -168,12 +170,13 @@ describe('WebviewSearch', () => {
;(webview as any).getWebContentsId = vi.fn(() => { ;(webview as any).getWebContentsId = vi.fn(() => {
throw error throw error
}) })
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => { const getWebContentsIdMock = vi.fn(() => {
throw error throw error
}) })
;(webview as any).getWebContentsId = getWebContentsIdMock ;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => { await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled() expect(getWebContentsIdMock).toHaveBeenCalled()
@@ -182,8 +185,8 @@ describe('WebviewSearch', () => {
;(webview as any).getWebContentsId = vi.fn(() => 1) ;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch activeWebview={webview} isWebviewReady={false} appId="app-1" />) rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => { await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled() expect(onFindShortcutMock).toHaveBeenCalled()
@@ -197,8 +200,9 @@ describe('WebviewSearch', () => {
throw error throw error
}) })
;(webview as any).getWebContentsId = getWebContentsIdMock ;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => { await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled() expect(getWebContentsIdMock).toHaveBeenCalled()
@@ -208,7 +212,7 @@ describe('WebviewSearch', () => {
throw new Error('should not be called') throw new Error('should not be called')
}) })
rerender(<WebviewSearch activeWebview={webview} isWebviewReady={false} appId="app-1" />) rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled() expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount() unmount()
@@ -217,8 +221,9 @@ describe('WebviewSearch', () => {
it('closes the search overlay when escape is forwarded from the webview', async () => { it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock() const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => { await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled() expect(onFindShortcutMock).toHaveBeenCalled()
@@ -240,9 +245,10 @@ describe('WebviewSearch', () => {
it('performs searches and navigates between results', async () => { it('performs searches and navigates between results', async () => {
const { emit, findInPageMock, webview } = createWebviewMock() const { emit, findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup() const user = userEvent.setup()
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay() await openSearchOverlay()
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
@@ -280,9 +286,10 @@ describe('WebviewSearch', () => {
it('navigates results when enter is forwarded from the webview', async () => { it('navigates results when enter is forwarded from the webview', async () => {
const { findInPageMock, webview } = createWebviewMock() const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup() const user = userEvent.setup()
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => { await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled() expect(onFindShortcutMock).toHaveBeenCalled()
@@ -318,9 +325,10 @@ describe('WebviewSearch', () => {
it('clears search state when appId changes', async () => { it('clears search state when appId changes', async () => {
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock() const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup() const user = userEvent.setup()
const { rerender } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay() await openSearchOverlay()
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
@@ -330,7 +338,7 @@ describe('WebviewSearch', () => {
}) })
await act(async () => { await act(async () => {
rerender(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-2" />) rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
}) })
await waitFor(() => { await waitFor(() => {
@@ -344,9 +352,10 @@ describe('WebviewSearch', () => {
findInPageMock.mockImplementation(() => { findInPageMock.mockImplementation(() => {
throw new Error('findInPage failed') throw new Error('findInPage failed')
}) })
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const user = userEvent.setup() const user = userEvent.setup()
render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay() await openSearchOverlay()
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
@@ -359,8 +368,9 @@ describe('WebviewSearch', () => {
it('stops search when component unmounts', async () => { it('stops search when component unmounts', async () => {
const { stopFindInPageMock, webview } = createWebviewMock() const { stopFindInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { unmount } = render(<WebviewSearch activeWebview={webview} isWebviewReady appId="app-1" />) const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await openSearchOverlay() await openSearchOverlay()
stopFindInPageMock.mockClear() stopFindInPageMock.mockClear()
@@ -372,8 +382,9 @@ describe('WebviewSearch', () => {
it('ignores keyboard shortcut when webview is not ready', async () => { it('ignores keyboard shortcut when webview is not ready', async () => {
const { findInPageMock, webview } = createWebviewMock() const { findInPageMock, webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
render(<WebviewSearch activeWebview={webview} isWebviewReady={false} appId="app-1" />) render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
await act(async () => { await act(async () => {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true }) fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
+10 -6
View File
@@ -9,7 +9,7 @@ import { findNode } from '@renderer/services/NotesTreeService'
import { Dropdown, Input, Tooltip } from 'antd' import { Dropdown, Input, Tooltip } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react' import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { menuItems } from './MenuConfig' import { menuItems } from './MenuConfig'
@@ -19,6 +19,9 @@ const logger = loggerService.withContext('HeaderNavbar')
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => { const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace() const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
const { activeNode } = useActiveNode(notesTree) const { activeNode } = useActiveNode(notesTree)
const [breadcrumbItems, setBreadcrumbItems] = useState<
Array<{ key: string; title: string; treePath: string; isFolder: boolean }>
>([])
const [titleValue, setTitleValue] = useState('') const [titleValue, setTitleValue] = useState('')
const titleInputRef = useRef<any>(null) const titleInputRef = useRef<any>(null)
const { settings, updateSettings } = useNotesSettings() const { settings, updateSettings } = useNotesSettings()
@@ -138,17 +141,18 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
// 同步标题值 // 同步标题值
useEffect(() => { useEffect(() => {
if (activeNode?.type === 'file') { if (activeNode?.type === 'file') {
startTransition(() => setTitleValue(activeNode.name.replace('.md', ''))) setTitleValue(activeNode.name.replace('.md', ''))
} }
}, [activeNode]) }, [activeNode])
// 构建面包屑路径 // 构建面包屑路径
const breadcrumbItems = useMemo(() => { useEffect(() => {
if (!activeNode || !notesTree) { if (!activeNode || !notesTree) {
return [] setBreadcrumbItems([])
return
} }
const node = findNode(notesTree, activeNode.id) const node = findNode(notesTree, activeNode.id)
if (!node) return [] if (!node) return
const pathParts = node.treePath.split('/').filter(Boolean) const pathParts = node.treePath.split('/').filter(Boolean)
const items = pathParts.map((part, index) => { const items = pathParts.map((part, index) => {
@@ -162,7 +166,7 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
} }
}) })
return items setBreadcrumbItems(items)
}, [activeNode, notesTree]) }, [activeNode, notesTree])
return ( return (

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