Compare commits

..

35 Commits

Author SHA1 Message Date
suyao 229a8f60b2 fix: ci 2025-08-14 21:15:40 +08:00
suyao aa635eba88 fix/magistral-2507 2025-08-14 21:09:00 +08:00
Teo bf30bf28a9 fix(TopicMessages): fix topic style (#9178)
* fix(TopicMessages): fix topic style
2025-08-14 16:55:08 +08:00
Jason Young 1bf380a921 fix: auto-close panel when no commands match (#7824) (#8784)
* fix(QuickPanel): auto-close panel when no commands match (#7824)

Fixes the issue where QuickPanel remains visible when user types
invalid slash commands. Now the panel intelligently closes after
300ms when no matching commands are found.

- Add smart delayed closing mechanism for unmatched searches
- Optimize memory management with proper timer cleanup
- Preserve existing trigger behavior for / and @ symbols

* feat(inputbar): intelligent @ symbol handling on model selection panel close

- Add smart @ character deletion when user selects models and closes panel with ESC/Backspace
- Preserve @ character when user closes panel without selecting any models
- Implement action tracking using useRef to detect user model interactions
- Support both ESC key and Backspace key for consistent behavior
- Use React setState instead of DOM manipulation for proper state management

Resolves user experience issue where @ symbol always remained after closing model selection panel

* perf(QuickPanel): optimize timer management and fix React anti-patterns

- Move side effects from useMemo to useEffect for proper React lifecycle
- Add automatic timer cleanup on component unmount and dependency changes
- Remove unnecessary timer creation/destruction on each search input
- Improve memory management and prevent potential memory leaks
- Maintain existing smart auto-close functionality with better performance

Fixes React anti-pattern where side effects were executed in useMemo,
which should be a pure function. This improves performance especially
when users type quickly in the search input.

* refactor(QuickPanel): remove redundant timer cleanup useEffect

Remove duplicate timer cleanup logic as the existing useEffect at line 141-164
already handles component unmount cleanup properly.

* refactor(QuickPanel): optimize useEffect dependencies and timer cleanup logic

- Replace overly broad `ctx` dependency with precise `[ctx.isVisible, searchText, list.length, ctx.close]`
- Move timer cleanup before visibility check to ensure proper cleanup on panel hide
- Add early return when panel is invisible to prevent unnecessary timer creation
- Improve performance by avoiding redundant effect executions
- Fix edge case where timers might not be cleared when panel becomes invisible

Addresses review feedback about dependency array optimization while maintaining
existing auto-close functionality and improving memory management.

* feat(QuickPanel): implement smart re-opening with dependency optimization

Features:
- Implement smart re-opening during deletion with real-time matching
- Only reopen panel when actual matches exist to avoid unnecessary interactions
- Add intelligent @ symbol handling on model selection panel close
- Optimize search text length limits (≤10 chars) for performance

Fixes:
- Fix useMemo dependency from overly broad [ctx, searchText] to precise [ctx.isVisible, ctx.symbol, ctx.list, searchText]
- Resolve trailing whitespace formatting issues
- Eliminate ESLint exhaustive-deps warnings while maintaining stability
- Prevent unnecessary re-renders when unrelated ctx properties change

Performance improvements ensure optimal QuickPanel responsiveness while maintaining
existing auto-close functionality and improving user experience.

* fix(ci): add eslint-disable comment for exhaustive-deps warning

The useEffect dependency array [ctx.isVisible, searchText, list.length, ctx.close]
is intentionally precise to avoid unnecessary re-renders when unrelated ctx
properties change. Adding ctx object would cause performance degradation.

* refactor(QuickPanel): remove smart re-opening logic during deletion

- Remove 62 lines of complex deletion detection logic from Inputbar component
- Eliminates performance overhead from frequent string matching during typing
- Reduces code complexity and potential edge cases
- Maintains simple and predictable QuickPanel behavior
- Improves maintainability by removing unnecessary "smart" features

The deletion-triggered smart reopening feature added unnecessary complexity
without significant user benefit. Users can simply type / or @ again to
reopen panels when needed.
2025-08-14 16:35:14 +08:00
周子健 a4c61bcd66 fix: @cherry/memory i18n key wrong (#9164) 2025-08-14 10:00:45 +08:00
one a172a1052a refactor: use hook useTemporaryValue in Table, CitationList, TranslatePage (#9134) 2025-08-13 16:58:50 +08:00
Phantom f4ef2ec934 fix: remove gpt-5-chat from OpenAI reasoning models (#9136)
fix: 从OpenAI推理模型判断中移除gpt-5-chat
2025-08-13 16:30:24 +08:00
one 4cda5f1787 feat(Markdown): support disabling single dollar math (#9131)
* feat(Markdown): support disabling single dollar math

* fix: lint error
2025-08-13 16:14:59 +08:00
Teo ceef19e55b feat: add message outline (#9090)
* feat: add message outline feature
2025-08-13 14:57:58 +08:00
Phantom 0634baf780 fix(providers): qiniu doesn't support developer role (#9125)
fix(providers): 更新不支持developer角色的提供商列表
2025-08-13 14:51:23 +08:00
one d424bb1224 fix: codeblock special view (#9120)
* Revert "fix(CodeBlockView): initial view mode (#9047)"

This reverts commit 28e6135f8c.

* fix: code block border radius

* chore: bump mermaid to 11.9.0
2025-08-13 14:41:13 +08:00
anghunk f97943006e fix: set the inconsistency of column background color issue (#9109) 2025-08-13 11:53:44 +08:00
one ea8b7f317d fix: selection toolbar in CodeViewer (#9103) 2025-08-13 11:37:31 +08:00
kangfenmao 2dd2bee940 refactor(settings): reorganize the menu layout in the settings page
- Introduced QuickPhraseSettings component for managing quick phrases with add, edit, and delete functionality.
- Added PreprocessSettings component to configure document preprocessing options, including provider selection.
- Updated SettingsPage to include links to the new Quick Phrase and Preprocess settings.
- Removed the deprecated ToolSettings component and its associated routes.
- Enhanced WebSearchSettings with new compression settings and improved UI for managing web search providers.
2025-08-13 10:54:35 +08:00
beyondkmp d579872078 chore: update windows-system-proxy dependency and remove obsolete patch (#9108)
- Removed the patch for windows-system-proxy@npm:1.0.0 and updated the dependency to version 1.0.1 in package.json and yarn.lock.
- Deleted the corresponding patch file to clean up the project.
2025-08-13 10:26:39 +08:00
one df587fc61f chore: use destroyOnHidden instead of deprecated destroyOnClose (#9102)
* chore: use destroyOnHidden instead of deprecated destroyOnClose

* Update src/renderer/src/components/MinApp/MinappPopupContainer.tsx

* Update src/renderer/src/pages/settings/SelectionAssistantSettings/components/SelectionFilterListModal.tsx

---------

Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com>
2025-08-13 10:08:36 +08:00
Phantom 7c2a9d141e fix: web search button memory leak (#9100)
* refactor(InputbarTools): 重命名getMenuItems为menuItems以提高可读性

* docs(Inputbar): 添加内存泄露风险注释

* Revert "refactor(InputbarTools): 重命名getMenuItems为menuItems以提高可读性"

This reverts commit 6076d5b74c.

* perf(WebSearchButton): 使用startTransition优化性能并移除setTimeout

移除setTimeout延迟更新,减少内存泄露,改用startTransition来优化UI卡顿问题,提升用户体验
2025-08-13 10:05:40 +08:00
beyondkmp e4e1325b08 refactor(AppUpdater): remove mainWindow dependency and utilize windowService (#9091)
- Updated AppUpdater to eliminate the mainWindow parameter from the constructor.
- Replaced direct mainWindow references with windowService calls to retrieve the main window, improving modularity and decoupling.
2025-08-12 23:09:37 +08:00
kangfenmao 399118174e chore: release v1.5.6 2025-08-12 20:56:16 +08:00
kangfenmao fecf452592 chore: release v1.5.6-rc.2 2025-08-12 11:59:48 +08:00
亢奋猫 1c7b7a1a55 feat: add code tools (#9043)
* feat: add code tools

* feat(CodeToolsService): add CLI executable management and installation check

- Introduced methods to determine the CLI executable name based on the tool.
- Added functionality to check if a package is installed and create the necessary bin directory if it doesn't exist.
- Enhanced the run method to handle installation and execution of CLI tools based on their installation status.
- Updated terminal command handling for different operating systems with improved comments and error messages.

* feat(ipService): implement IP address country detection and npm registry URL selection

- Added a new module for IP address country detection using the ipinfo.io API.
- Implemented functions to check if the user is in China and to return the appropriate npm registry URL based on the user's location.
- Updated AppUpdater and CodeToolsService to utilize the new ipService functions for improved user experience based on geographical location.
- Enhanced error handling and logging for better debugging and user feedback.

* feat: remember cli model

* feat(CodeToolsService): update options for auto-update functionality

- Refactored the options parameter in CodeToolsService to replace checkUpdate and forceUpdate with autoUpdateToLatest.
- Updated logic to handle automatic updates when the CLI tool is already installed.
- Modified related UI components to reflect the new auto-update option.
- Added corresponding translations for the new feature in multiple languages.

* feat(CodeToolsService): enhance CLI tool launch with debugging support

- Added detailed logging for CLI tool launch process, including environment variables and options.
- Implemented a temporary batch file for Windows to facilitate debugging and command execution.
- Improved error handling and cleanup for the temporary batch file after execution.
- Updated terminal command handling to use the new batch file for safer execution.

* refactor(CodeToolsService): simplify command execution output

- Removed display of environment variable settings during command execution in the CLI tool.
- Updated comments for clarity on the command execution process.

* feat(CodePage): add model filtering logic for provider selection

- Introduced a modelPredicate function to filter out embedding, rerank, and text-to-image models from the available providers.
- Updated the ModelSelector component to utilize the new predicate for improved model selection experience.

* refactor(CodeToolsService): improve logging and cleanup for CLI tool execution

- Updated logging to display only the keys of environment variables during CLI tool launch for better clarity.
- Introduced a variable to store the path of the temporary batch file for Windows.
- Enhanced cleanup logic to remove the temporary batch file after execution, improving resource management.

* feat(Router): replace CodePage with CodeToolsPage and add new page for code tools

- Updated Router to import and route to the new CodeToolsPage instead of the old CodePage.
- Introduced CodeToolsPage component, which provides a user interface for selecting CLI tools and models, managing directories, and launching code tools with enhanced functionality.

* refactor(CodeToolsService): improve temporary file management and cleanup

- Removed unused variable for Windows batch file path.
- Added a cleanup task to delete the temporary batch file after 10 seconds to enhance resource management.
- Updated logging to ensure clarity during the execution of CLI tools.

* refactor(CodeToolsService): streamline environment variable handling for CLI tool execution

- Introduced a utility function to remove proxy-related environment variables before launching terminal processes.
- Updated logging to display only the relevant environment variable keys, enhancing clarity during execution.

* refactor(MCPService, CodeToolsService): unify proxy environment variable handling

- Replaced custom proxy removal logic with a shared utility function `removeEnvProxy` to streamline environment variable management across services.
- Updated logging to reflect changes in environment variable handling during CLI tool execution.
2025-08-12 11:54:38 +08:00
kangfenmao 793ccf978e feat(ProviderSettings): add API options settings and popup for providers
- Introduced ApiOptionsSettings component to manage API options for providers.
- Added ApiOptionsSettingsPopup for displaying API options in a modal.
- Updated ProviderSetting to include a button for opening the API options popup for non-system providers.
- Refactored imports and adjusted layout in ProviderSetting for better UI consistency.
2025-08-12 11:53:38 +08:00
kangfenmao ef57e543c6 fervert: feat(ProviderSettings): resizable provider settings (#9004)
This reverts commit 5713a278cd.
2025-08-12 11:32:04 +08:00
Phantom 42800a6195 style(Inputbar): use primary color for inputbar tools (#9058)
style(Inputbar): 将激活状态图标颜色从--color-link改为--color-primary
2025-08-12 11:26:44 +08:00
Phantom be29f163a3 refactor(models.ts): Adjust the logo matching order of the GPT-5 model (#9073)
* refactor(models.ts): 调整GPT-5模型logo匹配顺序

* refactor(models): 简化GPT-5模型logo的正则匹配模式

* Revert "Update gpt-5.png"

This reverts commit 1e8143eb8c.
2025-08-12 11:25:05 +08:00
beyondkmp 207f2e1689 refactor(proxy): update proxy handling logic in useAppInit and GeneralSettings (#9081)
- Simplified proxy setting logic by removing unnecessary dispatches for 'system' and 'none' modes.
- Updated useAppInit to set proxy to undefined for 'system' mode and clarified direct mode handling with comments.
2025-08-12 11:21:47 +08:00
Phantom 4fd00af273 feat: support swap auto detected language in translate page (#9072)
* feat(translate): 支持自动检测语言时交换语言并添加异常处理

* fix(i18n): 更新翻译错误信息并添加缺失的翻译项

* docs(translate): 添加与自动检测相关的交换条件检查注释

* fix(translate): 为翻译结果添加类型声明
2025-08-12 11:20:18 +08:00
牡丹凤凰 1e8143eb8c Update gpt-5.png 2025-08-11 22:06:08 +08:00
kangfenmao 5398953555 chore: release v1.5.6-rc.1 2025-08-11 18:37:36 +08:00
kangfenmao 809a532a6c refactor(translate): reorganize translation settings and remove deprecated components
- Removed TranslateSettings and TranslateModelSettings components to streamline the translation settings interface.
- Introduced CustomLanguageSettings and TranslatePromptSettings components for better management of custom languages and prompt settings.
- Updated ModelSettings to utilize the new TranslateSettingsPopup for handling translation-related configurations.
- Enhanced the overall structure and readability of the translation settings page.
2025-08-11 18:10:56 +08:00
Phantom c666361611 fix: trace usage (#9018)
* fix(openai): 移除不必要的类型断言并更新类型定义

更新OpenAIApiClient中的usage处理逻辑,移除不必要的类型断言
在sdk.ts中更新OpenAISdkRawChunk类型定义,明确包含可能的cost字段

* fix(openai): 修复流式响应中完成信号触发逻辑

调整完成信号的触发条件,不再区分 OpenRouter 和其他提供商
统一等待 usage 信息后再触发完成信号,以统一适配 OpenAI Chat Completions API

* fix(sdk类型): 将OpenAISdkRawChunk中的usage字段改为可选

* refactor(openai): 移除对OpenRouter的特殊处理逻辑
2025-08-11 17:05:05 +08:00
beyondkmp 5771d0c9e8 refactor: file path improve (#8990)
* refactor(FileManager): streamline file path handling in FilesPage and ImageBlock components

* refactor(file): implement getSafeFilePath utility for consistent file path handling across loaders and preprocessors

* refactor(FileStorage): replace getSafeFilePath with fileStorage.getFilePathById for consistent file path retrieval across services

* refactor(file): unify file path retrieval across loaders and preprocessors for improved consistency

* refactor(Inputbar, MessageEditor): replace getFileExtension with file.ext for improved file type handling

* refactor(FileStorage): simplify getFilePathById method by removing redundant checks for file path retrieval

* fix(FileStorage): update getFilePathById to ensure file.path is consistent with generated filePath

* refactor(FileStorage): simplify getFilePathById method by removing unnecessary file path consistency checks

* fix(FileStorage): update duplicate file check to use file.path for accurate detection

* fix(FileStorage): correct file path usage in uploadFile method for accurate duplicate detection

* fix(loader): update file path retrieval to use file.path for consistency across loaders
2025-08-11 16:35:46 +08:00
陈天寒 bfd2f9d156 fix(aws-bedrock): add auto get model list (#9052)
* fix(aws-bedrock): add auto get model list

* fix(aws-bedrock): fix type definition
2025-08-11 16:20:11 +08:00
kangfenmao 30b7028dd8 refactor(translate): streamline TranslatePage layout and component structure
- Removed unused imports and components to simplify the codebase.
- Refactored the token count calculation for improved readability.
- Adjusted the layout of the operation bar and input/output containers for better spacing and alignment.
- Enhanced the copy button functionality and visibility within the output area.
- Updated styles for consistency and improved user experience.
2025-08-11 16:11:47 +08:00
beyondkmp d68529096b chore: update release workflow artifacts to include beta YAML files (#9055) 2025-08-11 16:06:08 +08:00
113 changed files with 3538 additions and 1002 deletions
+1 -1
View File
@@ -127,5 +127,5 @@ jobs:
allowUpdates: true
makeLatest: false
tag: ${{ steps.get-tag.outputs.tag }}
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap'
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/beta*.yml,dist/*.blockmap'
token: ${{ secrets.GITHUB_TOKEN }}
@@ -1,23 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index b54962b2d332c1a3affadbdb37d39fdf90ab9f82..7906b4ea3bf9dffe60d74c279e9cfe885489c9f9 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -36,12 +36,12 @@ async function getWindowsSystemProxy() {
const proxies = Object.fromEntries(proxyConfigString
.split(';')
.map((proxyPair) => proxyPair.split('=')));
- const proxyUrl = proxies['https']
- ? `https://${proxies['https']}`
- : proxies['http']
- ? `http://${proxies['http']}`
- : proxies['socks']
- ? `socks://${proxies['socks']}`
+ const proxyUrl = proxies['http']
+ ? `http://${proxies['http']}`
+ : proxies['socks']
+ ? `socks://${proxies['socks']}`
+ : proxies['https']
+ ? `https://${proxies['https']}`
: undefined;
if (!proxyUrl) {
throw new Error(`Could not get usable proxy URL from ${proxyConfigString}`);
+5
View File
@@ -116,4 +116,9 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
支持 GPT-5 模型
新增代码工具,支持快速启动 Qwen Code, Gemini Cli, Claude Code
翻译页面改版,支持更多设置
支持保存整个话题到知识库
坚果云备份支持设置最大备份数量
稳定性改进和错误修复
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.5.5",
"version": "1.5.6",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -88,6 +88,7 @@
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
"@aws-sdk/client-bedrock": "^3.840.0",
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
"@aws-sdk/client-s3": "^3.840.0",
"@cherrystudio/embedjs": "^0.1.31",
@@ -212,7 +213,7 @@
"lucide-react": "^0.525.0",
"macos-release": "^3.4.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.7.0",
"mermaid": "^11.9.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"notion-helper": "^1.3.22",
@@ -269,7 +270,7 @@
"winston-daily-rotate-file": "^5.0.0",
"word-extractor": "^1.0.4",
"zipread": "^1.3.3",
"zod": "^3.25.74"
"zod": "^4.0.0"
},
"resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
@@ -285,7 +286,6 @@
"vite": "npm:rolldown-vite@latest",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"windows-system-proxy@npm:^1.0.0": "patch:windows-system-proxy@npm%3A1.0.0#~/.yarn/patches/windows-system-proxy-npm-1.0.0-ff2a828eec.patch",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch"
},
+4 -1
View File
@@ -276,5 +276,8 @@ export enum IpcChannel {
TRACE_SET_TITLE = 'trace:setTitle',
TRACE_ADD_END_MESSAGE = 'trace:addEndMessage',
TRACE_CLEAN_LOCAL_DATA = 'trace:cleanLocalData',
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage'
TRACE_ADD_STREAM_MESSAGE = 'trace:addStreamMessage',
// CodeTools
CodeTools_Run = 'code-tools:run'
}
+88
View File
@@ -0,0 +1,88 @@
const https = require('https')
const { loggerService } = require('@logger')
const logger = loggerService.withContext('IpService')
/**
* 获取用户的IP地址所在国家
* @returns {Promise<string>} 返回国家代码,默认为'CN'
*/
async function getIpCountry() {
return new Promise((resolve) => {
// 添加超时控制
const timeout = setTimeout(() => {
logger.info('IP Address Check Timeout, default to China Mirror')
resolve('CN')
}, 5000)
const options = {
hostname: 'ipinfo.io',
path: '/json',
method: 'GET',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
}
const req = https.request(options, (res) => {
clearTimeout(timeout)
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
try {
const parsed = JSON.parse(data)
const country = parsed.country || 'CN'
logger.info(`Detected user IP address country: ${country}`)
resolve(country)
} catch (error) {
logger.error('Failed to parse IP address information:', error.message)
resolve('CN')
}
})
})
req.on('error', (error) => {
clearTimeout(timeout)
logger.error('Failed to get IP address information:', error.message)
resolve('CN')
})
req.end()
})
}
/**
* 检查用户是否在中国
* @returns {Promise<boolean>} 如果用户在中国返回true,否则返回false
*/
async function isUserInChina() {
const country = await getIpCountry()
return country.toLowerCase() === 'cn'
}
/**
* 根据用户位置获取适合的npm镜像URL
* @returns {Promise<string>} 返回npm镜像URL
*/
async function getNpmRegistryUrl() {
const inChina = await isUserInChina()
if (inChina) {
logger.info('User in China, using Taobao npm mirror')
return 'https://registry.npmmirror.com'
} else {
logger.info('User not in China, using default npm mirror')
return 'https://registry.npmjs.org'
}
}
module.exports = {
getIpCountry,
isUserInChina,
getNpmRegistryUrl
}
+7 -4
View File
@@ -16,11 +16,12 @@ import { Notification } from 'src/renderer/src/types/notification'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { codeToolsService } from './services/CodeToolsService'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
import FileStorage from './services/FileStorage'
import { fileStorage as fileManager } from './services/FileStorage'
import FileService from './services/FileSystemService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
@@ -61,16 +62,15 @@ import { compress, decompress } from './utils/zip'
const logger = loggerService.withContext('IPC')
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
const exportService = new ExportService()
const obsidianVaultService = new ObsidianVaultService()
const vertexAIService = VertexAIService.getInstance()
const memoryService = MemoryService.getInstance()
const dxtService = new DxtService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const appUpdater = new AppUpdater()
const notificationService = new NotificationService(mainWindow)
// Initialize Python service with main window
@@ -701,4 +701,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
(_, spanId: string, modelName: string, context: string, msg: any) =>
addStreamMessage(spanId, modelName, context, msg)
)
// CodeTools
ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run)
}
+10 -8
View File
@@ -73,17 +73,19 @@ export async function addFileLoader(
// 获取文件类型,如果没有匹配则默认为文本类型
const loaderType = FILE_LOADER_MAP[file.ext.toLowerCase()] || 'text'
let loaderReturn: AddLoaderReturn
// 使用文件的实际路径
const filePath = file.path
// JSON类型处理
let jsonObject = {}
let jsonParsed = true
logger.info(`[KnowledgeBase] processing file ${file.path} as ${loaderType} type`)
logger.info(`[KnowledgeBase] processing file ${filePath} as ${loaderType} type`)
switch (loaderType) {
case 'common':
// 内置类型处理
loaderReturn = await ragApplication.addLoader(
new LocalPathLoader({
path: file.path,
path: filePath,
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@@ -99,7 +101,7 @@ export async function addFileLoader(
// epub类型处理
loaderReturn = await ragApplication.addLoader(
new EpubLoader({
filePath: file.path,
filePath: filePath,
chunkSize: base.chunkSize ?? 1000,
chunkOverlap: base.chunkOverlap ?? 200
}) as any,
@@ -109,14 +111,14 @@ export async function addFileLoader(
case 'drafts':
// Drafts类型处理
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(file.path) as any, forceReload)
loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(filePath), forceReload)
break
case 'html':
// HTML类型处理
loaderReturn = await ragApplication.addLoader(
new WebLoader({
urlOrContent: await readTextFileWithAutoEncoding(file.path),
urlOrContent: await readTextFileWithAutoEncoding(filePath),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@@ -126,11 +128,11 @@ export async function addFileLoader(
case 'json':
try {
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(filePath))
} catch (error) {
jsonParsed = false
logger.warn(
`[KnowledgeBase] failed parsing json file, falling back to text processing: ${file.path}`,
`[KnowledgeBase] failed parsing json file, falling back to text processing: ${filePath}`,
error as Error
)
}
@@ -145,7 +147,7 @@ export async function addFileLoader(
// 如果是其他文本类型且尚未读取文件,则读取文件
loaderReturn = await ragApplication.addLoader(
new TextLoader({
text: await readTextFileWithAutoEncoding(file.path),
text: await readTextFileWithAutoEncoding(filePath),
chunkSize: base.chunkSize,
chunkOverlap: base.chunkOverlap
}) as any,
@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios, { AxiosRequestConfig } from 'axios'
@@ -54,20 +55,21 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
try {
logger.info(`Preprocess processing started: ${file.path}`)
const filePath = fileStorage.getFilePathById(file)
logger.info(`Preprocess processing started: ${filePath}`)
// 步骤1: 准备上传
const { uid, url } = await this.preupload()
logger.info(`Preprocess preupload completed: uid=${uid}`)
await this.validateFile(file.path)
await this.validateFile(filePath)
// 步骤2: 上传文件
await this.putFile(file.path, url)
await this.putFile(filePath, url)
// 步骤3: 等待处理完成
await this.waitForProcessing(sourceId, uid)
logger.info(`Preprocess parsing completed successfully for: ${file.path}`)
logger.info(`Preprocess parsing completed successfully for: ${filePath}`)
// 步骤4: 导出文件
const { path: outputPath } = await this.exportFile(file, uid)
@@ -77,9 +79,7 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
processedFile: this.createProcessedFileInfo(file, outputPath)
}
} catch (error) {
logger.error(
`Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}`
)
logger.error(`Preprocess processing failed for:`, error as Error)
throw error
}
}
@@ -102,11 +102,12 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
* @returns 导出文件的路径
*/
public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> {
logger.info(`Exporting file: ${file.path}`)
const filePath = fileStorage.getFilePathById(file)
logger.info(`Exporting file: ${filePath}`)
// 步骤1: 转换文件
await this.convertFile(uid, file.path)
logger.info(`File conversion completed for: ${file.path}`)
await this.convertFile(uid, filePath)
logger.info(`File conversion completed for: ${filePath}`)
// 步骤2: 等待导出并获取URL
const exportUrl = await this.waitForExport(uid)
@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileMetadata, PreprocessProvider } from '@types'
import AdmZip from 'adm-zip'
import axios from 'axios'
@@ -63,8 +64,9 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota: number }> {
try {
logger.info(`MinerU preprocess processing started: ${file.path}`)
await this.validateFile(file.path)
const filePath = fileStorage.getFilePathById(file)
logger.info(`MinerU preprocess processing started: ${filePath}`)
await this.validateFile(filePath)
// 1. 获取上传URL并上传文件
const batchId = await this.uploadFile(file)
@@ -86,7 +88,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
quota
}
} catch (error: any) {
logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`)
logger.error(`MinerU preprocess processing failed for:`, error as Error)
throw new Error(error.message)
}
}
@@ -205,16 +207,14 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
try {
// 步骤1: 获取上传URL
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
logger.debug(`Got upload URLs for batch: ${batchId}`)
logger.debug(`batchId: ${batchId}, fileurls: ${fileUrls}`)
// 步骤2: 上传文件到获取的URL
await this.putFileToUrl(file.path, fileUrls[0])
logger.info(`File uploaded successfully: ${file.path}`)
const filePath = fileStorage.getFilePathById(file)
await this.putFileToUrl(filePath, fileUrls[0])
logger.info(`File uploaded successfully: ${filePath}`, { batchId, fileUrls })
return batchId
} catch (error: any) {
logger.error(`Failed to upload file ${file.path}: ${error.message}`)
logger.error(`Failed to upload file:`, error as Error)
throw new Error(error.message)
}
}
@@ -1,6 +1,7 @@
import fs from 'node:fs'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { MistralClientManager } from '@main/services/MistralClientManager'
import { MistralService } from '@main/services/remotefile/MistralService'
import { Mistral } from '@mistralai/mistralai'
@@ -38,7 +39,8 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
private async preupload(file: FileMetadata): Promise<PreuploadResponse> {
let document: PreuploadResponse
logger.info(`preprocess preupload started for local file: ${file.path}`)
const filePath = fileStorage.getFilePathById(file)
logger.info(`preprocess preupload started for local file: ${filePath}`)
if (file.ext.toLowerCase() === '.pdf') {
const uploadResponse = await this.fileService.uploadFile(file)
@@ -58,7 +60,7 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
documentUrl: fileUrl.url
}
} else {
const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64')
const base64Image = Buffer.from(fs.readFileSync(filePath)).toString('base64')
document = {
type: 'image_url',
imageUrl: `data:image/png;base64,${base64Image}`
@@ -97,8 +99,8 @@ export default class MistralPreprocessProvider extends BasePreprocessProvider {
// 使用统一的存储路径:Data/Files/{file.id}/
const conversionId = file.id
const outputPath = path.join(this.storageDir, file.id)
// const outputPath = this.storageDir
const outputFileName = path.basename(file.path, path.extname(file.path))
const filePath = fileStorage.getFilePathById(file)
const outputFileName = path.basename(filePath, path.extname(filePath))
fs.mkdirSync(outputPath, { recursive: true })
const markdownParts: string[] = []
+1 -1
View File
@@ -4,7 +4,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { JSDOM } from 'jsdom'
import TurndownService from 'turndown'
import { z } from 'zod'
import { z } from 'zod/v3'
export const RequestPayloadSchema = z.object({
url: z.string().url(),
+9 -31
View File
@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
@@ -11,6 +12,7 @@ import path from 'path'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
const logger = loggerService.withContext('AppUpdater')
@@ -20,7 +22,7 @@ export default class AppUpdater {
private cancellationToken: CancellationToken = new CancellationToken()
private updateCheckResult: UpdateCheckResult | null = null
constructor(mainWindow: BrowserWindow) {
constructor() {
autoUpdater.logger = logger as Logger
autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = configManager.getAutoUpdate()
@@ -32,12 +34,12 @@ export default class AppUpdater {
autoUpdater.on('error', (error) => {
logger.error('update error', error as Error)
mainWindow.webContents.send(IpcChannel.UpdateError, error)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateError, error)
})
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
logger.info('update available', releaseInfo)
mainWindow.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
})
// 检测到不需要更新时
@@ -48,17 +50,17 @@ export default class AppUpdater {
return
}
mainWindow.webContents.send(IpcChannel.UpdateNotAvailable)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateNotAvailable)
})
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
mainWindow.webContents.send(IpcChannel.DownloadProgress, progress)
windowService.getMainWindow()?.webContents.send(IpcChannel.DownloadProgress, progress)
})
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
mainWindow.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
this.releaseInfo = releaseInfo
logger.info('update downloaded', releaseInfo)
})
@@ -98,30 +100,6 @@ export default class AppUpdater {
}
}
private async _getIpCountry() {
try {
// add timeout using AbortController
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
})
clearTimeout(timeoutId)
const data = await ipinfo.json()
return data.country || 'CN'
} catch (error) {
logger.error('Failed to get ipinfo:', error as Error)
return 'CN'
}
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
@@ -186,7 +164,7 @@ export default class AppUpdater {
}
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
const ipCountry = await this._getIpCountry()
const ipCountry = await getIpCountry()
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
if (ipCountry.toLowerCase() !== 'cn') {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
+476
View File
@@ -0,0 +1,476 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { removeEnvProxy } from '@main/utils'
import { isUserInChina } from '@main/utils/ipService'
import { getBinaryName } from '@main/utils/process'
import { spawn } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(require('child_process').exec)
const logger = loggerService.withContext('CodeToolsService')
interface VersionInfo {
installed: string | null
latest: string | null
needsUpdate: boolean
}
class CodeToolsService {
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
constructor() {
this.getBunPath = this.getBunPath.bind(this)
this.getPackageName = this.getPackageName.bind(this)
this.getCliExecutableName = this.getCliExecutableName.bind(this)
this.isPackageInstalled = this.isPackageInstalled.bind(this)
this.getVersionInfo = this.getVersionInfo.bind(this)
this.updatePackage = this.updatePackage.bind(this)
this.run = this.run.bind(this)
}
public async getBunPath() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const bunName = await getBinaryName('bun')
const bunPath = path.join(dir, bunName)
return bunPath
}
public async getPackageName(cliTool: string) {
if (cliTool === 'claude-code') {
return '@anthropic-ai/claude-code'
}
if (cliTool === 'gemini-cli') {
return '@google/gemini-cli'
}
return '@qwen-code/qwen-code'
}
public async getCliExecutableName(cliTool: string) {
if (cliTool === 'claude-code') {
return 'claude'
}
if (cliTool === 'gemini-cli') {
return 'gemini'
}
return 'qwen'
}
private async isPackageInstalled(cliTool: string): Promise<boolean> {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
// Ensure bin directory exists
if (!fs.existsSync(binDir)) {
fs.mkdirSync(binDir, { recursive: true })
}
return fs.existsSync(executablePath)
}
/**
* Get version information for a CLI tool
*/
public async getVersionInfo(cliTool: string): Promise<VersionInfo> {
logger.info(`Starting version check for ${cliTool}`)
const packageName = await this.getPackageName(cliTool)
const isInstalled = await this.isPackageInstalled(cliTool)
let installedVersion: string | null = null
let latestVersion: string | null = null
// Get installed version if package is installed
if (isInstalled) {
logger.info(`${cliTool} is installed, getting current version`)
try {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
// Extract version number from output (format may vary by tool)
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
logger.info(`${cliTool} current installed version: ${installedVersion}`)
} catch (error) {
logger.warn(`Failed to get installed version for ${cliTool}:`, error as Error)
}
} else {
logger.info(`${cliTool} is not installed`)
}
// Get latest version from npm (with cache)
const cacheKey = `${packageName}-latest`
const cached = this.versionCache.get(cacheKey)
const now = Date.now()
if (cached && now - cached.timestamp < this.CACHE_DURATION) {
logger.info(`Using cached latest version for ${packageName}: ${cached.version}`)
latestVersion = cached.version
} else {
logger.info(`Fetching latest version for ${packageName} from npm`)
try {
const bunPath = await this.getBunPath()
const { stdout } = await execAsync(`"${bunPath}" info ${packageName} version`, { timeout: 15000 })
latestVersion = stdout.trim().replace(/["']/g, '')
logger.info(`${packageName} latest version: ${latestVersion}`)
// Cache the result
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
logger.debug(`Cached latest version for ${packageName}`)
} catch (error) {
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
// If we have a cached version, use it even if expired
if (cached) {
logger.info(`Using expired cached version for ${packageName}: ${cached.version}`)
latestVersion = cached.version
}
}
}
const needsUpdate = !!(installedVersion && latestVersion && installedVersion !== latestVersion)
logger.info(
`Version check result for ${cliTool}: installed=${installedVersion}, latest=${latestVersion}, needsUpdate=${needsUpdate}`
)
return {
installed: installedVersion,
latest: latestVersion,
needsUpdate
}
}
/**
* Get npm registry URL based on user location
*/
private async getNpmRegistryUrl(): Promise<string> {
try {
const inChina = await isUserInChina()
if (inChina) {
logger.info('User in China, using Taobao npm mirror')
return 'https://registry.npmmirror.com'
} else {
logger.info('User not in China, using default npm mirror')
return 'https://registry.npmjs.org'
}
} catch (error) {
logger.warn('Failed to detect user location, using default npm mirror')
return 'https://registry.npmjs.org'
}
}
/**
* Update a CLI tool to the latest version
*/
public async updatePackage(cliTool: string): Promise<{ success: boolean; message: string }> {
logger.info(`Starting update process for ${cliTool}`)
try {
const packageName = await this.getPackageName(cliTool)
const bunPath = await this.getBunPath()
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
const registryUrl = await this.getNpmRegistryUrl()
const installEnvPrefix =
process.platform === 'win32'
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
logger.info(`Executing update command: ${updateCommand}`)
await execAsync(updateCommand, { timeout: 60000 })
logger.info(`Successfully executed update command for ${cliTool}`)
// Clear version cache for this package
const cacheKey = `${packageName}-latest`
this.versionCache.delete(cacheKey)
logger.debug(`Cleared version cache for ${packageName}`)
const successMessage = `Successfully updated ${cliTool} to the latest version`
logger.info(successMessage)
return {
success: true,
message: successMessage
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const failureMessage = `Failed to update ${cliTool}: ${errorMessage}`
logger.error(failureMessage, error as Error)
return {
success: false,
message: failureMessage
}
}
}
async run(
_: Electron.IpcMainInvokeEvent,
cliTool: string,
_model: string,
directory: string,
env: Record<string, string>,
options: { autoUpdateToLatest?: boolean } = {}
) {
logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`)
logger.debug(`Environment variables:`, Object.keys(env))
logger.debug(`Options:`, options)
const packageName = await this.getPackageName(cliTool)
const bunPath = await this.getBunPath()
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : ''))
logger.debug(`Package name: ${packageName}`)
logger.debug(`Bun path: ${bunPath}`)
logger.debug(`Executable name: ${executableName}`)
logger.debug(`Executable path: ${executablePath}`)
// Check if package is already installed
const isInstalled = await this.isPackageInstalled(cliTool)
// Check for updates and auto-update if requested
let updateMessage = ''
if (isInstalled && options.autoUpdateToLatest) {
logger.info(`Auto update to latest enabled for ${cliTool}`)
try {
const versionInfo = await this.getVersionInfo(cliTool)
if (versionInfo.needsUpdate) {
logger.info(`Update available for ${cliTool}: ${versionInfo.installed} -> ${versionInfo.latest}`)
logger.info(`Auto-updating ${cliTool} to latest version`)
updateMessage = ` && echo "Updating ${cliTool} from ${versionInfo.installed} to ${versionInfo.latest}..."`
const updateResult = await this.updatePackage(cliTool)
if (updateResult.success) {
logger.info(`Update completed successfully for ${cliTool}`)
updateMessage += ` && echo "Update completed successfully"`
} else {
logger.error(`Update failed for ${cliTool}: ${updateResult.message}`)
updateMessage += ` && echo "Update failed: ${updateResult.message}"`
}
} else if (versionInfo.installed && versionInfo.latest) {
logger.info(`${cliTool} is already up to date (${versionInfo.installed})`)
updateMessage = ` && echo "${cliTool} is up to date (${versionInfo.installed})"`
}
} catch (error) {
logger.warn(`Failed to check version for ${cliTool}:`, error as Error)
}
}
// Select different terminal based on operating system
const platform = process.platform
let terminalCommand: string
let terminalArgs: string[]
// Build environment variable prefix (based on platform)
const buildEnvPrefix = (isWindows: boolean) => {
if (Object.keys(env).length === 0) return ''
if (isWindows) {
// Windows uses set command
return Object.entries(env)
.map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`)
.join(' && ')
} else {
// Unix-like systems use export command
return Object.entries(env)
.map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`)
.join(' && ')
}
}
// Build command to execute
let baseCommand: string
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) {
// If already installed, run executable directly (with optional update message)
baseCommand = `"${executablePath}"`
if (updateMessage) {
baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
}
} else {
// If not installed, install first then run
const registryUrl = await this.getNpmRegistryUrl()
const installEnvPrefix =
platform === 'win32'
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && "${executablePath}"`
}
switch (platform) {
case 'darwin': {
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
terminalCommand = 'osascript'
terminalArgs = [
'-e',
`tell application "Terminal"
activate
do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}"
end tell`
]
break
}
case 'win32': {
// Windows - Use temp bat file for debugging
const envPrefix = buildEnvPrefix(true)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
// Create temp bat file for debugging and avoid complex command line escaping issues
const tempDir = path.join(os.tmpdir(), 'cherrystudio')
const timestamp = Date.now()
const batFileName = `launch_${cliTool}_${timestamp}.bat`
const batFilePath = path.join(tempDir, batFileName)
// Ensure temp directory exists
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
// Build bat file content, including debug information
const batContent = [
'@echo off',
`title ${cliTool} - Cherry Studio`, // Set window title in bat file
'echo ================================================',
'echo Cherry Studio CLI Tool Launcher',
`echo Tool: ${cliTool}`,
`echo Directory: ${directory}`,
`echo Time: ${new Date().toLocaleString()}`,
'echo ================================================',
'',
':: Change to target directory',
`cd /d "${directory}" || (`,
' echo ERROR: Failed to change directory',
` echo Target directory: ${directory}`,
' pause',
' exit /b 1',
')',
'',
':: Clear screen',
'cls',
'',
':: Execute command (without displaying environment variable settings)',
command,
'',
':: Command execution completed',
'echo.',
'echo Command execution completed.',
'echo Press any key to close this window...',
'pause >nul'
].join('\r\n')
// Write to bat file
try {
fs.writeFileSync(batFilePath, batContent, 'utf8')
logger.info(`Created temp bat file: ${batFilePath}`)
} catch (error) {
logger.error(`Failed to create bat file: ${error}`)
throw new Error(`Failed to create launch script: ${error}`)
}
// Launch bat file - Use safest start syntax, no title parameter
terminalCommand = 'cmd'
terminalArgs = ['/c', 'start', batFilePath]
// Set cleanup task (delete temp file after 5 minutes)
setTimeout(() => {
try {
fs.existsSync(batFilePath) && fs.unlinkSync(batFilePath)
} catch (error) {
logger.warn(`Failed to cleanup temp bat file: ${error}`)
}
}, 10 * 1000) // Delete temp file after 10 seconds
break
}
case 'linux': {
// Linux - Try to use common terminal emulators
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator']
let foundTerminal = 'xterm' // Default to xterm
for (const terminal of linuxTerminals) {
try {
// Check if terminal exists
const checkResult = spawn('which', [terminal], { stdio: 'pipe' })
await new Promise((resolve) => {
checkResult.on('close', (code) => {
if (code === 0) {
foundTerminal = terminal
}
resolve(code)
})
})
if (foundTerminal === terminal) break
} catch (error) {
// Continue trying next terminal
}
}
if (foundTerminal === 'gnome-terminal') {
terminalCommand = 'gnome-terminal'
terminalArgs = ['--working-directory', directory, '--', 'bash', '-c', `clear && ${command}; exec bash`]
} else if (foundTerminal === 'konsole') {
terminalCommand = 'konsole'
terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
} else {
// Default to xterm
terminalCommand = 'xterm'
terminalArgs = ['-e', `cd "${directory}" && clear && ${command} && bash`]
}
break
}
default:
throw new Error(`Unsupported operating system: ${platform}`)
}
const processEnv = { ...process.env, ...env }
removeEnvProxy(processEnv as Record<string, string>)
// Launch terminal process
try {
logger.info(`Launching terminal with command: ${terminalCommand}`)
logger.debug(`Terminal arguments:`, terminalArgs)
logger.debug(`Working directory: ${directory}`)
logger.debug(`Process environment keys: ${Object.keys(processEnv)}`)
spawn(terminalCommand, terminalArgs, {
detached: true,
stdio: 'ignore',
cwd: directory,
env: processEnv
})
const successMessage = `Launched ${cliTool} in new terminal window`
logger.info(successMessage)
return {
success: true,
message: successMessage,
command: `${terminalCommand} ${terminalArgs.join(' ')}`
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const failureMessage = `Failed to launch terminal: ${errorMessage}`
logger.error(failureMessage, error as Error)
return {
success: false,
message: failureMessage,
command: `${terminalCommand} ${terminalArgs.join(' ')}`
}
}
}
}
export const codeToolsService = new CodeToolsService()
+3 -5
View File
@@ -21,15 +21,13 @@ import {
import { dialog } from 'electron'
import MarkdownIt from 'markdown-it'
import FileStorage from './FileStorage'
import { fileStorage } from './FileStorage'
const logger = loggerService.withContext('ExportService')
export class ExportService {
private fileManager: FileStorage
private md: MarkdownIt
constructor(fileManager: FileStorage) {
this.fileManager = fileManager
constructor() {
this.md = new MarkdownIt()
}
@@ -399,7 +397,7 @@ export class ExportService {
})
if (filePath) {
await this.fileManager.writeFile(_, filePath, buffer)
await fileStorage.writeFile(_, filePath, buffer)
logger.debug('Document exported successfully')
}
} catch (error) {
+10 -5
View File
@@ -156,7 +156,8 @@ class FileStorage {
}
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
const duplicateFile = await this.findDuplicateFile(file.path)
const filePath = file.path
const duplicateFile = await this.findDuplicateFile(filePath)
if (duplicateFile) {
return duplicateFile
@@ -167,13 +168,13 @@ class FileStorage {
const ext = path.extname(origin_name).toLowerCase()
const destPath = path.join(this.storageDir, uuid + ext)
logger.info(`[FileStorage] Uploading file: ${file.path}`)
logger.info(`[FileStorage] Uploading file: ${filePath}`)
// 根据文件类型选择处理方式
if (imageExts.includes(ext)) {
await this.compressImage(file.path, destPath)
await this.compressImage(filePath, destPath)
} else {
await fs.promises.copyFile(file.path, destPath)
await fs.promises.copyFile(filePath, destPath)
}
const stats = await fs.promises.stat(destPath)
@@ -624,6 +625,10 @@ class FileStorage {
throw error
}
}
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}
}
export default FileStorage
export const fileStorage = new FileStorage()
+4 -2
View File
@@ -27,6 +27,7 @@ import { addFileLoader } from '@main/knowledge/loader'
import { NoteLoader } from '@main/knowledge/loader/noteLoader'
import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider'
import Reranker from '@main/knowledge/reranker/Reranker'
import { fileStorage } from '@main/services/FileStorage'
import { windowService } from '@main/services/WindowService'
import { getDataPath } from '@main/utils'
import { getAllFiles } from '@main/utils/file'
@@ -689,15 +690,16 @@ class KnowledgeService {
if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') {
try {
const provider = new PreprocessProvider(base.preprocessProvider.provider, userId)
const filePath = fileStorage.getFilePathById(file)
// Check if file has already been preprocessed
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
if (alreadyProcessed) {
logger.debug(`File already preprocess processed, using cached result: ${file.path}`)
logger.debug(`File already preprocess processed, using cached result: ${filePath}`)
return alreadyProcessed
}
// Execute preprocessing
logger.debug(`Starting preprocess processing for scanned PDF: ${file.path}`)
logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`)
const { processedFile, quota } = await provider.parseFile(item.id, file)
fileToProcess = processedFile
const mainWindow = windowService.getMainWindow()
+2 -10
View File
@@ -4,7 +4,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { createInMemoryMCPServer } from '@main/mcpServers/factory'
import { makeSureDirExists } from '@main/utils'
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
import { buildFunctionCallToolName } from '@main/utils/mcp'
import { getBinaryName, getBinaryPath } from '@main/utils/process'
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
@@ -280,7 +280,7 @@ class McpService {
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
if (cmd.includes('bun')) {
this.removeProxyEnv(loginShellEnv)
removeEnvProxy(loginShellEnv)
}
const transportOptions: any = {
@@ -827,14 +827,6 @@ class McpService {
}
})
private removeProxyEnv(env: Record<string, string>) {
delete env.HTTPS_PROXY
delete env.HTTP_PROXY
delete env.grpc_proxy
delete env.http_proxy
delete env.https_proxy
}
// 实现 abortTool 方法
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
const activeToolCall = this.activeToolCalls.get(callId)
@@ -1,5 +1,6 @@
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import { v4 as uuidv4 } from 'uuid'
@@ -29,7 +30,7 @@ export class GeminiService extends BaseFileService {
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
try {
const uploadResult = await this.fileManager.upload({
file: file.path,
file: fileStorage.getFilePathById(file),
config: {
mimeType: 'application/pdf',
name: file.id,
@@ -1,6 +1,7 @@
import fs from 'node:fs/promises'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { Mistral } from '@mistralai/mistralai'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
@@ -21,7 +22,7 @@ export class MistralService extends BaseFileService {
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
try {
const fileBuffer = await fs.readFile(file.path)
const fileBuffer = await fs.readFile(fileStorage.getFilePathById(file))
const response = await this.client.files.upload({
file: {
fileName: file.origin_name,
+8
View File
@@ -70,3 +70,11 @@ export async function calculateDirectorySize(directoryPath: string): Promise<num
}
return totalSize
}
export const removeEnvProxy = (env: Record<string, string>) => {
delete env.HTTPS_PROXY
delete env.HTTP_PROXY
delete env.grpc_proxy
delete env.http_proxy
delete env.https_proxy
}
+42
View File
@@ -0,0 +1,42 @@
import { loggerService } from '@logger'
const logger = loggerService.withContext('IpService')
/**
* 获取用户的IP地址所在国家
* @returns 返回国家代码,默认为'CN'
*/
export async function getIpCountry(): Promise<string> {
try {
// 添加超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
})
clearTimeout(timeoutId)
const data = await ipinfo.json()
const country = data.country || 'CN'
logger.info(`Detected user IP address country: ${country}`)
return country
} catch (error) {
logger.error('Failed to get IP address information:', error as Error)
return 'CN'
}
}
/**
* 检查用户是否在中国
* @returns 如果用户在中国返回true,否则返回false
*/
export async function isUserInChina(): Promise<boolean> {
const country = await getIpCountry()
return country.toLowerCase() === 'cn'
}
+9
View File
@@ -394,6 +394,15 @@ const api = {
cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA),
addStreamMessage: (spanId: string, modelName: string, context: string, message: any) =>
ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message)
},
codeTools: {
run: (
cliTool: string,
model: string,
directory: string,
env: Record<string, string>,
options?: { autoUpdateToLatest?: boolean }
) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options)
}
}
+2
View File
@@ -8,6 +8,7 @@ import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useSettings'
import AgentsPage from './pages/agents/AgentsPage'
import CodeToolsPage from './pages/code/CodeToolsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
@@ -30,6 +31,7 @@ const Router: FC = () => {
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
</Routes>
@@ -1,3 +1,4 @@
import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from '@aws-sdk/client-bedrock'
import {
BedrockRuntimeClient,
ConverseCommand,
@@ -87,7 +88,15 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
})
this.sdkInstance = { client, region }
const bedrockClient = new BedrockClient({
region,
credentials: {
accessKeyId,
secretAccessKey
}
})
this.sdkInstance = { client, bedrockClient, region }
return this.sdkInstance
}
@@ -132,6 +141,8 @@ export class AwsBedrockAPIClient extends BaseApiClient<
})
}))
logger.info('Creating completions with model ID:', { modelId: payload.modelId })
const commonParams = {
modelId: payload.modelId,
messages: awsMessages as any,
@@ -295,9 +306,76 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
}
// @ts-ignore sdk未提供
override async listModels(): Promise<SdkModel[]> {
return []
try {
const sdk = await this.getSdkInstance()
// 获取支持ON_DEMAND的基础模型列表
const modelsCommand = new ListFoundationModelsCommand({
byInferenceType: 'ON_DEMAND',
byOutputModality: 'TEXT'
})
const modelsResponse = await sdk.bedrockClient.send(modelsCommand)
// 获取推理配置文件列表
const profilesCommand = new ListInferenceProfilesCommand({})
const profilesResponse = await sdk.bedrockClient.send(profilesCommand)
logger.info('Found ON_DEMAND foundation models:', { count: modelsResponse.modelSummaries?.length || 0 })
logger.info('Found inference profiles:', { count: profilesResponse.inferenceProfileSummaries?.length || 0 })
const models: any[] = []
// 处理ON_DEMAND基础模型
if (modelsResponse.modelSummaries) {
for (const model of modelsResponse.modelSummaries) {
if (!model.modelId || !model.modelName) continue
logger.info('Adding ON_DEMAND model', { modelId: model.modelId })
models.push({
id: model.modelId,
name: model.modelName,
display_name: model.modelName,
description: `${model.providerName || 'AWS'} - ${model.modelName}`,
owned_by: model.providerName || 'AWS',
provider: this.provider.id,
group: 'AWS Bedrock',
isInferenceProfile: false
})
}
}
// 处理推理配置文件
if (profilesResponse.inferenceProfileSummaries) {
for (const profile of profilesResponse.inferenceProfileSummaries) {
if (!profile.inferenceProfileArn || !profile.inferenceProfileName) continue
logger.info('Adding inference profile', {
profileArn: profile.inferenceProfileArn,
profileName: profile.inferenceProfileName
})
models.push({
id: profile.inferenceProfileArn,
name: `${profile.inferenceProfileName} (Profile)`,
display_name: `${profile.inferenceProfileName} (Profile)`,
description: `AWS Inference Profile - ${profile.inferenceProfileName}`,
owned_by: 'AWS',
provider: this.provider.id,
group: 'AWS Bedrock Profiles',
isInferenceProfile: true,
inferenceProfileId: profile.inferenceProfileId,
inferenceProfileArn: profile.inferenceProfileArn
})
}
}
logger.info('Total models added to list', { count: models.length })
return models
} catch (error) {
logger.error('Failed to list AWS Bedrock models:', error as Error)
return []
}
}
public async convertMessageToSdkParam(message: Message): Promise<AwsBedrockSdkMessageParam> {
@@ -52,6 +52,7 @@ import {
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
MistralDeltaSchema,
OpenAISdkMessageParam,
OpenAISdkParams,
OpenAISdkRawChunk,
@@ -758,12 +759,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
let accumulatingText = false
return (context: ResponseChunkTransformerContext) => ({
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
const isOpenRouter = context.provider?.id === 'openrouter'
// 持续更新usage信息
logger.silly('chunk', chunk)
if (chunk.usage) {
const usage = chunk.usage as any // OpenRouter may include additional fields like cost
const usage = chunk.usage
lastUsageInfo = {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
@@ -771,19 +770,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// Handle OpenRouter specific cost fields
...(usage.cost !== undefined ? { cost: usage.cost } : {})
}
// For OpenRouter, if we've seen finish_reason and now have usage, emit completion signals
if (isOpenRouter && hasFinishReason && !isFinished) {
emitCompletionSignals(controller)
return
}
}
// For OpenRouter, if this chunk only contains usage without choices, emit completion signals
if (isOpenRouter && chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
if (!isFinished) {
emitCompletionSignals(controller)
}
// if we've already seen finish_reason, emit completion signals. No matter whether we get usage or not.
if (hasFinishReason && !isFinished) {
emitCompletionSignals(controller)
return
}
@@ -814,7 +805,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
(typeof choice.delta.content === 'string' && choice.delta.content !== '') ||
(typeof (choice.delta as any).reasoning_content === 'string' &&
(choice.delta as any).reasoning_content !== '') ||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== ''))
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') ||
Array.isArray(choice.delta.content))
) {
contentSource = choice.delta
} else if ('message' in choice) {
@@ -825,23 +817,26 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (!contentSource?.content) {
accumulatingText = false
}
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
if (!contentSource?.reasoning_content && !contentSource?.reasoning) {
const mistralDelta = MistralDeltaSchema.safeParse(contentSource?.content)
if (
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
!contentSource?.reasoning_content &&
// @ts-ignore - reasoning is not in standard OpenAI types but some providers use it
!contentSource?.reasoning &&
(mistralDelta.data?.[0]?.type !== 'thinking' || !mistralDelta.success)
) {
isThinking = false
}
if (!contentSource) {
if ('finish_reason' in choice && choice.finish_reason) {
// For OpenRouter, don't emit completion signals immediately after finish_reason
// Wait for the usage chunk that comes after
if (isOpenRouter) {
hasFinishReason = true
// If we already have usage info, emit completion signals now
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
emitCompletionSignals(controller)
}
} else {
// For other providers, emit completion signals immediately
// OpenAI Chat Completions API 在启用 stream_options: { include_usage: true } 以后
// 包含 usage chunk 会在包含 finish_reason: stop 的 chunk 之后
// 所以试图等到拿到 usage 之后再发出结束信号
hasFinishReason = true
// If we already have usage info, emit completion signals now
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
emitCompletionSignals(controller)
}
}
@@ -864,12 +859,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
const reasoningText =
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
contentSource.reasoning_content ||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
contentSource.reasoning ||
(mistralDelta.data?.[0]?.type === 'thinking' ? mistralDelta.data?.[0]?.thinking[0]?.text : undefined)
if (reasoningText) {
// logger.silly('since reasoningText is trusy, try to enqueue THINKING_START AND THINKING_DELTA')
if (!isThinking) {
// logger.silly('since isThinking is falsy, try to enqueue THINKING_START')
controller.enqueue({
type: ChunkType.THINKING_START
} as ThinkingStartChunk)
@@ -886,22 +883,35 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
// 处理文本内容
if (contentSource.content) {
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
if (mistralDelta.success && mistralDelta.data?.[0]?.type === 'text') {
if (!accumulatingText) {
// logger.silly('enqueue TEXT_START')
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
accumulatingText = true
}
// logger.silly('enqueue TEXT_DELTA')
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: contentSource.content
text: mistralDelta.data?.[0]?.text
})
} else {
accumulatingText = false
} else if (!mistralDelta.success) {
if (contentSource.content) {
// logger.silly('since contentSource.content is trusy, try to enqueue TEXT_START and TEXT_DELTA')
if (!accumulatingText) {
// logger.silly('enqueue TEXT_START')
controller.enqueue({
type: ChunkType.TEXT_START
} as TextStartChunk)
accumulatingText = true
}
// logger.silly('enqueue TEXT_DELTA')
controller.enqueue({
type: ChunkType.TEXT_DELTA,
text: contentSource.content
})
} else {
accumulatingText = false
}
}
// 处理工具调用
@@ -947,16 +957,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
})
}
// For OpenRouter, don't emit completion signals immediately after finish_reason
// Don't emit completion signals immediately after finish_reason
// Wait for the usage chunk that comes after
if (isOpenRouter) {
hasFinishReason = true
// If we already have usage info, emit completion signals now
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
emitCompletionSignals(controller)
}
} else {
// For other providers, emit completion signals immediately
hasFinishReason = true
// If we already have usage info, emit completion signals now
if (lastUsageInfo && lastUsageInfo.total_tokens > 0) {
emitCompletionSignals(controller)
}
}
-23
View File
@@ -184,26 +184,3 @@
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
}
}
.ant-splitter-bar {
.ant-splitter-bar-dragger {
&::before {
background-color: var(--color-border) !important;
transition: background-color 0.15s ease-in-out;
}
&:hover {
&::before {
width: 4px !important;
background-color: var(--color-primary) !important;
transition: background-color 0.15s ease-in-out;
}
}
}
.ant-splitter-bar-dragger-active {
&::before {
width: 4px !important;
background-color: var(--color-primary) !important;
}
}
}
@@ -133,7 +133,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
open={open}
afterClose={onClose}
centered={!isFullscreen}
destroyOnClose
destroyOnHidden
mask={!isFullscreen}
maskClosable={false}
width={isFullscreen ? '100vw' : '90vw'}
@@ -58,12 +58,9 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const { t } = useTranslation()
const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings()
const [viewState, setViewState] = useState(() => {
const initialMode = SPECIAL_VIEWS.includes(language) ? 'special' : 'source'
return {
mode: initialMode as ViewMode,
previousMode: initialMode as ViewMode
}
const [viewState, setViewState] = useState({
mode: 'special' as ViewMode,
previousMode: 'special' as ViewMode
})
const { mode: viewMode } = viewState
@@ -99,18 +96,10 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
// TODO: 考虑移除
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
}, [hasSpecialView, viewMode])
// 不支持特殊视图时回退到 source
useEffect(() => {
if (!hasSpecialView && viewMode !== 'source') {
setViewMode('source')
}
}, [hasSpecialView, viewMode, setViewMode])
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
@@ -298,11 +287,14 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图
const renderContent = useMemo(() => {
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
const showSpecialView = !!specialView && ['special', 'split'].includes(viewMode)
const showSourceView = !specialView || viewMode !== 'special'
return (
<SplitViewWrapper className="split-view-wrapper" $viewMode={viewMode}>
<SplitViewWrapper
className="split-view-wrapper"
$isSpecialView={showSpecialView && !showSourceView}
$isSplitView={showSpecialView && showSourceView}>
{showSpecialView && specialView}
{showSourceView && sourceView}
</SplitViewWrapper>
@@ -373,7 +365,7 @@ const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
`
const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>`
const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boolean }>`
display: flex;
> * {
@@ -383,13 +375,13 @@ const SplitViewWrapper = styled.div<{ $viewMode?: ViewMode }>`
&:not(:has(+ [class*='Container'])) {
// 特殊视图的 header 会隐藏,所以全都使用圆角
border-radius: ${(props) => (props.$viewMode === 'special' ? '8px' : '0 0 8px 8px')};
border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')};
overflow: hidden;
}
// 在 split 模式下添加中间分隔线
${(props) =>
props.$viewMode === 'split' &&
props.$isSplitView &&
css`
position: relative;
+2 -1
View File
@@ -54,7 +54,8 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c
if (properties.style) {
shikiTheme.style.cssText += `${properties.style}`
}
shikiTheme.tabIndex = properties.tabindex
// FIXME: 临时解决 SelectionToolbar 无法弹出,走剪贴板回退的问题
// shikiTheme.tabIndex = properties.tabindex
}
})
return () => {
@@ -499,7 +499,6 @@ const MinappPopupContainer: React.FC = () => {
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
destroyOnClose={false}
mask={false}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
@@ -349,7 +349,7 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
destroyOnClose
destroyOnHidden
centered
width={500}
okText={t('common.save')}
@@ -66,6 +66,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
// 无匹配项自动关闭的定时器
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 处理搜索,过滤列表
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
@@ -128,12 +131,44 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSymbolRef.current = ctx.symbol
return newList
}, [ctx.isVisible, ctx.list, ctx.symbol, searchText])
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
}, [list, historyPanel])
// 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板
useEffect(() => {
const _searchText = searchText.replace(/^[/@]/, '')
// 清除之前的定时器(无论面板是否可见都要清理)
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
// 面板不可见时不设置新定时器
if (!ctx.isVisible) {
return
}
// 只有在有搜索文本但无匹配项时才设置延迟关闭
if (_searchText && _searchText.length > 0 && list.length === 0) {
noMatchTimeoutRef.current = setTimeout(() => {
ctx.close('no-matches')
}, 300)
}
// 清理函数
return () => {
if (noMatchTimeoutRef.current) {
clearTimeout(noMatchTimeoutRef.current)
noMatchTimeoutRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染
}, [ctx.isVisible, searchText, list.length, ctx.close])
const clearSearchText = useCallback(
(includeSymbol = false) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
@@ -275,7 +310,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchText(newSearchText)
} else {
handleClose('delete-symbol')
ctx.close('delete-symbol')
}
}
@@ -24,6 +24,7 @@ import {
Sparkle,
SquareTerminal,
Sun,
Terminal,
X
} from 'lucide-react'
import { useCallback, useEffect } from 'react'
@@ -57,6 +58,8 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
return <Folder size={14} />
case 'settings':
return <Settings size={14} />
case 'code':
return <Terminal size={14} />
default:
return null
}
+2 -2
View File
@@ -392,10 +392,10 @@ export function getModelLogo(modelId: string) {
'gpt-image': ChatGPTImageModelLogo,
'gpt-3': isLight ? ChatGPT35ModelLogo : ChatGPT35ModelLogoDark,
'gpt-4': isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
'gpt-5(?:-[0-9]+(?:-[0-9]+)*)?': GPT5ModelLogo,
'gpt-5-mini': GPT5MiniModelLogo,
'gpt-5-nano': GPT5NanoModelLogo,
'gpt-5-chat': GPT5ChatModelLogo,
'gpt-5': GPT5ModelLogo,
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
'text-moderation': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
@@ -2469,7 +2469,7 @@ export function isVisionModel(model: Model): boolean {
export function isOpenAIReasoningModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id, '/')
return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1') || modelId.includes('gpt-5-chat')
return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1')
}
export function isOpenAILLMModel(model: Model): boolean {
+1 -1
View File
@@ -1246,7 +1246,7 @@ export const isSupportArrayContentProvider = (provider: Provider) => {
)
}
const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe'] as const satisfies SystemProviderId[]
const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe', 'qiniu'] as const satisfies SystemProviderId[]
/**
* developer message role Only for OpenAI API.
@@ -99,11 +99,6 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
},
Divider: {
colorSplit: 'rgba(128,128,128,0.15)'
},
Splitter: {
splitBarDraggableSize: 0,
splitBarSize: 0.5,
splitTriggerSize: 10
}
},
token: {
+3 -2
View File
@@ -86,11 +86,12 @@ export function useAppInit() {
useEffect(() => {
if (proxyMode === 'system') {
window.api.setProxy('system', proxyBypassRules)
window.api.setProxy('system', undefined)
} else if (proxyMode === 'custom') {
proxyUrl && window.api.setProxy(proxyUrl, proxyBypassRules)
} else {
window.api.setProxy('')
// set proxy to none for direct mode
window.api.setProxy('', undefined)
}
}, [proxyUrl, proxyMode, proxyBypassRules])
+109
View File
@@ -0,0 +1,109 @@
import { loggerService } from '@renderer/services/LoggerService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addDirectory,
clearDirectories,
removeDirectory,
resetCodeTools,
setCurrentDirectory,
setSelectedCliTool,
setSelectedModel
} from '@renderer/store/codeTools'
import { Model } from '@renderer/types'
import { useCallback } from 'react'
export const useCodeTools = () => {
const dispatch = useAppDispatch()
const codeToolsState = useAppSelector((state) => state.codeTools)
const logger = loggerService.withContext('useCodeTools')
// 设置选择的 CLI 工具
const setCliTool = useCallback(
(tool: string) => {
dispatch(setSelectedCliTool(tool))
},
[dispatch]
)
// 设置选择的模型
const setModel = useCallback(
(model: Model | null) => {
dispatch(setSelectedModel(model))
},
[dispatch]
)
// 添加目录
const addDir = useCallback(
(directory: string) => {
dispatch(addDirectory(directory))
},
[dispatch]
)
// 删除目录
const removeDir = useCallback(
(directory: string) => {
dispatch(removeDirectory(directory))
},
[dispatch]
)
// 设置当前目录
const setCurrentDir = useCallback(
(directory: string) => {
dispatch(setCurrentDirectory(directory))
},
[dispatch]
)
// 清空所有目录
const clearDirs = useCallback(() => {
dispatch(clearDirectories())
}, [dispatch])
// 重置所有设置
const resetSettings = useCallback(() => {
dispatch(resetCodeTools())
}, [dispatch])
// 选择文件夹的辅助函数
const selectFolder = useCallback(async () => {
try {
const folderPath = await window.api.file.selectFolder()
if (folderPath) {
setCurrentDir(folderPath)
return folderPath
}
return null
} catch (error) {
logger.error('选择文件夹失败:', error as Error)
throw error
}
}, [setCurrentDir, logger])
// 获取当前CLI工具选择的模型
const selectedModel = codeToolsState.selectedModels[codeToolsState.selectedCliTool] || null
// 检查是否可以启动(所有必需字段都已填写)
const canLaunch = Boolean(codeToolsState.selectedCliTool && selectedModel && codeToolsState.currentDirectory)
return {
// 状态
selectedCliTool: codeToolsState.selectedCliTool,
selectedModel: selectedModel,
directories: codeToolsState.directories,
currentDirectory: codeToolsState.currentDirectory,
canLaunch,
// 操作函数
setCliTool,
setModel,
addDir,
removeDir,
setCurrentDir,
clearDirs,
resetSettings,
selectFolder
}
}
+2 -1
View File
@@ -111,6 +111,7 @@ export const getProgressLabel = (key: string): string => {
const titleKeyMap = {
agents: 'title.agents',
apps: 'title.apps',
code: 'title.code',
files: 'title.files',
home: 'title.home',
knowledge: 'title.knowledge',
@@ -292,7 +293,7 @@ export const getFileFieldLabel = (key: string): string => {
const builtInMcpDescriptionKeyMap = {
'@cherry/mcp-auto-install': 'settings.mcp.builtinServersDescriptions.mcp_auto_install',
'@cherry/memory': 'settings.mcp.builtinServersDescriptions.mcp_auto_install',
'@cherry/memory': 'settings.mcp.builtinServersDescriptions.memory',
'@cherry/sequentialthinking': 'settings.mcp.builtinServersDescriptions.sequentialthinking',
'@cherry/brave-search': 'settings.mcp.builtinServersDescriptions.brave_search',
'@cherry/fetch': 'settings.mcp.builtinServersDescriptions.fetch',
+45 -9
View File
@@ -648,6 +648,31 @@
},
"translate": "Translate"
},
"code": {
"auto_update_to_latest": "Automatically update to latest version",
"bun_required_message": "Bun environment is required to run CLI tools",
"cli_tool": "CLI Tool",
"cli_tool_placeholder": "Select the CLI tool to use",
"description": "Quickly launch multiple code CLI tools to improve development efficiency",
"folder_placeholder": "Select working directory",
"install_bun": "Install Bun",
"installing_bun": "Installing...",
"launch": {
"bun_required": "Please install Bun environment first before launching CLI tools",
"error": "Launch failed, please try again",
"label": "Launch",
"success": "Launch successful",
"validation_error": "Please complete all required fields: CLI tool, model, and working directory"
},
"launching": "Launching...",
"model": "Model",
"model_placeholder": "Select the model to use",
"model_required": "Please select a model",
"select_folder": "Select Folder",
"title": "Code Tools",
"update_options": "Update Options",
"working_directory": "Working Directory"
},
"code_block": {
"collapse": "Collapse",
"copy": {
@@ -2690,6 +2715,17 @@
"title": "Launch",
"totray": "Minimize to Tray on Launch"
},
"math": {
"engine": {
"label": "Math engine",
"none": "None"
},
"single_dollar": {
"label": "Enable $...$",
"tip": "Render math equations quoted by single dollar signs $...$. Default is enabled."
},
"title": "Math Settings"
},
"mcp": {
"actions": "Actions",
"active": "Active",
@@ -2864,7 +2900,7 @@
"tagsPlaceholder": "Enter tags",
"timeout": "Timeout",
"timeoutTooltip": "Timeout in seconds for requests to this server, default is 60 seconds",
"title": "MCP Settings",
"title": "MCP",
"tools": {
"autoApprove": {
"label": "Auto Approve",
@@ -2920,10 +2956,6 @@
"title": "Input Settings"
},
"markdown_rendering_input_message": "Markdown render input message",
"math_engine": {
"label": "Math engine",
"none": "None"
},
"metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
"model": {
"title": "Model Settings"
@@ -2935,6 +2967,7 @@
"none": "None"
},
"prompt": "Show prompt",
"show_message_outline": "Show message outline",
"title": "Message Settings",
"use_serif_font": "Use serif font"
},
@@ -3407,10 +3440,10 @@
"title": "Settings",
"tool": {
"preprocess": {
"provider": "Pre Process Provider",
"provider_placeholder": "Choose a Pre Process provider",
"title": "Pre Process",
"tooltip": "In Settings -> Tools, set a document preprocessing service provider. Document preprocessing can effectively improve the retrieval performance of complex format documents and scanned documents."
"provider": "Document Processing Provider",
"provider_placeholder": "Choose a document processing provider",
"title": "Document Processing",
"tooltip": "In Settings -> Tools, set a document processing service provider. Document processing can effectively improve the retrieval performance of complex format documents and scanned documents."
},
"title": "Other Settings",
"websearch": {
@@ -3561,6 +3594,7 @@
"title": {
"agents": "Agents",
"apps": "Apps",
"code": "Code",
"files": "Files",
"home": "Home",
"knowledge": "Knowledge Base",
@@ -3612,8 +3646,10 @@
},
"empty": "Translation content is empty",
"error": {
"detected_unknown": "Unknown language cannot be exchanged",
"empty": "The translation result is empty content",
"failed": "Translation failed",
"invalid_source": "Invalid source language",
"not_configured": "Translation model is not configured",
"not_supported": "Unsupported language {{language}}",
"unknown": "An unknown error occurred during translation"
+41 -5
View File
@@ -648,6 +648,31 @@
},
"translate": "翻訳"
},
"code": {
"auto_update_to_latest": "最新バージョンを自動的に更新する",
"bun_required_message": "CLI ツールを実行するには Bun 環境が必要です",
"cli_tool": "CLI ツール",
"cli_tool_placeholder": "使用する CLI ツールを選択してください",
"description": "開発効率を向上させるために、複数のコード CLI ツールを迅速に起動します",
"folder_placeholder": "作業ディレクトリを選択してください",
"install_bun": "Bun をインストール",
"installing_bun": "インストール中...",
"launch": {
"bun_required": "CLI ツールを実行するには Bun 環境が必要です。まず Bun をインストールしてください",
"error": "起動に失敗しました。もう一度試してください",
"label": "起動",
"success": "起動成功",
"validation_error": "必須項目を入力してください:CLI ツール、モデル、作業ディレクトリ"
},
"launching": "起動中...",
"model": "モデル",
"model_placeholder": "使用するモデルを選択してください",
"model_required": "モデルを選択してください",
"select_folder": "フォルダを選択",
"title": "コードツール",
"update_options": "更新オプション",
"working_directory": "作業ディレクトリ"
},
"code_block": {
"collapse": "折りたたむ",
"copy": {
@@ -2690,6 +2715,17 @@
"title": "起動",
"totray": "起動時にトレイに最小化"
},
"math": {
"engine": {
"label": "数式エンジン",
"none": "なし"
},
"single_dollar": {
"label": "$...$ を有効にする",
"tip": "単一のドル記号 $...$ で囲まれた数式をレンダリングします。デフォルトで有効です。"
},
"title": "数式設定"
},
"mcp": {
"actions": "操作",
"active": "有効",
@@ -2864,7 +2900,7 @@
"tagsPlaceholder": "タグを入力",
"timeout": "タイムアウト",
"timeoutTooltip": "このサーバーへのリクエストのタイムアウト時間(秒)、デフォルトは60秒です",
"title": "MCP 設定",
"title": "MCP",
"tools": {
"autoApprove": {
"label": "自動承認",
@@ -2920,10 +2956,6 @@
"title": "入力設定"
},
"markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
"math_engine": {
"label": "数式エンジン",
"none": "なし"
},
"metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
"model": {
"title": "モデル設定"
@@ -2935,6 +2967,7 @@
"none": "表示しない"
},
"prompt": "プロンプト表示",
"show_message_outline": "メッセージの概要を表示します",
"title": "メッセージ設定",
"use_serif_font": "セリフフォントを使用"
},
@@ -3561,6 +3594,7 @@
"title": {
"agents": "エージェント",
"apps": "アプリ",
"code": "Code",
"files": "ファイル",
"home": "ホーム",
"knowledge": "ナレッジベース",
@@ -3612,8 +3646,10 @@
},
"empty": "翻訳内容が空です",
"error": {
"detected_unknown": "未知の言語は交換できません",
"empty": "翻訳結果が空の内容です",
"failed": "翻訳に失敗しました",
"invalid_source": "無効なソース言語",
"not_configured": "翻訳モデルが設定されていません",
"not_supported": "サポートされていない言語 {{language}}",
"unknown": "翻訳中に不明なエラーが発生しました"
+45 -9
View File
@@ -648,6 +648,31 @@
},
"translate": "Перевести"
},
"code": {
"auto_update_to_latest": "Автоматически обновлять до последней версии",
"bun_required_message": "Запуск CLI-инструментов требует установки среды Bun",
"cli_tool": "Инструмент",
"cli_tool_placeholder": "Выберите CLI-инструмент для использования",
"description": "Быстро запускает несколько CLI-инструментов для кода, повышая эффективность разработки",
"folder_placeholder": "Выберите рабочую директорию",
"install_bun": "Установить Bun",
"installing_bun": "Установка...",
"launch": {
"bun_required": "Пожалуйста, установите среду Bun перед запуском CLI-инструментов",
"error": "Не удалось запустить. Пожалуйста, попробуйте снова",
"label": "Запуск",
"success": "Запуск успешно завершен",
"validation_error": "Пожалуйста, заполните все обязательные поля: CLI-инструмент, модель и рабочая директория"
},
"launching": "Запуск...",
"model": "Модель",
"model_placeholder": "Выберите модель для использования",
"model_required": "Пожалуйста, выберите модель",
"select_folder": "Выберите папку",
"title": "Инструменты кода",
"update_options": "Параметры обновления",
"working_directory": "Рабочая директория"
},
"code_block": {
"collapse": "Свернуть",
"copy": {
@@ -2690,6 +2715,17 @@
"title": "Запуск",
"totray": "Свернуть в трей при запуске"
},
"math": {
"engine": {
"label": "Математический движок",
"none": "Нет"
},
"single_dollar": {
"label": "Включить $...$",
"tip": "Отображать математические формулы, заключенные в одиночные символы доллара $...$. По умолчанию включено."
},
"title": "Настройки математических формул"
},
"mcp": {
"actions": "Действия",
"active": "Активен",
@@ -2864,7 +2900,7 @@
"tagsPlaceholder": "Введите теги",
"timeout": "Тайм-аут",
"timeoutTooltip": "Тайм-аут в секундах для запросов к этому серверу, по умолчанию 60 секунд",
"title": "Настройки MCP",
"title": "MCP",
"tools": {
"autoApprove": {
"label": "Автоматическое одобрение",
@@ -2920,10 +2956,6 @@
"title": "Настройки ввода"
},
"markdown_rendering_input_message": "Отображение ввода в формате Markdown",
"math_engine": {
"label": "Математический движок",
"none": "Нет"
},
"metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
"model": {
"title": "Настройки модели"
@@ -2935,6 +2967,7 @@
"none": "Не показывать"
},
"prompt": "Показывать подсказки",
"show_message_outline": "Показать наброски сообщения",
"title": "Настройки сообщений",
"use_serif_font": "Использовать serif шрифт"
},
@@ -3407,10 +3440,10 @@
"title": "Настройки",
"tool": {
"preprocess": {
"provider": редварительная обработка Поставщик",
"provider_placeholder": "Выберите поставщика услуг предварительной обработки",
"title": "Предварительная обработка",
"tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуги предварительной обработки документов. Предварительная обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов."
"provider": "Поставщик обработки документов",
"provider_placeholder": "Выберите поставщика услуг обработки документов",
"title": "Обработка документов",
"tooltip": "В настройках (Настройки -> Инструменты) укажите поставщика услуг обработки документов. Обработка документов может значительно повысить эффективность поиска для документов сложных форматов и отсканированных документов."
},
"title": "Другие настройки",
"websearch": {
@@ -3561,6 +3594,7 @@
"title": {
"agents": "Агенты",
"apps": "Приложения",
"code": "Code",
"files": "Файлы",
"home": "Главная",
"knowledge": "База знаний",
@@ -3612,8 +3646,10 @@
},
"empty": "Содержимое перевода пусто",
"error": {
"detected_unknown": "Неизвестный язык не подлежит обмену",
"empty": "Результат перевода пуст",
"failed": "Перевод не удалось",
"invalid_source": "Недопустимый исходный язык",
"not_configured": "Модель перевода не настроена",
"not_supported": "Язык не поддерживается {{language}}",
"unknown": "Во время перевода возникла неизвестная ошибка"
+46 -10
View File
@@ -176,7 +176,7 @@
"enableFirst": "请先在 MCP 设置中启用此服务器",
"label": "MCP 服务器",
"noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
"title": "MCP 设置"
"title": "MCP 服务器"
},
"model": "模型设置",
"more": "助手设置",
@@ -648,6 +648,31 @@
},
"translate": "翻译"
},
"code": {
"auto_update_to_latest": "检查更新并安装最新版本",
"bun_required_message": "运行 CLI 工具需要安装 Bun 环境",
"cli_tool": "CLI 工具",
"cli_tool_placeholder": "选择要使用的 CLI 工具",
"description": "快速启动多个代码 CLI 工具,提高开发效率",
"folder_placeholder": "选择工作目录",
"install_bun": "安装 Bun",
"installing_bun": "安装中...",
"launch": {
"bun_required": "请先安装 Bun 环境再启动 CLI 工具",
"error": "启动失败,请重试",
"label": "启动",
"success": "启动成功",
"validation_error": "请完成所有必填项:CLI 工具、模型和工作目录"
},
"launching": "启动中...",
"model": "模型",
"model_placeholder": "选择要使用的模型",
"model_required": "请选择模型",
"select_folder": "选择文件夹",
"title": "代码工具",
"update_options": "更新选项",
"working_directory": "工作目录"
},
"code_block": {
"collapse": "收起",
"copy": {
@@ -2690,6 +2715,17 @@
"title": "启动",
"totray": "启动时最小化到托盘"
},
"math": {
"engine": {
"label": "数学公式引擎",
"none": "无"
},
"single_dollar": {
"label": "启用 $...$",
"tip": "渲染单个美元符号 $...$ 包裹的数学公式,默认启用。"
},
"title": "数学公式设置"
},
"mcp": {
"actions": "操作",
"active": "启用",
@@ -2864,7 +2900,7 @@
"tagsPlaceholder": "输入标签",
"timeout": "超时",
"timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒",
"title": "MCP 设置",
"title": "MCP",
"tools": {
"autoApprove": {
"label": "自动批准",
@@ -2920,10 +2956,6 @@
"title": "输入设置"
},
"markdown_rendering_input_message": "Markdown 渲染输入消息",
"math_engine": {
"label": "数学公式引擎",
"none": "无"
},
"metrics": "首字时延 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens",
"model": {
"title": "模型设置"
@@ -2935,6 +2967,7 @@
"none": "不显示"
},
"prompt": "显示提示词",
"show_message_outline": "显示消息大纲",
"title": "消息设置",
"use_serif_font": "使用衬线字体"
},
@@ -3407,10 +3440,10 @@
"title": "设置",
"tool": {
"preprocess": {
"provider": "文档处理服务商",
"provider_placeholder": "选择一个文档处理服务商",
"title": "文档处理",
"tooltip": "在设置 -> 工具中设置文档处理服务商,文档处理可以有效提升复杂格式文档与扫描版文档的检索效果"
"provider": "文档处理服务商",
"provider_placeholder": "选择一个文档处理服务商",
"title": "文档处理",
"tooltip": "在设置 -> 工具中设置文档处理服务商,文档处理可以有效提升复杂格式文档与扫描版文档的检索效果"
},
"title": "其他设置",
"websearch": {
@@ -3561,6 +3594,7 @@
"title": {
"agents": "智能体",
"apps": "小程序",
"code": "Code",
"files": "文件",
"home": "首页",
"knowledge": "知识库",
@@ -3612,8 +3646,10 @@
},
"empty": "翻译内容为空",
"error": {
"detected_unknown": "未知语言不可交换",
"empty": "翻译结果为空内容",
"failed": "翻译失败",
"invalid_source": "无效的源语言",
"not_configured": "翻译模型未配置",
"not_supported": "不支持的语言 {{language}}",
"unknown": "翻译过程中遇到未知错误"
+45 -9
View File
@@ -648,6 +648,31 @@
},
"translate": "翻譯"
},
"code": {
"auto_update_to_latest": "檢查更新並安裝最新版本",
"bun_required_message": "運行 CLI 工具需要安裝 Bun 環境",
"cli_tool": "CLI 工具",
"cli_tool_placeholder": "選擇要使用的 CLI 工具",
"description": "快速啟動多個程式碼 CLI 工具,提高開發效率",
"folder_placeholder": "選擇工作目錄",
"install_bun": "安裝 Bun",
"installing_bun": "安裝中...",
"launch": {
"bun_required": "請先安裝 Bun 環境再啟動 CLI 工具",
"error": "啟動失敗,請重試",
"label": "啟動",
"success": "啟動成功",
"validation_error": "請完成所有必填項目:CLI 工具、模型和工作目錄"
},
"launching": "啟動中...",
"model": "模型",
"model_placeholder": "選擇要使用的模型",
"model_required": "請選擇模型",
"select_folder": "選擇資料夾",
"title": "程式碼工具",
"update_options": "更新選項",
"working_directory": "工作目錄"
},
"code_block": {
"collapse": "折疊",
"copy": {
@@ -2690,6 +2715,17 @@
"title": "啟動",
"totray": "啟動時最小化到系统匣"
},
"math": {
"engine": {
"label": "數學公式引擎",
"none": "無"
},
"single_dollar": {
"label": "啟用 $...$",
"tip": "渲染單個美元符號 $...$ 包裹的數學公式,默認啟用。"
},
"title": "數學公式設定"
},
"mcp": {
"actions": "操作",
"active": "啟用",
@@ -2864,7 +2900,7 @@
"tagsPlaceholder": "輸入標籤",
"timeout": "超時",
"timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為 60 秒",
"title": "MCP 設定",
"title": "MCP",
"tools": {
"autoApprove": {
"label": "自動批准",
@@ -2920,10 +2956,6 @@
"title": "輸入設定"
},
"markdown_rendering_input_message": "Markdown 渲染輸入訊息",
"math_engine": {
"label": "數學公式引擎",
"none": "無"
},
"metrics": "首字延遲 {{time_first_token_millsec}} ms | 每秒 {{token_speed}} tokens",
"model": {
"title": "模型設定"
@@ -2935,6 +2967,7 @@
"none": "不顯示"
},
"prompt": "提示詞顯示",
"show_message_outline": "顯示消息大綱",
"title": "訊息設定",
"use_serif_font": "使用襯線字型"
},
@@ -3407,10 +3440,10 @@
"title": "設定",
"tool": {
"preprocess": {
"provider": "前置處理供應商",
"provider_placeholder": "選擇一個處理供應商",
"title": "前置處理",
"tooltip": "在「設定」->「工具」中設定文件處理服務供應商。文件處理可有效提升複雜格式文件及掃描文件的檢索效能"
"provider": "文件處理供應商",
"provider_placeholder": "選擇一個文件處理供應商",
"title": "文件處理",
"tooltip": "在「設定」->「工具」中設定文件處理服務供應商。文件處理可有效提升複雜格式文件及掃描文件的檢索效能"
},
"title": "其他設定",
"websearch": {
@@ -3561,6 +3594,7 @@
"title": {
"agents": "智能體",
"apps": "小程序",
"code": "Code",
"files": "文件",
"home": "主頁",
"knowledge": "知識庫",
@@ -3612,8 +3646,10 @@
},
"empty": "翻譯內容為空",
"error": {
"detected_unknown": "未知語言不可交換",
"empty": "翻译结果为空内容",
"failed": "翻譯失敗",
"invalid_source": "無效的源語言",
"not_configured": "翻譯模型未設定",
"not_supported": "不支援的語言 {{language}}",
"unknown": "翻譯過程中遇到未知錯誤"
@@ -3612,8 +3612,10 @@
},
"empty": "Το μεταφρασμένο κείμενο είναι κενό",
"error": {
"detected_unknown": "Άγνωστη γλώσσα μη ανταλλάξιμη",
"empty": "το αποτέλεσμα της μετάφρασης είναι κενό περιεχόμενο",
"failed": "Η μετάφραση απέτυχε",
"invalid_source": "Ακύρωση γλώσσας πηγής",
"not_configured": "Το μοντέλο μετάφρασης δεν είναι ρυθμισμένο",
"not_supported": "Μη υποστηριζόμενη γλώσσα {{language}}",
"unknown": "κατά τη μετάφραση παρουσιάστηκε άγνωστο σφάλμα"
@@ -3612,8 +3612,10 @@
},
"empty": "El contenido de traducción está vacío",
"error": {
"detected_unknown": "Idioma desconocido no intercambiable",
"empty": "El resultado de la traducción está vacío",
"failed": "Fallo en la traducción",
"invalid_source": "Invalid source language",
"not_configured": "El modelo de traducción no está configurado",
"not_supported": "Idioma no compatible {{language}}",
"unknown": "Se produjo un error desconocido durante la traducción"
@@ -3612,8 +3612,10 @@
},
"empty": "Le contenu à traduire est vide",
"error": {
"detected_unknown": "Langue inconnue non échangeable",
"empty": "Le résultat de la traduction est un contenu vide",
"failed": "échec de la traduction",
"invalid_source": "Langue source invalide",
"not_configured": "le modèle de traduction n'est pas configuré",
"not_supported": "Langue non prise en charge {{language}}",
"unknown": "Une erreur inconnue s'est produite lors de la traduction"
@@ -3612,8 +3612,10 @@
},
"empty": "O conteúdo de tradução está vazio",
"error": {
"detected_unknown": "Idioma desconhecido não pode ser trocado",
"empty": "Resultado da tradução está vazio",
"failed": "Tradução falhou",
"invalid_source": "Idioma de origem inválido",
"not_configured": "Modelo de tradução não configurado",
"not_supported": "Idioma não suportado {{language}}",
"unknown": "Ocorreu um erro desconhecido durante a tradução"
@@ -0,0 +1,383 @@
import AiProvider from '@renderer/aiCore'
import ModelSelector from '@renderer/components/ModelSelector'
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { useCodeTools } from '@renderer/hooks/useCodeTools'
import { useProviders } from '@renderer/hooks/useProvider'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled } from '@renderer/store/mcp'
import { Model } from '@renderer/types'
import { Alert, Button, Checkbox, Select, Space } from 'antd'
import { Download, Terminal, X } from 'lucide-react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// CLI 工具选项
const CLI_TOOLS = [
{ value: 'qwen-code', label: 'Qwen Code' },
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'gemini-cli', label: 'Gemini CLI' }
]
const logger = loggerService.withContext('CodeToolsPage')
const CodeToolsPage: FC = () => {
const { t } = useTranslation()
const { providers } = useProviders()
const dispatch = useAppDispatch()
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
const {
selectedCliTool,
selectedModel,
directories,
currentDirectory,
canLaunch,
setCliTool,
setModel,
setCurrentDir,
removeDir,
selectFolder
} = useCodeTools()
// 状态管理
const [isLaunching, setIsLaunching] = useState(false)
const [isInstallingBun, setIsInstallingBun] = useState(false)
const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false)
// 处理 CLI 工具选择
const handleCliToolChange = (value: string) => {
setCliTool(value)
// 不再清空模型选择,因为每个工具都会记住自己的模型
}
const openAiProviders = providers.filter((p) => p.type.includes('openai'))
const geminiProviders = providers.filter((p) => p.type === 'gemini')
const claudeProviders = providers.filter((p) => p.type === 'anthropic')
const modelPredicate = useCallback(
(m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) && !isTextToImageModel(m),
[]
)
const availableProviders =
selectedCliTool === 'claude-code'
? claudeProviders
: selectedCliTool === 'gemini-cli'
? geminiProviders
: openAiProviders
// 处理模型选择
const handleModelChange = (value: string) => {
if (!value) {
setModel(null)
return
}
// 从所有 providers 中查找选中的模型
for (const provider of providers || []) {
const model = provider.models.find((m) => getModelUniqId(m) === value)
if (model) {
setModel(model)
break
}
}
}
// 处理文件夹选择
const handleFolderSelect = async () => {
try {
await selectFolder()
} catch (error) {
logger.error('选择文件夹失败:', error as Error)
}
}
// 处理目录选择
const handleDirectoryChange = (value: string) => {
setCurrentDir(value)
}
// 处理删除目录
const handleRemoveDirectory = (directory: string, e: React.MouseEvent) => {
e.stopPropagation()
removeDir(directory)
}
// 检查 bun 是否安装
const checkBunInstallation = useCallback(async () => {
try {
const bunExists = await window.api.isBinaryExist('bun')
dispatch(setIsBunInstalled(bunExists))
} catch (error) {
logger.error('检查 bun 安装状态失败:', error as Error)
dispatch(setIsBunInstalled(false))
}
}, [dispatch])
// 安装 bun
const handleInstallBun = async () => {
try {
setIsInstallingBun(true)
await window.api.installBunBinary()
dispatch(setIsBunInstalled(true))
window.message.success({
content: t('settings.mcp.installSuccess'),
key: 'bun-install-message'
})
} catch (error: any) {
logger.error('安装 bun 失败:', error as Error)
window.message.error({
content: `${t('settings.mcp.installError')}: ${error.message}`,
key: 'bun-install-message'
})
} finally {
setIsInstallingBun(false)
// 重新检查安装状态
setTimeout(checkBunInstallation, 1000)
}
}
// 处理启动
const handleLaunch = async () => {
if (!canLaunch || !isBunInstalled) {
if (!isBunInstalled) {
window.message.warning({
content: t('code.launch.bun_required'),
key: 'code-launch-message'
})
} else {
window.message.warning({
content: t('code.launch.validation_error'),
key: 'code-launch-message'
})
}
return
}
setIsLaunching(true)
if (!selectedModel) {
window.message.error({
content: t('code.model_required'),
key: 'code-launch-message'
})
return
}
const modelProvider = getProviderByModel(selectedModel)
const aiProvider = new AiProvider(modelProvider)
const baseUrl = await aiProvider.getBaseURL()
const apiKey = await aiProvider.getApiKey()
let env: Record<string, string> = {}
if (selectedCliTool === 'claude-code') {
env = {
ANTHROPIC_API_KEY: apiKey,
ANTHROPIC_MODEL: selectedModel.id
}
}
if (selectedCliTool === 'gemini-cli') {
env = {
GEMINI_API_KEY: apiKey
}
}
if (selectedCliTool === 'qwen-code') {
env = {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: selectedModel.id
}
}
try {
// 这里可以添加实际的启动逻辑
logger.info('启动配置:', {
cliTool: selectedCliTool,
model: selectedModel,
folder: currentDirectory
})
window.api.codeTools.run(selectedCliTool, selectedModel?.id, currentDirectory, env, {
autoUpdateToLatest
})
window.message.success({
content: t('code.launch.success'),
key: 'code-launch-message'
})
} catch (error) {
logger.error('启动失败:', error as Error)
window.message.error({
content: t('code.launch.error'),
key: 'code-launch-message'
})
} finally {
setIsLaunching(false)
}
}
// 页面加载时检查 bun 安装状态
useEffect(() => {
checkBunInstallation()
}, [checkBunInstallation])
return (
<Container>
<Title>{t('code.title')}</Title>
<Description>{t('code.description')}</Description>
{/* Bun 安装状态提示 */}
{!isBunInstalled && (
<BunInstallAlert>
<Alert
type="warning"
banner
style={{ borderRadius: 'var(--list-item-border-radius)' }}
message={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{t('code.bun_required_message')}</span>
<Button
type="primary"
size="small"
icon={<Download size={14} />}
onClick={handleInstallBun}
loading={isInstallingBun}
disabled={isInstallingBun}>
{isInstallingBun ? t('code.installing_bun') : t('code.install_bun')}
</Button>
</div>
}
/>
</BunInstallAlert>
)}
<SettingsPanel>
<SettingsItem>
<div className="settings-label">{t('code.cli_tool')}</div>
<Select
style={{ width: '100%' }}
placeholder={t('code.cli_tool_placeholder')}
value={selectedCliTool}
onChange={handleCliToolChange}
options={CLI_TOOLS}
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">{t('code.model')}</div>
<ModelSelector
providers={availableProviders}
predicate={modelPredicate}
style={{ width: '100%' }}
placeholder={t('code.model_placeholder')}
value={selectedModel ? getModelUniqId(selectedModel) : undefined}
onChange={handleModelChange}
allowClear
/>
</SettingsItem>
<SettingsItem>
<div className="settings-label">{t('code.working_directory')}</div>
<Space.Compact style={{ width: '100%', display: 'flex' }}>
<Select
style={{ flex: 1, width: 480 }}
placeholder={t('code.folder_placeholder')}
value={currentDirectory || undefined}
onChange={handleDirectoryChange}
allowClear
showSearch
filterOption={(input, option) => {
const label = typeof option?.label === 'string' ? option.label : String(option?.value || '')
return label.toLowerCase().includes(input.toLowerCase())
}}
options={directories.map((dir) => ({
value: dir,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{dir}</span>
<X
size={14}
style={{ marginLeft: 8, cursor: 'pointer', color: '#999' }}
onClick={(e) => handleRemoveDirectory(dir, e)}
/>
</div>
)
}))}
/>
<Button onClick={handleFolderSelect} style={{ width: 120 }}>
{t('code.select_folder')}
</Button>
</Space.Compact>
</SettingsItem>
<SettingsItem>
<div className="settings-label">{t('code.update_options')}</div>
<Checkbox checked={autoUpdateToLatest} onChange={(e) => setAutoUpdateToLatest(e.target.checked)}>
{t('code.auto_update_to_latest')}
</Checkbox>
</SettingsItem>
</SettingsPanel>
<Button
type="primary"
icon={<Terminal size={16} />}
size="large"
onClick={handleLaunch}
loading={isLaunching}
disabled={!canLaunch || !isBunInstalled}
block>
{isLaunching ? t('code.launching') : t('code.launch.label')}
</Button>
</Container>
)
}
// 样式组件
const Container = styled.div`
width: 600px;
margin: auto;
`
const Title = styled.h1`
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
margin-top: -50px;
color: var(--color-text-1);
`
const Description = styled.p`
font-size: 14px;
color: var(--color-text-2);
margin-bottom: 32px;
line-height: 1.5;
`
const SettingsPanel = styled.div`
margin-bottom: 32px;
`
const SettingsItem = styled.div`
margin-bottom: 24px;
.settings-label {
font-size: 14px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-1);
font-weight: 500;
}
`
const BunInstallAlert = styled.div`
margin-bottom: 24px;
`
export default CodeToolsPage
+1
View File
@@ -0,0 +1 @@
export { default } from './CodeToolsPage'
+6 -2
View File
@@ -46,11 +46,15 @@ const FilesPage: FC = () => {
const dataSource = sortedFiles?.map((file) => {
return {
key: file.id,
file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>,
file: (
<span onClick={() => window.api.file.openPath(FileManager.getFilePath(file))}>
{FileManager.formatFileName(file)}
</span>
),
size: formatFileSize(file.size),
size_bytes: file.size,
count: file.count,
path: file.path,
path: FileManager.getFilePath(file),
ext: file.ext,
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
created_at_unix: dayjs(file.created_at).unix(),
@@ -3,6 +3,7 @@ import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
@@ -10,6 +11,7 @@ import NavigationService from '@renderer/services/NavigationService'
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next'
import { Forward } from 'lucide-react'
@@ -26,6 +28,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const dispatch = useAppDispatch()
const { messageStyle } = useSettings()
useEffect(() => {
topic && dispatch(loadTopicMessagesThunk(topic.id))
@@ -48,9 +51,9 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
return (
<MessageEditingProvider>
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll}>
<ContainerWrapper>
<ContainerWrapper className={messageStyle}>
{topic?.messages.map((message) => (
<div key={message.id} style={{ position: 'relative' }}>
<MessageWrapper key={message.id} className={classNames([messageStyle, message.role])}>
<MessageItem message={message} topic={topic} hideMenuBar={true} />
<Button
type="text"
@@ -60,7 +63,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
icon={<Forward size={16} />}
/>
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
</div>
</MessageWrapper>
))}
{isEmpty && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />}
{!isEmpty && (
@@ -91,4 +94,11 @@ const ContainerWrapper = styled.div`
flex-direction: column;
`
const MessageWrapper = styled.div`
position: relative;
&.bubble.user {
padding-top: 26px;
}
`
export default TopicMessages
@@ -35,7 +35,7 @@ import { setSearching } from '@renderer/store/runtime'
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
import { classNames, delay, formatFileSize } from '@renderer/utils'
import { formatQuotedText } from '@renderer/utils/formats'
import {
getFilesFromDropEvent,
@@ -590,7 +590,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
let supportedFiles = 0
files.forEach((file) => {
if (supportedExts.includes(getFileExtension(file.path))) {
if (supportedExts.includes(file.ext)) {
setFiles((prevFiles) => [...prevFiles, file])
supportedFiles++
}
@@ -397,6 +397,7 @@ const InputbarTools = ({
ToolbarButton={ToolbarButton}
couldMentionNotVisionModel={couldMentionNotVisionModel}
files={files}
setText={setText}
/>
)
},
@@ -27,6 +27,7 @@ interface Props {
couldMentionNotVisionModel: boolean
files: FileType[]
ToolbarButton: any
setText: React.Dispatch<React.SetStateAction<string>>
}
const MentionModelsButton: FC<Props> = ({
@@ -35,13 +36,17 @@ const MentionModelsButton: FC<Props> = ({
onMentionModel,
couldMentionNotVisionModel,
files,
ToolbarButton
ToolbarButton,
setText
}) => {
const { providers } = useProviders()
const { t } = useTranslation()
const navigate = useNavigate()
const quickPanel = useQuickPanel()
// 记录是否有模型被选择的动作发生
const hasModelActionRef = useRef<boolean>(false)
const pinnedModels = useLiveQuery(
async () => {
const setting = await db.settings.get('pinned:models')
@@ -74,7 +79,10 @@ const MentionModelsButton: FC<Props> = ({
</Avatar>
),
filterText: getFancyProviderName(p) + m.name,
action: () => onMentionModel(m),
action: () => {
hasModelActionRef.current = true // 标记有模型动作发生
onMentionModel(m)
},
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
)
@@ -107,7 +115,10 @@ const MentionModelsButton: FC<Props> = ({
</Avatar>
),
filterText: getFancyProviderName(p) + m.name,
action: () => onMentionModel(m),
action: () => {
hasModelActionRef.current = true // 标记有模型动作发生
onMentionModel(m)
},
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
@@ -127,6 +138,9 @@ const MentionModelsButton: FC<Props> = ({
}, [pinnedModels, providers, t, couldMentionNotVisionModel, mentionedModels, onMentionModel, navigate])
const openQuickPanel = useCallback(() => {
// 重置模型动作标记
hasModelActionRef.current = false
quickPanel.open({
title: t('agents.edit.model.select.title'),
list: modelItems,
@@ -134,9 +148,25 @@ const MentionModelsButton: FC<Props> = ({
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
},
onClose({ action }) {
// ESC或Backspace关闭时的特殊处理
if (action === 'esc' || action === 'delete-symbol') {
// 如果有模型选择动作发生,删除@字符
if (hasModelActionRef.current) {
// 使用React的setText来更新状态,而不是直接操作DOM
setText((currentText) => {
const lastAtIndex = currentText.lastIndexOf('@')
if (lastAtIndex !== -1) {
return currentText.slice(0, lastAtIndex) + currentText.slice(lastAtIndex + 1)
}
return currentText
})
}
}
}
})
}, [modelItems, quickPanel, t])
}, [modelItems, quickPanel, t, setText])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '@') {
@@ -73,7 +73,7 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
}, [currentReasoningEffort, supportedOptions, updateAssistantSettings, model.id])
const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => {
const iconColor = isActive ? 'var(--color-link)' : 'var(--color-icon)'
const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)'
switch (true) {
case option === 'minimal':
@@ -7,7 +7,7 @@ import { Assistant, WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Tooltip } from 'antd'
import { Globe } from 'lucide-react'
import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react'
import { FC, memo, startTransition, useCallback, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export interface WebSearchButtonRef {
@@ -29,23 +29,22 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
const updateSelectedWebSearchProvider = useCallback(
(providerId?: WebSearchProvider['id']) => {
async (providerId?: WebSearchProvider['id']) => {
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
// NOTE: 也许可以用startTransition优化卡顿问题
setTimeout(() => {
const currentWebSearchProviderId = assistant.webSearchProviderId
const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId
const currentWebSearchProviderId = assistant.webSearchProviderId
const newWebSearchProviderId = currentWebSearchProviderId === providerId ? undefined : providerId
startTransition(() => {
updateAssistant({ ...assistant, webSearchProviderId: newWebSearchProviderId, enableWebSearch: false })
}, 200)
})
},
[assistant, updateAssistant]
)
const updateSelectedWebSearchBuiltin = useCallback(() => {
const updateSelectedWebSearchBuiltin = useCallback(async () => {
// TODO: updateAssistant有性能问题,会导致关闭快捷面板卡顿
setTimeout(() => {
startTransition(() => {
updateAssistant({ ...assistant, webSearchProviderId: undefined, enableWebSearch: !assistant.enableWebSearch })
}, 200)
})
}, [assistant, updateAssistant])
const providerItems = useMemo<QuickPanelListItem[]>(() => {
@@ -92,11 +91,13 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const openQuickPanel = useCallback(() => {
if (assistant.webSearchProviderId) {
return updateSelectedWebSearchProvider(undefined)
updateSelectedWebSearchProvider(undefined)
return
}
if (assistant.enableWebSearch) {
return updateSelectedWebSearchBuiltin()
updateSelectedWebSearchBuiltin()
return
}
quickPanel.open({
@@ -137,7 +138,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
<Globe
size={18}
style={{
color: enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
color: enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
}}
/>
</ToolbarButton>
@@ -29,6 +29,7 @@ import { Pluggable } from 'unified'
import CodeBlock from './CodeBlock'
import Link from './Link'
import rehypeHeadingIds from './plugins/rehypeHeadingIds'
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
import Table from './Table'
@@ -45,7 +46,7 @@ interface Props {
const Markdown: FC<Props> = ({ block, postProcess }) => {
const { t } = useTranslation()
const { mathEngine } = useSettings()
const { mathEngine, mathEnableSingleDollar } = useSettings()
const isTrulyDone = 'status' in block && block.status === 'success'
const [displayedContent, setDisplayedContent] = useState(postProcess ? postProcess(block.content) : block.content)
@@ -97,10 +98,10 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
remarkDisableConstructs(['codeIndented'])
]
if (mathEngine !== 'none') {
plugins.push(remarkMath)
plugins.push([remarkMath, { singleDollarTextMath: mathEnableSingleDollar }])
}
return plugins
}, [mathEngine])
}, [mathEngine, mathEnableSingleDollar])
const messageContent = useMemo(() => {
if ('status' in block && block.status === 'paused' && isEmpty(block.content)) {
@@ -110,17 +111,18 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
}, [block, displayedContent, t])
const rehypePlugins = useMemo(() => {
const plugins: any[] = []
const plugins: Pluggable[] = []
if (ALLOWED_ELEMENTS.test(messageContent)) {
plugins.push(rehypeRaw)
}
plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }])
if (mathEngine === 'KaTeX') {
plugins.push(rehypeKatex as any)
plugins.push(rehypeKatex)
} else if (mathEngine === 'MathJax') {
plugins.push(rehypeMathjax as any)
plugins.push(rehypeMathjax)
}
return plugins
}, [mathEngine, messageContent])
}, [mathEngine, messageContent, block.id])
const onSaveCodeBlock = useCallback(
(id: string, newContent: string) => {
@@ -1,9 +1,10 @@
import { CopyIcon } from '@renderer/components/Icons'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { Tooltip } from 'antd'
import { Check } from 'lucide-react'
import React, { memo, useCallback, useState } from 'react'
import React, { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -18,7 +19,7 @@ interface Props {
*/
const Table: React.FC<Props> = ({ children, node, blockId }) => {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const [copied, setCopied] = useTemporaryValue(false, 2000)
const handleCopyTable = useCallback(() => {
const tableMarkdown = extractTableMarkdown(blockId ?? '', node?.position)
@@ -28,12 +29,11 @@ const Table: React.FC<Props> = ({ children, node, blockId }) => {
.writeText(tableMarkdown)
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
.catch((error) => {
window.message?.error({ content: `${t('message.copy.failed')}: ${error}`, key: 'copy-table-error' })
})
}, [node, blockId, t])
}, [blockId, node?.position, setCopied, t])
return (
<TableWrapper className="table-wrapper">
@@ -144,7 +144,7 @@ describe('Markdown', () => {
vi.clearAllMocks()
// Default settings
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true })
mockUseTranslation.mockReturnValue({
t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key)
})
@@ -270,7 +270,7 @@ describe('Markdown', () => {
describe('math engine configuration', () => {
it('should configure KaTeX when mathEngine is KaTeX', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true })
render(<Markdown block={createMainTextBlock()} />)
@@ -279,7 +279,7 @@ describe('Markdown', () => {
})
it('should configure MathJax when mathEngine is MathJax', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax', mathEnableSingleDollar: true })
render(<Markdown block={createMainTextBlock()} />)
@@ -288,7 +288,7 @@ describe('Markdown', () => {
})
it('should not load math plugins when mathEngine is none', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'none' })
mockUseSettings.mockReturnValue({ mathEngine: 'none', mathEnableSingleDollar: true })
render(<Markdown block={createMainTextBlock()} />)
@@ -384,12 +384,12 @@ describe('Markdown', () => {
})
it('should re-render when math engine changes', () => {
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX' })
mockUseSettings.mockReturnValue({ mathEngine: 'KaTeX', mathEnableSingleDollar: true })
const { rerender } = render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('markdown-content')).toBeInTheDocument()
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax' })
mockUseSettings.mockReturnValue({ mathEngine: 'MathJax', mathEnableSingleDollar: true })
rerender(<Markdown block={createMainTextBlock()} />)
// Should still render correctly with new math engine
@@ -0,0 +1,70 @@
import type { Element, Node, Root, Text } from 'hast'
import { visit } from 'unist-util-visit'
/**
* GitHub slug
* -
* -
* -
* - '-'
* - slug -1, -2...
*/
export function createSlugger() {
const seen = new Map<string, number>()
const normalize = (text: string): string => {
const slug = (text || 'section')
.toLowerCase()
.trim()
// 移除常见分隔符和标点
.replace(/[\u200B-\u200D\uFEFF]/g, '') // 零宽字符
.replace(/["'`(){}[\]:;!?.,]/g, '')
// 将空白和非字母数字字符转换为 '-'
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
// 合并多余的 '-'
.replace(/-{2,}/g, '-')
// 去除首尾 '-'
.replace(/^-|-$/g, '')
return slug
}
const slug = (text: string): string => {
const base = normalize(text)
const count = seen.get(base) || 0
seen.set(base, count + 1)
return `${base}-${count}`
}
return { slug }
}
export function extractTextFromNode(node: Node | Text | Element | null | undefined): string {
if (!node) return ''
if (typeof (node as Text).value === 'string') {
return (node as Text).value
}
if ((node as Element).children?.length) {
return (node as Element).children.map(extractTextFromNode).join('')
}
return ''
}
export default function rehypeHeadingIds(options?: { prefix?: string }) {
return (tree: Root) => {
const slugger = createSlugger()
const prefix = options?.prefix ? `${options.prefix}--` : ''
visit(tree, 'element', (node) => {
if (!node || typeof node.tagName !== 'string') return
const tag = node.tagName.toLowerCase()
if (!/^h[1-6]$/.test(tag)) return
const text = extractTextFromNode(node)
const id = prefix + slugger.slug(text)
node.properties = node.properties || {}
if (!node.properties.id) node.properties.id = id
})
}
}
@@ -1,4 +1,5 @@
import ImageViewer from '@renderer/components/ImageViewer'
import FileManager from '@renderer/services/FileManager'
import { type ImageMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
import { Skeleton } from 'antd'
import React from 'react'
@@ -13,8 +14,8 @@ const ImageBlock: React.FC<Props> = ({ block }) => {
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
const images = block.metadata?.generateImageResponse?.images?.length
? block.metadata?.generateImageResponse?.images
: block?.file?.path
? [`file://${block?.file?.path}`]
: block?.file
? [`file://${FileManager.getFilePath(block?.file)}`]
: []
return (
<Container>
@@ -403,7 +403,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
onClose={handleDrawerClose}
open={showChatHistory}
width={680}
destroyOnClose
destroyOnHidden
styles={{
header: { border: 'none' },
body: {
@@ -1,13 +1,14 @@
import ContextMenu from '@renderer/components/ContextMenu'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import Scrollbar from '@renderer/components/Scrollbar'
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
import { Citation } from '@renderer/types'
import { fetchWebContent } from '@renderer/utils/fetch'
import { cleanMarkdownContent } from '@renderer/utils/formats'
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { Button, message, Popover, Skeleton } from 'antd'
import { Check, Copy, FileSearch } from 'lucide-react'
import React, { useState } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -116,7 +117,7 @@ const handleLinkClick = (url: string, event: React.MouseEvent) => {
}
const CopyButton: React.FC<{ content: string }> = ({ content }) => {
const [copied, setCopied] = useState(false)
const [copied, setCopied] = useTemporaryValue(false, 2000)
const { t } = useTranslation()
const handleCopy = () => {
@@ -126,7 +127,6 @@ const CopyButton: React.FC<{ content: string }> = ({ content }) => {
.then(() => {
setCopied(true)
window.message.success(t('common.copied'))
setTimeout(() => setCopied(false), 2000)
})
.catch(() => {
message.error(t('message.copy.failed'))
@@ -23,6 +23,7 @@ import MessageEditor from './MessageEditor'
import MessageErrorBoundary from './MessageErrorBoundary'
import MessageHeader from './MessageHeader'
import MessageMenubar from './MessageMenubar'
import MessageOutline from './MessageOutline'
interface Props {
message: Message
@@ -66,7 +67,7 @@ const MessageItem: FC<Props> = ({
const { assistant, setModel } = useAssistant(message.assistantId)
const { isMultiSelectMode } = useChatContext(topic)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { messageFont, fontSize, messageStyle } = useSettings()
const { messageFont, fontSize, messageStyle, showMessageOutline } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
const messageContainerRef = useRef<HTMLDivElement>(null)
const { editingMessageId, stopEditing } = useMessageEditing()
@@ -183,6 +184,9 @@ const MessageItem: FC<Props> = ({
)}
{!isEditing && (
<>
{!isMultiSelectMode && message.role === 'assistant' && showMessageOutline && (
<MessageOutline message={message} />
)}
<MessageContentContainer
className="message-content-container"
style={{
@@ -10,7 +10,7 @@ import { useAppSelector } from '@renderer/store'
import { selectMessagesForTopic } from '@renderer/store/newMessage'
import { FileMetadata, FileTypes } from '@renderer/types'
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { classNames, getFileExtension } from '@renderer/utils'
import { classNames } from '@renderer/utils'
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create'
import { findAllBlocks } from '@renderer/utils/messageUtils/find'
@@ -173,7 +173,7 @@ const MessageBlockEditor: FC<Props> = ({ message, topicId, onSave, onResend, onC
if (files) {
let supportedFiles = 0
files.forEach((file) => {
if (extensions.includes(getFileExtension(file.path))) {
if (extensions.includes(file.ext)) {
setFiles((prevFiles) => [...prevFiles, file])
supportedFiles++
}
@@ -378,7 +378,7 @@ interface MessageWrapperProps {
const MessageWrapper = styled.div<MessageWrapperProps>`
&.horizontal {
padding-right: 1px;
padding: 1px;
overflow-y: auto;
.message {
height: 100%;
@@ -0,0 +1,180 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { Message, MessageBlockType } from '@renderer/types/newMessage'
import React, { FC, useMemo, useRef } from 'react'
import { useSelector } from 'react-redux'
import remarkParse from 'remark-parse'
import styled from 'styled-components'
import { unified } from 'unified'
import { visit } from 'unist-util-visit'
import { createSlugger, extractTextFromNode } from '../Markdown/plugins/rehypeHeadingIds'
interface MessageOutlineProps {
message: Message
}
interface HeadingItem {
id: string
level: number
text: string
}
const MessageOutline: FC<MessageOutlineProps> = ({ message }) => {
const blockEntities = useSelector((state: RootState) => messageBlocksSelectors.selectEntities(state))
const headings: HeadingItem[] = useMemo(() => {
const mainTextBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((b) => b?.type === MessageBlockType.MAIN_TEXT)
if (!mainTextBlocks?.length) return []
const result: HeadingItem[] = []
mainTextBlocks.forEach((mainTextBlock) => {
const tree = unified().use(remarkParse).parse(mainTextBlock?.content)
const slugger = createSlugger()
visit(tree, ['heading', 'html'], (node) => {
if (node.type === 'heading') {
const level = node.depth ?? 0
if (!level || level < 1 || level > 6) return
const text = extractTextFromNode(node)
if (!text) return
const id = `heading-${mainTextBlock.id}--` + slugger.slug(text || '')
result.push({ id, level, text: text })
} else if (node.type === 'html') {
// 匹配 <h1>...</h1> 到 <h6>...</h6>
const match = node.value.match(/<h([1-6])[^>]*>(.*?)<\/h\1>/i)
if (match) {
const level = parseInt(match[1], 10)
const text = match[2].replace(/<[^>]*>/g, '').trim() // 移除内部的HTML标签
if (text) {
const id = `heading-${mainTextBlock.id}--${slugger.slug(text || '')}`
result.push({ id, level, text })
}
}
}
})
})
return result
}, [message.blocks, blockEntities])
const miniLevel = useMemo(() => {
return headings.length ? Math.min(...headings.map((heading) => heading.level)) : 1
}, [headings])
const messageOutlineContainerRef = useRef<HTMLDivElement>(null)
const scrollToHeading = (id: string) => {
const parent = messageOutlineContainerRef.current?.parentElement
const messageContentContainer = parent?.querySelector('.message-content-container')
if (messageContentContainer) {
const headingElement = messageContentContainer.querySelector(`#${id}`)
if (headingElement) {
const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start'
headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock })
}
}
}
// 暂时不支持 grid,因为在锚点滚动时会导致渲染错位
if (message.multiModelMessageStyle === 'grid' || !headings.length) return null
return (
<MessageOutlineContainer ref={messageOutlineContainerRef}>
<MessageOutlineBody $count={headings.length}>
{headings.map((heading, index) => (
<MessageOutlineItem key={index} onClick={() => scrollToHeading(heading.id)}>
<MessageOutlineItemDot $level={heading.level} />
<MessageOutlineItemText $level={heading.level} $miniLevel={miniLevel}>
{heading.text}
</MessageOutlineItemText>
</MessageOutlineItem>
))}
</MessageOutlineBody>
</MessageOutlineContainer>
)
}
const MessageOutlineContainer = styled.div`
position: absolute;
inset: 63px 0 36px 10px;
z-index: 999;
pointer-events: none;
& ~ .message-content-container {
padding-left: 46px !important;
}
& ~ .MessageFooter {
margin-left: 46px !important;
}
`
const MessageOutlineItemDot = styled.div<{ $level: number }>`
width: ${({ $level }) => 16 - $level * 2}px;
height: 4px;
background: var(--color-border);
border-radius: 2px;
margin-right: 4px;
flex-shrink: 0;
transition: background 0.2s ease;
`
const MessageOutlineItemText = styled.div<{ $level: number; $miniLevel: number }>`
overflow: hidden;
color: var(--color-text-3);
opacity: 0;
display: none;
transition: opacity 0.2s ease;
padding: 2px 8px;
padding-left: ${({ $level, $miniLevel }) => ($level - $miniLevel) * 8}px;
font-size: ${({ $level }) => 16 - $level}px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`
const MessageOutlineItem = styled.div`
height: 24px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
flex-shrink: 0;
&:hover {
${MessageOutlineItemText} {
color: var(--color-text-2);
}
${MessageOutlineItemDot} {
background: var(--color-text-3);
}
}
`
const MessageOutlineBody = styled(Scrollbar)<{ $count: number }>`
max-width: 50%;
max-height: min(100%, 70vh);
position: sticky;
top: max(calc(50% - ${({ $count }) => ($count * 24) / 2 + 10}px), 20px);
bottom: 0;
overflow-x: hidden;
overflow-y: hidden;
display: inline-flex;
flex-direction: column;
padding: 10px 0 10px 10px;
gap: 4px;
border-radius: 10px;
pointer-events: auto;
&:hover {
padding: 10px 10px 10px 10px;
overflow-y: auto;
background: var(--color-background);
box-shadow: 0 0 10px 0 rgba(128, 128, 128, 0.2);
${MessageOutlineItemText} {
opacity: 1;
display: block;
}
}
`
export default React.memo(MessageOutline)
@@ -29,6 +29,7 @@ import {
setEnableBackspaceDeleteModel,
setEnableQuickPanelTriggers,
setFontSize,
setMathEnableSingleDollar,
setMathEngine,
setMessageFont,
setMessageNavigation,
@@ -38,6 +39,7 @@ import {
setPasteLongTextThreshold,
setRenderInputMessageAsMarkdown,
setShowInputEstimatedTokens,
setShowMessageOutline,
setShowPrompt,
setShowTranslateConfirm,
setThoughtAutoCollapse
@@ -96,6 +98,7 @@ const SettingsTab: FC<Props> = (props) => {
codeImageTools,
codeExecution,
mathEngine,
mathEnableSingleDollar,
autoTranslateWithSpace,
pasteLongTextThreshold,
multiModelMessageStyle,
@@ -103,7 +106,8 @@ const SettingsTab: FC<Props> = (props) => {
messageNavigation,
enableQuickPanelTriggers,
enableBackspaceDeleteModel,
showTranslateConfirm
showTranslateConfirm,
showMessageOutline
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@@ -332,6 +336,15 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.show_message_outline')}</SettingRowTitleSmall>
<Switch
size="small"
checked={showMessageOutline}
onChange={(checked) => dispatch(setShowMessageOutline(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.style.label')}</SettingRowTitleSmall>
<Selector
@@ -371,19 +384,6 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.math_engine.label')}</SettingRowTitleSmall>
<Selector
value={mathEngine}
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
options={[
{ value: 'KaTeX', label: 'KaTeX' },
{ value: 'MathJax', label: 'MathJax' },
{ value: 'none', label: t('settings.messages.math_engine.none') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.font_size.title')}</SettingRowTitleSmall>
</SettingRow>
@@ -407,6 +407,37 @@ const SettingsTab: FC<Props> = (props) => {
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
<CollapsibleSettingGroup title={t('settings.math.title')} defaultExpanded={true}>
<SettingGroup>
<SettingRow>
<SettingRowTitleSmall>{t('settings.math.engine.label')}</SettingRowTitleSmall>
<Selector
value={mathEngine}
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
options={[
{ value: 'KaTeX', label: 'KaTeX' },
{ value: 'MathJax', label: 'MathJax' },
{ value: 'none', label: t('settings.math.engine.none') }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('settings.math.single_dollar.label')}{' '}
<Tooltip title={t('settings.math.single_dollar.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Switch
size="small"
checked={mathEnableSingleDollar}
onChange={(checked) => dispatch(setMathEnableSingleDollar(checked))}
/>
</SettingRow>
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
<CollapsibleSettingGroup title={t('chat.settings.code.title')} defaultExpanded={true}>
<SettingGroup>
<SettingRow>
@@ -21,7 +21,7 @@ const KnowledgeBaseFormModal: React.FC<KnowledgeBaseFormModalProps> = ({ panels,
return (
<StyledModal
destroyOnClose
destroyOnHidden
maskClosable={false}
centered
transitionName="animation-move-down"
@@ -3,7 +3,7 @@ import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import tabsService from '@renderer/services/TabsService'
import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle } from 'lucide-react'
import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle, Terminal } from 'lucide-react'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
@@ -52,6 +52,12 @@ const LaunchpadPage: FC = () => {
text: t('title.files'),
path: '/files',
bgColor: 'linear-gradient(135deg, #F59E0B, #FBBF24)' // 文件:金色,代表资源和重要性
},
{
icon: <Terminal size={32} className="icon" />,
text: t('title.code'),
path: '/code',
bgColor: 'linear-gradient(135deg, #1F2937, #374151)' // Code CLI:高级暗黑色,代表专业和技术
}
]
@@ -577,7 +577,7 @@ const DataSettings: FC = () => {
)
)}
</MenuList>
<SettingContainer theme={theme} style={{ display: 'flex', flex: 1 }}>
<SettingContainer theme={theme} style={{ display: 'flex', flex: 1, height: '100%' }}>
{menu === 'data' && (
<>
<SettingGroup theme={theme}>
@@ -113,12 +113,6 @@ const GeneralSettings: FC = () => {
const onProxyModeChange = (mode: 'system' | 'custom' | 'none') => {
dispatch(setProxyMode(mode))
if (mode === 'system') {
dispatch(_setProxyUrl(undefined))
} else if (mode === 'none') {
dispatch(_setProxyUrl(undefined))
dispatch(_setProxyBypassRules(undefined))
}
}
const languagesOptions: { value: LanguageVarious; label: string; flag: string }[] = [
@@ -274,7 +274,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
onClose()
}}
confirmLoading={loading}
destroyOnClose
destroyOnHidden
centered
transitionName="animation-move-down"
width={600}>
@@ -1,7 +1,6 @@
import { RedoOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import ModelSelector from '@renderer/components/ModelSelector'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import { useTheme } from '@renderer/context/ThemeProvider'
@@ -19,6 +18,7 @@ import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingDescription, SettingGroup, SettingTitle } from '..'
import TranslateSettingsPopup from '../TranslateSettingsPopup/TranslateSettingsPopup'
import DefaultAssistantSettings from './DefaultAssistantSettings'
import TopicNamingModalPopup from './TopicNamingModalPopup'
@@ -53,21 +53,6 @@ const ModelSettings: FC = () => {
[translateModel]
)
const onUpdateTranslateModel = async () => {
const prompt = await PromptPopup.show({
title: t('settings.models.translate_model_prompt_title'),
message: t('settings.models.translate_model_prompt_message'),
defaultValue: translateModelPrompt,
inputProps: {
rows: 10,
onPressEnter: () => {}
}
})
if (prompt) {
dispatch(setTranslateModelPrompt(prompt))
}
}
const onResetTranslatePrompt = () => {
dispatch(setTranslateModelPrompt(TRANSLATE_PROMPT))
}
@@ -133,7 +118,11 @@ const ModelSettings: FC = () => {
onChange={(value) => setTranslateModel(find(allModels, JSON.parse(value)) as Model)}
placeholder={t('settings.models.empty')}
/>
<Button icon={<Settings2 size={16} />} style={{ marginLeft: 8 }} onClick={onUpdateTranslateModel} />
<Button
icon={<Settings2 size={16} />}
style={{ marginLeft: 8 }}
onClick={() => TranslateSettingsPopup.show()}
/>
{translateModelPrompt !== TRANSLATE_PROMPT && (
<Tooltip title={t('common.reset')}>
<Button icon={<RedoOutlined />} style={{ marginLeft: 8 }} onClick={onResetTranslatePrompt}></Button>
@@ -20,7 +20,7 @@ import {
SettingRowTitle,
SettingSubtitle,
SettingTitle
} from '../..'
} from '..'
interface Props {
provider: PreprocessProvider
@@ -6,7 +6,7 @@ import { Select } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import PreprocessProviderSettings from './PreprocessSettings'
const PreprocessSettings: FC = () => {
@@ -1,8 +1,8 @@
import InfoTooltip from '@renderer/components/InfoTooltip'
import { HStack } from '@renderer/components/Layout'
import { useProvider } from '@renderer/hooks/useProvider'
import { isSystemProvider, Provider } from '@renderer/types'
import { Collapse, Flex, Switch } from 'antd'
import { Provider } from '@renderer/types'
import { Flex, Switch } from 'antd'
import { startTransition, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -55,17 +55,6 @@ const ApiOptionsSettings = ({ providerId }: Props) => {
},
checked: !provider.apiOptions?.isNotSupportStreamOptions
},
{
key: 'openai_array_content',
label: t('settings.provider.api.options.array_content.label'),
tip: t('settings.provider.api.options.array_content.help'),
onChange: (checked: boolean) => {
updateProviderTransition({
apiOptions: { ...provider.apiOptions, isNotSupportArrayContent: !checked }
})
},
checked: !provider.apiOptions?.isNotSupportArrayContent
},
{
key: 'openai_service_tier',
label: t('settings.provider.api.options.service_tier.label'),
@@ -93,64 +82,41 @@ const ApiOptionsSettings = ({ providerId }: Props) => {
)
const options = useMemo(() => {
const items: OptionType[] = []
const items: OptionType[] = [
{
key: 'openai_array_content',
label: t('settings.provider.api.options.array_content.label'),
tip: t('settings.provider.api.options.array_content.help'),
onChange: (checked: boolean) => {
updateProviderTransition({
apiOptions: { ...provider.apiOptions, isNotSupportArrayContent: !checked }
})
},
checked: !provider.apiOptions?.isNotSupportArrayContent
}
]
if (provider.type === 'openai' || provider.type === 'openai-response' || provider.type === 'azure-openai') {
items.push(...openAIOptions)
}
return items
}, [openAIOptions, provider.type])
if (options.length === 0 || isSystemProvider(provider)) {
return null
}
return items
}, [openAIOptions, provider.apiOptions, provider.type, t, updateProviderTransition])
return (
<>
<Collapse
items={[
{
key: 'settings',
styles: {
header: {
paddingLeft: 0,
paddingRight: 6
},
body: {
padding: 0
}
},
label: (
<div
style={{
fontSize: 14,
color: 'var(--color-text-1)',
userSelect: 'none',
fontWeight: 'bold'
}}>
{t('settings.provider.api.options.label')}
</div>
),
children: (
<Flex vertical gap="middle">
{options.map((item) => (
<HStack key={item.key} justifyContent="space-between">
<HStack alignItems="center" gap={6}>
<label style={{ cursor: 'pointer' }} htmlFor={item.key}>
{item.label}
</label>
<InfoTooltip title={item.tip}></InfoTooltip>
</HStack>
<Switch id={item.key} checked={item.checked} onChange={item.onChange} />
</HStack>
))}
</Flex>
)
}
]}
ghost
expandIconPosition="end"
/>
</>
<Flex vertical gap="middle">
{options.map((item) => (
<HStack key={item.key} justifyContent="space-between">
<HStack alignItems="center" gap={6}>
<label style={{ cursor: 'pointer' }} htmlFor={item.key}>
{item.label}
</label>
<InfoTooltip title={item.tip}></InfoTooltip>
</HStack>
<Switch id={item.key} checked={item.checked} onChange={item.onChange} />
</HStack>
))}
</Flex>
)
}
@@ -0,0 +1,71 @@
import { TopView } from '@renderer/components/TopView'
import { Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ApiOptionsSettings from './ApiOptionsSettings'
interface ShowParams {
providerId: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ providerId, resolve }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
ApiOptionsSettingsPopup.hide = onCancel
return (
<Modal
title={t('settings.provider.api.options.label')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
styles={{ body: { padding: '20px 16px' } }}
footer={null}
centered>
<ApiOptionsSettings providerId={providerId} />
</Modal>
)
}
const TopViewKey = 'ApiOptionsSettingsPopup'
export default class ApiOptionsSettingsPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -12,7 +12,7 @@ import ManageModelsPopup from '@renderer/pages/settings/ProviderSettings/ModelLi
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup'
import { Model } from '@renderer/types'
import { filterModelsByKeywords } from '@renderer/utils'
import { Button, Empty, Flex, Spin, Tooltip } from 'antd'
import { Button, Flex, Spin, Tooltip } from 'antd'
import { groupBy, isEmpty, sortBy, toPairs } from 'lodash'
import { ListCheck, Plus } from 'lucide-react'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
@@ -120,7 +120,7 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
</HStack>
</SettingSubtitle>
<Spin spinning={isLoading} indicator={<LoadingIcon color="var(--color-text-2)" />}>
{displayedModelGroups && !isEmpty(displayedModelGroups) ? (
{displayedModelGroups && !isEmpty(displayedModelGroups) && (
<Flex gap={12} vertical>
{Object.keys(displayedModelGroups).map((group, i) => (
<ModelListGroup
@@ -135,12 +135,6 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
/>
))}
</Flex>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('settings.models.empty')}
style={{ visibility: isLoading ? 'hidden' : 'visible' }}
/>
)}
</Spin>
<Flex justify="space-between" align="center">
@@ -10,13 +10,14 @@ import i18n from '@renderer/i18n'
import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList'
import { checkApi } from '@renderer/services/ApiService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { isSystemProvider } from '@renderer/types'
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip, Typography } from 'antd'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
import { Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react'
import { Bolt, Check, Settings2, SquareArrowOutUpRight, TriangleAlert } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -29,7 +30,7 @@ import {
SettingSubtitle,
SettingTitle
} from '..'
import ApiOptionsSettings from './ApiOptionsSettings'
import ApiOptionsSettingsPopup from './ApiOptionsSettings/ApiOptionsSettingsPopup'
import AwsBedrockSettings from './AwsBedrockSettings'
import CustomHeaderPopup from './CustomHeaderPopup'
import DMXAPISettings from './DMXAPISettings'
@@ -229,13 +230,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
return (
<SettingContainer theme={theme} style={{ background: 'var(--color-background)' }}>
<SettingTitle>
<Flex align="center" gap={5} flex={1} style={{ overflow: 'hidden' }}>
<ProviderName ellipsis>{fancyProviderName}</ProviderName>
<Flex align="center" gap={8}>
<ProviderName>{fancyProviderName}</ProviderName>
{officialWebsite && (
<Link target="_blank" href={providerConfig.websites.official} style={{ display: 'flex' }}>
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
</Link>
)}
{!isSystemProvider(provider) && (
<Button
type="text"
icon={<Bolt size={14} />}
size="small"
onClick={() => ApiOptionsSettingsPopup.show({ providerId: provider.id })}
/>
)}
</Flex>
<Switch
value={provider.enabled}
@@ -366,13 +375,12 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
{provider.id === 'vertexai' && <VertexAISettings providerId={provider.id} />}
<ApiOptionsSettings providerId={provider.id} />
<ModelList providerId={provider.id} />
</SettingContainer>
)
}
const ProviderName = styled(Typography.Text)`
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;
margin-right: -2px;
@@ -15,7 +15,7 @@ import {
matchKeywordsInProvider,
uuid
} from '@renderer/utils'
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Splitter, Tag } from 'antd'
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
import { Eye, EyeOff, GripVertical, PlusIcon, Search, UserPen } from 'lucide-react'
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -455,77 +455,70 @@ const ProvidersList: FC = () => {
return (
<Container className="selectable">
<Splitter>
<Splitter.Panel min={250} defaultSize={250}>
<ProviderListContainer>
<AddButtonWrapper>
<Input
type="text"
placeholder={t('settings.provider.search')}
value={searchText}
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35 }}
suffix={<Search size={14} />}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchText('')
}
}}
allowClear
disabled={dragging}
/>
</AddButtonWrapper>
<DraggableVirtualList
list={filteredProviders}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
estimateSize={useCallback(() => 40, [])}
itemKey={itemKey}
overscan={3}
style={{
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
}}
scrollerStyle={{
padding: 8,
paddingRight: 5
}}
itemContainerStyle={{ paddingBottom: 5 }}>
{(provider) => (
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
<ProviderListItem
key={provider.id}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
<DragHandle>
<GripVertical size={12} />
</DragHandle>
{getProviderAvatar(provider)}
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
ON
</Tag>
)}
</ProviderListItem>
</Dropdown>
)}
</DraggableVirtualList>
<AddButtonWrapper>
<Button
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
icon={<PlusIcon size={16} />}
onClick={onAddProvider}
disabled={dragging}>
{t('button.add')}
</Button>
</AddButtonWrapper>
</ProviderListContainer>
</Splitter.Panel>
<Splitter.Panel min={'50%'}>
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
</Splitter.Panel>
</Splitter>
<ProviderListContainer>
<AddButtonWrapper>
<Input
type="text"
placeholder={t('settings.provider.search')}
value={searchText}
style={{ borderRadius: 'var(--list-item-border-radius)', height: 35 }}
suffix={<Search size={14} />}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchText('')
}
}}
allowClear
disabled={dragging}
/>
</AddButtonWrapper>
<DraggableVirtualList
list={filteredProviders}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
estimateSize={useCallback(() => 40, [])}
itemKey={itemKey}
overscan={3}
style={{
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
}}
scrollerStyle={{
padding: 8,
paddingRight: 5
}}
itemContainerStyle={{ paddingBottom: 5 }}>
{(provider) => (
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
<ProviderListItem
key={provider.id}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
<DragHandle>
<GripVertical size={12} />
</DragHandle>
{getProviderAvatar(provider)}
<ProviderItemName className="text-nowrap">{getFancyProviderName(provider)}</ProviderItemName>
{provider.enabled && (
<Tag color="green" style={{ marginLeft: 'auto', marginRight: 0, borderRadius: 16 }}>
ON
</Tag>
)}
</ProviderListItem>
</Dropdown>
)}
</DraggableVirtualList>
<AddButtonWrapper>
<Button
style={{ width: '100%', borderRadius: 'var(--list-item-border-radius)' }}
icon={<PlusIcon size={16} />}
onClick={onAddProvider}
disabled={dragging}>
{t('button.add')}
</Button>
</AddButtonWrapper>
</ProviderListContainer>
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
</Container>
)
}
@@ -540,7 +533,10 @@ const Container = styled.div`
const ProviderListContainer = styled.div`
display: flex;
flex-direction: column;
min-width: calc(var(--settings-width) + 10px);
height: calc(100vh - var(--navbar-height));
padding-bottom: 5px;
border-right: 0.5px solid var(--color-border);
`
const ProviderListItem = styled.div`
@@ -11,7 +11,7 @@ import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '..'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '.'
const { TextArea } = Input
@@ -39,7 +39,7 @@ const MacProcessTrustHintModal: FC<MacProcessTrustHintModalProps> = ({ open, onC
</div>
}
centered
destroyOnClose>
destroyOnHidden>
<ContentContainer>
<Paragraph>
<Text>
@@ -149,7 +149,7 @@ const SelectionActionSearchModal: FC<SelectionActionSearchModalProps> = ({
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
destroyOnClose
destroyOnHidden
centered>
<Form
form={form}
@@ -46,7 +46,7 @@ const SelectionFilterListModal: FC<SelectionFilterListModalProps> = ({ open, onC
onCancel={onClose}
maskClosable={false}
keyboard={true}
destroyOnClose={true}
destroyOnHidden
footer={[
<Button key="modal-cancel" onClick={onClose}>
{t('common.cancel')}
@@ -1,19 +1,22 @@
import { GlobalOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import Scrollbar from '@renderer/components/Scrollbar'
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
import { Divider as AntDivider } from 'antd'
import {
Brain,
Cloud,
Command,
FolderCog,
FileCode,
HardDrive,
Info,
Languages,
MonitorCog,
Package,
PictureInPicture2,
Settings2,
SquareTerminal,
TextCursorInput
TextCursorInput,
Zap
} from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -26,12 +29,13 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings'
import MCPSettings from './MCPSettings'
import MemorySettings from './MemorySettings'
import PreprocessSettings from './PreprocessSettings'
import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
import QuickPhraseSettings from './QuickPhraseSettings'
import SelectionAssistantSettings from './SelectionAssistantSettings/SelectionAssistantSettings'
import ShortcutSettings from './ShortcutSettings'
import ToolSettings from './ToolSettings'
import TranslateSettings from './TranslateSettings/TranslateSettings'
import WebSearchSettings from './WebSearchSettings'
const SettingsPage: FC = () => {
const { pathname } = useLocation()
@@ -58,6 +62,7 @@ const SettingsPage: FC = () => {
{t('settings.model')}
</MenuItem>
</MenuItemLink>
<Divider />
<MenuItemLink to="/settings/general">
<MenuItem className={isRoute('/settings/general')}>
<Settings2 size={18} />
@@ -76,16 +81,17 @@ const SettingsPage: FC = () => {
{t('settings.data.title')}
</MenuItem>
</MenuItemLink>
<Divider />
<MenuItemLink to="/settings/mcp">
<MenuItem className={isRoute('/settings/mcp')}>
<SquareTerminal size={18} />
{t('settings.mcp.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/translate">
<MenuItem className={isRoute('/settings/translate')}>
<Languages size={18} />
{t('settings.translate.title')}
<MenuItemLink to="/settings/websearch">
<MenuItem className={isRoute('/settings/websearch')}>
<GlobalOutlined style={{ fontSize: 18 }} />
{t('settings.tool.websearch.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/memory">
@@ -94,18 +100,25 @@ const SettingsPage: FC = () => {
{t('memory.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/preprocess">
<MenuItem className={isRoute('/settings/preprocess')}>
<FileCode size={18} />
{t('settings.tool.preprocess.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/quickphrase">
<MenuItem className={isRoute('/settings/quickphrase')}>
<Zap size={18} />
{t('settings.quickPhrase.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/shortcut">
<MenuItem className={isRoute('/settings/shortcut')}>
<Command size={18} />
{t('settings.shortcuts.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/tool">
<MenuItem className={isRoute('/settings/tool')}>
<FolderCog size={18} />
{t('settings.tool.title')}
</MenuItem>
</MenuItemLink>
<Divider />
<MenuItemLink to="/settings/quickAssistant">
<MenuItem className={isRoute('/settings/quickAssistant')}>
<PictureInPicture2 size={18} />
@@ -118,6 +131,7 @@ const SettingsPage: FC = () => {
{t('selection.name')}
</MenuItem>
</MenuItemLink>
<Divider />
<MenuItemLink to="/settings/about">
<MenuItem className={isRoute('/settings/about')}>
<Info size={18} />
@@ -129,9 +143,10 @@ const SettingsPage: FC = () => {
<Routes>
<Route path="provider" element={<ProvidersList />} />
<Route path="model" element={<ModelSettings />} />
<Route path="tool/*" element={<ToolSettings />} />
<Route path="websearch" element={<WebSearchSettings />} />
<Route path="preprocess" element={<PreprocessSettings />} />
<Route path="quickphrase" element={<QuickPhraseSettings />} />
<Route path="mcp/*" element={<MCPSettings />} />
<Route path="translate" element={<TranslateSettings />} />
<Route path="memory" element={<MemorySettings />} />
<Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} />
@@ -158,21 +173,22 @@ const ContentContainer = styled.div`
flex: 1;
flex-direction: row;
height: calc(100vh - var(--navbar-height));
padding: 1px 0;
`
const SettingMenus = styled.ul`
const SettingMenus = styled(Scrollbar)`
display: flex;
flex-direction: column;
min-width: var(--settings-width);
border-right: 0.5px solid var(--color-border);
padding: 10px;
user-select: none;
gap: 5px;
`
const MenuItemLink = styled(Link)`
text-decoration: none;
color: var(--color-text-1);
margin-bottom: 5px;
`
const MenuItem = styled.li`
@@ -191,12 +207,6 @@ const MenuItem = styled.li`
font-size: 16px;
opacity: 0.8;
}
.iconfont {
font-size: 18px;
line-height: 18px;
opacity: 0.7;
margin-left: -1px;
}
&:hover {
background: var(--color-background-soft);
}
@@ -212,4 +222,8 @@ const SettingContent = styled.div`
flex: 1;
`
const Divider = styled(AntDivider)`
margin: 3px 0;
`
export default SettingsPage
@@ -1,79 +0,0 @@
import { GlobalOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import ListItem from '@renderer/components/ListItem'
import { FileCode, Zap } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, Route, Routes, useLocation } from 'react-router-dom'
import styled from 'styled-components'
import PreprocessSettings from './PreprocessSettings'
import QuickPhraseSettings from './QuickPhraseSettings'
import WebSearchSettings from './WebSearchSettings'
const ToolSettings: FC = () => {
const { t } = useTranslation()
const { pathname } = useLocation()
const menuItems = [
{ key: 'web-search', title: 'settings.tool.websearch.title', icon: <GlobalOutlined style={{ fontSize: 16 }} /> },
{ key: 'preprocess', title: 'settings.tool.preprocess.title', icon: <FileCode size={16} /> },
{ key: 'quick-phrase', title: 'settings.quickPhrase.title', icon: <Zap size={16} /> }
]
const isActive = (key: string): boolean => {
const basePath = '/settings/tool'
if (key === 'web-search') {
return pathname === basePath || pathname === `${basePath}/` || pathname === `${basePath}/${key}`
}
return pathname === `${basePath}/${key}`
}
return (
<Container>
<MenuList>
{menuItems.map((item) => (
<Link key={item.key} to={`/settings/tool/${item.key}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<ListItem
title={t(item.title)}
active={isActive(item.key)}
titleStyle={{ fontWeight: 500 }}
icon={item.icon}
/>
</Link>
))}
</MenuList>
<ContentArea>
<Routes>
<Route path="/" element={<WebSearchSettings />} />
<Route path="/web-search" element={<WebSearchSettings />} />
<Route path="/preprocess" element={<PreprocessSettings />} />
<Route path="/quick-phrase" element={<QuickPhraseSettings />} />
</Routes>
</ContentArea>
</Container>
)
}
const Container = styled(HStack)`
flex: 1;
`
const MenuList = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
width: var(--settings-width);
padding: 12px;
border-right: 0.5px solid var(--color-border);
height: 100%;
.iconfont {
line-height: 16px;
}
`
const ContentArea = styled.div`
display: flex;
flex: 1;
height: 100%;
`
export default ToolSettings
@@ -1,56 +0,0 @@
import { HStack } from '@renderer/components/Layout'
import ModelSelector from '@renderer/components/ModelSelector'
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId, hasModel } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { find } from 'lodash'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDescription, SettingGroup, SettingTitle } from '..'
const TranslateModelSettings = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const { providers } = useProviders()
const { translateModel, setTranslateModel } = useDefaultModel()
const allModels = useMemo(() => providers.map((p) => p.models).flat(), [providers])
const modelPredicate = useCallback(
(m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) && !isTextToImageModel(m),
[]
)
const defaultTranslateModel = useMemo(
() => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined),
[translateModel]
)
return (
<SettingGroup theme={theme}>
<SettingTitle style={{ marginBottom: 12 }}>
<HStack alignItems="center" gap={10}>
{t('settings.models.translate_model')}
</HStack>
</SettingTitle>
<HStack alignItems="center">
<ModelSelector
providers={providers}
predicate={modelPredicate}
value={defaultTranslateModel}
defaultValue={defaultTranslateModel}
style={{ width: 360 }}
onChange={(value) => setTranslateModel(find(allModels, JSON.parse(value)) as Model)}
placeholder={t('settings.models.empty')}
/>
</HStack>
<SettingDescription>{t('settings.models.translate_model_description')}</SettingDescription>
</SettingGroup>
)
}
export default TranslateModelSettings
@@ -1,51 +0,0 @@
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { useTheme } from '@renderer/context/ThemeProvider'
import CustomLanguageSettings from '@renderer/pages/settings/TranslateSettings/CustomLanguageSettings'
import { getAllCustomLanguages } from '@renderer/services/TranslateService'
import { CustomTranslateLanguage } from '@renderer/types'
import { Suspense, useEffect, useState } from 'react'
import { SettingContainer, SettingGroup } from '..'
import TranslateModelSettings from './TranslateModelSettings'
import TranslatePromptSettings from './TranslatePromptSettings'
const TranslateSettings = () => {
const { theme } = useTheme()
const [dataPromise, setDataPromise] = useState<Promise<CustomTranslateLanguage[]>>(Promise.resolve([]))
useEffect(() => {
setDataPromise(getAllCustomLanguages())
}, [])
return (
<>
<SettingContainer theme={theme}>
<TranslateModelSettings />
<TranslatePromptSettings />
<SettingGroup theme={theme} style={{ flex: 1 }}>
<Suspense fallback={<CustomLanguagesSettingsFallback />}>
<CustomLanguageSettings dataPromise={dataPromise} />
</Suspense>
</SettingGroup>
</SettingContainer>
</>
)
}
const CustomLanguagesSettingsFallback = () => {
return (
<div
style={{
width: '100%',
height: 250,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<SvgSpinners180Ring />
</div>
)
}
export default TranslateSettings
@@ -98,7 +98,9 @@ const CustomLanguageModal = ({ isOpen, editingCustomLanguage, onAdd, onEdit, onC
footer={footer}
onCancel={onCancel}
maskClosable={false}
transitionName="animation-move-down"
forceRender
centered
styles={{
body: {
padding: '20px'
@@ -1,20 +1,19 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { HStack } from '@renderer/components/Layout'
import { deleteCustomLanguage } from '@renderer/services/TranslateService'
import { deleteCustomLanguage, getAllCustomLanguages } from '@renderer/services/TranslateService'
import { CustomTranslateLanguage } from '@renderer/types'
import { Button, Popconfirm, Space, Table, TableProps } from 'antd'
import { memo, startTransition, use, useCallback, useEffect, useMemo, useState } from 'react'
import { memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingRowTitle } from '..'
import CustomLanguageModal from './CustomLanguageModal'
type Props = {
dataPromise: Promise<CustomTranslateLanguage[]>
}
const logger = loggerService.withContext('CustomLanguageSettings')
const CustomLanguageSettings = ({ dataPromise }: Props) => {
const CustomLanguageSettings = () => {
const { t } = useTranslation()
const [displayedItems, setDisplayedItems] = useState<CustomTranslateLanguage[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
@@ -104,18 +103,28 @@ const CustomLanguageSettings = ({ dataPromise }: Props) => {
[onDelete, t]
)
const data = use(dataPromise)
useEffect(() => {
setDisplayedItems(data)
}, [data])
const loadData = async () => {
try {
const data = await getAllCustomLanguages()
setDisplayedItems(data)
} catch (error) {
logger.error('Failed to load custom languages:', error as Error)
}
}
loadData()
}, [])
return (
<>
<CustomLanguageSettingsContainer>
<HStack justifyContent="space-between" style={{ padding: '4px 0' }}>
<SettingRowTitle>{t('translate.custom.label')}</SettingRowTitle>
<Button type="primary" icon={<PlusOutlined size={16} />} onClick={onClickAdd}>
<Button
type="primary"
icon={<PlusOutlined size={16} />}
onClick={onClickAdd}
style={{ marginBottom: 5, marginTop: -5 }}>
{t('common.add')}
</Button>
</HStack>
@@ -45,7 +45,8 @@ const TranslatePromptSettings = () => {
onChange={(e) => setLocalPrompt(e.target.value)}
onBlur={(e) => dispatch(setTranslateModelPrompt(e.target.value))}
autoSize={{ minRows: 4, maxRows: 10 }}
placeholder={t('settings.models.translate_model_prompt_message')}></Input.TextArea>
placeholder={t('settings.models.translate_model_prompt_message')}
/>
</SettingGroup>
)
}
@@ -0,0 +1,74 @@
import { TopView } from '@renderer/components/TopView'
import { useTheme } from '@renderer/context/ThemeProvider'
import { Modal } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingContainer, SettingGroup } from '..'
import CustomLanguageSettings from './CustomLanguageSettings'
import TranslatePromptSettings from './TranslatePromptSettings'
interface Props {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const { theme } = useTheme()
const { t } = useTranslation()
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
TranslateSettingsPopup.hide = onCancel
return (
<Modal
title={t('settings.translate.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
width="80vw"
centered>
<SettingContainer theme={theme} style={{ padding: '10px 0' }}>
<TranslatePromptSettings />
<SettingGroup theme={theme} style={{ flex: 1 }}>
<CustomLanguageSettings />
</SettingGroup>
</SettingContainer>
</Modal>
)
}
const TopViewKey = 'TranslateSettingsPopup'
export default class TranslateSettingsPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -7,7 +7,7 @@ import { t } from 'i18next'
import { Info } from 'lucide-react'
import { FC } from 'react'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const BasicSettings: FC = () => {
const { theme } = useTheme()
@@ -10,7 +10,7 @@ import TextArea from 'antd/es/input/TextArea'
import { t } from 'i18next'
import { FC, useEffect, useState } from 'react'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import AddSubscribePopup from './AddSubscribePopup'
type TableRowSelection<T extends object = object> = TableProps<T>['rowSelection']

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