Compare commits

..

1 Commits

Author SHA1 Message Date
beyondkmp
441fb1de53 refactor by codex 2025-10-29 17:48:35 +08:00
82 changed files with 1719 additions and 3585 deletions

View File

@@ -0,0 +1,13 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -1,26 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -1,76 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
message: import_v42.z.object({
role: import_v42.z.literal("assistant").nullish(),
content: import_v42.z.string().nullish(),
+ reasoning_content: import_v42.z.string().nullish(),
tool_calls: import_v42.z.array(
import_v42.z.object({
id: import_v42.z.string().nullish(),
@@ -340,6 +341,7 @@ var openaiChatChunkSchema = (0, import_provider_utils3.lazyValidator)(
delta: import_v42.z.object({
role: import_v42.z.enum(["assistant"]).nullish(),
content: import_v42.z.string().nullish(),
+ reasoning_content: import_v42.z.string().nullish(),
tool_calls: import_v42.z.array(
import_v42.z.object({
index: import_v42.z.number(),
@@ -785,6 +787,14 @@ var OpenAIChatLanguageModel = class {
if (text != null && text.length > 0) {
content.push({ type: "text", text });
}
+ const reasoning =
+ choice.message.reasoning_content;
+ if (reasoning != null && reasoning.length > 0) {
+ content.push({
+ type: 'reasoning',
+ text: reasoning,
+ });
+ }
for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) {
content.push({
type: "tool-call",
@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class {
};
let isFirstChunk = true;
let isActiveText = false;
+ let isActiveReasoning = false;
const providerMetadata = { openai: {} };
return {
stream: response.pipeThrough(
@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class {
return;
}
const delta = choice.delta;
+ const reasoningContent = delta.reasoning_content;
+ if (reasoningContent) {
+ if (!isActiveReasoning) {
+ controller.enqueue({
+ type: 'reasoning-start',
+ id: 'reasoning-0',
+ });
+ isActiveReasoning = true;
+ }
+
+ controller.enqueue({
+ type: 'reasoning-delta',
+ id: 'reasoning-0',
+ delta: reasoningContent,
+ });
+ }
if (delta.content != null) {
if (!isActiveText) {
controller.enqueue({ type: "text-start", id: "0" });
@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class {
}
},
flush(controller) {
+ if (isActiveReasoning) {
+ controller.enqueue({ type: 'reasoning-end', id: 'reasoning-0' });
+ }
if (isActiveText) {
controller.enqueue({ type: "text-end", id: "0" });
}

View File

@@ -1,191 +0,0 @@
# Cherry Studio 快捷键系统重构设计文档 v2.1
> 最近更新2025-01-30
> 维护者Architecture Team
## 目录
- [背景与目标](#背景与目标)
- [核心原则](#核心原则)
- [架构分层](#架构分层)
- [关键实现](#关键实现)
- [数据流](#数据流)
- [默认快捷键](#默认快捷键)
- [迁移与兼容性](#迁移与兼容性)
- [后续演进方向](#后续演进方向)
---
## 背景与目标
旧版快捷键系统存在以下问题:
1. 依赖已弃用的 `configManager`,与 v2 架构不兼容;
2. Redux store 与本地存储重复维护状态;
3. 处理器通过 `switch-case` 硬编码,可维护性差;
4. 快捷键定义分散,缺乏统一真相源;
5. 新增快捷键需要触达多处文件,易错且低效。
新版系统要实现:
- **单一真相源**:快捷键定义集中管理,保证一致性;
- **偏好服务优先**:所有运行时状态通过 `preferenceService` 管理;
- **处理器注册表**:解除 `switch-case` 依赖,改用 Map 注册;
- **类型安全**:从定义、存储到消费全链路具备 TypeScript 约束;
- **易扩展**:新增快捷键仅需「定义 → 注册处理器 → 使用」三步;
- **性能稳定**:支持 100+ 快捷键规模,主/渲染进程高效同步;
- **多窗口同步**:借助 `preferenceService` 自动推送变更。
---
## 核心原则
1. **关注点分离**
- 定义层:静态元数据(名称、默认绑定、作用域、分类等);
- 偏好层:用户可变配置(绑定、启用状态等);
- 服务层:主进程注册、电焦/失焦时的生命周期管理;
- UI 层:设置面板、快捷键提示等。
2. **复用基础设施**
- 所有持久化均依赖 `preferenceService`SQLite + 内存缓存 + IPC
- 变更通过订阅自动广播至所有窗口;
- 新增键位无需改动主进程/渲染进程的底层框架代码。
---
## 架构分层
```
┌──────────────────────────────────────────────┐
│ Shortcut 系统 │
├──────────────────────────────────────────────┤
│ 📋 Definitions (packages/shared/shortcuts) │
│ - types.ts类型、作用域、分类 │
│ - definitions.ts静态定义真相之源
│ - utils.ts转换/校验工具 │
│ │
│ 💾 Preferences (preferenceService) │
│ - preferenceSchemas.ts 默认值 │
│ - preferenceTypes.ts 类型导出 │
│ │
│ ⚙️ Services │
│ - src/main/services/ShortcutService.ts │
│ · 处理器注册表、focus/blur 生命周期 │
│ · preference 订阅、主进程快捷键注册 │
│ - 渲染进程 useShortcut/useShortcutDisplay │
│ │
│ 🎨 UI │
│ - 设置页 ShortcutSettings │
│ - 各功能模块中的 useShortcut/useShortcutDisplay │
└──────────────────────────────────────────────┘
```
---
## 关键实现
### 1. 静态定义
- 所有快捷键在 `packages/shared/shortcuts/definitions.ts` 中集中维护;
- 包含 `scope`main / renderer / both`category``persistOnBlur` 等元信息;
- `enabledWhen` 支持动态启用(如 mini window 与 quick assistant 开关关联);
- 新增快捷键步骤:
1.`preferenceSchemas.ts` 中声明默认值;
2.`definitions.ts` 中补充静态定义;
3. 在主/渲染进程相关模块注册处理器或消费 Hook。
### 2. 偏好系统
- 所有运行时配置通过 `preferenceService` 读写;
- 默认值与 `PreferenceShortcutType` 结构保持一致;
- `ShortcutService` / `useShortcuts` 访问偏好时统一调用 `coerceShortcutPreference`,确保 fallback 与类型安全;
- 批量重置通过 `preferenceService.setMultiple` 实现。
### 3. 主进程服务
- `ShortcutService` 负责:
- 生命周期:随着窗口 focus/blur 注册或卸载快捷键;
- 处理器注册Map 替换 `switch-case`
- 订阅偏好变更:自动重新注册;
- `persistOnBlur`:例如 `show_main_window` 在窗口失焦时仍可触发;
- `shortcut.app.show_settings` 会在需要时唤起窗口并调用 `window.navigate('/settings/provider')`,避免重复 blur/focus。
### 4. 渲染进程 Hook
- `useShortcut`:从偏好获取绑定 → 转为 `react-hotkeys-hook` 字符串 → 注册快捷键;
- `useShortcutDisplay`:转换为 UI 显示字符串(`⌘` / `Ctrl+` 等);
- `useAllShortcuts`:批量拉取配置 + diff 默认值,供设置面板使用;
- 新增 `enableOnContentEditable` 等配置支撑设置页和富文本场景。
### 5. 设置界面
- `ShortcutSettings` 直接消费 `useAllShortcuts`
- 支持录制、清空、重置默认、启用/禁用、冲突检测;
- 重新绑定时使用 `convertKeyToAccelerator` / `isValidShortcut` / `formatShortcutDisplay`
- “重置全部” 通过 `preferenceService.setMultiple` 一次性写入默认配置;
- 新增表格展示 `hasCustomBinding`,区分用户自定义与继承默认值。
---
## 数据流
### 启动阶段
1. `preferenceService.initialize()` 载入缓存;
2. `shortcutService` 构造时注册处理器与订阅;
3. 窗口创建后调用 `shortcutService.registerForWindow`,在 `focus` 时注册主进程快捷键。
### 运行时变更
1. 设置页或其他模块调用 `preferenceService.set` / `setMultiple`
2. 主进程订阅触发 → `globalShortcut.unregisterAll()` → 按新配置重注册;
3. 渲染进程通过 `usePreference`/`useMultiplePreferences` 自动收到更新UI 即时刷新。
---
## 默认快捷键
| preference key | 默认绑定 | 描述 / 备注 |
|----------------------------------------|-----------------------------|--------------------------------------|
| `shortcut.app.show_main_window` | `Cmd/Ctrl + Shift + A` | 主窗口显示(失焦持久) |
| `shortcut.app.show_mini_window` | `Cmd/Ctrl + E` | Mini 窗口(与 quick assistant 联动) |
| `shortcut.app.show_settings` | `Cmd/Ctrl + ,` | 设置页入口 |
| `shortcut.app.toggle_show_assistants` | `Cmd/Ctrl + [` | 助手侧边栏 |
| `shortcut.app.exit_fullscreen` | `Escape` | 系统级,不可编辑 |
| `shortcut.app.zoom_in/out/reset` | `Cmd/Ctrl + = / - / 0` | 包含数字键盘变体 |
| `shortcut.app.search_message` | `Cmd/Ctrl + Shift + F` | 全局搜索 |
| `shortcut.chat.clear` | `Cmd/Ctrl + L` | 清空消息 |
| `shortcut.chat.search_message` | `Cmd/Ctrl + F` | 聊天内搜索 |
| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl + K` | 新上下文 |
| `shortcut.chat.copy_last_message` | `Cmd/Ctrl + Shift + C` | 复制最后一条 |
| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl + Shift + E` | 编辑最后一条用户消息 |
| `shortcut.topic.new` | `Cmd/Ctrl + N` | 新增话题(默认启用) |
| `shortcut.topic.rename` | `Cmd/Ctrl + T` | 重命名话题(默认启用,自 2025-01 调整) |
| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl + ]` | 话题侧边栏 |
| `shortcut.selection.*` | 无默认绑定 | 划词助手开关、取词 |
> 具体配置以 `preferenceSchemas.ts` 为准,可在设置页查看或调整。
---
## 迁移与兼容性
- 已有用户偏好:沿用旧值;新增键(如 `shortcut.topic.rename`)在数据库不存在时继承新默认;
- 旧版 Redux store / `configManager` 已彻底移除;
- `IpcChannel.Shortcuts_Update``window.api.shortcuts.update` 相关逻辑已弃用;
- `PreferenceMigrator` 中保留与旧 keys 的映射,确保升级顺畅。
---
## 后续演进方向
1. **冲突检测增强**:主/渲染进程联动校验冲突并提示;
2. **导入导出**:允许用户批量备份/恢复自定义快捷键;
3. **多作用域绑定**:同一逻辑支持按窗口类型或上下文切换;
4. **可视化录制**:增加「录制模式」避免输入框手动录制;
5. **自动化测试**:补充主进程/渲染进程快捷键单元测试样板。
---
> 如需扩展或有疑问,请联系架构团队或在仓库中提交 Issue。
> 设计文档 v2.1 同步最新实现2025-01包含 `shortcut.topic.rename` 默认启用、`show_settings` 优化等补充说明。

View File

@@ -106,8 +106,8 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.42",
"@ai-sdk/google-vertex": "^3.0.48",
"@ai-sdk/amazon-bedrock": "^3.0.35",
"@ai-sdk/google-vertex": "^3.0.40",
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch",
"@ai-sdk/mistral": "^2.0.19",
"@ai-sdk/perplexity": "^2.0.13",
@@ -230,7 +230,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.76",
"ai": "^5.0.68",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
@@ -393,8 +393,7 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch",
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",

View File

@@ -36,10 +36,10 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.32",
"@ai-sdk/azure": "^2.0.53",
"@ai-sdk/anthropic": "^2.0.27",
"@ai-sdk/azure": "^2.0.49",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@ai-sdk/openai": "^2.0.48",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.12",

View File

@@ -204,7 +204,9 @@ export enum IpcChannel {
Export_Word = 'export:word',
Shortcuts_GetAll = 'shortcuts:getAll',
Shortcuts_Update = 'shortcuts:update',
Shortcuts_Updated = 'shortcuts:updated',
// backup
Backup_Backup = 'backup:backup',

View File

@@ -11,12 +11,23 @@
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
import * as PreferenceTypes from '@shared/data/preference/preferenceTypes'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
/* eslint @typescript-eslint/member-ordering: ["error", {
"interfaces": { "order": "alphabetically" },
"typeLiterals": { "order": "alphabetically" }
}] */
const defaultShortcutPreferences: PreferenceTypes.ShortcutPreferencesValue = Object.fromEntries(
shortcutDefinitions.map((definition) => [
definition.name,
{
enabled: definition.defaultEnabled,
key: [...definition.defaultKey]
}
])
)
export interface PreferenceSchemas {
default: {
// redux/settings/enableDeveloperMode
@@ -373,22 +384,18 @@ export interface PreferenceSchemas {
'shortcut.chat.clear': Record<string, unknown>
// redux/shortcuts/shortcuts.copy_last_message
'shortcut.chat.copy_last_message': Record<string, unknown>
// redux/shortcuts/shortcuts.edit_last_user_message
'shortcut.chat.edit_last_user_message': Record<string, unknown>
// redux/shortcuts/shortcuts.search_message_in_chat
'shortcut.chat.search_message': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_new_context
'shortcut.chat.toggle_new_context': Record<string, unknown>
// unified shortcut overrides
'shortcut.preferences': PreferenceTypes.ShortcutPreferencesValue
// redux/shortcuts/shortcuts.selection_assistant_select_text
'shortcut.selection.get_text': Record<string, unknown>
// redux/shortcuts/shortcuts.selection_assistant_toggle
'shortcut.selection.toggle_enabled': Record<string, unknown>
// redux/shortcuts/shortcuts.new_topic
'shortcut.topic.new': Record<string, unknown>
// redux/shortcuts/shortcuts.rename_topic
'shortcut.topic.rename': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_show_topics
'shortcut.topic.toggle_show_topics': Record<string, unknown>
// redux/settings/enableTopicNaming
'topic.naming.enabled': boolean
// redux/settings/topicNamingPrompt
@@ -644,12 +651,6 @@ export const DefaultPreferences: PreferenceSchemas = {
key: ['CommandOrControl', 'Shift', 'C'],
system: false
},
'shortcut.chat.edit_last_user_message': {
editable: true,
enabled: false,
key: ['CommandOrControl', 'Shift', 'E'],
system: false
},
'shortcut.chat.search_message': { editable: true, enabled: true, key: ['CommandOrControl', 'F'], system: false },
'shortcut.chat.toggle_new_context': {
editable: true,
@@ -657,21 +658,10 @@ export const DefaultPreferences: PreferenceSchemas = {
key: ['CommandOrControl', 'K'],
system: false
},
'shortcut.preferences': defaultShortcutPreferences,
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true },
'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },
'shortcut.topic.rename': {
editable: true,
enabled: true,
key: ['CommandOrControl', 'T'],
system: false
},
'shortcut.topic.toggle_show_topics': {
editable: true,
enabled: true,
key: ['CommandOrControl', ']'],
system: false
},
'topic.naming.enabled': true,
'topic.naming_prompt': '',
'topic.position': 'left',

View File

@@ -1,3 +1,5 @@
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
import type { PreferenceSchemas } from './preferenceSchemas'
export type PreferenceDefaultScopeType = PreferenceSchemas['default']
@@ -14,6 +16,8 @@ export type PreferenceShortcutType = {
system: boolean
}
export type ShortcutPreferencesValue = ShortcutPreferenceMap
export enum SelectionTriggerMode {
Selected = 'selected',
Ctrlkey = 'ctrlkey',

View File

@@ -1,148 +1,175 @@
import type { ShortcutCategory, ShortcutDefinition } from './types'
import type { ShortcutDefinition } from './types'
export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
// ==================== 应用级快捷键 ====================
export const shortcutDefinitions: ShortcutDefinition[] = [
{
key: 'shortcut.app.show_main_window',
defaultKey: ['CommandOrControl', 'Shift', 'A'],
name: 'show_app',
defaultKey: [],
defaultEnabled: true,
description: 'Show or hide the main window',
scope: 'main',
category: 'app',
persistOnBlur: true
editable: true,
system: true
},
{
key: 'shortcut.app.show_mini_window',
name: 'show_mini_window',
defaultKey: ['CommandOrControl', 'E'],
defaultEnabled: false,
description: 'Show or hide the mini window',
scope: 'main',
category: 'app',
persistOnBlur: true,
enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled')
editable: true,
system: true
},
{
key: 'shortcut.app.show_settings',
defaultKey: ['CommandOrControl', ','],
scope: 'both',
category: 'app'
name: 'selection_assistant_toggle',
defaultKey: [],
defaultEnabled: false,
description: 'Enable or disable the selection assistant',
scope: 'main',
editable: true,
system: true
},
{
key: 'shortcut.app.toggle_show_assistants',
defaultKey: ['CommandOrControl', '['],
scope: 'renderer',
category: 'app'
name: 'selection_assistant_select_text',
defaultKey: [],
defaultEnabled: false,
description: 'Trigger selection assistant text capture',
scope: 'main',
editable: true,
system: true
},
{
key: 'shortcut.app.exit_fullscreen',
defaultKey: ['Escape'],
scope: 'renderer',
category: 'app'
},
{
key: 'shortcut.app.zoom_in',
name: 'zoom_in',
defaultKey: ['CommandOrControl', '='],
defaultEnabled: true,
description: 'Zoom in',
scope: 'main',
category: 'app',
variants: [['CommandOrControl', 'numadd']]
editable: false,
system: true
},
{
key: 'shortcut.app.zoom_out',
name: 'zoom_out',
defaultKey: ['CommandOrControl', '-'],
defaultEnabled: true,
description: 'Zoom out',
scope: 'main',
category: 'app',
variants: [['CommandOrControl', 'numsub']]
editable: false,
system: true
},
{
key: 'shortcut.app.zoom_reset',
name: 'zoom_reset',
defaultKey: ['CommandOrControl', '0'],
defaultEnabled: true,
description: 'Reset zoom',
scope: 'main',
category: 'app'
editable: false,
system: true
},
{
key: 'shortcut.app.search_message',
defaultKey: ['CommandOrControl', 'Shift', 'F'],
name: 'show_settings',
defaultKey: ['CommandOrControl', ','],
defaultEnabled: true,
description: 'Open settings',
scope: 'renderer',
category: 'app'
},
// ==================== 聊天相关快捷键 ====================
{
key: 'shortcut.chat.clear',
defaultKey: ['CommandOrControl', 'L'],
scope: 'renderer',
category: 'chat'
editable: false,
system: true
},
{
key: 'shortcut.chat.search_message',
defaultKey: ['CommandOrControl', 'F'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.toggle_new_context',
defaultKey: ['CommandOrControl', 'K'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.copy_last_message',
defaultKey: ['CommandOrControl', 'Shift', 'C'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.edit_last_user_message',
defaultKey: ['CommandOrControl', 'Shift', 'E'],
scope: 'renderer',
category: 'chat'
},
// ==================== 话题管理快捷键 ====================
{
key: 'shortcut.topic.new',
name: 'new_topic',
defaultKey: ['CommandOrControl', 'N'],
defaultEnabled: true,
description: 'Start a new chat topic',
scope: 'renderer',
category: 'topic'
editable: true,
system: false
},
{
key: 'shortcut.topic.rename',
name: 'rename_topic',
defaultKey: ['CommandOrControl', 'T'],
defaultEnabled: false,
description: 'Rename current topic',
scope: 'renderer',
category: 'topic'
editable: true,
system: false
},
{
key: 'shortcut.topic.toggle_show_topics',
name: 'toggle_show_assistants',
defaultKey: ['CommandOrControl', '['],
defaultEnabled: true,
description: 'Toggle assistant sidebar',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'toggle_show_topics',
defaultKey: ['CommandOrControl', ']'],
defaultEnabled: true,
description: 'Toggle topic sidebar',
scope: 'renderer',
category: 'topic'
},
// ==================== 划词助手快捷键 ====================
{
key: 'shortcut.selection.toggle_enabled',
defaultKey: [],
scope: 'main',
category: 'selection',
persistOnBlur: true
editable: true,
system: false
},
{
key: 'shortcut.selection.get_text',
defaultKey: [],
scope: 'main',
category: 'selection',
persistOnBlur: true
name: 'copy_last_message',
defaultKey: ['CommandOrControl', 'Shift', 'C'],
defaultEnabled: false,
description: 'Copy the last assistant reply',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'edit_last_user_message',
defaultKey: ['CommandOrControl', 'Shift', 'E'],
defaultEnabled: false,
description: 'Edit the last user message',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'search_message_in_chat',
defaultKey: ['CommandOrControl', 'F'],
defaultEnabled: true,
description: 'Search messages in current chat',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'search_message',
defaultKey: ['CommandOrControl', 'Shift', 'F'],
defaultEnabled: true,
description: 'Search messages globally',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'clear_topic',
defaultKey: ['CommandOrControl', 'L'],
defaultEnabled: true,
description: 'Clear current topic',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'toggle_new_context',
defaultKey: ['CommandOrControl', 'K'],
defaultEnabled: true,
description: 'Toggle new context mode',
scope: 'renderer',
editable: true,
system: false
},
{
name: 'exit_fullscreen',
defaultKey: ['Escape'],
defaultEnabled: true,
description: 'Exit fullscreen mode',
scope: 'renderer',
editable: false,
system: true
}
] as const
export const getShortcutsByCategory = () => {
const groups: Record<ShortcutCategory, ShortcutDefinition[]> = {
app: [],
chat: [],
topic: [],
selection: []
}
SHORTCUT_DEFINITIONS.forEach((definition) => {
groups[definition.category].push(definition)
})
return groups
}
export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => {
return SHORTCUT_DEFINITIONS.find((definition) => definition.key === key)
}
]

View File

@@ -1,40 +1,25 @@
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes'
import type { BrowserWindow } from 'electron'
export type ShortcutScope = 'main' | 'renderer' | 'both'
export type ShortcutCategory = 'app' | 'chat' | 'topic' | 'selection'
export type ShortcutPreferenceKey = Extract<PreferenceKeyType, `shortcut.${string}`>
export type GetPreferenceFn = <K extends PreferenceKeyType>(key: K) => PreferenceDefaultScopeType[K]
export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boolean
export type ShortcutScope = 'main' | 'renderer'
export interface ShortcutDefinition {
key: ShortcutPreferenceKey
name: string
defaultKey: string[]
defaultEnabled: boolean
description: string
scope: ShortcutScope
category: ShortcutCategory
persistOnBlur?: boolean
variants?: string[][]
enabledWhen?: ShortcutEnabledPredicate
}
export interface ShortcutPreferenceValue {
binding: string[]
rawBinding: string[]
hasCustomBinding: boolean
enabled: boolean
editable: boolean
system: boolean
}
export interface ShortcutRuntimeConfig extends ShortcutDefinition {
binding: string[]
enabled: boolean
editable: boolean
system: boolean
export interface ShortcutPreferenceEntry {
key?: string[]
enabled?: boolean
}
export type ShortcutHandler = (window?: BrowserWindow) => void | Promise<void>
export type ShortcutPreferenceMap = Record<string, ShortcutPreferenceEntry>
export type HydratedShortcut = ShortcutDefinition & {
key: string[]
enabled: boolean
}
export type HydratedShortcutMap = Record<string, HydratedShortcut>

View File

@@ -1,137 +0,0 @@
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import type { ShortcutDefinition, ShortcutPreferenceValue } from './types'
const modifierKeys = ['CommandOrControl', 'Ctrl', 'Alt', 'Shift', 'Meta', 'Command']
const specialSingleKeys = ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']
export const convertKeyToAccelerator = (key: string): string => {
const keyMap: Record<string, string> = {
Command: 'CommandOrControl',
Cmd: 'CommandOrControl',
Control: 'Ctrl',
Meta: 'Meta',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
AltGraph: 'AltGr',
Slash: '/',
Semicolon: ';',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Quote: "'",
Comma: ',',
Minus: '-',
Equal: '='
}
return keyMap[key] || key
}
export const convertAcceleratorToHotkey = (accelerator: string[]): string => {
return accelerator
.map((key) => {
switch (key.toLowerCase()) {
case 'commandorcontrol':
return 'mod'
case 'command':
case 'cmd':
return 'meta'
case 'control':
case 'ctrl':
return 'ctrl'
case 'alt':
return 'alt'
case 'shift':
return 'shift'
case 'meta':
return 'meta'
default:
return key.toLowerCase()
}
})
.join('+')
}
export const formatShortcutDisplay = (keys: string[], isMac: boolean): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'ctrl':
case 'control':
return isMac ? '⌃' : 'Ctrl'
case 'command':
case 'cmd':
return isMac ? '⌘' : 'Win'
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'meta':
return isMac ? '⌘' : 'Win'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join(isMac ? '' : '+')
}
export const isValidShortcut = (keys: string[]): boolean => {
if (!keys.length) {
return false
}
const hasModifier = keys.some((key) => modifierKeys.includes(key))
const isSpecialKey = keys.length === 1 && specialSingleKeys.includes(keys[0])
return hasModifier || isSpecialKey
}
const ensureArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string')
}
return []
}
const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback)
export const getDefaultShortcutPreference = (definition: ShortcutDefinition): ShortcutPreferenceValue => {
const fallback = DefaultPreferences.default[definition.key] as PreferenceShortcutType
const rawBinding = ensureArray(fallback?.key)
const binding = rawBinding.length ? rawBinding : definition.defaultKey
return {
binding,
rawBinding: binding,
hasCustomBinding: false,
enabled: ensureBoolean(fallback?.enabled, true),
editable: ensureBoolean(fallback?.editable, true),
system: ensureBoolean(fallback?.system, false)
}
}
export const coerceShortcutPreference = (
definition: ShortcutDefinition,
value?: PreferenceShortcutType | null
): ShortcutPreferenceValue => {
const fallback = getDefaultShortcutPreference(definition)
const hasCustomBinding = Array.isArray((value as PreferenceShortcutType | undefined)?.key)
const rawBinding = hasCustomBinding ? ensureArray((value as PreferenceShortcutType).key) : fallback.binding
const binding = rawBinding.length > 0 ? rawBinding : fallback.binding
return {
binding,
rawBinding,
hasCustomBinding,
enabled: ensureBoolean(value?.enabled, fallback.enabled),
editable: ensureBoolean(value?.editable, fallback.editable),
system: ensureBoolean(value?.system, fallback.system)
}
}

View File

@@ -1,368 +0,0 @@
# Cherry Studio Design System 集成方案
本文档聚焦三个核心问题:
1. **如何将 todocss.css 集成到 Tailwind CSS v4**
2. **如何在项目中使用集成后的设计系统**
3. **如何平衡 UI 库和主包的需求**
---
## 一、集成策略
### 1.1 文件架构
```
todocss.css (设计师提供)
↓ 转换 & 优化
design-tokens.css (--ds-* 变量)
↓ @theme inline 映射
globals.css (cs-* 工具类)
↓ 开发者使用
React Components
```
### 1.2 核心转换规则
#### 变量简化
```css
/* todocss.css */
--Brand--Base_Colors--Primary: hsla(84, 81%, 44%, 1);
/* ↓ 转换为 design-tokens.css */
--ds-primary: hsla(84, 81%, 44%, 1);
/* ↓ 映射到 globals.css */
@theme inline {
--color-cs-primary: var(--ds-primary);
}
/* ↓ 生成工具类 */
bg-cs-primary, text-cs-primary, border-cs-primary
```
#### 去除冗余
- **间距/尺寸合并**: `--Spacing--md``--Sizing--md` 值相同 → 统一为 `--ds-size-md`
- **透明度废弃**: `--Opacity--Red--Red-80` → 使用 `bg-cs-destructive/80`
- **错误修正**: `--Font_weight--Regular: 400px``--ds-font-weight-regular: 400`
### 1.3 命名规范
| 层级 | 前缀 | 示例 | 用途 |
|------|------|------|------|
| 设计令牌 | `--ds-*` | `--ds-primary` | 定义值 |
| Tailwind 映射 | `--color-cs-*` | `--color-cs-primary` | 生成工具类 |
| 工具类 | `cs-*` | `bg-cs-primary` | 开发者使用 |
#### Tailwind v4 映射规则
| 变量前缀 | 生成的工具类 |
|----------|-------------|
| `--color-cs-*` | `bg-*`, `text-*`, `border-*`, `fill-*` |
| `--spacing-cs-*` | `p-*`, `m-*`, `gap-*` |
| `--size-cs-*` | `w-*`, `h-*`, `size-*` |
| `--radius-cs-*` | `rounded-*` |
| `--font-size-cs-*` | `text-*` |
### 1.4 为什么使用 @theme inline
```css
/* ❌ @theme - 静态编译,不支持运行时主题切换 */
@theme {
--color-primary: var(--ds-primary);
}
/* ✅ @theme inline - 保留变量引用,支持运行时切换 */
@theme inline {
--color-cs-primary: var(--ds-primary);
}
```
**关键差异**`@theme inline` 使 CSS 变量在运行时动态解析,实现明暗主题切换。
---
## 二、项目使用指南
### 2.1 在 UI 库中使用
#### 文件结构
```
packages/ui/
├── src/styles/
│ ├── design-tokens.css # 核心变量定义
│ └── globals.css # Tailwind 集成
└── package.json # 导出配置
```
#### globals.css 示例
```css
@import 'tailwindcss';
@import './design-tokens.css';
@theme inline {
/* 颜色 */
--color-cs-primary: var(--ds-primary);
--color-cs-bg: var(--ds-background);
--color-cs-fg: var(--ds-foreground);
/* 间距 */
--spacing-cs-xs: var(--ds-size-xs);
--spacing-cs-sm: var(--ds-size-sm);
--spacing-cs-md: var(--ds-size-md);
/* 尺寸 */
--size-cs-xs: var(--ds-size-xs);
--size-cs-sm: var(--ds-size-sm);
/* 圆角 */
--radius-cs-sm: var(--ds-radius-sm);
--radius-cs-md: var(--ds-radius-md);
}
@custom-variant dark (&:is(.dark *));
```
#### 组件中使用
```tsx
// packages/ui/src/components/Button.tsx
export const Button = ({ children }) => (
<button className="
bg-cs-primary
text-white
px-cs-sm
py-cs-xs
rounded-cs-md
hover:bg-cs-primary/90
transition-colors
">
{children}
</button>
)
```
### 2.2 在主项目中使用
#### 导入 UI 库样式
```css
/* src/renderer/src/assets/styles/tailwind.css */
@import 'tailwindcss' source('../../../../renderer');
@import '@cherrystudio/ui/styles/globals.css';
@custom-variant dark (&:is(.dark *));
```
#### 覆盖或扩展变量
```css
/* src/renderer/src/assets/styles/tailwind.css */
@import '@cherrystudio/ui/styles/globals.css';
/* 主项目特定覆盖 */
:root {
--ds-primary: #custom-color; /* 覆盖 UI 库的主题色 */
}
```
#### 在主项目组件中使用
```tsx
// src/renderer/src/pages/Home.tsx
export const Home = () => (
<div className="
bg-cs-bg
p-cs-md
rounded-cs-lg
">
<Button></Button>
</div>
)
```
### 2.3 主题切换实现
```tsx
// App.tsx
import { useState } from 'react'
export function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<div className={theme}>
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
</button>
{/* 所有子组件自动响应主题 */}
</div>
)
}
```
### 2.4 透明度修饰符
```tsx
<div className="
bg-cs-primary/10 /* 10% 透明度 */
bg-cs-primary/50 /* 50% 透明度 */
bg-cs-primary/[0.15] /* 自定义透明度 */
">
```
---
## 三、UI 库与主包平衡策略
### 3.1 UI 库职责
**目标**:提供可复用、可定制的基础设计系统
```json
// packages/ui/package.json
{
"exports": {
"./styles/design-tokens.css": "./src/styles/design-tokens.css",
"./styles/globals.css": "./src/styles/globals.css"
}
}
```
**原则**
- ✅ 定义通用的设计令牌(`--ds-*`
- ✅ 提供默认的 Tailwind 映射(`--color-cs-*`
- ✅ 保持变量语义化,不包含业务逻辑
- ❌ 不包含主项目特定的颜色或尺寸
### 3.2 主包职责
**目标**:导入 UI 库,根据业务需求扩展或覆盖
```css
/* src/renderer/src/assets/styles/tailwind.css */
@import '@cherrystudio/ui/styles/globals.css';
/* 主项目扩展 */
@theme inline {
--color-cs-brand-accent: #ff6b6b; /* 新增颜色 */
}
/* 主项目覆盖 */
:root {
--ds-primary: #custom-primary; /* 覆盖 UI 库的主题色 */
}
```
**原则**
- ✅ 导入 UI 库的 `globals.css`
- ✅ 通过覆盖 `--ds-*` 变量定制主题
- ✅ 添加项目特定的 `--color-cs-*` 映射
- ✅ 保留向后兼容的旧变量(如 `color.css`
### 3.3 向后兼容方案
#### 保留旧变量
```css
/* src/renderer/src/assets/styles/color.css */
:root {
--color-primary: #00b96b; /* 旧变量 */
--color-background: #181818; /* 旧变量 */
}
/* 映射到新系统 */
:root {
--ds-primary: var(--color-primary);
--ds-background: var(--color-background);
}
```
#### 渐进式迁移
```tsx
// 阶段 1旧代码继续工作
<div style={{ color: 'var(--color-primary)' }}></div>
// 阶段 2新代码使用工具类
<div className="text-cs-primary"></div>
// 阶段 3逐步替换旧代码
```
### 3.4 冲突处理
| 场景 | 策略 |
|------|------|
| UI 库与 Tailwind 默认类冲突 | 使用 `cs-` 前缀隔离 |
| 主包需要覆盖 UI 库颜色 | 覆盖 `--ds-*` 变量 |
| 主包需要新增颜色 | 添加新的 `--color-cs-*` 映射 |
| 旧变量与新系统共存 | 通过 `var()` 映射到 `--ds-*` |
### 3.5 独立发布 UI 库
```json
// packages/ui/package.json
{
"name": "@cherrystudio/ui",
"exports": {
"./styles/design-tokens.css": "./src/styles/design-tokens.css",
"./styles/globals.css": "./src/styles/globals.css"
},
"peerDependencies": {
"tailwindcss": "^4.1.13"
}
}
```
**外部项目使用**
```css
/* 其他项目的 tailwind.css */
@import 'tailwindcss';
@import '@cherrystudio/ui/styles/globals.css';
/* 覆盖主题色 */
:root {
--ds-primary: #your-brand-color;
}
```
---
## 四、完整映射示例
### todocss.css → design-tokens.css
| todocss.css | design-tokens.css | 说明 |
|-------------|-------------------|------|
| `--Brand--Base_Colors--Primary` | `--ds-primary` | 简化命名 |
| `--Spacing--md` + `--Sizing--md` | `--ds-size-md` | 合并重复 |
| `--Opacity--Red--Red-80` | *(删除)* | 使用 `/80` 修饰符 |
| `--Font_weight--Regular: 400px` | `--ds-font-weight-regular: 400` | 修正错误 |
| `--Brand--UI_Element_Colors--Primary_Button--Background` | `--ds-btn-primary` | 简化语义 |
### design-tokens.css → globals.css → 工具类
| design-tokens.css | globals.css | 工具类 |
|-------------------|-------------|--------|
| `--ds-primary` | `--color-cs-primary` | `bg-cs-primary` |
| `--ds-size-md` | `--spacing-cs-md` | `p-cs-md` |
| `--ds-size-md` | `--size-cs-md` | `w-cs-md` |
| `--ds-radius-lg` | `--radius-cs-lg` | `rounded-cs-lg` |
---
## 五、关键决策记录
1. **使用 `@theme inline`** - 支持运行时主题切换
2. **`cs-` 前缀** - 命名空间隔离,避免冲突
3. **合并 Spacing/Sizing** - 消除冗余
4. **废弃 Opacity 变量** - 使用 Tailwind 的 `/modifier` 语法
5. **双层变量系统** - `--ds-*` (定义) → `--color-cs-*` (映射)
6. **共存策略** - Tailwind 默认类 + `cs-` 品牌类

View File

@@ -1,26 +1,4 @@
# Cherry Studio UI Migration Plan
## Overview
This document outlines the detailed plan for migrating Cherry Studio from antd + styled-components to shadcn/ui + Tailwind CSS. We will adopt a progressive migration strategy to ensure system stability and development efficiency, while gradually implementing UI refactoring in collaboration with UI designers.
## Migration Strategy
### Target Tech Stack
- **UI Component Library**: shadcn/ui (replacing antd and previously migrated HeroUI)
- **Styling Solution**: Tailwind CSS (replacing styled-components)
- **Design System**: Custom CSS variable system (see [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md))
- **Theme System**: CSS variables + shadcn/ui theme
### Migration Principles
1. **Backward Compatibility**: Old components continue working until new components are fully available
2. **Progressive Migration**: Migrate components one by one to avoid large-scale rewrites
3. **Feature Parity**: Ensure new components have all the functionality of old components
4. **Design Consistency**: Follow new design system specifications (see DESIGN_SYSTEM.md)
5. **Performance Priority**: Optimize bundle size and rendering performance
6. **Designer Collaboration**: Work with UI designers for gradual component encapsulation and UI optimization
# UI Component Library Migration Status
## Usage Example
@@ -46,68 +24,115 @@ function MyComponent() {
@packages/ui/
├── src/
│ ├── components/ # Main components directory
│ │ ├── primitives/ # Basic/primitive components (Avatar, ErrorBoundary, Selector, etc.)
│ │ │ └── shadcn-io/ # shadcn/ui components (dropzone, etc.)
│ │ ├── icons/ # Icon components (Icon, FileIcons, etc.)
│ │ ── composites/ # Composite components (CodeEditor, ListItem, etc.)
│ │ ├── base/ # Basic components (buttons, inputs, labels, etc.)
│ │ ├── display/ # Display components (cards, lists, tables, etc.)
│ │ ├── layout/ # Layout components (containers, grids, spacing, etc.)
│ │ ── icons/ # Icon components
│ │ ├── interactive/ # Interactive components (modals, tooltips, dropdowns, etc.)
│ │ └── composite/ # Composite components (made from multiple base components)
│ ├── hooks/ # Custom React Hooks
── styles/ # Global styles and CSS variables
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions
│ └── index.ts # Main export file
── types/ # TypeScript type definitions
```
### Component Classification Guide
When submitting PRs, please place components in the correct directory based on their function:
- **primitives**: Basic and primitive UI elements, shadcn/ui components
- `Avatar`: Avatar components
- `ErrorBoundary`: Error boundary components
- `Selector`: Selection components
- `shadcn-io/`: Direct shadcn/ui components or adaptations
- **base**: Most basic UI elements like buttons, inputs, switches, labels, etc.
- **display**: Components for displaying content like cards, lists, tables, tabs, etc.
- **layout**: Components for page layout like containers, grid systems, dividers, etc.
- **icons**: All icon-related components
- `Icon`: Icon factory and basic icons
- `FileIcons`: File-specific icons
- Loading/spinner icons (SvgSpinners180Ring, ToolsCallingIcon, etc.)
- **composites**: Complex components made from multiple primitives
- `CodeEditor`: Code editing components
- `ListItem`: List item components
- `ThinkingEffect`: Animation components
- Form and interaction components (DraggableList, EditableNumber, etc.)
- **interactive**: Components requiring user interaction like modals, drawers, tooltips, dropdowns, etc.
- **composite**: Composite components made from multiple base components
## Component Extraction Criteria
## Migration Overview
### Extraction Standards
- **Total Components**: 236
- **Migrated**: 34
- **Refactored**: 18
- **Pending Migration**: 184
1. **Usage Frequency**: Component is used in ≥ 3 places in the codebase
2. **Future Reusability**: Expected to be used in multiple scenarios in the future
3. **Business Complexity**: Component contains complex interaction logic or state management
4. **Maintenance Cost**: Centralized management can reduce maintenance overhead
5. **Design Consistency**: Components that require unified visual and interaction experience
6. **Test Coverage**: As common components, they facilitate unit test writing and maintenance
## Component Status Table
### Extraction Principles
- **Single Responsibility**: Each component should only handle one clear function
- **Highly Configurable**: Provide flexible configuration options through props
- **Backward Compatible**: New versions maintain API backward compatibility
- **Complete Documentation**: Provide clear API documentation and usage examples
- **Type Safety**: Use TypeScript to ensure type safety
### Cases Not Recommended for Extraction
- Simple display components used only on a single page
- Overly customized business logic components
- Components tightly coupled to specific data sources
| Category | Component Name | Migration Status | Refactoring Status | Description |
| ----------------- | ------------------------- | ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **base** | | | | Base components |
| | CopyButton | ✅ | ✅ | Copy button |
| | CustomTag | ✅ | ✅ | Custom tag |
| | DividerWithText | ✅ | ✅ | Divider with text |
| | EmojiIcon | ✅ | ✅ | Emoji icon |
| | ErrorBoundary | ✅ | ✅ | Error boundary (decoupled via props) |
| | StatusTag | ✅ | ✅ | Unified status tag (merged ErrorTag, SuccessTag, WarnTag, InfoTag) |
| | IndicatorLight | ✅ | ✅ | Indicator light |
| | Spinner | ✅ | ✅ | Loading spinner |
| | TextBadge | ✅ | ✅ | Text badge |
| | CustomCollapse | ✅ | ✅ | Custom collapse panel |
| **display** | | | | Display components |
| | Ellipsis | ✅ | ✅ | Text ellipsis |
| | ExpandableText | ✅ | ✅ | Expandable text |
| | ThinkingEffect | ✅ | ✅ | Thinking effect animation |
| | EmojiAvatar | ✅ | ✅ | Emoji avatar |
| | ListItem | ✅ | ✅ | List item |
| | MaxContextCount | ✅ | ✅ | Max context count display |
| | ProviderAvatar | ✅ | ✅ | Provider avatar |
| | CodeViewer | ❌ | ❌ | Code viewer (external deps) |
| | OGCard | ❌ | ❌ | OG card |
| | MarkdownShadowDOMRenderer | ❌ | ❌ | Markdown renderer |
| | Preview/* | ❌ | ❌ | Preview components |
| **layout** | | | | Layout components |
| | HorizontalScrollContainer | ✅ | ❌ | Horizontal scroll container |
| | Scrollbar | ✅ | ❌ | Scrollbar |
| | Layout/* | ✅ | ✅ | Layout components |
| | Tab/* | ❌ | ❌ | Tab (Redux dependency) |
| | TopView | ❌ | ❌ | Top view (window.api dependency) |
| **icons** | | | | Icon components |
| | Icon | ✅ | ✅ | Icon factory function and predefined icons (merged CopyIcon, DeleteIcon, EditIcon, RefreshIcon, ResetIcon, ToolIcon, VisionIcon, WebSearchIcon, WrapIcon, UnWrapIcon, OcrIcon) |
| | FileIcons | ✅ | ❌ | File icons (FileSvgIcon, FilePngIcon) |
| | ReasoningIcon | ✅ | ❌ | Reasoning icon |
| | SvgSpinners180Ring | ✅ | ❌ | Spinner loading icon |
| | ToolsCallingIcon | ✅ | ❌ | Tools calling icon |
| **interactive** | | | | Interactive components |
| | InfoTooltip | ✅ | ❌ | Info tooltip |
| | HelpTooltip | ✅ | ❌ | Help tooltip |
| | WarnTooltip | ✅ | ❌ | Warning tooltip |
| | EditableNumber | ✅ | ❌ | Editable number |
| | InfoPopover | ✅ | ❌ | Info popover |
| | CollapsibleSearchBar | ✅ | ❌ | Collapsible search bar |
| | ImageToolButton | ✅ | ❌ | Image tool button |
| | DraggableList | ✅ | ❌ | Draggable list |
| | CodeEditor | ✅ | ❌ | Code editor |
| | EmojiPicker | ❌ | ❌ | Emoji picker (useTheme dependency) |
| | Selector | ✅ | ❌ | Selector (i18n dependency) |
| | ModelSelector | ❌ | ❌ | Model selector (Redux dependency) |
| | LanguageSelect | ❌ | ❌ | Language select |
| | TranslateButton | ❌ | ❌ | Translate button (window.api dependency) |
| **composite** | | | | Composite components |
| | - | - | - | No composite components yet |
| **Uncategorized** | | | | Components needing categorization |
| | Popups/* (16+ files) | ❌ | ❌ | Popup components (business coupled) |
| | RichEditor/* (30+ files) | ❌ | ❌ | Rich text editor |
| | MarkdownEditor/* | ❌ | ❌ | Markdown editor |
| | MinApp/* | ❌ | ❌ | Mini app (Redux dependency) |
| | Avatar/* | ❌ | ❌ | Avatar components |
| | ActionTools/* | ❌ | ❌ | Action tools |
| | CodeBlockView/* | ❌ | ❌ | Code block view (window.api dependency) |
| | ContextMenu | ❌ | ❌ | Context menu (Electron API) |
| | WindowControls | ❌ | ❌ | Window controls (Electron API) |
| | ErrorBoundary | ❌ | ❌ | Error boundary (window.api dependency) |
## Migration Steps
| Phase | Status | Main Tasks | Description |
| --- | --- | --- | --- |
| **Phase 1** | 🚧 **In Progress** | **Design System Integration** | • Integrate design system CSS variables (todocss.css → design-tokens.css → globals.css)<br>• Configure Tailwind CSS to use custom design tokens<br>• Establish basic style guidelines and theme system |
| **Phase 2** | ⏳ **To Start** | **Component Migration and Optimization** | • Filter components for migration based on extraction criteria<br>• Remove antd dependencies, replace with shadcn/ui<br>• Remove HeroUI dependencies, replace with shadcn/ui<br>• Remove styled-components, replace with Tailwind CSS + design system variables<br>• Optimize component APIs and type definitions |
| **Phase 3** | ⏳ **To Start** | **UI Refactoring and Optimization** | • Gradually implement UI refactoring with UI designers<br>• Ensure visual consistency and user experience<br>• Performance optimization and code quality improvement |
### Phase 1: Copy Migration (Current Phase)
- Copy components as-is to @packages/ui
- Retain original dependencies (antd, styled-components, etc.)
- Add original path comment at file top
### Phase 2: Refactor and Optimize
- Remove antd dependencies, replace with HeroUI
- Remove styled-components, replace with Tailwind CSS
- Optimize component APIs and type definitions
## Notes
@@ -118,27 +143,9 @@ When submitting PRs, please place components in the correct directory based on t
2. **Can migrate** but need decoupling later:
- Components using i18n (change i18n to props)
- Components using antd (replace with shadcn/ui later)
- Components using HeroUI (replace with shadcn/ui later)
- Components using antd (replace with HeroUI later)
3. **Submission Guidelines**:
- Each PR should focus on one category of components
- Ensure all migrated components are exported
- Follow component extraction criteria, only migrate qualified components
## Design System Integration
### CSS Variable System
- Refer to [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) for complete design system planning
- Design variables will be managed through CSS variable system, naming conventions TBD
- Support theme switching and responsive design
### Migration Priority Adjustment
1. **High Priority**: Basic components (buttons, inputs, tags, etc.)
2. **Medium Priority**: Display components (cards, lists, tables, etc.)
3. **Low Priority**: Composite components and business-coupled components
### UI Designer Collaboration
- All component designs need confirmation from UI designers
- Gradually implement UI refactoring to maintain visual consistency
- New components must comply with design system specifications
- Update migration status in this document

View File

@@ -1,870 +0,0 @@
:root {
/* Typography: Desktop mode */
--Font_family--Heading: Inter;
--Font_weight--Regular: 400px;
--Font_size--Heading--2xl: 60px;
--Font_size--Heading--xl: 48px;
--Font_size--Heading--lg: 40px;
--Font_size--Heading--md: 32px;
--Font_size--Heading--sm: 24px;
--Font_size--Heading--xs: 20px;
--Line_height--Heading--xl: 80px;
--Line_height--Body--lg: 28px;
--Line_height--Body--md: 24px;
--Line_height--Body--sm: 24px;
--Line_height--Body--xs: 20px;
--Paragraph_spacing--Body--lg: 18px;
--Paragraph_spacing--Body--md: 16px;
--Paragraph_spacing--Body--sm: 14px;
--Paragraph_spacing--Body--xs: 12px;
--Line_height--Heading--lg: 60px;
--Line_height--Heading--md: 48px;
--Line_height--Heading--sm: 40px;
--Line_height--Heading--xs: 32px;
--Font_size--Body--lg: 18px;
--Font_size--Body--md: 16px;
--Font_size--Body--sm: 14px;
--Font_size--Body--xs: 12px;
--Font_weight--Italic: 400px;
--Font_weight--Medium: 500px;
--Font_weight--Bold: 700px;
--Font_family--Body: Inter;
--Paragraph_spacing--Heading--2xl: 60px;
--Paragraph_spacing--Heading--xl: 48px;
--Paragraph_spacing--Heading--lg: 40px;
--Paragraph_spacing--Heading--md: 32px;
--Paragraph_spacing--Heading--sm: 24px;
--Paragraph_spacing--Heading--xs: 20px;
--typography_components--h1--font-family: Inter;
--typography_components--h2--font-family: Inter;
--typography_components--h2--font-size: 30px;
--typography_components--h2--line-height: 36px;
--typography_components--h2--font-weight: 600;
--typography_components--h2--letter-spacing: -0.4000000059604645px;
--typography_components--h1--font-size: 36px;
--typography_components--h1--font-size-lg: 48px;
--typography_components--h1--line-height: 40px;
--typography_components--h1--font-weight: 800;
--typography_components--h1--letter-spacing: -0.4000000059604645px;
--typography_components--h3--font-family: Inter;
--typography_components--h3--font-size: 24px;
--typography_components--h3--line-height: 32px;
--typography_components--h3--font-weight: 600;
--typography_components--h3--letter-spacing: -0.4000000059604645px;
--typography_components--h4--font-family: Inter;
--typography_components--h4--font-size: 20px;
--typography_components--h4--line-height: 28px;
--typography_components--h4--font-weight: 600;
--typography_components--h4--letter-spacing: -0.4000000059604645px;
--typography_components--p--font-family: Inter;
--typography_components--p--font-size: 16px;
--typography_components--p--line-height: 28px;
--typography_components--p--font-weight: 400;
--typography_components--p--letter-spacing: 0px;
--typography_components--blockquote--font-family: Inter;
--typography_components--blockquote--font-size: 16px;
--typography_components--blockquote--line-height: 24px;
--typography_components--blockquote--letter-spacing: 0px;
--typography_components--blockquote--font-style: italic;
--typography_components--list--font-family: Inter;
--typography_components--list--font-size: 16px;
--typography_components--list--line-height: 28px;
--typography_components--list--letter-spacing: 0px;
--typography_components--inline_code--font-family: Menlo;
--typography_components--inline_code--font-size: 14px;
--typography_components--inline_code--line-height: 20px;
--typography_components--inline_code--font-weight: 600;
--typography_components--inline_code--letter-spacing: 0px;
--typography_components--lead--font-family: Inter;
--typography_components--lead--font-size: 20px;
--typography_components--lead--line-height: 28px;
--typography_components--lead--font-weight: 400;
--typography_components--lead--letter-spacing: 0px;
--typography_components--large--font-family: Inter;
--typography_components--large--font-size: 18px;
--typography_components--large--line-height: 28px;
--typography_components--large--font-weight: 600;
--typography_components--large--letter-spacing: 0px;
--typography_components--small--font-family: Inter;
--typography_components--small--font-size: 14px;
--typography_components--small--line-height: 14px;
--typography_components--small--font-weight: 500;
--typography_components--table--font-family: Inter;
--typography_components--table--font-size: 16px;
--typography_components--table--font-weight: 400;
--typography_components--table--font-weight-bold: 700;
--typography_components--table--letter-spacing: 0px;
/* Spacing and sizing: Desktop */
--Border_width--sm: 1px;
--Border_width--md: 2px;
--Border_width--lg: 3px;
--Radius--4xs: 4px;
--Radius--3xs: 8px;
--Radius--2xs: 12px;
--Radius--xs: 16px;
--Radius--sm: 24px;
--Radius--md: 32px;
--Radius--lg: 40px;
--Radius--xl: 48px;
--Radius--2xl: 56px;
--Radius--3xl: 64px;
--Radius--round: 999px;
--Spacing--5xs: 4px;
--Spacing--4xs: 8px;
--Spacing--3xs: 12px;
--Spacing--2xs: 16px;
--Spacing--xs: 24px;
--Spacing--sm: 32px;
--Spacing--md: 40px;
--Spacing--lg: 48px;
--Spacing--xl: 56px;
--Spacing--2xl: 64px;
--Spacing--3xl: 72px;
--Spacing--4xl: 80px;
--Spacing--5xl: 88px;
--Spacing--6xl: 96px;
--Spacing--7xl: 104px;
--Spacing--8xl: 112px;
--Sizing--5xs: 4px;
--Sizing--4xs: 8px;
--Sizing--3xs: 12px;
--Sizing--2xs: 16px;
--Sizing--xs: 24px;
--Sizing--sm: 32px;
--Sizing--md: 40px;
--Sizing--lg: 48px;
--Sizing--xl: 56px;
--Sizing--2xl: 64px;
--Sizing--3xl: 72px;
--Sizing--4xl: 80px;
--Sizing--5xl: 88px;
/* Color: Light mode */
--Opacity--Red--Red-100: var(--Primitive--Red--600);
--Opacity--Red--Red-80: hsla(0, 72%, 51%, 0.8);
--Opacity--Red--Red-60: hsla(0, 72%, 51%, 0.6);
--Opacity--Red--Red-40: hsla(0, 72%, 51%, 0.4);
--Opacity--Red--Red-20: hsla(0, 72%, 51%, 0.2);
--Opacity--Red--Red-10: hsla(0, 72%, 51%, 0.1);
--Opacity--Green--Green-100: var(--Primitive--Green--600);
--Opacity--Green--Green-80: hsla(142, 76%, 36%, 0.8);
--Opacity--Green--Green-60: hsla(142, 76%, 36%, 0.6);
--Opacity--Green--Green-40: hsla(142, 76%, 36%, 0.4);
--Opacity--Green--Green-20: hsla(142, 76%, 36%, 0.2);
--Opacity--Green--Green-10: hsla(142, 76%, 36%, 0.1);
--Opacity--Yellow--Yellow-100: var(--Primitive--Amber--400);
--Opacity--Yellow--Yellow-80: hsla(48, 96%, 53%, 0.8);
--Opacity--Yellow--Yellow-60: hsla(48, 96%, 53%, 0.6);
--Opacity--Yellow--Yellow-40: hsla(48, 96%, 53%, 0.4);
--Opacity--Yellow--Yellow-20: hsla(48, 96%, 53%, 0.2);
--Opacity--Yellow--Yellow-10: hsla(48, 96%, 53%, 0.1);
--Opacity--Violet--Violet-100: var(--Primitive--Violet--500);
--Opacity--Violet--Violet-80: hsla(258, 90%, 66%, 0.8);
--Opacity--Violet--Violet-60: hsla(258, 90%, 66%, 0.6);
--Opacity--Violet--Violet-40: hsla(258, 90%, 66%, 0.4);
--Opacity--Violet--Violet-20: hsla(258, 90%, 66%, 0.2);
--Opacity--Violet--Violet-10: hsla(258, 90%, 66%, 0.1);
--Opacity--Indigo--Indigo-100: var(--Primitive--Indigo--500);
--Opacity--Indigo--Indigo-80: hsla(239, 84%, 67%, 0.8);
--Opacity--Indigo--Indigo-60: hsla(239, 84%, 67%, 0.6);
--Opacity--Indigo--Indigo-40: hsla(239, 84%, 67%, 0.4);
--Opacity--Indigo--Indigo-20: hsla(239, 84%, 67%, 0.2);
--Opacity--Indigo--Indigo-10: hsla(239, 84%, 67%, 0.1);
--Opacity--Blue--Blue-100: var(--Primitive--Blue--500);
--Opacity--Blue--Blue-80: hsla(217, 91%, 60%, 0.8);
--Opacity--Blue--Blue-60: hsla(217, 91%, 60%, 0.6);
--Opacity--Blue--Blue-40: hsla(217, 91%, 60%, 0.4);
--Opacity--Blue--Blue-20: hsla(217, 91%, 60%, 0.2);
--Opacity--Blue--Blue-10: hsla(217, 91%, 60%, 0.1);
--Opacity--Grey--Grey-100: var(--Primitive--Gray--500);
--Opacity--Grey--Grey-80: hsla(220, 9%, 46%, 0.8);
--Opacity--Grey--Grey-60: hsla(220, 9%, 46%, 0.6);
--Opacity--Grey--Grey-40: hsla(220, 9%, 46%, 0.4);
--Opacity--Grey--Grey-20: hsla(220, 9%, 46%, 0.2);
--Opacity--Grey--Grey-10: hsla(220, 9%, 46%, 0.1);
--Opacity--White--White-100: var(--Primitive--White);
--Opacity--White--White-80: hsla(0, 0%, 100%, 0.8);
--Opacity--White--White-60: hsla(0, 0%, 100%, 0.6);
--Opacity--White--White-40: hsla(0, 0%, 100%, 0.4);
--Opacity--White--White-20: hsla(0, 0%, 100%, 0.2);
--Opacity--White--White-10: hsla(0, 0%, 100%, 0.1);
--Opacity--White--White-0: hsla(0, 0%, 100%, 0);
--Status--Error--colorErrorBg: var(--color--Red--50);
--Status--Error--colorErrorBgHover: var(--color--Red--100);
--Status--Error--colorErrorBorder: var(--color--Red--200);
--Status--Error--colorErrorBorderHover: var(--color--Red--300);
--Status--Error--colorErrorBase: var(--color--Red--500);
--Status--Error--colorErrorActive: var(--color--Red--600);
--Status--Error--colorErrorTextHover: var(--color--Red--700);
--Status--Error--colorErrorText: var(--color--Red--800);
--Status--Success--colorSuccessBg: var(--color--Green--50);
--Status--Success--colorSuccessBgHover: var(--color--Green--100);
--Status--Success--colorSuccessBase: var(--color--Green--500);
--Status--Success--colorSuccessTextHover: var(--color--Green--700);
--Status--Warning--colorWarningBg: var(--color--Yellow--50);
--Status--Warning--colorWarningBgHover: var(--color--Yellow--100);
--Status--Warning--colorWarningBase: var(--color--Yellow--500);
--Status--Warning--colorWarningActive: var(--color--Yellow--600);
--Status--Warning--colorWarningTextHover: var(--color--Yellow--700);
--Primitive--Black: hsla(0, 0%, 0%, 1);
--Primitive--White: hsla(0, 0%, 100%, 1);
--Brand--Base_Colors--Primary: var(--Primitive--Lime--500);
--Primitive--Neutral--50: hsla(0, 0%, 98%, 1);
--Primitive--Neutral--100: hsla(0, 0%, 96%, 1);
--Primitive--Neutral--200: hsla(0, 0%, 90%, 1);
--Primitive--Neutral--300: hsla(0, 0%, 83%, 1);
--Primitive--Neutral--400: hsla(0, 0%, 64%, 1);
--Primitive--Neutral--500: hsla(0, 0%, 45%, 1);
--Primitive--Neutral--600: hsla(215, 14%, 34%, 1);
--Primitive--Neutral--700: hsla(0, 0%, 25%, 1);
--Primitive--Neutral--800: hsla(0, 0%, 15%, 1);
--Primitive--Neutral--900: hsla(0, 0%, 9%, 1);
--Primitive--Neutral--950: hsla(0, 0%, 4%, 1);
--Primitive--Stone--50: hsla(60, 9%, 98%, 1);
--Primitive--Stone--100: hsla(60, 5%, 96%, 1);
--Primitive--Stone--200: hsla(20, 6%, 90%, 1);
--Primitive--Stone--300: hsla(24, 6%, 83%, 1);
--Primitive--Stone--400: hsla(24, 5%, 64%, 1);
--Primitive--Stone--500: hsla(25, 5%, 45%, 1);
--Primitive--Stone--600: hsla(33, 5%, 32%, 1);
--Primitive--Stone--700: hsla(30, 6%, 25%, 1);
--Primitive--Stone--800: hsla(12, 6%, 15%, 1);
--Primitive--Stone--900: hsla(24, 10%, 10%, 1);
--Primitive--Stone--950: hsla(20, 14%, 4%, 1);
--Primitive--Zinc--50: hsla(0, 0%, 98%, 1);
--Primitive--Zinc--100: hsla(240, 5%, 96%, 1);
--Primitive--Zinc--200: hsla(240, 6%, 90%, 1);
--Primitive--Zinc--300: hsla(240, 5%, 84%, 1);
--Primitive--Zinc--400: hsla(240, 5%, 65%, 1);
--Primitive--Zinc--500: hsla(240, 4%, 46%, 1);
--Primitive--Zinc--600: hsla(240, 5%, 34%, 1);
--Primitive--Zinc--700: hsla(240, 5%, 26%, 1);
--Primitive--Zinc--800: hsla(240, 4%, 16%, 1);
--Primitive--Zinc--900: hsla(240, 6%, 10%, 1);
--Primitive--Zinc--950: hsla(240, 10%, 4%, 1);
--Primitive--Slate--50: hsla(210, 40%, 98%, 1);
--Primitive--Slate--100: hsla(210, 40%, 96%, 1);
--Primitive--Slate--200: hsla(214, 32%, 91%, 1);
--Primitive--Slate--300: hsla(213, 27%, 84%, 1);
--Primitive--Slate--400: hsla(215, 20%, 65%, 1);
--Primitive--Slate--500: hsla(215, 16%, 47%, 1);
--Primitive--Slate--600: hsla(215, 19%, 35%, 1);
--Primitive--Slate--700: hsla(215, 25%, 27%, 1);
--Primitive--Slate--800: hsla(217, 33%, 17%, 1);
--Primitive--Slate--900: hsla(222, 47%, 11%, 1);
--Primitive--Slate--950: hsla(229, 84%, 5%, 1);
--Primitive--Gray--50: hsla(210, 20%, 98%, 1);
--Primitive--Gray--100: hsla(220, 14%, 96%, 1);
--Primitive--Gray--200: hsla(220, 13%, 91%, 1);
--Primitive--Gray--300: hsla(216, 12%, 84%, 1);
--Primitive--Gray--400: hsla(218, 11%, 65%, 1);
--Primitive--Gray--500: hsla(220, 9%, 46%, 1);
--Primitive--Gray--600: hsla(0, 0%, 32%, 1);
--Primitive--Gray--700: hsla(217, 19%, 27%, 1);
--Primitive--Gray--800: hsla(215, 28%, 17%, 1);
--Primitive--Gray--900: hsla(221, 39%, 11%, 1);
--Primitive--Gray--950: hsla(224, 71%, 4%, 1);
--Primitive--Red--50: hsla(0, 86%, 97%, 1);
--Primitive--Red--100: hsla(0, 93%, 94%, 1);
--Primitive--Red--200: hsla(0, 96%, 89%, 1);
--Primitive--Red--300: hsla(0, 94%, 82%, 1);
--Primitive--Red--400: hsla(0, 91%, 71%, 1);
--Primitive--Red--500: hsla(0, 84%, 60%, 1);
--Primitive--Red--600: hsla(0, 72%, 51%, 1);
--Primitive--Red--700: hsla(0, 74%, 42%, 1);
--Primitive--Red--800: hsla(0, 70%, 35%, 1);
--Primitive--Red--900: hsla(0, 63%, 31%, 1);
--Primitive--Red--950: hsla(0, 75%, 15%, 1);
--Primitive--Orange--50: hsla(33, 100%, 96%, 1);
--Primitive--Orange--100: hsla(34, 100%, 92%, 1);
--Primitive--Orange--200: hsla(32, 98%, 83%, 1);
--Primitive--Orange--300: hsla(31, 97%, 72%, 1);
--Primitive--Orange--400: hsla(27, 96%, 61%, 1);
--Primitive--Orange--500: hsla(25, 95%, 53%, 1);
--Primitive--Orange--600: hsla(21, 90%, 48%, 1);
--Primitive--Orange--700: hsla(17, 88%, 40%, 1);
--Primitive--Orange--800: hsla(15, 79%, 34%, 1);
--Primitive--Orange--900: hsla(15, 75%, 28%, 1);
--Primitive--Orange--950: hsla(13, 81%, 15%, 1);
--Primitive--Amber--50: hsla(48, 100%, 96%, 1);
--Primitive--Amber--100: hsla(48, 96%, 89%, 1);
--Primitive--Amber--200: hsla(48, 97%, 77%, 1);
--Primitive--Amber--300: hsla(46, 97%, 65%, 1);
--Primitive--Amber--400: hsla(43, 96%, 56%, 1);
--Primitive--Amber--500: hsla(38, 92%, 50%, 1);
--Primitive--Amber--600: hsla(32, 95%, 44%, 1);
--Primitive--Amber--700: hsla(26, 90%, 37%, 1);
--Primitive--Amber--800: hsla(23, 83%, 31%, 1);
--Primitive--Amber--900: hsla(22, 78%, 26%, 1);
--Primitive--Amber--950: hsla(21, 92%, 14%, 1);
--Primitive--Yellow--50: hsla(55, 92%, 95%, 1);
--Primitive--Yellow--100: hsla(55, 97%, 88%, 1);
--Primitive--Yellow--200: hsla(53, 98%, 77%, 1);
--Primitive--Yellow--300: hsla(50, 98%, 64%, 1);
--Primitive--Yellow--400: hsla(48, 96%, 53%, 1);
--Primitive--Yellow--500: hsla(45, 93%, 47%, 1);
--Primitive--Yellow--600: hsla(41, 96%, 40%, 1);
--Primitive--Yellow--700: hsla(35, 92%, 33%, 1);
--Primitive--Yellow--800: hsla(32, 81%, 29%, 1);
--Primitive--Yellow--900: hsla(28, 73%, 26%, 1);
--Primitive--Yellow--950: hsla(26, 83%, 14%, 1);
--Primitive--Lime--50: hsla(78, 92%, 95%, 1);
--Primitive--Lime--100: hsla(80, 89%, 89%, 1);
--Primitive--Lime--200: hsla(81, 88%, 80%, 1);
--Primitive--Lime--300: hsla(82, 85%, 67%, 1);
--Primitive--Lime--400: hsla(83, 78%, 55%, 1);
--Primitive--Lime--500: hsla(84, 81%, 44%, 1);
--Primitive--Lime--600: hsla(85, 85%, 35%, 1);
--Primitive--Lime--700: hsla(86, 78%, 27%, 1);
--Primitive--Lime--800: hsla(86, 69%, 23%, 1);
--Primitive--Lime--900: hsla(88, 61%, 20%, 1);
--Primitive--Lime--950: hsla(89, 80%, 10%, 1);
--Primitive--Green--50: hsla(138, 76%, 97%, 1);
--Primitive--Green--100: hsla(141, 84%, 93%, 1);
--Primitive--Green--200: hsla(141, 79%, 85%, 1);
--Primitive--Green--300: hsla(142, 77%, 73%, 1);
--Primitive--Green--400: hsla(142, 69%, 58%, 1);
--Primitive--Green--500: hsla(142, 71%, 45%, 1);
--Primitive--Green--600: hsla(142, 76%, 36%, 1);
--Primitive--Green--700: hsla(142, 72%, 29%, 1);
--Primitive--Green--800: hsla(143, 64%, 24%, 1);
--Primitive--Green--900: hsla(144, 61%, 20%, 1);
--Primitive--Green--950: hsla(145, 80%, 10%, 1);
--Primitive--Emerald--50: hsla(152, 81%, 96%, 1);
--Primitive--Emerald--100: hsla(149, 80%, 90%, 1);
--Primitive--Emerald--200: hsla(152, 76%, 80%, 1);
--Primitive--Emerald--300: hsla(156, 72%, 67%, 1);
--Primitive--Emerald--400: hsla(158, 64%, 52%, 1);
--Primitive--Emerald--500: hsla(160, 84%, 39%, 1);
--Primitive--Emerald--600: hsla(161, 94%, 30%, 1);
--Primitive--Emerald--700: hsla(163, 94%, 24%, 1);
--Primitive--Emerald--800: hsla(163, 88%, 20%, 1);
--Primitive--Emerald--900: hsla(164, 86%, 16%, 1);
--Primitive--Emerald--950: hsla(166, 91%, 9%, 1);
--Primitive--Teal--50: hsla(166, 76%, 97%, 1);
--Primitive--Teal--100: hsla(167, 85%, 89%, 1);
--Primitive--Teal--200: hsla(168, 84%, 78%, 1);
--Primitive--Teal--300: hsla(171, 77%, 64%, 1);
--Primitive--Teal--400: hsla(172, 66%, 50%, 1);
--Primitive--Teal--500: hsla(173, 80%, 40%, 1);
--Primitive--Teal--600: hsla(175, 84%, 32%, 1);
--Primitive--Teal--700: hsla(175, 77%, 26%, 1);
--Primitive--Teal--800: hsla(176, 69%, 22%, 1);
--Primitive--Teal--900: hsla(176, 61%, 19%, 1);
--Primitive--Teal--950: hsla(179, 84%, 10%, 1);
--Primitive--Cyan--50: hsla(183, 100%, 96%, 1);
--Primitive--Cyan--100: hsla(185, 96%, 90%, 1);
--Primitive--Cyan--200: hsla(186, 94%, 82%, 1);
--Primitive--Cyan--300: hsla(187, 92%, 69%, 1);
--Primitive--Cyan--400: hsla(188, 86%, 53%, 1);
--Primitive--Cyan--500: hsla(189, 94%, 43%, 1);
--Primitive--Cyan--600: hsla(192, 91%, 36%, 1);
--Primitive--Cyan--700: hsla(193, 82%, 31%, 1);
--Primitive--Cyan--800: hsla(194, 70%, 27%, 1);
--Primitive--Cyan--900: hsla(196, 64%, 24%, 1);
--Primitive--Cyan--950: hsla(197, 79%, 15%, 1);
--Primitive--Sky--50: hsla(204, 100%, 97%, 1);
--Primitive--Sky--100: hsla(204, 94%, 94%, 1);
--Primitive--Sky--200: hsla(201, 94%, 86%, 1);
--Primitive--Sky--300: hsla(199, 95%, 74%, 1);
--Primitive--Sky--400: hsla(198, 93%, 60%, 1);
--Primitive--Sky--500: hsla(199, 89%, 48%, 1);
--Primitive--Sky--600: hsla(200, 98%, 39%, 1);
--Primitive--Sky--700: hsla(201, 96%, 32%, 1);
--Primitive--Sky--800: hsla(201, 90%, 27%, 1);
--Primitive--Sky--900: hsla(202, 80%, 24%, 1);
--Primitive--Sky--950: hsla(204, 80%, 16%, 1);
--Primitive--Blue--50: hsla(214, 100%, 97%, 1);
--Primitive--Blue--100: hsla(214, 95%, 93%, 1);
--Primitive--Blue--200: hsla(213, 97%, 87%, 1);
--Primitive--Blue--300: hsla(212, 96%, 78%, 1);
--Primitive--Blue--400: hsla(213, 94%, 68%, 1);
--Primitive--Blue--500: hsla(217, 91%, 60%, 1);
--Primitive--Blue--600: hsla(221, 83%, 53%, 1);
--Primitive--Blue--700: hsla(224, 76%, 48%, 1);
--Primitive--Blue--800: hsla(226, 71%, 40%, 1);
--Primitive--Blue--900: hsla(224, 64%, 33%, 1);
--Primitive--Blue--950: hsla(226, 57%, 21%, 1);
--Primitive--Indigo--50: hsla(226, 100%, 97%, 1);
--Primitive--Indigo--100: hsla(226, 100%, 94%, 1);
--Primitive--Indigo--200: hsla(228, 96%, 89%, 1);
--Primitive--Indigo--300: hsla(230, 94%, 82%, 1);
--Primitive--Indigo--400: hsla(234, 89%, 74%, 1);
--Primitive--Indigo--500: hsla(239, 84%, 67%, 1);
--Primitive--Indigo--600: hsla(243, 75%, 59%, 1);
--Primitive--Indigo--700: hsla(245, 58%, 51%, 1);
--Primitive--Indigo--800: hsla(244, 55%, 41%, 1);
--Primitive--Indigo--900: hsla(242, 47%, 34%, 1);
--Primitive--Indigo--950: hsla(244, 47%, 20%, 1);
--Primitive--Violet--50: hsla(250, 100%, 98%, 1);
--Primitive--Violet--100: hsla(251, 91%, 95%, 1);
--Primitive--Violet--200: hsla(251, 95%, 92%, 1);
--Primitive--Violet--300: hsla(253, 95%, 85%, 1);
--Primitive--Violet--400: hsla(255, 92%, 76%, 1);
--Primitive--Violet--500: hsla(258, 90%, 66%, 1);
--Primitive--Violet--600: hsla(262, 83%, 58%, 1);
--Primitive--Violet--700: hsla(263, 70%, 50%, 1);
--Primitive--Violet--800: hsla(263, 69%, 42%, 1);
--Primitive--Violet--900: hsla(264, 67%, 35%, 1);
--Primitive--Violet--950: hsla(262, 78%, 23%, 1);
--Primitive--Purple--50: hsla(270, 100%, 98%, 1);
--Primitive--Purple--100: hsla(269, 100%, 95%, 1);
--Primitive--Purple--200: hsla(269, 100%, 92%, 1);
--Primitive--Purple--300: hsla(269, 97%, 85%, 1);
--Primitive--Purple--400: hsla(270, 95%, 75%, 1);
--Primitive--Purple--500: hsla(271, 91%, 65%, 1);
--Primitive--Purple--600: hsla(271, 81%, 56%, 1);
--Primitive--Purple--700: hsla(272, 72%, 47%, 1);
--Primitive--Purple--800: hsla(273, 67%, 39%, 1);
--Primitive--Purple--900: hsla(274, 66%, 32%, 1);
--Primitive--Purple--950: hsla(274, 87%, 21%, 1);
--Primitive--Fuchsia--50: hsla(289, 100%, 98%, 1);
--Primitive--Fuchsia--100: hsla(287, 100%, 95%, 1);
--Primitive--Fuchsia--200: hsla(288, 96%, 91%, 1);
--Primitive--Fuchsia--300: hsla(291, 93%, 83%, 1);
--Primitive--Fuchsia--400: hsla(292, 91%, 73%, 1);
--Primitive--Fuchsia--500: hsla(292, 84%, 61%, 1);
--Primitive--Fuchsia--600: hsla(293, 69%, 49%, 1);
--Primitive--Fuchsia--700: hsla(295, 72%, 40%, 1);
--Primitive--Fuchsia--800: hsla(295, 70%, 33%, 1);
--Primitive--Fuchsia--900: hsla(297, 64%, 28%, 1);
--Primitive--Fuchsia--950: hsla(297, 90%, 16%, 1);
--Primitive--Pink--50: hsla(327, 73%, 97%, 1);
--Primitive--Pink--100: hsla(326, 78%, 95%, 1);
--Primitive--Pink--200: hsla(326, 85%, 90%, 1);
--Primitive--Pink--300: hsla(327, 87%, 82%, 1);
--Primitive--Pink--400: hsla(329, 86%, 70%, 1);
--Primitive--Pink--500: hsla(330, 81%, 60%, 1);
--Primitive--Pink--600: hsla(333, 71%, 51%, 1);
--Primitive--Pink--700: hsla(335, 78%, 42%, 1);
--Primitive--Pink--800: hsla(336, 74%, 35%, 1);
--Primitive--Pink--900: hsla(336, 69%, 30%, 1);
--Primitive--Pink--950: hsla(336, 84%, 17%, 1);
--Primitive--Rose--50: hsla(356, 100%, 97%, 1);
--Primitive--Rose--100: hsla(356, 100%, 95%, 1);
--Primitive--Rose--200: hsla(353, 96%, 90%, 1);
--Primitive--Rose--300: hsla(353, 96%, 82%, 1);
--Primitive--Rose--400: hsla(351, 95%, 71%, 1);
--Primitive--Rose--500: hsla(350, 89%, 60%, 1);
--Primitive--Rose--600: hsla(347, 77%, 50%, 1);
--Primitive--Rose--700: hsla(345, 83%, 41%, 1);
--Primitive--Rose--800: hsla(343, 80%, 35%, 1);
--Primitive--Rose--900: hsla(342, 75%, 30%, 1);
--Primitive--Rose--950: hsla(343, 88%, 16%, 1);
--Brand--Base_Colors--Destructive: var(--Primitive--Red--500);
--Brand--Base_Colors--Success: var(--Primitive--Green--500);
--Brand--Base_Colors--Warning: var(--Primitive--Amber--500);
--Brand--Base_Colors--White: var(--Primitive--White);
--Brand--Base_Colors--Black: var(--Primitive--Black);
--Brand--Semantic_Colors--Background: var(--Primitive--Zinc--50); /*页面背景色:应用在整个页面的最底层。*/
--Brand--Semantic_Colors--Background-subtle: hsla(
0,
0%,
0%,
0.02
); /*细微背景色:用于需要与主背景有微弱区分的区域,如代码块背景。*/
--Brand--Semantic_Colors--Foreground: hsla(0, 0%, 0%, 0.9); /*主要前景/文字色:用于正文、标题等。*/
--Brand--Semantic_Colors--Foreground-secondary: hsla(0, 0%, 0%, 0.6); /*次要前景/文字色:用于辅助性文本、描述。*/
--Brand--Semantic_Colors--Foreground-muted: hsla(0, 0%, 0%, 0.4); /*静默前景/文字色:用于禁用状态的文字、占位符。*/
--Brand--Semantic_Colors--Border: hsla(0, 0%, 0%, 0.1); /*默认边框色:用于卡片、输入框、分隔线。*/
--Brand--Semantic_Colors--Border-hover: hsla(0, 0%, 0%, 0.2); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Border-active: hsla(0, 0%, 0%, 0.3); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Ring: hsla(
84,
81%,
44%,
0.4
); /*聚焦环颜色:用于输入框等元素在聚焦 (Focus) 状态下的外发光。*/
--Brand--UI_Element_Colors--Modal--Backdrop: hsla(0, 0%, 0%, 0.4);
--Brand--UI_Element_Colors--Modal--Thumb: hsla(0, 0%, 0%, 0.2);
--Brand--UI_Element_Colors--Modal--Thumb_Hover: hsla(0, 0%, 0%, 0.3);
--Brand--UI_Element_Colors--Icon--Default: var(--Brand--Semantic_Colors--Foreground-secondary);
--Brand--UI_Element_Colors--Icon--Hover: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Input_Select--Background: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Input_Select--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Input_Select--Border_Hover: var(--Brand--Semantic_Colors--Border-hover);
--Brand--UI_Element_Colors--Input_Select--Border_Focus: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Primary_Button--Background: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Card_Container--Background: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Card_Container--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Ghost_Button--Background: hsla(0, 0%, 0%, 0);
--Brand--UI_Element_Colors--Ghost_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Ghost_Button--Background_Hover: hsla(0, 0%, 0%, 0.05);
--Brand--UI_Element_Colors--Ghost_Button--Background_Active: hsla(0, 0%, 0%, 0.1);
--Brand--UI_Element_Colors--Secondary_Button--Background: hsla(0, 0%, 0%, 0.05);
--Brand--UI_Element_Colors--Secondary_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Secondary_Button--Background_Hover: hsla(0, 0%, 0%, 0.85);
--Brand--UI_Element_Colors--Secondary_Button--Background_Active: hsla(0, 0%, 0%, 0.7);
--Brand--UI_Element_Colors--Secondary_Button--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Primary_Button--Text: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Primary_Button--Background_Hover: hsla(84, 81%, 44%, 0.85);
--Brand--UI_Element_Colors--Primary_Button--2nd_Background: hsla(84, 81%, 44%, 0.1);
--Brand--UI_Element_Colors--Primary_Button--3rd_Background: hsla(84, 81%, 44%, 0.05);
--Brand--UI_Element_Colors--Primary_Button--Background_Active: hsla(84, 81%, 44%, 0.7);
--Boolean: false;
/* Color: Dark mode */
--Opacity--Red--Red-100: var(--Primitive--Red--600);
--Opacity--Red--Red-80: hsla(0, 72%, 51%, 0.8);
--Opacity--Red--Red-60: hsla(0, 72%, 51%, 0.6);
--Opacity--Red--Red-40: hsla(0, 72%, 51%, 0.4);
--Opacity--Red--Red-20: hsla(0, 72%, 51%, 0.2);
--Opacity--Red--Red-10: hsla(0, 72%, 51%, 0.1);
--Opacity--Green--Green-100: var(--Primitive--Green--600);
--Opacity--Green--Green-80: hsla(142, 76%, 36%, 0.8);
--Opacity--Green--Green-60: hsla(142, 76%, 36%, 0.6);
--Opacity--Green--Green-40: hsla(142, 76%, 36%, 0.4);
--Opacity--Green--Green-20: hsla(142, 76%, 36%, 0.2);
--Opacity--Green--Green-10: hsla(142, 76%, 36%, 0.1);
--Opacity--Yellow--Yellow-100: var(--Primitive--Yellow--400);
--Opacity--Yellow--Yellow-80: hsla(48, 96%, 53%, 0.8);
--Opacity--Yellow--Yellow-60: hsla(48, 96%, 53%, 0.6);
--Opacity--Yellow--Yellow-40: hsla(48, 96%, 53%, 0.4);
--Opacity--Yellow--Yellow-20: hsla(48, 96%, 53%, 0.2);
--Opacity--Yellow--Yellow-10: hsla(48, 96%, 53%, 0.1);
--Opacity--Violet--Violet-100: var(--Primitive--Violet--500);
--Opacity--Violet--Violet-80: hsla(258, 90%, 66%, 0.8);
--Opacity--Violet--Violet-60: hsla(258, 90%, 66%, 0.6);
--Opacity--Violet--Violet-40: hsla(258, 90%, 66%, 0.4);
--Opacity--Violet--Violet-20: hsla(258, 90%, 66%, 0.2);
--Opacity--Violet--Violet-10: hsla(258, 90%, 66%, 0.1);
--Opacity--Indigo--Indigo-100: var(--Primitive--Indigo--500);
--Opacity--Indigo--Indigo-80: hsla(239, 84%, 67%, 0.8);
--Opacity--Indigo--Indigo-60: hsla(239, 84%, 67%, 0.6);
--Opacity--Indigo--Indigo-40: hsla(239, 84%, 67%, 0.4);
--Opacity--Indigo--Indigo-20: hsla(239, 84%, 67%, 0.2);
--Opacity--Indigo--Indigo-10: hsla(239, 84%, 67%, 0.1);
--Opacity--Blue--Blue-100: var(--Primitive--Blue--500);
--Opacity--Blue--Blue-80: hsla(217, 91%, 60%, 0.8);
--Opacity--Blue--Blue-60: hsla(217, 91%, 60%, 0.6);
--Opacity--Blue--Blue-40: hsla(217, 91%, 60%, 0.4);
--Opacity--Blue--Blue-20: hsla(217, 91%, 60%, 0.2);
--Opacity--Blue--Blue-10: hsla(217, 91%, 60%, 0.1);
--Opacity--Grey--Grey-100: var(--Primitive--Gray--500);
--Opacity--Grey--Grey-80: hsla(220, 9%, 46%, 0.8);
--Opacity--Grey--Grey-60: hsla(220, 9%, 46%, 0.6);
--Opacity--Grey--Grey-40: hsla(220, 9%, 46%, 0.4);
--Opacity--Grey--Grey-20: hsla(220, 9%, 46%, 0.2);
--Opacity--Grey--Grey-10: hsla(220, 9%, 46%, 0.1);
--Opacity--White--White-100: var(--Primitive--White);
--Opacity--White--White-80: hsla(0, 0%, 100%, 0.8);
--Opacity--White--White-60: hsla(0, 0%, 100%, 0.6);
--Opacity--White--White-40: hsla(0, 0%, 100%, 0.4);
--Opacity--White--White-20: hsla(0, 0%, 100%, 0.2);
--Opacity--White--White-10: hsla(0, 0%, 100%, 0.1);
--Opacity--White--White-0: hsla(0, 0%, 100%, 0);
--Status--Error--colorErrorBg: var(--color--Red--900);
--Status--Error--colorErrorBgHover: var(--color--Red--800);
--Status--Error--colorErrorBorder: var(--color--Red--700);
--Status--Error--colorErrorBorderHover: var(--color--Red--600);
--Status--Error--colorErrorBase: var(--color--Red--400);
--Status--Error--colorErrorActive: var(--color--Red--300);
--Status--Error--colorErrorTextHover: var(--color--Red--200);
--Status--Error--colorErrorText: var(--color--Red--100);
--Status--Success--colorSuccessBg: var(--color--Green--900);
--Status--Success--colorSuccessBgHover: var(--color--Green--800);
--Status--Success--colorSuccessBase: var(--color--Green--400);
--Status--Success--colorSuccessTextHover: var(--color--Green--200);
--Status--Warning--colorWarningBg: var(--color--Yellow--900);
--Status--Warning--colorWarningBgHover: var(--color--Yellow--800);
--Status--Warning--colorWarningBase: var(--color--Yellow--400);
--Status--Warning--colorWarningActive: var(--color--Yellow--300);
--Status--Warning--colorWarningTextHover: var(--color--Yellow--200);
--Primitive--Black: hsla(0, 0%, 0%, 1);
--Primitive--White: hsla(0, 0%, 100%, 1);
--Brand--Base_Colors--Primary: var(--Primitive--Lime--500);
--Primitive--Neutral--50: hsla(0, 0%, 98%, 1);
--Primitive--Neutral--100: hsla(0, 0%, 96%, 1);
--Primitive--Neutral--200: hsla(0, 0%, 90%, 1);
--Primitive--Neutral--300: hsla(0, 0%, 83%, 1);
--Primitive--Neutral--400: hsla(0, 0%, 64%, 1);
--Primitive--Neutral--500: hsla(0, 0%, 45%, 1);
--Primitive--Neutral--600: hsla(215, 14%, 34%, 1);
--Primitive--Neutral--700: hsla(0, 0%, 25%, 1);
--Primitive--Neutral--800: hsla(0, 0%, 15%, 1);
--Primitive--Neutral--900: hsla(0, 0%, 9%, 1);
--Primitive--Neutral--950: hsla(0, 0%, 4%, 1);
--Primitive--Stone--50: hsla(60, 9%, 98%, 1);
--Primitive--Stone--100: hsla(60, 5%, 96%, 1);
--Primitive--Stone--200: hsla(20, 6%, 90%, 1);
--Primitive--Stone--300: hsla(24, 6%, 83%, 1);
--Primitive--Stone--400: hsla(24, 5%, 64%, 1);
--Primitive--Stone--500: hsla(25, 5%, 45%, 1);
--Primitive--Stone--600: hsla(33, 5%, 32%, 1);
--Primitive--Stone--700: hsla(30, 6%, 25%, 1);
--Primitive--Stone--800: hsla(12, 6%, 15%, 1);
--Primitive--Stone--900: hsla(24, 10%, 10%, 1);
--Primitive--Stone--950: hsla(20, 14%, 4%, 1);
--Primitive--Zinc--50: hsla(0, 0%, 98%, 1);
--Primitive--Zinc--100: hsla(240, 5%, 96%, 1);
--Primitive--Zinc--200: hsla(240, 6%, 90%, 1);
--Primitive--Zinc--300: hsla(240, 5%, 84%, 1);
--Primitive--Zinc--400: hsla(240, 5%, 65%, 1);
--Primitive--Zinc--500: hsla(240, 4%, 46%, 1);
--Primitive--Zinc--600: hsla(240, 5%, 34%, 1);
--Primitive--Zinc--700: hsla(240, 5%, 26%, 1);
--Primitive--Zinc--800: hsla(240, 4%, 16%, 1);
--Primitive--Zinc--900: hsla(240, 6%, 10%, 1);
--Primitive--Zinc--950: hsla(240, 10%, 4%, 1);
--Primitive--Slate--50: hsla(210, 40%, 98%, 1);
--Primitive--Slate--100: hsla(210, 40%, 96%, 1);
--Primitive--Slate--200: hsla(214, 32%, 91%, 1);
--Primitive--Slate--300: hsla(213, 27%, 84%, 1);
--Primitive--Slate--400: hsla(215, 20%, 65%, 1);
--Primitive--Slate--500: hsla(215, 16%, 47%, 1);
--Primitive--Slate--600: hsla(215, 19%, 35%, 1);
--Primitive--Slate--700: hsla(215, 25%, 27%, 1);
--Primitive--Slate--800: hsla(217, 33%, 17%, 1);
--Primitive--Slate--900: hsla(222, 47%, 11%, 1);
--Primitive--Slate--950: hsla(229, 84%, 5%, 1);
--Primitive--Gray--50: hsla(210, 20%, 98%, 1);
--Primitive--Gray--100: hsla(220, 14%, 96%, 1);
--Primitive--Gray--200: hsla(220, 13%, 91%, 1);
--Primitive--Gray--300: hsla(216, 12%, 84%, 1);
--Primitive--Gray--400: hsla(218, 11%, 65%, 1);
--Primitive--Gray--500: hsla(220, 9%, 46%, 1);
--Primitive--Gray--600: hsla(0, 0%, 32%, 1);
--Primitive--Gray--700: hsla(217, 19%, 27%, 1);
--Primitive--Gray--800: hsla(215, 28%, 17%, 1);
--Primitive--Gray--900: hsla(221, 39%, 11%, 1);
--Primitive--Gray--950: hsla(224, 71%, 4%, 1);
--Primitive--Red--50: hsla(0, 86%, 97%, 1);
--Primitive--Red--100: hsla(0, 93%, 94%, 1);
--Primitive--Red--200: hsla(0, 96%, 89%, 1);
--Primitive--Red--300: hsla(0, 94%, 82%, 1);
--Primitive--Red--400: hsla(0, 91%, 71%, 1);
--Primitive--Red--500: hsla(0, 84%, 60%, 1);
--Primitive--Red--600: hsla(0, 72%, 51%, 1);
--Primitive--Red--700: hsla(0, 74%, 42%, 1);
--Primitive--Red--800: hsla(0, 70%, 35%, 1);
--Primitive--Red--900: hsla(0, 63%, 31%, 1);
--Primitive--Red--950: hsla(0, 75%, 15%, 1);
--Primitive--Orange--50: hsla(33, 100%, 96%, 1);
--Primitive--Orange--100: hsla(34, 100%, 92%, 1);
--Primitive--Orange--200: hsla(32, 98%, 83%, 1);
--Primitive--Orange--300: hsla(31, 97%, 72%, 1);
--Primitive--Orange--400: hsla(27, 96%, 61%, 1);
--Primitive--Orange--500: hsla(25, 95%, 53%, 1);
--Primitive--Orange--600: hsla(21, 90%, 48%, 1);
--Primitive--Orange--700: hsla(17, 88%, 40%, 1);
--Primitive--Orange--800: hsla(15, 79%, 34%, 1);
--Primitive--Orange--900: hsla(15, 75%, 28%, 1);
--Primitive--Orange--950: hsla(13, 81%, 15%, 1);
--Primitive--Amber--50: hsla(48, 100%, 96%, 1);
--Primitive--Amber--100: hsla(48, 96%, 89%, 1);
--Primitive--Amber--200: hsla(48, 97%, 77%, 1);
--Primitive--Amber--300: hsla(46, 97%, 65%, 1);
--Primitive--Amber--400: hsla(43, 96%, 56%, 1);
--Primitive--Amber--500: hsla(38, 92%, 50%, 1);
--Primitive--Amber--600: hsla(32, 95%, 44%, 1);
--Primitive--Amber--700: hsla(26, 90%, 37%, 1);
--Primitive--Amber--800: hsla(23, 83%, 31%, 1);
--Primitive--Amber--900: hsla(22, 78%, 26%, 1);
--Primitive--Amber--950: hsla(21, 92%, 14%, 1);
--Primitive--Yellow--50: hsla(55, 92%, 95%, 1);
--Primitive--Yellow--100: hsla(55, 97%, 88%, 1);
--Primitive--Yellow--200: hsla(53, 98%, 77%, 1);
--Primitive--Yellow--300: hsla(50, 98%, 64%, 1);
--Primitive--Yellow--400: hsla(48, 96%, 53%, 1);
--Primitive--Yellow--500: hsla(45, 93%, 47%, 1);
--Primitive--Yellow--600: hsla(41, 96%, 40%, 1);
--Primitive--Yellow--700: hsla(35, 92%, 33%, 1);
--Primitive--Yellow--800: hsla(32, 81%, 29%, 1);
--Primitive--Yellow--900: hsla(28, 73%, 26%, 1);
--Primitive--Yellow--950: hsla(26, 83%, 14%, 1);
--Primitive--Lime--50: hsla(78, 92%, 95%, 1);
--Primitive--Lime--100: hsla(80, 89%, 89%, 1);
--Primitive--Lime--200: hsla(81, 88%, 80%, 1);
--Primitive--Lime--300: hsla(82, 85%, 67%, 1);
--Primitive--Lime--400: hsla(83, 78%, 55%, 1);
--Primitive--Lime--500: hsla(84, 81%, 44%, 1);
--Primitive--Lime--600: hsla(85, 85%, 35%, 1);
--Primitive--Lime--700: hsla(86, 78%, 27%, 1);
--Primitive--Lime--800: hsla(86, 69%, 23%, 1);
--Primitive--Lime--900: hsla(88, 61%, 20%, 1);
--Primitive--Lime--950: hsla(89, 80%, 10%, 1);
--Primitive--Green--50: hsla(138, 76%, 97%, 1);
--Primitive--Green--100: hsla(141, 84%, 93%, 1);
--Primitive--Green--200: hsla(141, 79%, 85%, 1);
--Primitive--Green--300: hsla(142, 77%, 73%, 1);
--Primitive--Green--400: hsla(142, 69%, 58%, 1);
--Primitive--Green--500: hsla(142, 71%, 45%, 1);
--Primitive--Green--600: hsla(142, 76%, 36%, 1);
--Primitive--Green--700: hsla(142, 72%, 29%, 1);
--Primitive--Green--800: hsla(143, 64%, 24%, 1);
--Primitive--Green--900: hsla(144, 61%, 20%, 1);
--Primitive--Green--950: hsla(145, 80%, 10%, 1);
--Primitive--Emerald--50: hsla(152, 81%, 96%, 1);
--Primitive--Emerald--100: hsla(149, 80%, 90%, 1);
--Primitive--Emerald--200: hsla(152, 76%, 80%, 1);
--Primitive--Emerald--300: hsla(156, 72%, 67%, 1);
--Primitive--Emerald--400: hsla(158, 64%, 52%, 1);
--Primitive--Emerald--500: hsla(160, 84%, 39%, 1);
--Primitive--Emerald--600: hsla(161, 94%, 30%, 1);
--Primitive--Emerald--700: hsla(163, 94%, 24%, 1);
--Primitive--Emerald--800: hsla(163, 88%, 20%, 1);
--Primitive--Emerald--900: hsla(164, 86%, 16%, 1);
--Primitive--Emerald--950: hsla(166, 91%, 9%, 1);
--Primitive--Teal--50: hsla(166, 76%, 97%, 1);
--Primitive--Teal--100: hsla(167, 85%, 89%, 1);
--Primitive--Teal--200: hsla(168, 84%, 78%, 1);
--Primitive--Teal--300: hsla(171, 77%, 64%, 1);
--Primitive--Teal--400: hsla(172, 66%, 50%, 1);
--Primitive--Teal--500: hsla(173, 80%, 40%, 1);
--Primitive--Teal--600: hsla(175, 84%, 32%, 1);
--Primitive--Teal--700: hsla(175, 77%, 26%, 1);
--Primitive--Teal--800: hsla(176, 69%, 22%, 1);
--Primitive--Teal--900: hsla(176, 61%, 19%, 1);
--Primitive--Teal--950: hsla(179, 84%, 10%, 1);
--Primitive--Cyan--50: hsla(183, 100%, 96%, 1);
--Primitive--Cyan--100: hsla(185, 96%, 90%, 1);
--Primitive--Cyan--200: hsla(186, 94%, 82%, 1);
--Primitive--Cyan--300: hsla(187, 92%, 69%, 1);
--Primitive--Cyan--400: hsla(188, 86%, 53%, 1);
--Primitive--Cyan--500: hsla(189, 94%, 43%, 1);
--Primitive--Cyan--600: hsla(192, 91%, 36%, 1);
--Primitive--Cyan--700: hsla(193, 82%, 31%, 1);
--Primitive--Cyan--800: hsla(194, 70%, 27%, 1);
--Primitive--Cyan--900: hsla(196, 64%, 24%, 1);
--Primitive--Cyan--950: hsla(197, 79%, 15%, 1);
--Primitive--Sky--50: hsla(204, 100%, 97%, 1);
--Primitive--Sky--100: hsla(204, 94%, 94%, 1);
--Primitive--Sky--200: hsla(201, 94%, 86%, 1);
--Primitive--Sky--300: hsla(199, 95%, 74%, 1);
--Primitive--Sky--400: hsla(198, 93%, 60%, 1);
--Primitive--Sky--500: hsla(199, 89%, 48%, 1);
--Primitive--Sky--600: hsla(200, 98%, 39%, 1);
--Primitive--Sky--700: hsla(201, 96%, 32%, 1);
--Primitive--Sky--800: hsla(201, 90%, 27%, 1);
--Primitive--Sky--900: hsla(202, 80%, 24%, 1);
--Primitive--Sky--950: hsla(204, 80%, 16%, 1);
--Primitive--Blue--50: hsla(214, 100%, 97%, 1);
--Primitive--Blue--100: hsla(214, 95%, 93%, 1);
--Primitive--Blue--200: hsla(213, 97%, 87%, 1);
--Primitive--Blue--300: hsla(212, 96%, 78%, 1);
--Primitive--Blue--400: hsla(213, 94%, 68%, 1);
--Primitive--Blue--500: hsla(217, 91%, 60%, 1);
--Primitive--Blue--600: hsla(221, 83%, 53%, 1);
--Primitive--Blue--700: hsla(224, 76%, 48%, 1);
--Primitive--Blue--800: hsla(226, 71%, 40%, 1);
--Primitive--Blue--900: hsla(224, 64%, 33%, 1);
--Primitive--Blue--950: hsla(226, 57%, 21%, 1);
--Primitive--Indigo--50: hsla(226, 100%, 97%, 1);
--Primitive--Indigo--100: hsla(226, 100%, 94%, 1);
--Primitive--Indigo--200: hsla(228, 96%, 89%, 1);
--Primitive--Indigo--300: hsla(230, 94%, 82%, 1);
--Primitive--Indigo--400: hsla(234, 89%, 74%, 1);
--Primitive--Indigo--500: hsla(239, 84%, 67%, 1);
--Primitive--Indigo--600: hsla(243, 75%, 59%, 1);
--Primitive--Indigo--700: hsla(245, 58%, 51%, 1);
--Primitive--Indigo--800: hsla(244, 55%, 41%, 1);
--Primitive--Indigo--900: hsla(242, 47%, 34%, 1);
--Primitive--Indigo--950: hsla(244, 47%, 20%, 1);
--Primitive--Violet--50: hsla(250, 100%, 98%, 1);
--Primitive--Violet--100: hsla(251, 91%, 95%, 1);
--Primitive--Violet--200: hsla(251, 95%, 92%, 1);
--Primitive--Violet--300: hsla(253, 95%, 85%, 1);
--Primitive--Violet--400: hsla(255, 92%, 76%, 1);
--Primitive--Violet--500: hsla(258, 90%, 66%, 1);
--Primitive--Violet--600: hsla(262, 83%, 58%, 1);
--Primitive--Violet--700: hsla(263, 70%, 50%, 1);
--Primitive--Violet--800: hsla(263, 69%, 42%, 1);
--Primitive--Violet--900: hsla(264, 67%, 35%, 1);
--Primitive--Violet--950: hsla(262, 78%, 23%, 1);
--Primitive--Purple--50: hsla(270, 100%, 98%, 1);
--Primitive--Purple--100: hsla(269, 100%, 95%, 1);
--Primitive--Purple--200: hsla(269, 100%, 92%, 1);
--Primitive--Purple--300: hsla(269, 97%, 85%, 1);
--Primitive--Purple--400: hsla(270, 95%, 75%, 1);
--Primitive--Purple--500: hsla(271, 91%, 65%, 1);
--Primitive--Purple--600: hsla(271, 81%, 56%, 1);
--Primitive--Purple--700: hsla(272, 72%, 47%, 1);
--Primitive--Purple--800: hsla(273, 67%, 39%, 1);
--Primitive--Purple--900: hsla(274, 66%, 32%, 1);
--Primitive--Purple--950: hsla(274, 87%, 21%, 1);
--Primitive--Fuchsia--50: hsla(289, 100%, 98%, 1);
--Primitive--Fuchsia--100: hsla(287, 100%, 95%, 1);
--Primitive--Fuchsia--200: hsla(288, 96%, 91%, 1);
--Primitive--Fuchsia--300: hsla(291, 93%, 83%, 1);
--Primitive--Fuchsia--400: hsla(292, 91%, 73%, 1);
--Primitive--Fuchsia--500: hsla(292, 84%, 61%, 1);
--Primitive--Fuchsia--600: hsla(293, 69%, 49%, 1);
--Primitive--Fuchsia--700: hsla(295, 72%, 40%, 1);
--Primitive--Fuchsia--800: hsla(295, 70%, 33%, 1);
--Primitive--Fuchsia--900: hsla(297, 64%, 28%, 1);
--Primitive--Fuchsia--950: hsla(297, 90%, 16%, 1);
--Primitive--Pink--50: hsla(327, 73%, 97%, 1);
--Primitive--Pink--100: hsla(326, 78%, 95%, 1);
--Primitive--Pink--200: hsla(326, 85%, 90%, 1);
--Primitive--Pink--300: hsla(327, 87%, 82%, 1);
--Primitive--Pink--400: hsla(329, 86%, 70%, 1);
--Primitive--Pink--500: hsla(330, 81%, 60%, 1);
--Primitive--Pink--600: hsla(333, 71%, 51%, 1);
--Primitive--Pink--700: hsla(335, 78%, 42%, 1);
--Primitive--Pink--800: hsla(336, 74%, 35%, 1);
--Primitive--Pink--900: hsla(336, 69%, 30%, 1);
--Primitive--Pink--950: hsla(336, 84%, 17%, 1);
--Primitive--Rose--50: hsla(356, 100%, 97%, 1);
--Primitive--Rose--100: hsla(356, 100%, 95%, 1);
--Primitive--Rose--200: hsla(353, 96%, 90%, 1);
--Primitive--Rose--300: hsla(353, 96%, 82%, 1);
--Primitive--Rose--400: hsla(351, 95%, 71%, 1);
--Primitive--Rose--500: hsla(350, 89%, 60%, 1);
--Primitive--Rose--600: hsla(347, 77%, 50%, 1);
--Primitive--Rose--700: hsla(345, 83%, 41%, 1);
--Primitive--Rose--800: hsla(343, 80%, 35%, 1);
--Primitive--Rose--900: hsla(342, 75%, 30%, 1);
--Primitive--Rose--950: hsla(343, 88%, 16%, 1);
--Brand--Base_Colors--Destructive: var(--Primitive--Red--500);
--Brand--Base_Colors--Success: var(--Primitive--Green--500);
--Brand--Base_Colors--Warning: var(--Primitive--Amber--500);
--Brand--Base_Colors--White: var(--Primitive--White);
--Brand--Base_Colors--Black: var(--Primitive--Black);
--Brand--Semantic_Colors--Background: var(--Primitive--Zinc--900); /*页面背景色:应用在整个页面的最底层。*/
--Brand--Semantic_Colors--Background-subtle: hsla(
0,
0%,
100%,
0.02
); /*细微背景色:用于需要与主背景有微弱区分的区域,如代码块背景。*/
--Brand--Semantic_Colors--Foreground: hsla(0, 0%, 100%, 0.9); /*主要前景/文字色:用于正文、标题等。*/
--Brand--Semantic_Colors--Foreground-secondary: hsla(0, 0%, 100%, 0.6); /*次要前景/文字色:用于辅助性文本、描述。*/
--Brand--Semantic_Colors--Foreground-muted: hsla(0, 0%, 100%, 0.4); /*静默前景/文字色:用于禁用状态的文字、占位符。*/
--Brand--Semantic_Colors--Border: hsla(0, 0%, 100%, 0.1); /*默认边框色:用于卡片、输入框、分隔线。*/
--Brand--Semantic_Colors--Border-hover: hsla(0, 0%, 100%, 0.2); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Border-active: hsla(0, 0%, 100%, 0.3); /*激活边框色:用于元素被按下或激活时的边框。*/
--Brand--Semantic_Colors--Ring: hsla(
84,
81%,
44%,
0.4
); /*聚焦环颜色:用于输入框等元素在聚焦 (Focus) 状态下的外发光。*/
--Brand--UI_Element_Colors--Modal--Backdrop: hsla(0, 0%, 0%, 0.06);
--Brand--UI_Element_Colors--Modal--Thumb: hsla(0, 0%, 100%, 0.2);
--Brand--UI_Element_Colors--Modal--Thumb_Hover: hsla(0, 0%, 100%, 0.3);
--Brand--UI_Element_Colors--Icon--Default: var(--Brand--Semantic_Colors--Foreground-secondary);
--Brand--UI_Element_Colors--Icon--Hover: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Input_Select--Background: var(--Brand--Base_Colors--Black);
--Brand--UI_Element_Colors--Input_Select--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Input_Select--Border_Hover: var(--Brand--Semantic_Colors--Border-hover);
--Brand--UI_Element_Colors--Input_Select--Border_Focus: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Primary_Button--Background: var(--Brand--Base_Colors--Primary);
--Brand--UI_Element_Colors--Card_Container--Background: var(--Brand--Base_Colors--Black);
--Brand--UI_Element_Colors--Card_Container--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Ghost_Button--Background: hsla(0, 0%, 100%, 0);
--Brand--UI_Element_Colors--Ghost_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Ghost_Button--Background_Hover: var(--Opacity--White--White-10);
--Brand--UI_Element_Colors--Ghost_Button--Background_Active: hsla(0, 0%, 100%, 0.15);
--Brand--UI_Element_Colors--Secondary_Button--Background: var(--Opacity--White--White-10);
--Brand--UI_Element_Colors--Secondary_Button--Text: var(--Brand--Semantic_Colors--Foreground);
--Brand--UI_Element_Colors--Secondary_Button--Background_Hover: var(--Opacity--White--White-20);
--Brand--UI_Element_Colors--Secondary_Button--Background_Active: hsla(0, 0%, 100%, 0.25);
--Brand--UI_Element_Colors--Secondary_Button--Border: var(--Brand--Semantic_Colors--Border);
--Brand--UI_Element_Colors--Primary_Button--Text: var(--Brand--Base_Colors--White);
--Brand--UI_Element_Colors--Primary_Button--Background_Hover: hsla(84, 81%, 44%, 0.85);
--Brand--UI_Element_Colors--Primary_Button--2nd_Background: hsla(84, 81%, 44%, 0.1);
--Brand--UI_Element_Colors--Primary_Button--3rd_Background: hsla(84, 81%, 44%, 0.05);
--Brand--UI_Element_Colors--Primary_Button--Background_Active: hsla(84, 81%, 44%, 0.7);
--Boolean: false;
}

View File

@@ -29,7 +29,7 @@ import {
setupAppImageDeepLink
} from './services/ProtocolClient'
import selectionService, { initSelectionService } from './services/SelectionService'
import { shortcutService } from './services/ShortcutService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefactorMigrateService'
@@ -216,7 +216,7 @@ if (!app.requestSingleInstanceLock()) {
}
})
shortcutService.registerForWindow(mainWindow)
registerShortcuts(mainWindow)
registerIpc(mainWindow, app)
replaceDevtoolsFont(mainWindow)

View File

@@ -14,6 +14,7 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
import { IpcChannel } from '@shared/IpcChannel'
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
import type {
AgentPersistedMessage,
FileMetadata,
@@ -21,6 +22,7 @@ import type {
OcrProvider,
PluginError,
Provider,
Shortcut,
SupportedOcrFile
} from '@types'
import checkDiskSpace from 'check-disk-space'
@@ -578,6 +580,22 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, async (_, shortcuts: Shortcut[]) => {
const existingPreferences = preferenceService.get('shortcut.preferences') ?? {}
const nextPreferences: ShortcutPreferenceMap = { ...existingPreferences }
for (const shortcut of shortcuts) {
const name = shortcut.key === 'mini_window' ? 'show_mini_window' : shortcut.key
nextPreferences[name] = {
key: [...shortcut.shortcut],
enabled: shortcut.enabled
}
}
await preferenceService.set('shortcut.preferences', nextPreferences)
})
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete.bind(KnowledgeService))

View File

@@ -1,6 +1,7 @@
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
import type { ApiClient } from '@types'
import { VoyageEmbeddings } from './VoyageEmbeddings'
@@ -8,7 +9,7 @@ import { VoyageEmbeddings } from './VoyageEmbeddings'
export default class EmbeddingsFactory {
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings {
const batchSize = 10
const { model, provider, apiKey, baseURL } = embedApiClient
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
if (provider === 'voyageai') {
return new VoyageEmbeddings({
modelName: model,
@@ -37,7 +38,16 @@ export default class EmbeddingsFactory {
}
})
}
// NOTE: Azure OpenAI 也走 OpenAIEmbeddings, baseURL是https://xxxx.openai.azure.com/openai/v1
if (apiVersion !== undefined) {
return new AzureOpenAiEmbeddings({
azureOpenAIApiKey: apiKey,
azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model,
azureOpenAIEndpoint: baseURL,
dimensions,
batchSize
})
}
return new OpenAiEmbeddings({
model,
apiKey,

View File

@@ -1,307 +1,349 @@
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { handleZoomFactor } from '@main/utils/zoom'
import type { PreferenceDefaultScopeType } from '@shared/data/preference/preferenceTypes'
import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
import type {
ShortcutDefinition,
ShortcutHandler,
ShortcutPreferenceKey,
ShortcutPreferenceValue,
ShortcutRuntimeConfig
} from '@shared/shortcuts/types'
import { coerceShortcutPreference } from '@shared/shortcuts/utils'
import { IpcChannel } from '@shared/IpcChannel'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
import type { HydratedShortcut, ShortcutDefinition, ShortcutPreferenceMap } from '@shared/shortcuts/types'
import type { BrowserWindow } from 'electron'
import { globalShortcut } from 'electron'
import { BrowserWindow as ElectronBrowserWindow, globalShortcut, ipcMain } from 'electron'
import selectionService from './SelectionService'
import { windowService } from './WindowService'
const logger = loggerService.withContext('ShortcutService')
const toAccelerator = (keys: string[]): string => keys.join('+')
type ShortcutHandler = (window: BrowserWindow | undefined) => void
const relevantDefinitions = SHORTCUT_DEFINITIONS.filter((definition) => definition.scope !== 'renderer')
export class ShortcutService {
private handlers = new Map<ShortcutPreferenceKey, ShortcutHandler>()
private windowLifecycleHandlers = new Map<
BrowserWindow,
{ onFocus: () => void; onBlur: () => void; onClosed: () => void }
>()
private currentWindow: BrowserWindow | null = null
private preferenceUnsubscribers: Array<() => void> = []
class ShortcutService {
private handlers = new Map<string, ShortcutHandler>()
private hydratedShortcuts = new Map<string, HydratedShortcut>()
private registeredAccelerators = new Map<string, string[]>()
private readonly definitionMap = new Map<string, ShortcutDefinition>()
private ipcRegistered = false
constructor() {
this.registerBuiltInHandlers()
this.subscribeToPreferenceChanges()
this.definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
this.setupIpcHandlers()
this.registerDefaultHandlers()
this.hydrateShortcuts()
this.registerPreferenceListeners()
}
public registerHandler(key: ShortcutPreferenceKey, handler: ShortcutHandler): void {
if (this.handlers.has(key)) {
logger.warn(`Handler for ${key} is being overwritten`)
public registerHandler(name: string, handler: ShortcutHandler) {
if (this.handlers.has(name)) {
logger.warn(`Handler for shortcut '${name}' is being overwritten.`)
}
this.handlers.set(key, handler)
logger.debug(`Registered handler for ${key}`)
this.handlers.set(name, handler)
}
public registerForWindow(window: BrowserWindow): void {
if (this.windowLifecycleHandlers.has(window)) {
logger.warn(`Window ${window.id} already registered for shortcuts`)
return
public registerMainProcessShortcuts(window?: BrowserWindow) {
const targetWindow = this.getTargetWindow(window)
this.unregisterTrackedAccelerators()
for (const config of this.hydratedShortcuts.values()) {
if (config.scope !== 'main') {
continue
}
if (!config.enabled || config.key.length === 0) {
continue
}
const handler = this.handlers.get(config.name)
if (!handler) {
logger.warn(`No handler registered for shortcut '${config.name}'.`)
continue
}
const accelerators = this.buildAccelerators(config)
if (accelerators.length === 0) {
continue
}
for (const accelerator of accelerators) {
try {
const registered = globalShortcut.register(accelerator, () => {
try {
handler(this.getTargetWindow(targetWindow))
} catch (error) {
logger.error(`Error while executing handler for shortcut '${config.name}':`, error as Error)
}
})
if (!registered) {
logger.warn(`Electron rejected shortcut accelerator '${accelerator}' for '${config.name}'.`)
continue
}
this.trackAccelerator(config.name, accelerator)
} catch (error) {
logger.warn(`Failed to register shortcut '${config.name}' with accelerator '${accelerator}':`, error as Error)
}
}
}
const onFocus = () => {
logger.debug(`Window ${window.id} focused - registering shortcuts`)
this.currentWindow = window
this.registerAllShortcuts(window)
}
const onBlur = () => {
logger.debug(`Window ${window.id} blurred - unregistering non-persistent shortcuts`)
this.unregisterTransientShortcuts(window)
}
const onClosed = () => {
logger.debug(`Window ${window.id} closed - cleaning up shortcut registrations`)
this.unregisterWindow(window)
}
window.on('focus', onFocus)
window.on('blur', onBlur)
window.on('closed', onClosed)
this.windowLifecycleHandlers.set(window, { onFocus, onBlur, onClosed })
this.currentWindow = window
if (window.isFocused()) {
this.registerAllShortcuts(window)
} else {
this.unregisterTransientShortcuts(window)
}
logger.info(`ShortcutService attached to window ${window.id}`)
this.broadcastShortcuts()
}
public unregisterWindow(window: BrowserWindow): void {
const lifecycle = this.windowLifecycleHandlers.get(window)
if (!lifecycle) {
return
}
window.off('focus', lifecycle.onFocus)
window.off('blur', lifecycle.onBlur)
window.off('closed', lifecycle.onClosed)
this.windowLifecycleHandlers.delete(window)
if (this.currentWindow === window) {
this.currentWindow = null
globalShortcut.unregisterAll()
}
public unregisterAllShortcuts() {
this.unregisterTrackedAccelerators()
}
public cleanup(): void {
this.windowLifecycleHandlers.forEach((_handlers, window) => this.unregisterWindow(window))
this.windowLifecycleHandlers.clear()
this.handlers.clear()
this.currentWindow = null
this.preferenceUnsubscribers.forEach((unsubscribe) => unsubscribe())
this.preferenceUnsubscribers = []
globalShortcut.unregisterAll()
logger.info('ShortcutService cleaned up')
}
private registerBuiltInHandlers(): void {
this.registerHandler('shortcut.app.show_main_window', () => {
windowService.toggleMainWindow()
})
this.registerHandler('shortcut.app.show_settings', () => {
let targetWindow = windowService.getMainWindow()
if (
!targetWindow ||
targetWindow.isDestroyed() ||
targetWindow.isMinimized() ||
!targetWindow.isVisible() ||
!targetWindow.isFocused()
) {
windowService.showMainWindow()
targetWindow = windowService.getMainWindow()
}
if (!targetWindow || targetWindow.isDestroyed()) {
return
}
void targetWindow.webContents
.executeJavaScript(`typeof window.navigate === 'function' && window.navigate('/settings/provider')`, true)
.catch((error) => {
logger.warn('Failed to navigate to settings from shortcut:', error as Error)
})
})
this.registerHandler('shortcut.app.show_mini_window', () => {
windowService.toggleMiniWindow()
})
this.registerHandler('shortcut.app.zoom_in', (window) => {
if (window) {
handleZoomFactor([window], 0.1)
}
})
this.registerHandler('shortcut.app.zoom_out', (window) => {
if (window) {
handleZoomFactor([window], -0.1)
}
})
this.registerHandler('shortcut.app.zoom_reset', (window) => {
if (window) {
handleZoomFactor([window], 0, true)
}
})
this.registerHandler('shortcut.selection.toggle_enabled', () => {
if (selectionService) {
selectionService.toggleEnabled()
}
})
this.registerHandler('shortcut.selection.get_text', () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
})
}
private subscribeToPreferenceChanges(): void {
this.preferenceUnsubscribers = relevantDefinitions.map((definition) =>
preferenceService.subscribeChange(definition.key, () => {
logger.debug(`Shortcut preference changed: ${definition.key}`)
this.reregisterShortcuts()
})
public getHydratedShortcuts(): Record<string, HydratedShortcut> {
return Object.fromEntries(
[...this.hydratedShortcuts.entries()].map(([name, config]) => [
name,
{
...config,
key: [...config.key]
}
])
)
}
private registerAllShortcuts(window: BrowserWindow): void {
globalShortcut.unregisterAll()
private setupIpcHandlers() {
if (this.ipcRegistered) {
return
}
relevantDefinitions.forEach((definition) => {
const runtimeConfig = this.getRuntimeConfig(definition)
if (!runtimeConfig.enabled) {
return
}
ipcMain.handle(IpcChannel.Shortcuts_GetAll, () => {
return this.getHydratedShortcuts()
})
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
logger.debug(`Skipping ${definition.key} - enabledWhen condition not met`)
return
}
this.ipcRegistered = true
}
const handler = this.handlers.get(definition.key)
if (!handler) {
logger.warn(`No handler registered for ${definition.key}`)
return
}
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
if (definition.variants) {
definition.variants.forEach((variant) => {
this.registerSingleShortcut(variant, handler, window)
})
}
private registerPreferenceListeners() {
preferenceService.subscribeChange('shortcut.preferences', (newPreferences) => {
this.hydrateAndRegister(newPreferences)
})
}
private unregisterTransientShortcuts(window: BrowserWindow): void {
globalShortcut.unregisterAll()
private hydrateAndRegister(preferences?: ShortcutPreferenceMap) {
this.hydrateShortcuts(preferences)
this.registerMainProcessShortcuts()
}
relevantDefinitions
.filter((definition) => definition.persistOnBlur)
.forEach((definition) => {
const runtimeConfig = this.getRuntimeConfig(definition)
if (!runtimeConfig.enabled) {
return
}
private hydrateShortcuts(preferences?: ShortcutPreferenceMap) {
const preferenceSnapshot = preferences ?? preferenceService.get('shortcut.preferences')
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
return
}
this.hydratedShortcuts.clear()
const handler = this.handlers.get(definition.key)
if (!handler) {
return
}
for (const definition of shortcutDefinitions) {
const userPreference = preferenceSnapshot?.[definition.name]
const key =
userPreference?.key && userPreference.key.length > 0 ? [...userPreference.key] : [...definition.defaultKey]
const enabled = typeof userPreference?.enabled === 'boolean' ? userPreference.enabled : definition.defaultEnabled
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
if (definition.variants) {
definition.variants.forEach((variant) => {
this.registerSingleShortcut(variant, handler, window)
})
}
this.hydratedShortcuts.set(definition.name, {
...definition,
key,
enabled
})
}
private registerSingleShortcut(keys: string[], handler: ShortcutHandler, window: BrowserWindow): void {
if (!keys.length) {
return
}
const accelerator = toAccelerator(keys)
try {
globalShortcut.register(accelerator, () => {
logger.debug(`Shortcut triggered: ${accelerator}`)
const targetWindow = window?.isDestroyed?.() ? undefined : window
handler(targetWindow)
})
logger.verbose(`Registered shortcut: ${accelerator}`)
} catch (error) {
logger.error(`Failed to register shortcut ${accelerator}:`, error as Error)
}
}
private getRuntimeConfig(definition: ShortcutDefinition): ShortcutRuntimeConfig {
const preference = this.getPreference(definition)
return {
...definition,
binding: preference.binding,
enabled: preference.enabled,
editable: preference.editable,
system: preference.system
private broadcastShortcuts() {
const payload = this.getHydratedShortcuts()
for (const window of ElectronBrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
window.webContents.send(IpcChannel.Shortcuts_Updated, payload)
} catch (error) {
logger.warn('Failed to broadcast shortcut update to renderer window:', error as Error)
}
}
}
private getPreference(definition: ShortcutDefinition): ShortcutPreferenceValue {
const rawPreference = preferenceService.get(definition.key)
return coerceShortcutPreference(definition, rawPreference as any)
}
private getPreferenceValue = <K extends ShortcutPreferenceKey | keyof PreferenceDefaultScopeType>(
key: K
): PreferenceDefaultScopeType[K] => {
return preferenceService.get(key)
}
private reregisterShortcuts(): void {
if (!this.currentWindow || this.currentWindow.isDestroyed()) {
return
private unregisterTrackedAccelerators() {
for (const accelerators of this.registeredAccelerators.values()) {
for (const accelerator of accelerators) {
try {
globalShortcut.unregister(accelerator)
} catch (error) {
logger.warn(`Failed to unregister accelerator '${accelerator}':`, error as Error)
}
}
}
if (this.currentWindow.isFocused()) {
this.registerAllShortcuts(this.currentWindow)
return
this.registeredAccelerators.clear()
}
private trackAccelerator(name: string, accelerator: string) {
if (!this.registeredAccelerators.has(name)) {
this.registeredAccelerators.set(name, [])
}
this.registeredAccelerators.get(name)!.push(accelerator)
}
private buildAccelerators(config: HydratedShortcut): string[] {
if (config.key.length === 0) {
return []
}
this.unregisterTransientShortcuts(this.currentWindow)
const baseAccelerator = this.normalizeAccelerator(config.key)
if (!baseAccelerator) {
logger.warn(`Invalid shortcut configuration for '${config.name}', skipping registration.`)
return []
}
if (config.name === 'zoom_in' && this.isUsingDefaultKey(config)) {
return [baseAccelerator, 'CommandOrControl+numadd']
}
if (config.name === 'zoom_out' && this.isUsingDefaultKey(config)) {
return [baseAccelerator, 'CommandOrControl+numsub']
}
if (config.name === 'zoom_reset' && this.isUsingDefaultKey(config)) {
return [baseAccelerator, 'CommandOrControl+num0']
}
return [baseAccelerator]
}
private isUsingDefaultKey(config: HydratedShortcut): boolean {
const definition = this.definitionMap.get(config.name)
if (!definition) {
return false
}
if (definition.defaultKey.length !== config.key.length) {
return false
}
return definition.defaultKey.every((key, index) => key === config.key[index])
}
private normalizeAccelerator(keys: string[]): string | null {
const normalizedKeys = keys.map((key) => this.normalizeKeyForElectron(key)).filter((key): key is string => !!key)
if (normalizedKeys.length !== keys.length) {
return null
}
return normalizedKeys.join('+')
}
private normalizeKeyForElectron(key: string): string | null {
switch (key) {
case 'CommandOrControl':
case 'Ctrl':
case 'Alt':
case 'Meta':
case 'Shift':
return key
case 'Command':
case 'Cmd':
return 'CommandOrControl'
case 'Control':
return 'Ctrl'
case 'ArrowUp':
return 'Up'
case 'ArrowDown':
return 'Down'
case 'ArrowLeft':
return 'Left'
case 'ArrowRight':
return 'Right'
case 'AltGraph':
return 'AltGr'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
case 'Space':
return 'Space'
default:
return key
}
}
private registerDefaultHandlers() {
this.registerHandler('zoom_in', (window) => {
const target = this.getTargetWindow(window)
if (!target) {
return
}
handleZoomFactor([target], 0.1)
})
this.registerHandler('zoom_out', (window) => {
const target = this.getTargetWindow(window)
if (!target) {
return
}
handleZoomFactor([target], -0.1)
})
this.registerHandler('zoom_reset', (window) => {
const target = this.getTargetWindow(window)
if (!target) {
return
}
handleZoomFactor([target], 0, true)
})
this.registerHandler('show_app', () => {
windowService.toggleMainWindow()
})
this.registerHandler('show_mini_window', () => {
if (!preferenceService.get('feature.quick_assistant.enabled')) {
return
}
windowService.toggleMiniWindow()
})
this.registerHandler('selection_assistant_toggle', () => {
selectionService?.toggleEnabled()
})
this.registerHandler('selection_assistant_select_text', () => {
selectionService?.processSelectTextByShortcut()
})
}
private getTargetWindow(window?: BrowserWindow): BrowserWindow | undefined {
if (window && !window.isDestroyed()) {
return window
}
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
return mainWindow
}
return undefined
}
}
export const shortcutService = new ShortcutService()
export function registerShortcuts(window: BrowserWindow) {
shortcutService.registerMainProcessShortcuts(window)
}
export function unregisterAllShortcuts() {
shortcutService.unregisterAllShortcuts()
}

View File

@@ -237,6 +237,7 @@ const api = {
},
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
shortcuts: {
getAll: () => ipcRenderer.invoke(IpcChannel.Shortcuts_GetAll),
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
},
knowledgeBase: {

View File

@@ -6,14 +6,7 @@
import { loggerService } from '@logger'
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
import type {
BaseTool,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
MCPToolResultContent,
NormalToolResponse
} from '@renderer/types'
import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
@@ -262,7 +255,6 @@ export class ToolCallChunkHandler {
type: 'tool-result'
} & TypedToolResult<ToolSet>
): void {
// TODO: 基于AI SDK为供应商内置工具做更好的展示和类型安全处理
const { toolCallId, output, input } = chunk
if (!toolCallId) {
@@ -308,7 +300,12 @@ export class ToolCallChunkHandler {
responses: [toolResponse]
})
const images = extractImagesFromToolOutput(toolResponse.response)
const images: string[] = []
for (const content of toolResponse.response?.content || []) {
if (content.type === 'image' && content.data) {
images.push(`data:${content.mimeType};base64,${content.data}`)
}
}
if (images.length) {
this.onChunk({
@@ -355,41 +352,3 @@ export class ToolCallChunkHandler {
}
export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
function extractImagesFromToolOutput(output: unknown): string[] {
if (!output) {
return []
}
const contents: unknown[] = []
if (isMcpCallToolResponse(output)) {
contents.push(...output.content)
} else if (Array.isArray(output)) {
contents.push(...output)
} else if (hasContentArray(output)) {
contents.push(...output.content)
}
return contents
.filter(isMcpImageContent)
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
}
function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse {
return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content)
}
function hasContentArray(value: unknown): value is { content: unknown[] } {
return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content)
}
function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } {
if (typeof content !== 'object' || content === null) {
return false
}
const resultContent = content as MCPToolResultContent
return resultContent.type === 'image' && typeof resultContent.data === 'string'
}

View File

@@ -14,7 +14,6 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -79,7 +78,7 @@ export default class ModernAiProvider {
return this.actualProvider
}
public async completions(modelId: string, params: StreamTextParams, providerConfig: ModernAiProviderConfig) {
public async completions(modelId: string, params: StreamTextParams, config: ModernAiProviderConfig) {
// 检查model是否存在
if (!this.model) {
throw new Error('Model is required for completions. Please use constructor with model parameter.')
@@ -87,10 +86,7 @@ export default class ModernAiProvider {
// 每次请求时重新生成配置以确保API key轮换生效
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
logger.debug('Generated provider config for completions', this.config)
if (SUPPORTED_IMAGE_ENDPOINT_LIST.includes(this.config.options.endpoint)) {
providerConfig.isImageGenerationEndpoint = true
}
// 准备特殊配置
await prepareSpecialProviderConfig(this.actualProvider, this.config)
@@ -101,13 +97,13 @@ export default class ModernAiProvider {
// 提前构建中间件
const middlewares = buildAiSdkMiddlewares({
...providerConfig,
...config,
provider: this.actualProvider,
assistant: providerConfig.assistant
assistant: config.assistant
})
logger.debug('Built middlewares in completions', {
middlewareCount: middlewares.length,
isImageGeneration: providerConfig.isImageGenerationEndpoint
isImageGeneration: config.isImageGenerationEndpoint
})
if (!this.localProvider) {
throw new Error('Local provider not created')
@@ -115,7 +111,7 @@ export default class ModernAiProvider {
// 根据endpoint类型创建对应的模型
let model: AiSdkModel | undefined
if (providerConfig.isImageGenerationEndpoint) {
if (config.isImageGenerationEndpoint) {
model = this.localProvider.imageModel(modelId)
} else {
model = this.localProvider.languageModel(modelId)
@@ -131,15 +127,15 @@ export default class ModernAiProvider {
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (providerConfig.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
if (config.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
// TypeScript类型窄化确保topicId是string类型
const traceConfig = {
...providerConfig,
topicId: providerConfig.topicId
...config,
topicId: config.topicId
}
return await this._completionsForTrace(model, params, traceConfig)
} else {
return await this._completionsOrImageGeneration(model, params, providerConfig)
return await this._completionsOrImageGeneration(model, params, config)
}
}

View File

@@ -1,4 +1,5 @@
import type { Provider } from '@renderer/types'
import { isOpenAIProvider } from '@renderer/utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
@@ -201,4 +202,36 @@ describe('ApiClientFactory', () => {
expect(client).toBeDefined()
})
})
describe('isOpenAIProvider', () => {
it('should return true for openai type', () => {
const provider = createTestProvider('openai', 'openai')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return true for azure-openai type', () => {
const provider = createTestProvider('azure-openai', 'azure-openai')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return true for unknown type (fallback to OpenAI)', () => {
const provider = createTestProvider('unknown', 'unknown')
expect(isOpenAIProvider(provider)).toBe(true)
})
it('should return false for vertexai type', () => {
const provider = createTestProvider('vertex', 'vertexai')
expect(isOpenAIProvider(provider)).toBe(false)
})
it('should return false for anthropic type', () => {
const provider = createTestProvider('anthropic', 'anthropic')
expect(isOpenAIProvider(provider)).toBe(false)
})
it('should return false for gemini type', () => {
const provider = createTestProvider('gemini', 'gemini')
expect(isOpenAIProvider(provider)).toBe(false)
})
})
})

View File

@@ -1,5 +1,5 @@
import type { AiPlugin } from '@cherrystudio/ai-core'
import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import type { Assistant } from '@renderer/types'
@@ -68,9 +68,9 @@ export async function buildPlugins(
)
}
// if (middlewareConfig.enableUrlContext && middlewareConfig.) {
// plugins.push(googleToolsPlugin({ urlContext: true }))
// }
if (middlewareConfig.enableUrlContext) {
plugins.push(googleToolsPlugin({ urlContext: true }))
}
logger.debug(
'Final plugin list:',

View File

@@ -114,7 +114,7 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model):
}
/**
* 处理OpenAI兼容大文件上传
* 处理OpenAI大文件上传
*/
export async function handleOpenAILargeFileUpload(
file: FileMetadata,

View File

@@ -3,8 +3,6 @@
* 构建AI SDK的流式和非流式参数
*/
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
import { vertex } from '@ai-sdk/google-vertex/edge'
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
@@ -99,6 +97,10 @@ export async function buildStreamTextParams(
let tools = setupToolsConfig(mcpTools)
// if (webSearchProviderId) {
// tools['builtin_web_search'] = webSearchTool(webSearchProviderId)
// }
// 构建真正的 providerOptions
const webSearchConfig: CherryWebSearchConfig = {
maxResults: store.getState().websearch.maxResults,
@@ -141,34 +143,12 @@ export async function buildStreamTextParams(
}
}
if (enableUrlContext) {
// google-vertex
if (enableUrlContext && aiSdkProviderId === 'google-vertex') {
if (!tools) {
tools = {}
}
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
switch (aiSdkProviderId) {
case 'google-vertex':
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
break
case 'google':
tools.url_context = google.tools.urlContext({}) as ProviderDefinedTool
break
case 'anthropic':
case 'google-vertex-anthropic':
tools.web_fetch = (
aiSdkProviderId === 'anthropic'
? anthropic.tools.webFetch_20250910({
maxUses: webSearchConfig.maxResults,
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
})
: vertexAnthropic.tools.webFetch_20250910({
maxUses: webSearchConfig.maxResults,
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
})
) as ProviderDefinedTool
break
}
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
}
// 构建基础参数

View File

@@ -32,8 +32,7 @@ const AIHUBMIX_RULES: RuleSet = {
match: (model) =>
(startsWith('gemini')(model) || startsWith('imagen')(model)) &&
!model.id.endsWith('-nothink') &&
!model.id.endsWith('-search') &&
!model.id.includes('embedding'),
!model.id.endsWith('-search'),
provider: (provider: Provider) => {
return extraProviderConfig({
...provider,

View File

@@ -7,27 +7,24 @@ import {
} from '@cherrystudio/ai-core/provider'
import { cacheService } from '@data/CacheService'
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
import {
isAnthropicProvider,
isAzureOpenAIProvider,
isGeminiProvider,
isNewApiProvider
} from '@renderer/config/providers'
import { isNewApiProvider } from '@renderer/config/providers'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI'
import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI'
import { getProviderByModel } from '@renderer/services/AssistantService'
import { loggerService } from '@renderer/services/LoggerService'
import store from '@renderer/store'
import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api'
import { cloneDeep } from 'lodash'
import { isSystemProvider, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { cloneDeep, trim } from 'lodash'
import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config'
import { COPILOT_DEFAULT_HEADERS } from './constants'
import { getAiSdkProviderId } from './factory'
const logger = loggerService.withContext('ProviderConfigProcessor')
/**
* 获取轮询的API key
@@ -59,6 +56,13 @@ function getRotatedApiKey(provider: Provider): string {
* 处理特殊provider的转换逻辑
*/
function handleSpecialProviders(model: Model, provider: Provider): Provider {
// if (provider.type === 'vertexai' && !isVertexProvider(provider)) {
// if (!isVertexAIConfigured()) {
// throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.')
// }
// return createVertexProvider(provider)
// }
if (isNewApiProvider(provider)) {
return newApiResolverCreator(model, provider)
}
@@ -75,30 +79,43 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
}
/**
* 主要用来对齐AISdk的BaseURL格式
* @param provider
* @returns
* 格式化provider的API Host
*/
function formatAnthropicApiHost(host: string): string {
const trimmedHost = host?.trim()
if (!trimmedHost) {
return ''
}
if (trimmedHost.endsWith('/')) {
return trimmedHost
}
if (trimmedHost.endsWith('/v1')) {
return `${trimmedHost}/`
}
return formatApiHost(trimmedHost)
}
function formatProviderApiHost(provider: Provider): Provider {
const formatted = { ...provider }
if (formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost)
}
if (isAnthropicProvider(provider)) {
if (formatted.type === 'anthropic') {
const baseHost = formatted.anthropicApiHost || formatted.apiHost
formatted.apiHost = formatApiHost(baseHost)
formatted.apiHost = formatAnthropicApiHost(baseHost)
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === SystemProviderIds.copilot || formatted.id === SystemProviderIds.github) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else if (isGeminiProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, true, 'v1beta')
} else if (isAzureOpenAIProvider(formatted)) {
formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost)
} else if (isVertexProvider(formatted)) {
formatted.apiHost = formatVertexApiHost(formatted)
} else if (formatted.id === 'copilot') {
const trimmed = trim(formatted.apiHost)
formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
formatted.apiHost = formatApiHost(formatted.apiHost)
}
@@ -132,15 +149,15 @@ export function providerToAiSdkConfig(
options: ProviderSettingsMap[keyof ProviderSettingsMap]
} {
const aiSdkProviderId = getAiSdkProviderId(actualProvider)
logger.debug('providerToAiSdkConfig', { aiSdkProviderId })
// 构建基础配置
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
const baseConfig = {
baseURL: baseURL,
baseURL: trim(actualProvider.apiHost),
apiKey: getRotatedApiKey(actualProvider)
}
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
const isCopilotProvider = actualProvider.id === 'copilot'
if (isCopilotProvider) {
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
@@ -161,7 +178,6 @@ export function providerToAiSdkConfig(
// 处理OpenAI模式
const extraOptions: any = {}
extraOptions.endpoint = endpoint
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
extraOptions.mode = 'responses'
} else if (aiSdkProviderId === 'openai') {
@@ -183,11 +199,13 @@ export function providerToAiSdkConfig(
}
// azure
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1不使用azure endpoint
extraOptions.apiVersion = actualProvider.apiVersion
baseConfig.baseURL += '/openai'
if (actualProvider.apiVersion === 'preview') {
extraOptions.mode = 'responses'
} else {
extraOptions.mode = 'chat'
extraOptions.useDeploymentBasedUrls = true
}
}
@@ -209,7 +227,22 @@ export function providerToAiSdkConfig(
...googleCredentials,
privateKey: formatPrivateKey(googleCredentials.privateKey)
}
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
// extraOptions.headers = window.api.vertexAI.getAuthHeaders({
// projectId: project,
// serviceAccount: {
// privateKey: googleCredentials.privateKey,
// clientEmail: googleCredentials.clientEmail
// }
// })
if (baseConfig.baseURL.endsWith('/v1/')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -4)
} else if (baseConfig.baseURL.endsWith('/v1')) {
baseConfig.baseURL = baseConfig.baseURL.slice(0, -3)
}
if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) {
baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google`
}
}
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {

View File

@@ -5,16 +5,6 @@ import { describe, expect, it, vi } from 'vitest'
import { DraggableList } from '../'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
return {

View File

@@ -3,16 +3,6 @@ import { describe, expect, it, vi } from 'vitest'
import { DraggableVirtualList } from '../'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// Mock 依赖项
vi.mock('@hello-pangea/dnd', () => ({
__esModule: true,

View File

@@ -16,8 +16,8 @@ import {
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { permissionModeCards } from '@renderer/config/agent'
import { agentModelFilter, getModelLogoById } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'

View File

@@ -1,4 +1,4 @@
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
import { makeSvgSizeAdaptive } from '@renderer/utils'
import DOMPurify from 'dompurify'
/**

View File

@@ -18,7 +18,6 @@ interface BaseSelectorProps<V = string | number> {
options: SelectorOption<V>[]
placeholder?: string
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
style?: React.CSSProperties
/** 字体大小 */
size?: number
/** 是否禁用 */
@@ -46,7 +45,6 @@ const Selector = <V extends string | number>({
placement = 'bottomRight',
size = 13,
placeholder,
style,
disabled = false,
multiple = false
}: SelectorProps<V>) => {
@@ -139,7 +137,7 @@ const Selector = <V extends string | number>({
placement={placement}
open={open && !disabled}
onOpenChange={handleOpenChange}>
<Label style={style} $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
{label}
<LabelIcon size={size + 3} />
</Label>

View File

@@ -3,7 +3,7 @@ import { Box } from '@cherrystudio/ui'
import { getToastUtilities } from '@cherrystudio/ui'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { Modal } from 'antd'
import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
@@ -35,9 +35,8 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
elementsRef.current = elements
const [modal, modalContextHolder] = Modal.useModal()
const shortcuts = useAllShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.definition.key === 'shortcut.app.exit_fullscreen')
?.preference.enabled
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
useAppInit()

View File

@@ -23,16 +23,6 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// Mock antd components to prevent flaky snapshot tests
vi.mock('antd', () => {
const MockSpaceCompact: React.FC<React.PropsWithChildren<{ style?: React.CSSProperties }>> = ({

View File

@@ -18,15 +18,6 @@ describe('Qwen Model Detection', () => {
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' })
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
})
test('isQwenReasoningModel', () => {
expect(isQwenReasoningModel({ id: 'qwen3-thinking' } as Model)).toBe(true)

View File

@@ -2,16 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isLingReasoningModel } from '../models/reasoning'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
vi.mock('@renderer/services/AssistantService.ts', () => ({
getDefaultAssistant: () => {

View File

@@ -1,6 +1,5 @@
import ClaudeAvatar from '@renderer/assets/images/models/claude.png'
import type { AgentBase, AgentType } from '@renderer/types'
import type { PermissionModeCard } from '@renderer/types/agent'
// base agent config. no default config for now.
const DEFAULT_AGENT_CONFIG: Omit<AgentBase, 'model'> = {
@@ -20,47 +19,3 @@ export const getAgentTypeAvatar = (type: AgentType): string => {
return ''
}
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
// t('agent.settings.tooling.permissionMode.default.title')
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
// t('agent.settings.tooling.permissionMode.plan.title')
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
// t('agent.settings.tooling.permissionMode.acceptEdits.title')
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
// t('agent.settings.tooling.permissionMode.bypassPermissions.title')
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]

View File

@@ -1741,7 +1741,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
id: 'DeepSeek-R1',
provider: 'cephalon',
name: 'DeepSeek-R1满血版',
capabilities: [{ type: 'reasoning' }],
group: 'DeepSeek'
}
],

View File

@@ -1,9 +1,7 @@
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { Model } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider } from '../providers'
import { isEmbeddingModel, isRerankModel } from './embedding'
import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
@@ -67,16 +65,12 @@ export function isWebSearchModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id, '/')
// bedrock和vertex不支持
if (
isAnthropicModel(model) &&
(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
) {
// 不管哪个供应商都判断了
if (isAnthropicModel(model)) {
return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId)
}
// TODO: 当其他供应商采用Response端点时这个地方逻辑需要改进
if (isOpenAIProvider(provider)) {
if (provider.type === 'openai-response') {
if (isOpenAIWebSearchModel(model)) {
return true
}
@@ -84,11 +78,11 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (provider.id === SystemProviderIds.perplexity) {
if (provider.id === 'perplexity') {
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
}
if (provider.id === SystemProviderIds.aihubmix) {
if (provider.id === 'aihubmix') {
// modelId 不以-search结尾
if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) {
return true
@@ -101,13 +95,13 @@ export function isWebSearchModel(model: Model): boolean {
return false
}
if (isOpenAICompatibleProvider(provider) || isNewApiProvider(provider)) {
if (provider?.type === 'openai') {
if (GEMINI_SEARCH_REGEX.test(modelId) || isOpenAIWebSearchModel(model)) {
return true
}
}
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
if (provider.id === 'gemini' || provider?.type === 'gemini' || provider.type === 'vertexai') {
return GEMINI_SEARCH_REGEX.test(modelId)
}

View File

@@ -56,14 +56,7 @@ import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import type {
AtLeast,
AzureOpenAIProvider,
Provider,
ProviderType,
SystemProvider,
SystemProviderId
} from '@renderer/types'
import type { AtLeast, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types'
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
import { TOKENFLUX_HOST } from './constant'
@@ -355,7 +348,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
name: 'VertexAI',
type: 'vertexai',
apiKey: '',
apiHost: '',
apiHost: 'https://aiplatform.googleapis.com',
models: SYSTEM_MODELS.vertexai,
isSystem: true,
enabled: false,
@@ -1295,7 +1288,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
},
vertexai: {
api: {
url: ''
url: 'https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/overview'
},
websites: {
official: 'https://cloud.google.com/vertex-ai',
@@ -1375,8 +1368,7 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
'baichuan',
'minimax',
'xirang',
'poe',
'cephalon'
'poe'
] as const satisfies SystemProviderId[]
/**
@@ -1441,15 +1433,10 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
)
}
const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
'gemini',
'vertexai',
'anthropic',
'new-api'
] as const satisfies ProviderType[]
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
export const isSupportUrlContextProvider = (provider: Provider) => {
return SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
}
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
@@ -1462,37 +1449,3 @@ export const isGeminiWebSearchProvider = (provider: Provider) => {
export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}
/**
* 判断是否为 OpenAI 兼容的提供商
* @param {Provider} provider 提供商对象
* @returns {boolean} 是否为 OpenAI 兼容提供商
*/
export function isOpenAICompatibleProvider(provider: Provider): boolean {
return ['openai', 'new-api', 'mistral'].includes(provider.type)
}
export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider {
return provider.type === 'azure-openai'
}
export function isOpenAIProvider(provider: Provider): boolean {
return provider.type === 'openai-response'
}
export function isAnthropicProvider(provider: Provider): boolean {
return provider.type === 'anthropic'
}
export function isGeminiProvider(provider: Provider): boolean {
return provider.type === 'gemini'
}
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
export const isSupportAPIVersionProvider = (provider: Provider) => {
if (isSystemProvider(provider)) {
return !NOT_SUPPORT_API_VERSION_PROVIDERS.some((pid) => pid === provider.id)
}
return provider.apiOptions?.isNotSupportAPIVersion !== false
}

View File

@@ -0,0 +1,53 @@
import type { PermissionMode } from '@renderer/types'
export type PermissionModeCard = {
mode: PermissionMode
titleKey: string
titleFallback: string
descriptionKey: string
descriptionFallback: string
behaviorKey: string
behaviorFallback: string
caution?: boolean
unsupported?: boolean
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]

View File

@@ -1,23 +1,28 @@
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShortcutConfig } from '@renderer/hooks/useShortcuts'
import { IpcChannel } from '@shared/IpcChannel'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLocation, useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
const showSettingsShortcut = useShortcutConfig('show_settings')
const showSettingsShortcutEnabled = showSettingsShortcut?.enabled ?? false
useShortcut(
'shortcut.app.show_settings',
() => {
useHotkeys(
'meta+, ! ctrl+,',
function () {
if (location.pathname.startsWith('/settings')) {
return
}
navigate('/settings/provider')
},
{
splitKey: '!',
enableOnContentEditable: true,
enableOnFormTags: true,
enableOnContentEditable: true
enabled: showSettingsShortcutEnabled
}
)

View File

@@ -1,75 +1,146 @@
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import { isMac } from '@renderer/config/constant'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
import type { ShortcutDefinition, ShortcutPreferenceKey, ShortcutPreferenceValue } from '@shared/shortcuts/types'
import {
coerceShortcutPreference,
convertAcceleratorToHotkey,
formatShortcutDisplay,
getDefaultShortcutPreference
} from '@shared/shortcuts/utils'
import { useCallback, useMemo } from 'react'
import { preferenceService } from '@data/PreferenceService'
import { isMac, isWin } from '@renderer/config/constant'
import { shortcutRendererStore } from '@renderer/services/ShortcutService'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
import type { HydratedShortcut, ShortcutPreferenceEntry, ShortcutPreferenceMap } from '@shared/shortcuts/types'
import { orderBy } from 'lodash'
import { useMemo, useSyncExternalStore } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
interface UseShortcutOptions {
export interface UseShortcutOptions {
preventDefault?: boolean
enableOnFormTags?: boolean
enabled?: boolean
description?: string
enableOnContentEditable?: boolean
}
const defaultOptions: UseShortcutOptions = {
preventDefault: true,
enableOnFormTags: true,
enabled: true,
enableOnContentEditable: false
enabled: true
}
const resolvePreferenceValue = (
definition: ShortcutDefinition | undefined,
preference: PreferenceShortcutType | Record<string, unknown> | undefined
): ShortcutPreferenceValue | null => {
if (!definition) {
return null
}
return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined)
const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
const toHotkeysFormat = (keys: string[]): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'commandorcontrol':
case 'command':
case 'cmd':
return 'mod'
case 'control':
case 'ctrl':
return 'ctrl'
case 'alt':
case 'altgraph':
return 'alt'
case 'shift':
return 'shift'
case 'meta':
return 'meta'
case 'arrowup':
return 'up'
case 'arrowdown':
return 'down'
case 'arrowleft':
return 'left'
case 'arrowright':
return 'right'
case 'escape':
return 'escape'
case 'space':
return 'space'
default:
return key.toLowerCase()
}
})
.join('+')
}
const toDisplayFormat = (keys: string[]): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'control':
case 'ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'command':
case 'cmd':
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
case 'meta':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt':
case 'altgraph':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'arrowup':
return '↑'
case 'arrowdown':
return '↓'
case 'arrowleft':
return '←'
case 'arrowright':
return '→'
case 'slash':
return '/'
case 'semicolon':
return ';'
case 'bracketleft':
return '['
case 'bracketright':
return ']'
case 'backslash':
return '\\'
case 'quote':
return "'"
case 'comma':
return ','
case 'minus':
return '-'
case 'equal':
return '='
case 'escape':
return isMac ? '⎋' : 'Esc'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join(isMac ? '' : ' + ')
}
const useShortcutMap = () =>
useSyncExternalStore(
shortcutRendererStore.subscribe,
shortcutRendererStore.getSnapshot,
shortcutRendererStore.getServerSnapshot
)
export const useShortcut = (
shortcutKey: ShortcutPreferenceKey,
name: string,
callback: (event: KeyboardEvent) => void,
options: UseShortcutOptions = defaultOptions
) => {
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
const [preference] = usePreference(shortcutKey)
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
const shortcuts = useShortcutMap()
const shortcutConfig = shortcuts[name]
const hotkey = useMemo(() => {
if (!definition || !preferenceState) {
return 'none'
if (
!shortcutConfig ||
shortcutConfig.scope !== 'renderer' ||
!shortcutConfig.enabled ||
shortcutConfig.key.length === 0
) {
return null
}
if (definition.scope === 'main') {
return 'none'
}
if (!preferenceState.enabled) {
return 'none'
}
const effectiveBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
if (!effectiveBinding.length) {
return 'none'
}
return convertAcceleratorToHotkey(effectiveBinding)
}, [definition, preferenceState])
return toHotkeysFormat(shortcutConfig.key)
}, [shortcutConfig])
useHotkeys(
hotkey,
hotkey ?? 'none',
(event) => {
if (options.preventDefault) {
event.preventDefault()
@@ -80,112 +151,101 @@ export const useShortcut = (
},
{
enableOnFormTags: options.enableOnFormTags,
description: options.description ?? shortcutKey,
enabled: hotkey !== 'none',
enableOnContentEditable: options.enableOnContentEditable
description: options.description ?? shortcutConfig?.description,
enabled: Boolean(hotkey && shortcutConfig?.enabled)
},
[hotkey, callback, options]
[
callback,
hotkey,
shortcutConfig,
options.preventDefault,
options.enableOnFormTags,
options.enabled,
options.description
]
)
}
export const useShortcutDisplay = (shortcutKey: ShortcutPreferenceKey): string => {
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
const [preference] = usePreference(shortcutKey)
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
export function useShortcuts() {
const shortcuts = useShortcutMap()
const list = useMemo(() => {
return orderBy(
Object.values(shortcuts).map((shortcut) => ({
...shortcut,
key: [...shortcut.key]
})),
['system', 'name'],
['desc', 'asc']
)
}, [shortcuts])
if (!definition || !preferenceState || !preferenceState.enabled) {
return ''
return { shortcuts: list }
}
export function useShortcutConfig(name: string): HydratedShortcut | undefined {
const shortcuts = useShortcutMap()
return shortcuts[name]
}
export function useShortcutDisplay(name: string) {
const shortcut = useShortcutConfig(name)
return useMemo(() => {
if (!shortcut || !shortcut.enabled || shortcut.key.length === 0) {
return ''
}
return toDisplayFormat(shortcut.key)
}, [shortcut])
}
async function writeShortcutPreferences(updater: (current: ShortcutPreferenceMap) => ShortcutPreferenceMap) {
const current = await preferenceService.get('shortcut.preferences')
const next = updater({ ...current })
await preferenceService.set('shortcut.preferences', next)
}
export async function setShortcutBinding(name: string, keys: string[]) {
await writeShortcutPreferences((current) => {
const entry: ShortcutPreferenceEntry = { ...current[name] }
entry.key = [...keys]
current[name] = entry
return current
})
}
export async function setShortcutEnabled(name: string, enabled: boolean) {
await writeShortcutPreferences((current) => {
const entry: ShortcutPreferenceEntry = { ...current[name] }
entry.enabled = enabled
current[name] = entry
return current
})
}
export async function resetShortcut(name: string) {
const definition = definitionMap.get(name)
if (!definition) {
return
}
const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
if (!displayBinding.length) {
return ''
}
return formatShortcutDisplay(displayBinding, isMac)
await writeShortcutPreferences((current) => {
current[name] = {
key: [...definition.defaultKey],
enabled: definition.defaultEnabled
}
return current
})
}
export interface ShortcutListItem {
definition: ShortcutDefinition
preference: ShortcutPreferenceValue
defaultPreference: ShortcutPreferenceValue
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
}
export const useAllShortcuts = (): ShortcutListItem[] => {
const keyMap = useMemo(
() =>
SHORTCUT_DEFINITIONS.reduce<Record<string, ShortcutPreferenceKey>>((acc, definition) => {
acc[definition.key] = definition.key
return acc
}, {}),
[]
)
const [values, setValues] = useMultiplePreferences(keyMap)
const buildNextPreference = useCallback(
(
state: ShortcutPreferenceValue,
currentValue: PreferenceShortcutType | undefined,
patch: Partial<PreferenceShortcutType>
): PreferenceShortcutType => {
const current = (currentValue ?? {}) as PreferenceShortcutType
const nextKey = Array.isArray(patch.key) ? patch.key : Array.isArray(current.key) ? current.key : state.rawBinding
const nextEnabled =
typeof patch.enabled === 'boolean'
? patch.enabled
: typeof current.enabled === 'boolean'
? current.enabled
: state.enabled
const nextEditable =
typeof patch.editable === 'boolean'
? patch.editable
: typeof current.editable === 'boolean'
? current.editable
: state.editable
const nextSystem =
typeof patch.system === 'boolean'
? patch.system
: typeof current.system === 'boolean'
? current.system
: state.system
return {
key: nextKey,
enabled: nextEnabled,
editable: nextEditable,
system: nextSystem
}
},
[]
)
return useMemo(
() =>
SHORTCUT_DEFINITIONS.map((definition) => {
const rawValue = values[definition.key] as PreferenceShortcutType | undefined
const preference = coerceShortcutPreference(definition, rawValue)
const defaultPreference = getDefaultShortcutPreference(definition)
const updatePreference = async (patch: Partial<PreferenceShortcutType>) => {
const currentValue = values[definition.key] as PreferenceShortcutType | undefined
const nextValue = buildNextPreference(preference, currentValue, patch)
await setValues({ [definition.key]: nextValue } as Partial<Record<string, PreferenceShortcutType>>)
export async function resetAllShortcuts() {
await writeShortcutPreferences(() => {
return Object.fromEntries(
shortcutDefinitions.map((definition) => [
definition.name,
{
key: [...definition.defaultKey],
enabled: definition.defaultEnabled
}
return {
definition,
preference,
defaultPreference,
updatePreference
}
}),
[buildNextPreference, setValues, values]
)
])
)
})
}

View File

@@ -42,7 +42,7 @@ export function getVertexAIServiceAccount() {
* 类型守卫:检查 Provider 是否为 VertexProvider
*/
export function isVertexProvider(provider: Provider): provider is VertexProvider {
return provider.type === 'vertexai'
return provider.type === 'vertexai' && 'googleCredentials' in provider
}
/**

View File

@@ -200,6 +200,7 @@ const shortcutKeyMap = {
exit_fullscreen: 'settings.shortcuts.exit_fullscreen',
label: 'settings.shortcuts.label',
mini_window: 'settings.shortcuts.mini_window',
show_mini_window: 'settings.shortcuts.show_mini_window',
new_topic: 'settings.shortcuts.new_topic',
press_shortcut: 'settings.shortcuts.press_shortcut',
reset_defaults: 'settings.shortcuts.reset_defaults',

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Anthropic API Host",
"anthropic_api_host_preview": "Anthropic preview: {{url}}",
"anthropic_api_host_tip": "Only configure this when your provider exposes an Anthropic-compatible endpoint. Ending with / ignores v1, ending with # forces use of input address.",
"anthropic_api_host_tooltip": "Use only when the provider offers a Claude-compatible base URL.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Preview: {{url}}",
"reset": "Reset",
"tip": "ending with # forces use of input address"
"tip": "Ending with / ignores v1, ending with # forces use of input address"
}
},
"api_host": "API Host",
"api_host_no_valid": "API address is invalid",
"api_host_preview": "Preview: {{url}}",
"api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Anthropic API 地址",
"anthropic_api_host_preview": "Anthropic 预览:{{url}}",
"anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1以 # 结尾则强制使用原始地址。",
"anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "预览: {{url}}",
"reset": "重置",
"tip": "# 结尾强制使用输入地址"
"tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址"
}
},
"api_host": "API 地址",
"api_host_no_valid": "API 地址不合法",
"api_host_preview": "预览:{{url}}",
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Anthropic API 主機地址",
"anthropic_api_host_preview": "Anthropic 預覽:{{url}}",
"anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1以 # 結尾則強制使用原始地址。",
"anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "預覽:{{url}}",
"reset": "重設",
"tip": "# 結尾強制使用輸入位址"
"tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址"
}
},
"api_host": "API 主機地址",
"api_host_no_valid": "API 位址不合法",
"api_host_preview": "預覽:{{url}}",
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Anthropic API-Adresse",
"anthropic_api_host_preview": "Anthropic-Vorschau: {{url}}",
"anthropic_api_host_tip": "Nur bei Anbietern, die ein Anthropic-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
"anthropic_api_host_tooltip": "Nur bei Anbietern, die ein Claude-kompatibles Basis-Endpunkt anbieten.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Vorschau: {{url}}",
"reset": "Zurücksetzen",
"tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse"
"tip": "/ am Ende ignorieren v1-Version, # am Ende erzwingt die Verwendung der Eingabe-Adresse"
}
},
"api_host": "API-Adresse",
"api_host_no_valid": "API-Adresse ist ungültig",
"api_host_preview": "Vorschau: {{url}}",
"api_host_tooltip": "Nur bei Anbietern, die ein OpenAI-kompatibles Endpunkt anbieten. Eine / am Ende ignoriert automatisch hinzugefügtes v1, ein # am Ende erzwingt die Verwendung der ursprünglichen Adresse.",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Διεύθυνση API Anthropic",
"anthropic_api_host_preview": "Προεπισκόπηση Anthropic: {{url}}",
"anthropic_api_host_tip": "Συμπληρώστε μόνο εάν ο πάροχος προσφέρει συμβατή με Anthropic διεύθυνση. Η λήξη με / αγνοεί το v1 που προστίθεται αυτόματα, η λήξη με # επιβάλλει τη χρήση της αρχικής διεύθυνσης.",
"anthropic_api_host_tooltip": "Συμπληρώστε μόνο όταν ο πάροχος παρέχει βασική διεύθυνση συμβατή με Claude.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Προεπισκόπηση: {{url}}",
"reset": "Επαναφορά",
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
"tip": "/τέλος αγνόηση v1 έκδοσης, #τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
}
},
"api_host": "Διεύθυνση API",
"api_host_no_valid": "Η διεύθυνση API δεν είναι έγκυρη",
"api_host_preview": "Προεπισκόπηση: {{url}}",
"api_host_tooltip": "Αντικατάσταση μόνο όταν ο πάροχος απαιτεί προσαρμοσμένη διεύθυνση συμβατή με OpenAI.",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Dirección API de Anthropic",
"anthropic_api_host_preview": "Vista previa de Anthropic: {{url}}",
"anthropic_api_host_tip": "Rellenar solo si el proveedor ofrece una dirección compatible con Anthropic. Terminar con / ignora el v1 añadido automáticamente, terminar con # fuerza el uso de la dirección original.",
"anthropic_api_host_tooltip": "Rellenar solo cuando el proveedor proporcione una dirección base compatible con Claude.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Vista previa: {{url}}",
"reset": "Restablecer",
"tip": "forzar uso de dirección de entrada con # al final"
"tip": "Ignorar v1 al final con /, forzar uso de dirección de entrada con # al final"
}
},
"api_host": "Dirección API",
"api_host_no_valid": "La dirección de la API no es válida",
"api_host_preview": "Vista previa: {{url}}",
"api_host_tooltip": "Sobrescribir solo cuando el proveedor necesite una dirección compatible con OpenAI personalizada.",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Adresse API Anthropic",
"anthropic_api_host_preview": "Aperçu Anthropic : {{url}}",
"anthropic_api_host_tip": "Remplir seulement si le fournisseur propose une adresse compatible Anthropic. Se terminant par / ignore le v1 ajouté automatiquement, se terminant par # force l'utilisation de l'adresse originale.",
"anthropic_api_host_tooltip": "Remplir seulement lorsque le fournisseur propose une adresse de base compatible Claude.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Aperçu : {{url}}",
"reset": "Réinitialiser",
"tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #"
"tip": "Ignorer la version v1 si terminé par /, forcer l'utilisation de l'adresse d'entrée si terminé par #"
}
},
"api_host": "Adresse API",
"api_host_no_valid": "Adresse API invalide",
"api_host_preview": "Aperçu : {{url}}",
"api_host_tooltip": "Remplacer seulement lorsque le fournisseur nécessite une adresse compatible OpenAI personnalisée.",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Anthropic APIアドレス",
"anthropic_api_host_preview": "Anthropic プレビュー:{{url}}",
"anthropic_api_host_tip": "サービスプロバイダーがAnthropic互換のアドレスを提供する場合のみ入力してください。/で終わる場合は自動追加されるv1を無視し、#で終わる場合は元のアドレスを強制的に使用します。",
"anthropic_api_host_tooltip": "サービスプロバイダーがClaude互換のベースアドレスを提供する場合のみ入力してください。",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "プレビュー: {{url}}",
"reset": "リセット",
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
"tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します"
}
},
"api_host": "APIホスト",
"api_host_no_valid": "APIアドレスが無効です",
"api_host_preview": "プレビュー:{{url}}",
"api_host_tooltip": "サービスプロバイダーがカスタムOpenAI互換アドレスを必要とする場合のみ上書きしてください。",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Endereço da API Anthropic",
"anthropic_api_host_preview": "Pré-visualização Anthropic: {{url}}",
"anthropic_api_host_tip": "Preencher apenas se o fornecedor oferecer um endereço compatível com Anthropic. Terminar com / ignora o v1 adicionado automaticamente, terminar com # força o uso do endereço original.",
"anthropic_api_host_tooltip": "Preencher apenas quando o fornecedor fornece um endereço base compatível com Claude.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Pré-visualização: {{url}}",
"reset": "Redefinir",
"tip": "e forçar o uso do endereço original quando terminar com '#'"
"tip": "Ignorar v1 na versão finalizada com /, usar endereço de entrada forçado se terminar com #"
}
},
"api_host": "Endereço API",
"api_host_no_valid": "O endereço da API é inválido",
"api_host_preview": "Pré-visualização: {{url}}",
"api_host_tooltip": "Substituir apenas quando o fornecedor necessita de um endereço compatível com OpenAI personalizado.",
"api_key": {

View File

@@ -4148,6 +4148,7 @@
},
"anthropic_api_host": "Адрес API Anthropic",
"anthropic_api_host_preview": "Предпросмотр Anthropic: {{url}}",
"anthropic_api_host_tip": "Заполняйте только если провайдер предоставляет совместимый с Anthropic адрес. Окончание на / игнорирует автоматически добавляемое v1, окончание на # принудительно использует оригинальный адрес.",
"anthropic_api_host_tooltip": "Заполняйте только когда провайдер предоставляет базовый адрес, совместимый с Claude.",
"api": {
"key": {
@@ -4192,11 +4193,10 @@
"url": {
"preview": "Предпросмотр: {{url}}",
"reset": "Сброс",
"tip": "заканчивая на # принудительно использует введенный адрес"
"tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес"
}
},
"api_host": "Хост API",
"api_host_no_valid": "Недопустимый адрес API",
"api_host_preview": "Предпросмотр: {{url}}",
"api_host_tooltip": "Переопределяйте только когда провайдер требует пользовательский адрес, совместимый с OpenAI.",
"api_key": {

View File

@@ -3,6 +3,7 @@ import { loggerService } from '@logger'
import { startAutoSync } from './services/BackupService'
import { startNutstoreAutoSync } from './services/NutstoreService'
import { initializeShortcutService } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
import { webTraceService } from './services/WebTraceService'
loggerService.initWindowSource('mainWindow')
@@ -36,3 +37,4 @@ function initWebTrace() {
initAutoSync()
initStoreSync()
initWebTrace()
initializeShortcutService()

View File

@@ -70,7 +70,7 @@ const Chat: FC<Props> = (props) => {
contentSearchRef.current?.disable()
})
useShortcut('shortcut.chat.search_message', () => {
useShortcut('search_message_in_chat', () => {
try {
const selectedText = window.getSelection()?.toString().trim()
contentSearchRef.current?.enable(selectedText)
@@ -79,7 +79,7 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut('shortcut.topic.rename', async () => {
useShortcut('rename_topic', async () => {
const topic = props.activeTopic
if (!topic) return
@@ -98,7 +98,7 @@ const Chat: FC<Props> = (props) => {
})
useShortcut(
'shortcut.topic.new',
'new_topic',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return

View File

@@ -37,9 +37,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('shortcut.topic.toggle_show_topics', () => {
useShortcut('toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
@@ -47,7 +47,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
}
})
useShortcut('shortcut.app.search_message', () => {
useShortcut('search_message', () => {
SearchPopup.show()
})

View File

@@ -2,6 +2,7 @@ import { Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSession } from '@renderer/hooks/agents/useSession'
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
@@ -46,9 +47,10 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { session } = useSession(agentId, sessionId)
const { agent } = useAgent(agentId)
const { apiServer } = useSettings()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const newTopicShortcut = useShortcutDisplay('new_topic')
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)
@@ -183,7 +185,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const userMessageBlocks: MessageBlock[] = [mainBlock]
// Extract the actual model ID from session.model (format: "provider:modelId")
const [providerId, actualModelId] = session?.model?.split(':') ?? [undefined, undefined]
const [providerId, actualModelId] = agent?.model.split(':') ?? [undefined, undefined]
// Try to find the actual model from providers
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
@@ -229,7 +231,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
logger.warn('Failed to send message:', error as Error)
}
}, [
session?.model,
agent?.model,
agentId,
dispatch,
sendDisabled,

View File

@@ -723,13 +723,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [onPaste])
useShortcut('shortcut.topic.new', () => {
useShortcut('new_topic', () => {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
focusTextarea()
})
useShortcut('shortcut.chat.clear', clearTopic)
useShortcut('clear_topic', clearTopic)
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })

View File

@@ -5,7 +5,6 @@ import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
import {
isAnthropicModel,
isGeminiModel,
isGenerateImageModel,
isMandatoryWebSearchModel,
@@ -194,8 +193,8 @@ const InputbarTools = ({
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
}, [assistant.enableGenerateImage, updateAssistant])
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const clearTopicShortcut = useShortcutDisplay('shortcut.chat.clear')
const newTopicShortcut = useShortcutDisplay('new_topic')
const clearTopicShortcut = useShortcutDisplay('clear_topic')
const toggleToolVisibility = useCallback(
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
@@ -392,7 +391,7 @@ const InputbarTools = ({
label: t('chat.input.url_context'),
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
condition:
(isGeminiModel(model) || isAnthropicModel(model)) &&
isGeminiModel(model) &&
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
},
{

View File

@@ -9,10 +9,10 @@ interface Props {
}
const NewContextButton: FC<Props> = ({ onNewContext }) => {
const newContextShortcut = useShortcutDisplay('shortcut.chat.toggle_new_context')
const newContextShortcut = useShortcutDisplay('toggle_new_context')
const { t } = useTranslation()
useShortcut('shortcut.chat.toggle_new_context', onNewContext)
useShortcut('toggle_new_context', onNewContext)
return (
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })} closeDelay={0}>

View File

@@ -268,7 +268,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
)
}, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer])
useShortcut('shortcut.chat.copy_last_message', () => {
useShortcut('copy_last_message', () => {
const lastMessage = last(messages)
if (lastMessage) {
navigator.clipboard.writeText(getMainTextContent(lastMessage))
@@ -276,7 +276,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
}
})
useShortcut('shortcut.chat.edit_last_user_message', () => {
useShortcut('edit_last_user_message', () => {
const lastUserMessage = messagesRef.current.findLast((m) => m.role === 'user' && m.type !== 'clear')
if (lastUserMessage) {
EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, lastUserMessage.id)

View File

@@ -38,9 +38,9 @@ const HeaderNavbar: FC<Props> = ({
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { showTopics, toggleShowTopics } = useShowTopics()
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('shortcut.topic.toggle_show_topics', () => {
useShortcut('toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
@@ -48,7 +48,7 @@ const HeaderNavbar: FC<Props> = ({
}
})
useShortcut('shortcut.app.search_message', () => {
useShortcut('search_message', () => {
SearchPopup.show()
})

View File

@@ -93,7 +93,7 @@ const KnowledgePage: FC = () => {
[deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t]
)
useShortcut('shortcut.app.search_message', () => {
useShortcut('search_message', () => {
if (selectedBase) {
KnowledgeSearchPopup.show({ base: selectedBase }).then()
}

View File

@@ -1,5 +1,6 @@
import { Alert, Card, CardBody, CardHeader, Chip, Input, Switch } from '@heroui/react'
import { permissionModeCards } from '@renderer/config/agent'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgentClient } from '@renderer/hooks/agents/useAgentClient'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import type {
@@ -16,8 +17,9 @@ import { AgentConfigurationSchema } from '@renderer/types'
import { Modal } from 'antd'
import { ShieldAlert, ShieldCheck, Wrench } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
@@ -35,13 +37,6 @@ type AgentConfigurationState = AgentConfiguration & Record<string, unknown>
const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.parse({})
/**
* Computes the list of tool IDs that should be automatically approved for a given permission mode.
*
* @param mode - The permission mode to compute defaults for.
* @param tools - The full list of available tools.
* @returns An array of tool IDs that are approved by default for the specified mode.
*/
const computeModeDefaults = (mode: PermissionMode, tools: Tool[]): string[] => {
const defaultToolIds = tools.filter((tool) => !tool.requirePermissions).map((tool) => tool.id)
switch (mode) {
@@ -71,34 +66,52 @@ const unique = (values: string[]) => Array.from(new Set(values))
export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, update }) => {
const { containerRef, handleScroll } = useScrollPosition('AgentToolingSettings', 100)
const { t } = useTranslation()
const client = useAgentClient()
const { mcpServers: allServers } = useMCPServers()
const [modal, contextHolder] = Modal.useModal()
const configuration: AgentConfigurationState = useMemo(
() => agentBase?.configuration ?? defaultConfiguration,
[agentBase?.configuration]
)
const selectedMode = useMemo(
() => agentBase?.configuration?.permission_mode ?? defaultConfiguration.permission_mode,
[agentBase?.configuration?.permission_mode]
)
const availableTools = useMemo(() => agentBase?.tools ?? [], [agentBase?.tools])
const autoToolIds = useMemo(() => computeModeDefaults(selectedMode, availableTools), [availableTools, selectedMode])
const approvedToolIds = useMemo(() => {
const allowed = agentBase?.allowed_tools ?? []
const sanitized = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
// Ensure defaults are included even if backend omitted them
const merged = unique([...sanitized, ...autoToolIds])
return merged
}, [agentBase?.allowed_tools, autoToolIds, availableTools])
const selectedMcpIds = useMemo(() => agentBase?.mcps ?? [], [agentBase?.mcps])
const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
const [selectedMode, setSelectedMode] = useState<PermissionMode>(defaultConfiguration.permission_mode)
const [autoToolIds, setAutoToolIds] = useState<string[]>([])
const [approvedToolIds, setApprovedToolIds] = useState<string[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [isUpdatingMode, setIsUpdatingMode] = useState(false)
const [isUpdatingTools, setIsUpdatingTools] = useState(false)
const [selectedMcpIds, setSelectedMcpIds] = useState<string[]>([])
const [isUpdatingMcp, setIsUpdatingMcp] = useState(false)
const availableTools = useMemo(() => agentBase?.tools ?? [], [agentBase?.tools])
const availableServers = useMemo(() => allServers ?? [], [allServers])
useEffect(() => {
if (!agentBase) {
setConfiguration(defaultConfiguration)
setSelectedMode(defaultConfiguration.permission_mode)
setApprovedToolIds([])
setAutoToolIds([])
setSelectedMcpIds([])
return
}
const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {})
setConfiguration(parsed)
setSelectedMode(parsed.permission_mode)
const defaults = computeModeDefaults(parsed.permission_mode, availableTools)
setAutoToolIds(defaults)
const allowed = agentBase.allowed_tools ?? []
setApprovedToolIds((prev) => {
const sanitized = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
const isSame = sanitized.length === prev.length && sanitized.every((id) => prev.includes(id))
if (isSame) {
return prev
}
// Ensure defaults are included even if backend omitted them
const merged = unique([...sanitized, ...defaults])
return merged
})
setSelectedMcpIds(agentBase.mcps ?? [])
}, [agentBase, availableTools])
const filteredTools = useMemo(() => {
if (!searchTerm.trim()) {
return availableTools
@@ -134,6 +147,10 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
configuration: nextConfiguration,
allowed_tools: merged
} satisfies UpdateAgentBaseForm)
setConfiguration(nextConfiguration)
setSelectedMode(nextMode)
setAutoToolIds(defaults)
setApprovedToolIds(merged)
} finally {
setIsUpdatingMode(false)
}
@@ -195,25 +212,33 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
)
const handleToggleTool = useCallback(
async (toolId: string, isApproved: boolean) => {
(toolId: string, isApproved: boolean) => {
if (!agentBase || isUpdatingTools) {
return
}
const exists = approvedToolIds.includes(toolId)
if (isApproved === exists) {
return
}
setIsUpdatingTools(true)
const next = isApproved ? [...approvedToolIds, toolId] : approvedToolIds.filter((id) => id !== toolId)
const sanitized = unique(next.filter((id) => availableTools.some((tool) => tool.id === id)).concat(autoToolIds))
try {
await update({ id: agentBase.id, allowed_tools: sanitized } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingTools(false)
}
startTransition(() => {
setApprovedToolIds((prev) => {
const exists = prev.includes(toolId)
if (isApproved === exists) {
return prev
}
const next = isApproved ? [...prev, toolId] : prev.filter((id) => id !== toolId)
const sanitized = unique(
next.filter((id) => availableTools.some((tool) => tool.id === id)).concat(autoToolIds)
)
setIsUpdatingTools(true)
void (async () => {
try {
await update({ id: agentBase.id, allowed_tools: sanitized } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingTools(false)
}
})()
return sanitized
})
})
},
[agentBase, isUpdatingTools, approvedToolIds, autoToolIds, availableTools, update]
[agentBase, isUpdatingTools, availableTools, autoToolIds, update]
)
const { agentSummary, autoCount, customCount } = useMemo(() => {
@@ -233,24 +258,31 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
}, [selectedMode, autoToolIds, userAddedIds, availableTools.length, selectedMcpIds.length])
const handleToggleMcp = useCallback(
async (serverId: string, enabled: boolean) => {
(serverId: string, enabled: boolean) => {
if (!agentBase || isUpdatingMcp) {
return
}
const exists = selectedMcpIds.includes(serverId)
if (enabled === exists) {
return
}
const next = enabled ? [...selectedMcpIds, serverId] : selectedMcpIds.filter((id) => id !== serverId)
setIsUpdatingMcp(true)
try {
await update({ id: agentBase.id, mcps: next } satisfies UpdateAgentBaseForm)
} finally {
setIsUpdatingMcp(false)
}
setSelectedMcpIds((prev) => {
const exists = prev.includes(serverId)
if (enabled === exists) {
return prev
}
const next = enabled ? [...prev, serverId] : prev.filter((id) => id !== serverId)
setIsUpdatingMcp(true)
void (async () => {
try {
await update({ id: agentBase.id, mcps: next } satisfies UpdateAgentBaseForm)
const refreshed = await client.getAgent(agentBase.id)
const key = client.agentPaths.withId(agentBase.id)
mutate(key, refreshed, false)
} finally {
setIsUpdatingMcp(false)
}
})()
return next
})
},
[agentBase, isUpdatingMcp, selectedMcpIds, update]
[agentBase, isUpdatingMcp, client, update]
)
if (!agentBase) {

View File

@@ -2,22 +2,11 @@ import { Button, Flex, RowFlex, Switch, Tooltip, WarnTooltip } from '@cherrystud
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { LoadingIcon } from '@renderer/components/Icons'
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import Selector from '@renderer/components/Selector'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import {
isAnthropicProvider,
isAzureOpenAIProvider,
isGeminiProvider,
isNewApiProvider,
isOpenAICompatibleProvider,
isOpenAIProvider,
isSupportAPIVersionProvider,
PROVIDER_URLS
} from '@renderer/config/providers'
import { PROVIDER_URLS } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
import { useTimer } from '@renderer/hooks/useTimer'
import { isVertexProvider } from '@renderer/hooks/useVertexAI'
import i18n from '@renderer/i18n'
import AnthropicSettings from '@renderer/pages/settings/ProviderSettings/AnthropicSettings'
import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList'
@@ -25,17 +14,15 @@ import { checkApi } from '@renderer/services/ApiService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { useAppDispatch } from '@renderer/store'
import { updateWebSearchProvider } from '@renderer/store/websearch'
import type { SystemProviderId } from '@renderer/types'
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
import { HealthStatus } from '@renderer/types/healthCheck'
import {
formatApiHost,
formatApiKeys,
formatAzureOpenAIApiHost,
formatVertexApiHost,
getFancyProviderName,
validateApiHost
isAnthropicProvider,
isOpenAIProvider
} from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { Divider, Input, Select, Space } from 'antd'
@@ -78,9 +65,7 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
SystemProviderIds.dashscope,
SystemProviderIds.modelscope,
SystemProviderIds.aihubmix,
SystemProviderIds.grok,
SystemProviderIds.cherryin,
SystemProviderIds.longcat
SystemProviderIds.grok
] as const
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]
@@ -89,8 +74,6 @@ const isAnthropicCompatibleProviderId = (id: string): id is AnthropicCompatibleP
return ANTHROPIC_COMPATIBLE_PROVIDER_ID_SET.has(id)
}
type HostField = 'apiHost' | 'anthropicApiHost'
const ProviderSetting: FC<Props> = ({ providerId }) => {
const { provider, updateProvider, models } = useProvider(providerId)
const allProviders = useAllProviders()
@@ -98,23 +81,19 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const [apiHost, setApiHost] = useState(provider.apiHost)
const [anthropicApiHost, setAnthropicHost] = useState<string | undefined>(provider.anthropicApiHost)
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
const [activeHostField, setActiveHostField] = useState<HostField>('apiHost')
const { t } = useTranslation()
const { theme } = useTheme()
const { setTimeoutTimer } = useTimer()
const dispatch = useAppDispatch()
const isAzureOpenAI = isAzureOpenAIProvider(provider)
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
const isDmxapi = provider.id === 'dmxapi'
const noAPIInputProviders = ['aws-bedrock'] as const satisfies SystemProviderId[]
const hideApiInput = noAPIInputProviders.some((id) => id === provider.id)
const noAPIKeyInputProviders = ['copilot', 'vertexai'] as const satisfies SystemProviderId[]
const hideApiKeyInput = noAPIKeyInputProviders.some((id) => id === provider.id)
const hideApiInput = ['vertexai', 'aws-bedrock'].includes(provider.id)
const providerConfig = PROVIDER_URLS[provider.id]
const officialWebsite = providerConfig?.websites?.official
const apiKeyWebsite = providerConfig?.websites?.apiKey
const configuredApiHost = providerConfig?.api?.url
const configedApiHost = providerConfig?.api?.url
const fancyProviderName = getFancyProviderName(provider)
@@ -174,12 +153,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
)
const onUpdateApiHost = () => {
if (!validateApiHost(apiHost)) {
setApiHost(provider.apiHost)
window.toast.error(t('settings.provider.api_host_no_valid'))
return
}
if (isVertexProvider(provider) || apiHost.trim()) {
if (apiHost.trim()) {
updateProvider({ apiHost })
} else {
setApiHost(provider.apiHost)
@@ -266,46 +240,27 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}
}
const onReset = useCallback(() => {
setApiHost(configuredApiHost)
updateProvider({ apiHost: configuredApiHost })
}, [configuredApiHost, updateProvider])
const isApiHostResettable = useMemo(() => {
return !isEmpty(configuredApiHost) && apiHost !== configuredApiHost
}, [configuredApiHost, apiHost])
const onReset = () => {
setApiHost(configedApiHost)
updateProvider({ apiHost: configedApiHost })
}
const hostPreview = () => {
if (apiHost.endsWith('#')) {
return apiHost.replace('#', '')
}
if (isOpenAICompatibleProvider(provider)) {
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions'
if (provider.type === 'openai') {
return formatApiHost(apiHost) + 'chat/completions'
}
if (isAzureOpenAIProvider(provider)) {
const apiVersion = provider.apiVersion
const path = !['preview', 'v1'].includes(apiVersion)
? `/v1/chat/completion?apiVersion=v1`
: `/v1/responses?apiVersion=v1`
return formatAzureOpenAIApiHost(apiHost) + path
if (provider.type === 'azure-openai') {
return formatApiHost(apiHost) + 'openai/v1'
}
if (isAnthropicProvider(provider)) {
return formatApiHost(apiHost) + '/messages'
if (provider.type === 'anthropic') {
return formatApiHost(apiHost) + 'messages'
}
if (isGeminiProvider(provider)) {
return formatApiHost(apiHost, true, 'v1beta') + '/models'
}
if (isOpenAIProvider(provider)) {
return formatApiHost(apiHost) + '/responses'
}
if (isVertexProvider(provider)) {
return formatVertexApiHost(provider) + '/publishers/google'
}
return formatApiHost(apiHost)
return formatApiHost(apiHost) + 'responses'
}
// API key 连通性检查状态指示器,目前仅在失败时显示
@@ -334,44 +289,31 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}, [provider.anthropicApiHost])
const canConfigureAnthropicHost = useMemo(() => {
if (isNewApiProvider(provider)) {
return true
}
return (
provider.type !== 'anthropic' && isSystemProviderId(provider.id) && isAnthropicCompatibleProviderId(provider.id)
)
}, [provider])
const anthropicHostPreview = useMemo(() => {
const rawHost = anthropicApiHost ?? provider.anthropicApiHost
const normalizedHost = formatApiHost(rawHost)
const rawHost = (anthropicApiHost ?? provider.anthropicApiHost)?.trim()
if (!rawHost) {
return ''
}
if (/\/messages\/?$/.test(rawHost)) {
return rawHost.replace(/\/$/, '')
}
let normalizedHost = rawHost
if (/\/v\d+(?:\/)?$/i.test(normalizedHost)) {
normalizedHost = normalizedHost.replace(/\/$/, '')
} else {
normalizedHost = formatApiHost(normalizedHost).replace(/\/$/, '')
}
return `${normalizedHost}/messages`
}, [anthropicApiHost, provider.anthropicApiHost])
const hostSelectorOptions = useMemo(() => {
const options: { value: HostField; label: string }[] = [
{ value: 'apiHost', label: t('settings.provider.api_host') }
]
if (canConfigureAnthropicHost) {
options.push({ value: 'anthropicApiHost', label: t('settings.provider.anthropic_api_host') })
}
return options
}, [canConfigureAnthropicHost, t])
useEffect(() => {
if (!canConfigureAnthropicHost && activeHostField === 'anthropicApiHost') {
setActiveHostField('apiHost')
}
}, [canConfigureAnthropicHost, activeHostField])
const hostSelectorTooltip =
activeHostField === 'anthropicApiHost'
? t('settings.provider.anthropic_api_host_tooltip')
: t('settings.provider.api_host_tooltip')
const isAnthropicOAuth = () => provider.id === 'anthropic' && provider.authType === 'oauth'
return (
@@ -430,119 +372,104 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
)}
{!hideApiInput && !isAnthropicOAuth() && (
<>
{!hideApiKeyInput && (
<>
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key.label')}
{provider.id !== 'copilot' && (
<Tooltip title={t('settings.provider.api.key.list.open')} delay={500}>
<Button variant="ghost" onClick={openApiKeyList} size="icon">
<Settings2 size={16} />
</Button>
</Tooltip>
)}
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={localApiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setLocalApiKey(e.target.value)}
spellCheck={false}
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
suffix={renderStatusIndicator()}
/>
<Button
variant={isApiKeyConnectable ? 'ghost' : undefined}
onClick={onCheckApi}
disabled={!apiHost || apiKeyConnectivity.checking}>
{apiKeyConnectivity.checking ? (
<LoadingIcon />
) : apiKeyConnectivity.status === HealthStatus.SUCCESS ? (
<Check size={16} className="lucide-custom" />
) : (
t('settings.provider.check')
)}
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key.label')}
{provider.id !== 'copilot' && (
<Tooltip content={t('settings.provider.api.key.list.open')} delay={500}>
<Button variant="ghost" onClick={openApiKeyList} size="icon">
<Settings2 size={16} />
</Button>
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<RowFlex>
{apiKeyWebsite && !isDmxapi && (
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</RowFlex>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
</>
)}
{!isDmxapi && (
</Tooltip>
)}
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={localApiKey}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setLocalApiKey(e.target.value)}
spellCheck={false}
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
suffix={renderStatusIndicator()}
/>
<Button
variant={isApiKeyConnectable ? 'ghost' : undefined}
onClick={onCheckApi}
disabled={!apiHost || apiKeyConnectivity.checking}>
{apiKeyConnectivity.checking ? (
<LoadingIcon />
) : apiKeyConnectivity.status === HealthStatus.SUCCESS ? (
<Check size={16} className="lucide-custom" />
) : (
t('settings.provider.check')
)}
</Button>
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<RowFlex>
{apiKeyWebsite && !isDmxapi && (
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
)}
</RowFlex>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>
{!isDmxapi && !isAnthropicOAuth() && (
<>
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tooltip title={hostSelectorTooltip} delay={300}>
<Selector
size={14}
value={activeHostField}
onChange={(value) => setActiveHostField(value as HostField)}
options={hostSelectorOptions}
style={{ paddingLeft: 1, fontWeight: 'bold' }}
placement="bottomLeft"
/>
<Tooltip content={t('settings.provider.api_host_tooltip')} delay={300}>
<SubtitleLabel>{t('settings.provider.api_host')}</SubtitleLabel>
</Tooltip>
<Button variant="ghost" onClick={() => CustomHeaderPopup.show({ provider })} size="icon">
<Settings2 size={16} />
</Button>
</SettingSubtitle>
{activeHostField === 'apiHost' && (
<>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
{isApiHostResettable && (
<Button variant="destructive" onClick={onReset}>
{t('settings.provider.api.url.reset')}
</Button>
)}
</Space.Compact>
{isVertexProvider(provider) && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.api_host_help')}</SettingHelpText>
</SettingHelpTextRow>
)}
{(isOpenAICompatibleProvider(provider) ||
isAzureOpenAIProvider(provider) ||
isAnthropicProvider(provider) ||
isGeminiProvider(provider) ||
isVertexProvider(provider) ||
isOpenAIProvider(provider)) && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{
marginLeft: 6,
marginRight: '1em',
whiteSpace: 'break-spaces',
wordBreak: 'break-all'
}}>
{t('settings.provider.api_host_preview', { url: hostPreview() })}
</SettingHelpText>
</SettingHelpTextRow>
)}
</>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
{!isEmpty(configedApiHost) && apiHost !== configedApiHost && (
<Button variant="destructive" onClick={onReset}>
{t('settings.provider.api.url.reset')}
</Button>
)}
</Space.Compact>
{(isOpenAIProvider(provider) || isAnthropicProvider(provider)) && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<SettingHelpText
style={{ marginLeft: 6, marginRight: '1em', whiteSpace: 'break-spaces', wordBreak: 'break-all' }}>
{t('settings.provider.api_host_preview', { url: hostPreview() })}
</SettingHelpText>
<SettingHelpText style={{ minWidth: 'fit-content' }}>
{t('settings.provider.api.url.tip')}
</SettingHelpText>
</SettingHelpTextRow>
)}
{activeHostField === 'anthropicApiHost' && canConfigureAnthropicHost && (
{canConfigureAnthropicHost && (
<>
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<Tooltip content={t('settings.provider.anthropic_api_host_tooltip')} delay={300}>
<SubtitleLabel>{t('settings.provider.anthropic_api_host')}</SubtitleLabel>
</Tooltip>
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={anthropicApiHost ?? ''}
@@ -557,6 +484,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
url: anthropicHostPreview || '—'
})}
</SettingHelpText>
<SettingHelpText style={{ marginLeft: 6 }}>
{t('settings.provider.anthropic_api_host_tip')}
</SettingHelpText>
</SettingHelpTextRow>
</>
)}
@@ -586,12 +516,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
{provider.id === 'aws-bedrock' && <AwsBedrockSettings />}
{provider.id === 'vertexai' && <VertexAISettings />}
{provider.id === 'vertexai' && <VertexAISettings providerId={provider.id} />}
<ModelList providerId={provider.id} />
</SettingContainer>
)
}
const SubtitleLabel = styled.span`
display: inline-flex;
align-items: center;
line-height: inherit;
font-size: inherit;
font-weight: inherit;
color: inherit;
`
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;

View File

@@ -1,13 +1,19 @@
import { RowFlex } from '@cherrystudio/ui'
import { PROVIDER_URLS } from '@renderer/config/providers'
import { useProvider } from '@renderer/hooks/useProvider'
import { useVertexAISettings } from '@renderer/hooks/useVertexAI'
import { Alert, Input } from 'antd'
import { Alert, Input, Space } from 'antd'
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
const VertexAISettings = () => {
interface Props {
providerId: string
}
const VertexAISettings: FC<Props> = ({ providerId }) => {
const { t } = useTranslation()
const {
projectId,
@@ -22,9 +28,16 @@ const VertexAISettings = () => {
const [localProjectId, setLocalProjectId] = useState(projectId)
const [localLocation, setLocalLocation] = useState(location)
const { provider, updateProvider } = useProvider(providerId)
const [apiHost, setApiHost] = useState(provider.apiHost)
const providerConfig = PROVIDER_URLS['vertexai']
const apiKeyWebsite = providerConfig?.websites?.apiKey
const onUpdateApiHost = () => {
updateProvider({ apiHost })
}
const handleProjectIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalProjectId(e.target.value)
}
@@ -60,6 +73,18 @@ const VertexAISettings = () => {
return (
<>
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input
value={apiHost}
placeholder={t('settings.provider.api_host')}
onChange={(e) => setApiHost(e.target.value)}
onBlur={onUpdateApiHost}
/>
</Space.Compact>
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.vertex_ai.api_host_help')}</SettingHelpText>
</SettingHelpTextRow>
<SettingSubtitle style={{ marginTop: 5 }}>
{t('settings.provider.vertex_ai.service_account.title')}
</SettingSubtitle>

View File

@@ -1,150 +1,170 @@
import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
import { preferenceService } from '@data/PreferenceService'
import { isMac, isWin } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
import {
resetAllShortcuts,
resetShortcut,
setShortcutBinding,
setShortcutEnabled,
useShortcuts
} from '@renderer/hooks/useShortcuts'
import { useTimer } from '@renderer/hooks/useTimer'
import { getShortcutLabel } from '@renderer/i18n/label'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import type { ShortcutPreferenceKey } from '@shared/shortcuts/types'
import { convertKeyToAccelerator, formatShortcutDisplay, isValidShortcut } from '@shared/shortcuts/utils'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
import type { HydratedShortcut } from '@shared/shortcuts/types'
import type { InputRef } from 'antd'
import { Input, Table as AntTable } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { FC, KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useMemo, useRef, useState } from 'react'
import type { FC } from 'react'
import React, { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
const labelKeyMap: Record<string, string> = {
'shortcut.app.show_main_window': 'show_app',
'shortcut.app.show_mini_window': 'mini_window',
'shortcut.app.show_settings': 'show_settings',
'shortcut.app.toggle_show_assistants': 'toggle_show_assistants',
'shortcut.app.exit_fullscreen': 'exit_fullscreen',
'shortcut.app.zoom_in': 'zoom_in',
'shortcut.app.zoom_out': 'zoom_out',
'shortcut.app.zoom_reset': 'zoom_reset',
'shortcut.app.search_message': 'search_message',
'shortcut.chat.clear': 'clear_topic',
'shortcut.chat.search_message': 'search_message_in_chat',
'shortcut.chat.toggle_new_context': 'toggle_new_context',
'shortcut.chat.copy_last_message': 'copy_last_message',
'shortcut.chat.edit_last_user_message': 'edit_last_user_message',
'shortcut.topic.new': 'new_topic',
'shortcut.topic.rename': 'rename_topic',
'shortcut.topic.toggle_show_topics': 'toggle_show_topics',
'shortcut.selection.toggle_enabled': 'selection_assistant_toggle',
'shortcut.selection.get_text': 'selection_assistant_select_text'
}
type ShortcutRecord = {
id: string
label: string
key: ShortcutPreferenceKey
enabled: boolean
editable: boolean
displayKeys: string[]
rawKeys: string[]
hasCustomBinding: boolean
system: boolean
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
defaultPreference: {
binding: string[]
enabled: boolean
}
}
const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
const ShortcutSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const shortcuts = useAllShortcuts()
const { shortcuts: originalShortcuts } = useShortcuts()
const inputRefs = useRef<Record<string, InputRef>>({})
const [editingKey, setEditingKey] = useState<string | null>(null)
const { setTimeoutTimer } = useTimer()
const displayedShortcuts = useMemo<ShortcutRecord[]>(() => {
const filtered = !isWin && !isMac ? shortcuts.filter((item) => item.definition.category !== 'selection') : shortcuts
return filtered.map((item) => {
const labelKey = labelKeyMap[item.definition.key] ?? item.definition.key
const label = getShortcutLabel(labelKey)
const displayKeys = item.preference.hasCustomBinding
? item.preference.rawBinding
: item.preference.binding.length > 0
? item.preference.binding
: item.definition.defaultKey
return {
id: item.definition.key,
label,
key: item.definition.key,
enabled: item.preference.enabled,
editable: item.preference.editable,
displayKeys,
rawKeys: item.preference.rawBinding,
hasCustomBinding: item.preference.hasCustomBinding,
system: item.preference.system,
updatePreference: item.updatePreference,
defaultPreference: {
binding: item.defaultPreference.binding,
enabled: item.defaultPreference.enabled
}
}
})
}, [shortcuts])
const handleClear = (record: ShortcutRecord) => {
void record.updatePreference({ key: [] })
//if shortcut is not available on all the platforms, block the shortcut here
let shortcuts = originalShortcuts
if (!isWin && !isMac) {
//Selection Assistant only available on Windows now
const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text']
shortcuts = shortcuts.filter((shortcut) => !excludedShortcuts.includes(shortcut.name))
}
const handleAddShortcut = (record: ShortcutRecord) => {
setEditingKey(record.id)
const handleClear = (record: HydratedShortcut) => {
void setShortcutBinding(record.name, [])
}
const handleAddShortcut = (record: HydratedShortcut) => {
setEditingKey(record.name)
setTimeoutTimer(
`focus-${record.id}`,
'handleAddShortcut',
() => {
inputRefs.current[record.id]?.focus()
inputRefs.current[record.name]?.focus()
},
0
)
}
const isShortcutModified = (record: ShortcutRecord) => {
const bindingChanged = record.hasCustomBinding
? record.rawKeys.length !== record.defaultPreference.binding.length ||
record.rawKeys.some((key, index) => key !== record.defaultPreference.binding[index])
: false
const enabledChanged = record.enabled !== record.defaultPreference.enabled
return bindingChanged || enabledChanged
const isShortcutModified = (record: HydratedShortcut) => {
const definition = definitionMap.get(record.name)
if (!definition) {
return false
}
return definition.defaultKey.join('+') !== record.key.join('+')
}
const handleResetShortcut = (record: ShortcutRecord) => {
void record.updatePreference({
key: record.defaultPreference.binding,
enabled: record.defaultPreference.enabled
})
setEditingKey(null)
const handleResetShortcut = (record: HydratedShortcut) => {
void resetShortcut(record.name)
}
const isDuplicateShortcut = (keys: string[], currentKey: ShortcutPreferenceKey) => {
const normalized = keys.map((key) => key.toLowerCase()).join('+')
const isValidShortcut = (keys: string[]): boolean => {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
// const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
return displayedShortcuts.some((record) => {
if (record.key === currentKey) return false
const binding = record.displayKeys
if (!binding.length) return false
return binding.map((key) => key.toLowerCase()).join('+') === normalized
})
// NEW WAY FOR MODIFIER KEYS
const hasModifier = keys.some((key) => ['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key))
const hasNonModifier = keys.some((key) => !['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key))
const hasFnKey = keys.some((key) => /^F\d+$/.test(key))
return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey
}
const usableEndKeys = (event: ReactKeyboardEvent): string | null => {
const isDuplicateShortcut = (newShortcut: string[], currentName: string): boolean => {
return shortcuts.some(
(shortcut) =>
shortcut.name !== currentName && shortcut.key.length > 0 && shortcut.key.join('+') === newShortcut.join('+')
)
}
// how the shortcut is displayed in the UI
const formatShortcut = (shortcut: string[]): string => {
return shortcut
.map((key) => {
switch (key) {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// case 'Control':
// return isMac ? '⌃' : 'Ctrl'
// case 'Ctrl':
// return isMac ? '⌃' : 'Ctrl'
// case 'Command':
// return isMac ? '⌘' : isWin ? 'Win' : 'Super'
// case 'Alt':
// return isMac ? '⌥' : 'Alt'
// case 'Shift':
// return isMac ? '⇧' : 'Shift'
// case 'CommandOrControl':
// return isMac ? '⌘' : 'Ctrl'
// new way for modifier keys
case 'CommandOrControl':
return isMac ? '⌘' : 'Ctrl'
case 'Ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'Alt':
return isMac ? '⌥' : 'Alt'
case 'Meta':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'Shift':
return isMac ? '⇧' : 'Shift'
// for backward compatibility with old data
case 'Command':
case 'Cmd':
return isMac ? '⌘' : 'Ctrl'
case 'Control':
return isMac ? '⌃' : 'Ctrl'
case 'ArrowUp':
return '↑'
case 'ArrowDown':
return '↓'
case 'ArrowLeft':
return '←'
case 'ArrowRight':
return '→'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
default:
return key.charAt(0).toUpperCase() + key.slice(1)
}
})
.join(' + ')
}
const usableEndKeys = (event: React.KeyboardEvent): string | null => {
const { code } = event
// No lock keys
// Among the commonly used keys, not including: Escape, NumpadMultiply, NumpadDivide, NumpadSubtract, NumpadAdd, NumpadDecimal
// The react-hotkeys-hook library does not differentiate between `Digit` and `Numpad`
switch (code) {
case 'KeyA':
case 'KeyB':
@@ -194,15 +214,10 @@ const ShortcutSettings: FC = () => {
case 'Numpad9':
return code.slice(-1)
case 'Space':
return 'Space'
case 'Enter':
return 'Enter'
case 'Backspace':
return 'Backspace'
case 'Tab':
return 'Tab'
case 'Delete':
return 'Delete'
case 'PageUp':
case 'PageDown':
case 'Insert':
@@ -238,6 +253,7 @@ const ShortcutSettings: FC = () => {
return '.'
case 'NumpadEnter':
return 'Enter'
// The react-hotkeys-hook library does not handle the symbol strings for the following keys
case 'Slash':
case 'Semicolon':
case 'BracketLeft':
@@ -253,30 +269,39 @@ const ShortcutSettings: FC = () => {
}
}
const handleKeyDown = (event: ReactKeyboardEvent, record: ShortcutRecord) => {
const handleKeyDown = (event: React.KeyboardEvent, record: HydratedShortcut) => {
event.preventDefault()
const keys: string[] = []
if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl')
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// if (e.ctrlKey) keys.push(isMac ? 'Control' : 'Ctrl')
// if (e.metaKey) keys.push('Command')
// if (e.altKey) keys.push('Alt')
// if (e.shiftKey) keys.push('Shift')
// NEW WAY FOR MODIFIER KEYS
// for capability across platforms, we transform the modifier keys to the really meaning keys
// mainly consider the habit of users on different platforms
if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS
if (event.altKey) keys.push('Alt')
if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta')
if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux
if (event.shiftKey) keys.push('Shift')
const endKey = usableEndKeys(event)
if (endKey) {
keys.push(convertKeyToAccelerator(endKey))
keys.push(endKey)
}
if (!isValidShortcut(keys)) {
return
}
if (isDuplicateShortcut(keys, record.key)) {
if (isDuplicateShortcut(keys, record.name)) {
return
}
void record.updatePreference({ key: keys })
void setShortcutBinding(record.name, keys)
setEditingKey(null)
}
@@ -284,60 +309,52 @@ const ShortcutSettings: FC = () => {
window.modal.confirm({
title: t('settings.shortcuts.reset_defaults_confirm'),
centered: true,
onOk: async () => {
const updates: Record<string, PreferenceShortcutType> = {}
shortcuts.forEach((item) => {
updates[item.definition.key] = {
key: item.defaultPreference.binding,
enabled: item.defaultPreference.enabled,
editable: item.defaultPreference.editable,
system: item.defaultPreference.system
}
})
await preferenceService.setMultiple(updates)
}
onOk: () => resetAllShortcuts()
})
}
const columns: ColumnsType<ShortcutRecord> = [
// 由于启用了showHeader = false不再需要title字段
type ShortcutRow = HydratedShortcut & { displayName: string; shortcut: string[] }
const columns: ColumnsType<ShortcutRow> = [
{
dataIndex: 'label',
key: 'label'
// title: t('settings.shortcuts.action'),
dataIndex: 'displayName',
key: 'displayName'
},
{
dataIndex: 'displayKeys',
// title: t('settings.shortcuts.label'),
dataIndex: 'shortcut',
key: 'shortcut',
align: 'right',
render: (_value, record) => {
const isEditing = editingKey === record.id
const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : ''
const editingShortcut = record.rawKeys.length > 0 ? formatShortcutDisplay(record.rawKeys, isMac) : ''
render: (_: string[], record: ShortcutRow) => {
const isEditing = editingKey === record.name
const isEditable = record.editable !== false
const shortcutKeys = record.key
return (
<RowFlex className="items-center justify-end gap-2">
<RowFlex className="relative items-center">
{isEditing ? (
<ShortcutInput
ref={(element) => {
if (element) {
inputRefs.current[record.id] = element
ref={(el) => {
if (el) {
inputRefs.current[record.name] = el
}
}}
value={editingShortcut}
value={formatShortcut(shortcutKeys)}
placeholder={t('settings.shortcuts.press_shortcut')}
onKeyDown={(event) => handleKeyDown(event, record)}
onBlur={(event) => {
const isUndoClick = event.relatedTarget?.closest('.shortcut-undo-icon')
onKeyDown={(e) => handleKeyDown(e, record)}
onBlur={(e) => {
const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon')
if (!isUndoClick) {
setEditingKey(null)
}
}}
/>
) : (
<ShortcutText isEditable={record.editable} onClick={() => record.editable && handleAddShortcut(record)}>
{displayShortcut || t('settings.shortcuts.press_shortcut')}
<ShortcutText isEditable={isEditable} onClick={() => isEditable && handleAddShortcut(record)}>
{shortcutKeys.length > 0 ? formatShortcut(shortcutKeys) : t('settings.shortcuts.press_shortcut')}
</ShortcutText>
)}
</RowFlex>
@@ -346,10 +363,11 @@ const ShortcutSettings: FC = () => {
}
},
{
// title: t('settings.shortcuts.actions'),
key: 'actions',
align: 'right',
width: 70,
render: (record) => (
width: '70px',
render: (record: ShortcutRow) => (
<RowFlex className="items-center justify-end gap-2">
<Tooltip content={t('settings.shortcuts.reset_to_default')}>
<Button size="icon-sm" onClick={() => handleResetShortcut(record)} disabled={!isShortcutModified(record)}>
@@ -360,7 +378,7 @@ const ShortcutSettings: FC = () => {
<Button
size="icon-sm"
onClick={() => handleClear(record)}
disabled={record.rawKeys.length === 0 || !record.editable}>
disabled={record.shortcut.length === 0 || !record.editable}>
<ClearOutlined />
</Button>
</Tooltip>
@@ -368,14 +386,17 @@ const ShortcutSettings: FC = () => {
)
},
{
// title: t('settings.shortcuts.enabled'),
key: 'enabled',
align: 'right',
width: 50,
render: (record) => (
width: '50px',
render: (record: ShortcutRow) => (
<Switch
size="sm"
isSelected={record.enabled}
onValueChange={() => void record.updatePreference({ enabled: !record.enabled })}
onValueChange={(value) => {
void setShortcutEnabled(record.name, value)
}}
/>
)
}
@@ -388,11 +409,15 @@ const ShortcutSettings: FC = () => {
<SettingDivider style={{ marginBottom: 0 }} />
<Table
columns={columns as ColumnsType<unknown>}
dataSource={displayedShortcuts}
dataSource={shortcuts.map((shortcut) => ({
...shortcut,
shortcut: shortcut.key,
displayName: getShortcutLabel(shortcut.name)
}))}
rowKey="name"
pagination={false}
size="middle"
showHeader={false}
rowKey="id"
/>
<SettingDivider style={{ marginBottom: 0 }} />
<RowFlex className="justify-end p-4">

View File

@@ -3,7 +3,6 @@ import type { Span } from '@opentelemetry/api'
import AiProvider from '@renderer/aiCore'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { isGeminiProvider } from '@renderer/config/providers'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import store from '@renderer/store'
import type {
@@ -42,7 +41,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
let host = aiProvider.getBaseURL()
const rerankHost = rerankAiProvider.getBaseURL()
if (isGeminiProvider(provider)) {
if (provider.type === 'gemini') {
host = host + '/v1beta/openai/'
}

View File

@@ -0,0 +1,87 @@
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
import type { HydratedShortcutMap } from '@shared/shortcuts/types'
const logger = loggerService.withContext('RendererShortcutService')
type ShortcutListener = () => void
let shortcutsState: HydratedShortcutMap = buildDefaultState()
const listeners = new Set<ShortcutListener>()
let initialized = false
function buildDefaultState(): HydratedShortcutMap {
return Object.fromEntries(
shortcutDefinitions.map((definition) => [
definition.name,
{
...definition,
key: [...definition.defaultKey],
enabled: definition.defaultEnabled
}
])
)
}
function emitChange() {
listeners.forEach((listener) => {
try {
listener()
} catch (error) {
logger.error('Shortcut listener threw an error:', error as Error)
}
})
}
function setShortcuts(next: HydratedShortcutMap) {
shortcutsState = Object.fromEntries(
Object.entries(next).map(([name, config]) => [
name,
{
...config,
key: [...config.key]
}
])
)
emitChange()
}
function subscribe(listener: ShortcutListener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function getShortcutsSnapshot(): HydratedShortcutMap {
return shortcutsState
}
export function initializeShortcutService() {
if (initialized) {
return
}
initialized = true
window.electron.ipcRenderer.on(IpcChannel.Shortcuts_Updated, (_event, payload: HydratedShortcutMap) => {
setShortcuts(payload)
})
window.api.shortcuts
.getAll()
.then((payload: HydratedShortcutMap) => {
setShortcuts(payload)
})
.catch((error: unknown) => {
logger.warn('Failed to load shortcuts from main process, using defaults.', error as Error)
setShortcuts(buildDefaultState())
})
}
export const shortcutRendererStore = {
subscribe,
getSnapshot: getShortcutsSnapshot,
getServerSnapshot: getShortcutsSnapshot
}

View File

@@ -2720,6 +2720,7 @@ const migrateConfig = {
}
},
'166': (state: RootState) => {
// added after 1.6.5 and 1.7.0-beta.2
try {
if (state.assistants.presets === undefined) {
state.assistants.presets = []
@@ -2736,18 +2737,6 @@ const migrateConfig = {
if (dashscopeProvider) {
dashscopeProvider.anthropicApiHost = 'https://dashscope.aliyuncs.com/apps/anthropic'
}
state.llm.providers.forEach((provider) => {
if (provider.id === SystemProviderIds['new-api'] && provider.type !== 'new-api') {
provider.type = 'new-api'
}
if (provider.id === SystemProviderIds.longcat) {
// https://longcat.chat/platform/docs/zh/#anthropic-api-%E6%A0%BC%E5%BC%8F
if (!provider.anthropicApiHost) {
provider.anthropicApiHost = 'https://api.longcat.chat/anthropic'
}
}
})
return state
} catch (error) {
logger.error('migrate 166 error', error as Error)

View File

@@ -381,15 +381,3 @@ export type ReplaceSessionRequest = z.infer<typeof ReplaceSessionRequestSchema>
export const CreateSessionMessageRequestSchema = z.object({
content: z.string().min(1, 'Content must be a valid string')
})
export type PermissionModeCard = {
mode: PermissionMode
titleKey: string
titleFallback: string
descriptionKey: string
descriptionFallback: string
behaviorKey: string
behaviorFallback: string
caution?: boolean
unsupported?: boolean
}

View File

@@ -41,7 +41,7 @@ export type Assistant = {
/** enableWebSearch 代表使用模型内置网络搜索功能 */
enableWebSearch?: boolean
webSearchProviderId?: WebSearchProvider['id']
// enableUrlContext 是 Gemini/Anthropic 的特有功能
// enableUrlContext 是 Gemini 的特有功能
enableUrlContext?: boolean
enableGenerateImage?: boolean
mcpServers?: MCPServer[]

View File

@@ -6,6 +6,7 @@ export const ProviderTypeSchema = z.enum([
'openai-response',
'anthropic',
'gemini',
'qwenlm',
'azure-openai',
'vertexai',
'mistral',
@@ -36,8 +37,6 @@ export type ProviderApiOptions = {
isSupportServiceTier?: boolean
/** 是否不支持 enable_thinking 参数 */
isNotSupportEnableThinking?: boolean
/** 是否不支持 APIVersion */
isNotSupportAPIVersion?: boolean
}
export const OpenAIServiceTiers = {
@@ -188,11 +187,6 @@ export type VertexProvider = Provider & {
location: string
}
export type AzureOpenAIProvider = Provider & {
type: 'azure-openai'
apiVersion: string
}
/**
* 判断是否为系统内置的提供商。比直接使用`provider.isSystem`更好,因为该数据字段不会随着版本更新而变化。
* @param provider - Provider对象包含提供商的信息

View File

@@ -1,106 +1,31 @@
import store from '@renderer/store'
import type { VertexProvider } from '@renderer/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
formatApiHost,
formatApiKeys,
formatAzureOpenAIApiHost,
formatVertexApiHost,
hasAPIVersion,
maskApiKey,
routeToEndpoint,
splitApiKeyString,
validateApiHost
} from '../api'
vi.mock('@renderer/store', () => {
const getState = vi.fn()
return {
default: {
getState
}
}
})
const getStateMock = store.getState as unknown as ReturnType<typeof vi.fn>
const createVertexProvider = (apiHost: string): VertexProvider => ({
id: 'vertex-provider',
type: 'vertexai',
name: 'Vertex AI',
apiKey: '',
apiHost,
models: [],
googleCredentials: {
privateKey: '',
clientEmail: ''
},
project: '',
location: ''
})
beforeEach(() => {
getStateMock.mockReset()
getStateMock.mockReturnValue({
llm: {
settings: {
vertexai: {
projectId: 'test-project',
location: 'us-central1'
}
}
}
})
})
import { formatApiHost, maskApiKey, splitApiKeyString } from '../api'
describe('api', () => {
describe('formatApiHost', () => {
it('returns empty string for falsy host', () => {
expect(formatApiHost('')).toBe('')
expect(formatApiHost(undefined)).toBe('')
it('should return original host when it ends with a slash', () => {
expect(formatApiHost('https://api.example.com/')).toBe('https://api.example.com/')
expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/')
})
it('appends api version when missing', () => {
expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1')
expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/v1')
expect(formatApiHost(' https://api.openai.com ')).toBe('https://api.openai.com/v1')
})
it('keeps original host when api version already present', () => {
it('should return original host when it ends with volces.com/api/v3', () => {
expect(formatApiHost('https://api.volces.com/api/v3')).toBe('https://api.volces.com/api/v3')
expect(formatApiHost('http://localhost:5173/v2beta')).toBe('http://localhost:5173/v2beta')
expect(formatApiHost('http://volces.com/api/v3')).toBe('http://volces.com/api/v3')
})
it('supports custom api version parameter', () => {
expect(formatApiHost('https://api.example.com', true, 'v2')).toBe('https://api.example.com/v2')
it('should append /v1/ to hosts that do not match special conditions', () => {
expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1/')
expect(formatApiHost('http://localhost:5173')).toBe('http://localhost:5173/v1/')
expect(formatApiHost('https://api.openai.com')).toBe('https://api.openai.com/v1/')
})
it('keeps host untouched when api version unsupported', () => {
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
})
})
describe('hasAPIVersion', () => {
it('detects numeric version suffix', () => {
expect(hasAPIVersion('https://api.example.com/v1')).toBe(true)
expect(hasAPIVersion('http://localhost:3000/v2beta')).toBe(true)
expect(hasAPIVersion('/v3alpha/resources')).toBe(true)
it('should not modify hosts that already have a path but do not end with a slash', () => {
expect(formatApiHost('https://api.example.com/custom')).toBe('https://api.example.com/custom/v1/')
})
it('returns false when no version found', () => {
expect(hasAPIVersion('https://api.example.com')).toBe(false)
expect(hasAPIVersion('')).toBe(false)
expect(hasAPIVersion(undefined)).toBe(false)
})
it('return flase when starting without v character', () => {
expect(hasAPIVersion('https://api.example.com/a1v')).toBe(false)
expect(hasAPIVersion('/av1/users')).toBe(false)
})
it('return flase when starting with v- word', () => {
expect(hasAPIVersion('https://api.example.com/vendor')).toBe(false)
it('should handle empty string gracefully', () => {
expect(formatApiHost('')).toBe('')
})
})
@@ -198,122 +123,4 @@ describe('api', () => {
expect(result).toEqual(['key1', 'key2,withcomma', 'key3'])
})
})
describe('validateApiHost', () => {
it('accepts empty or whitespace-only host', () => {
expect(validateApiHost('')).toBe(true)
expect(validateApiHost(' ')).toBe(true)
})
it('rejects unsupported protocols', () => {
expect(validateApiHost('ftp://api.example.com')).toBe(false)
})
it('validates supported endpoint fragments when using hash suffix', () => {
expect(validateApiHost('https://api.example.com/v1/chat/completions#')).toBe(true)
expect(validateApiHost('https://api.example.com/v1/unknown#')).toBe(true)
})
})
describe('routeToEndpoint', () => {
it('returns host without endpoint when not using hash suffix', () => {
expect(routeToEndpoint(' https://api.example.com/v1 ')).toEqual({
baseURL: 'https://api.example.com/v1',
endpoint: ''
})
})
it('extracts known endpoint and base url when using hash suffix', () => {
expect(routeToEndpoint('https://api.example.com/v1/chat/completions#')).toEqual({
baseURL: 'https://api.example.com/v1',
endpoint: 'chat/completions'
})
})
it('returns empty endpoint when unsupported endpoint fragment is provided', () => {
expect(routeToEndpoint('https://api.example.com/v1/custom#')).toEqual({
baseURL: 'https://api.example.com/v1/custom',
endpoint: ''
})
})
it('prefers the most specific endpoint match when multiple matches exist', () => {
expect(routeToEndpoint('https://api.example.com/v1/streamGenerateContent#')).toEqual({
baseURL: 'https://api.example.com/v1',
endpoint: 'streamGenerateContent'
})
})
it('extract OpenAI images generations endpoint', () => {
expect(routeToEndpoint('https://open.cherryin.net/v1/images/generations#')).toEqual({
baseURL: 'https://open.cherryin.net/v1',
endpoint: 'images/generations'
})
})
it('extract Gemini images generation endpoint', () => {
expect(routeToEndpoint('https://open.cherryin.net/v1beta/models/imagen-4.0-generate-001:predict#')).toEqual({
baseURL: 'https://open.cherryin.net/v1beta/models/imagen-4.0-generate-001',
endpoint: 'predict'
})
})
})
describe('formatApiKeys', () => {
it('normalizes chinese commas and new lines', () => {
expect(formatApiKeys('key1key2\nkey3')).toBe('key1,key2,key3')
})
it('returns empty string unchanged', () => {
expect(formatApiKeys('')).toBe('')
})
})
describe('formatAzureOpenAIApiHost', () => {
it('normalizes trailing segments and disables auto version append', () => {
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/')).toBe(
'https://example.openai.azure.com/openai'
)
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/openai/')).toBe(
'https://example.openai.azure.com/openai'
)
})
})
describe('formatVertexApiHost', () => {
it('builds default google endpoint when host absent', () => {
expect(formatVertexApiHost(createVertexProvider(''))).toBe(
'https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1'
)
})
it('prefers default endpoint when host ends with google domain', () => {
expect(formatVertexApiHost(createVertexProvider('https://aiplatform.googleapis.com'))).toBe(
'https://us-central1-aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1'
)
})
it('appends api version to custom host', () => {
expect(formatVertexApiHost(createVertexProvider('https://custom.googleapis.com/vertex'))).toBe(
'https://custom.googleapis.com/vertex/v1'
)
})
it('uses global endpoint when location equals global', () => {
getStateMock.mockReturnValueOnce({
llm: {
settings: {
vertexai: {
projectId: 'global-project',
location: 'global'
}
}
}
})
expect(formatVertexApiHost(createVertexProvider(''))).toBe(
'https://aiplatform.googleapis.com/v1/projects/global-project/locations/global'
)
})
})
})

View File

@@ -1,18 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { runAsyncFunction } from '../index'
import { hasPath, isValidProxyUrl, removeQuotes, removeSpecialCharacters } from '../index'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
describe('Unclassified Utils', () => {
describe('runAsyncFunction', () => {
it('should execute async function', async () => {

View File

@@ -1,17 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { isJSON, parseJSON } from '../index'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
describe('json', () => {
describe('isJSON', () => {
it('should return true for valid JSON string', () => {

View File

@@ -1,7 +1,3 @@
import store from '@renderer/store'
import type { VertexProvider } from '@renderer/types'
import { trim } from 'lodash'
/**
* 格式化 API key 字符串。
*
@@ -13,166 +9,30 @@ export function formatApiKeys(value: string): string {
}
/**
* 判断 host 的 path 中是否包含形如版本的字符串(例如 /v1、/v2beta 等),
* 格式化 API 主机地址。
*
* @param host - 要检查的 host 或 path 字符串
* @returns 如果 path 中包含版本字符串则返回 true否则 false
* 根据传入的 host 判断是否需要在其末尾加 `/v1/`。
* - 不加host 以 `/` 结尾,或以 `volces.com/api/v3` 结尾。
* - 要加:其余情况。
*
* @param {string} host - 需要格式化的 API 主机地址。
* @param {string} apiVersion - 需要添加的 API 版本。
* @returns {string} 格式化后的 API 主机地址。
*/
export function hasAPIVersion(host?: string): boolean {
if (!host) return false
// 匹配路径中以 `/v<number>` 开头并可选跟随 `alpha` 或 `beta` 的版本段,
// 该段后面可以跟 `/` 或字符串结束(用于匹配诸如 `/v3alpha/resources` 的情况)。
const versionRegex = /\/v\d+(?:alpha|beta)?(?=\/|$)/i
try {
const url = new URL(host)
return versionRegex.test(url.pathname)
} catch {
// 若无法作为完整 URL 解析,则当作路径直接检测
return versionRegex.test(host)
}
}
/**
* Removes the trailing slash from a URL string if it exists.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to process
* @returns {T} The URL string without a trailing slash
*
* @example
* ```ts
* withoutTrailingSlash('https://example.com/') // 'https://example.com'
* withoutTrailingSlash('https://example.com') // 'https://example.com'
* ```
*/
export function withoutTrailingSlash<T extends string>(url: T): T {
return url.replace(/\/$/, '') as T
}
/**
* Formats an API host URL by normalizing it and optionally appending an API version.
*
* @param host - The API host URL to format. Leading/trailing whitespace will be trimmed and trailing slashes removed.
* @param isSupportedAPIVerion - Whether the API version is supported. Defaults to `true`.
* @param apiVersion - The API version to append if needed. Defaults to `'v1'`.
*
* @returns The formatted API host URL. If the host is empty after normalization, returns an empty string.
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host as-is.
* Otherwise, returns the host with the API version appended.
*
* @example
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#'
* formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2'
*/
export function formatApiHost(host?: string, isSupportedAPIVerion: boolean = true, apiVersion: string = 'v1'): string {
const normalizedHost = withoutTrailingSlash(trim(host))
if (!normalizedHost) {
export function formatApiHost(host: string, apiVersion: string = 'v1'): string {
if (!host) {
return ''
}
if (normalizedHost.endsWith('#') || !isSupportedAPIVerion || hasAPIVersion(normalizedHost)) {
return normalizedHost
}
return `${normalizedHost}/${apiVersion}`
}
/**
* 格式化 Azure OpenAI 的 API 主机地址。
*/
export function formatAzureOpenAIApiHost(host: string): string {
const normalizedHost = withoutTrailingSlash(host)
?.replace(/\/v1$/, '')
.replace(/\/openai$/, '')
// NOTE: AISDK会添加上`v1`
return formatApiHost(normalizedHost + '/openai', false)
}
export function formatVertexApiHost(provider: VertexProvider): string {
const { apiHost } = provider
const { projectId: project, location } = store.getState().llm.settings.vertexai
const trimmedHost = withoutTrailingSlash(trim(apiHost))
if (!trimmedHost || trimmedHost.endsWith('aiplatform.googleapis.com')) {
const host =
location == 'global' ? 'https://aiplatform.googleapis.com' : `https://${location}-aiplatform.googleapis.com`
return `${formatApiHost(host)}/projects/${project}/locations/${location}`
}
return formatApiHost(trimmedHost)
}
// 目前对话界面只支持这些端点
export const SUPPORTED_IMAGE_ENDPOINT_LIST = ['images/generations', 'images/edits', 'predict'] as const
export const SUPPORTED_ENDPOINT_LIST = [
'chat/completions',
'responses',
'messages',
'generateContent',
'streamGenerateContent',
...SUPPORTED_IMAGE_ENDPOINT_LIST
] as const
/**
* Converts an API host URL into separate base URL and endpoint components.
*
* @param apiHost - The API host string to parse. Expected to be a trimmed URL that may end with '#' followed by an endpoint identifier.
* @returns An object containing:
* - `baseURL`: The base URL without the endpoint suffix
* - `endpoint`: The matched endpoint identifier, or empty string if no match found
*
* @description
* This function extracts endpoint information from a composite API host string.
* If the host ends with '#', it attempts to match the preceding part against the supported endpoint list.
* The '#' delimiter is removed before processing.
*
* @example
* routeToEndpoint('https://api.example.com/openai/chat/completions#')
* // Returns: { baseURL: 'https://api.example.com/v1', endpoint: 'chat/completions' }
*
* @example
* routeToEndpoint('https://api.example.com/v1')
* // Returns: { baseURL: 'https://api.example.com/v1', endpoint: '' }
*/
export function routeToEndpoint(apiHost: string): { baseURL: string; endpoint: string } {
const trimmedHost = trim(apiHost)
// 前面已经确保apiHost合法
if (!trimmedHost.endsWith('#')) {
return { baseURL: trimmedHost, endpoint: '' }
}
// 去掉结尾的 #
const host = trimmedHost.slice(0, -1)
const endpointMatch = SUPPORTED_ENDPOINT_LIST.find((endpoint) => host.endsWith(endpoint))
if (!endpointMatch) {
const baseURL = withoutTrailingSlash(host)
return { baseURL, endpoint: '' }
}
const baseSegment = host.slice(0, host.length - endpointMatch.length)
const baseURL = withoutTrailingSlash(baseSegment).replace(/:$/, '') // 去掉结尾可能存在的冒号(gemini的特殊情况)
return { baseURL, endpoint: endpointMatch }
}
/**
* 验证 API 主机地址是否合法。
*
* @param {string} apiHost - 需要验证的 API 主机地址。
* @returns {boolean} 如果是合法的 URL 则返回 true否则返回 false。
*/
export function validateApiHost(apiHost: string): boolean {
// 允许apiHost为空
if (!apiHost || !trim(apiHost)) {
return true
}
try {
const url = new URL(trim(apiHost))
// 验证协议是否为 http 或 https
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false
const forceUseOriginalHost = () => {
if (host.endsWith('/')) {
return true
}
return true
} catch {
return false
return host.endsWith('volces.com/api/v3')
}
return forceUseOriginalHost() ? host : `${host}/${apiVersion}/`
}
/**

View File

@@ -1,5 +1,5 @@
import { loggerService } from '@logger'
import type { Model, ModelType } from '@renderer/types'
import type { Model, ModelType, Provider } from '@renderer/types'
import type { ModalFuncProps } from 'antd'
import { isEqual } from 'lodash'
import { v4 as uuidv4 } from 'uuid'
@@ -55,13 +55,7 @@ export const waitAsyncFunction = (
})()
}
/**
* Generate a UUID v4 string.
* @returns {string} A UUID v4 string
*/
export function uuid(): string {
return uuidv4()
}
export const uuid = () => uuidv4()
/**
* 从错误对象中提取错误信息。
@@ -202,6 +196,19 @@ export function getMcpConfigSampleFromReadme(readme: string): Record<string, any
return null
}
/**
* 判断是否为 OpenAI 兼容的提供商
* @param {Provider} provider 提供商对象
* @returns {boolean} 是否为 OpenAI 兼容提供商
*/
export function isOpenAIProvider(provider: Provider): boolean {
return !['anthropic', 'gemini', 'vertexai'].includes(provider.type)
}
export function isAnthropicProvider(provider: Provider): boolean {
return provider.type === 'anthropic'
}
/**
* 判断模型是否为用户手动选择
* @param {Model} model 模型对象

137
yarn.lock
View File

@@ -74,11 +74,11 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/amazon-bedrock@npm:^3.0.42":
version: 3.0.42
resolution: "@ai-sdk/amazon-bedrock@npm:3.0.42"
"@ai-sdk/amazon-bedrock@npm:^3.0.35":
version: 3.0.35
resolution: "@ai-sdk/amazon-bedrock@npm:3.0.35"
dependencies:
"@ai-sdk/anthropic": "npm:2.0.32"
"@ai-sdk/anthropic": "npm:2.0.27"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
"@smithy/eventstream-codec": "npm:^4.0.1"
@@ -86,32 +86,32 @@ __metadata:
aws4fetch: "npm:^1.0.20"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/659de3d62f1907489bb14cd7fe049274c0a5f754222eda41b500d66573422ddaad3380cf8fc6eaae8a39ab25445e81aca7664ca2068b4a93c49bcb605889b2ba
checksum: 10c0/0e3e0ed1730fa6a14d8d7ca14b7823ec0b80c9d666435d97a505e7fb0c1818378343cdb647e3cc08d7f15d201cbeb04272c5128065f6cc6858b4404961eca761
languageName: node
linkType: hard
"@ai-sdk/anthropic@npm:2.0.32, @ai-sdk/anthropic@npm:^2.0.32":
version: 2.0.32
resolution: "@ai-sdk/anthropic@npm:2.0.32"
"@ai-sdk/anthropic@npm:2.0.27, @ai-sdk/anthropic@npm:^2.0.27":
version: 2.0.27
resolution: "@ai-sdk/anthropic@npm:2.0.27"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/f83ec81fe150dacd9207b67a173f7e150b44a0b2b57e6361c061e35b663bbb95240ea18066bd2bce73df722b85772ca174c4f1546b29eb6e6d1fcf4f349e756b
checksum: 10c0/b568b3b8639af8ec7ea9b766061a4f18bcdef16f2bb12da3a4c4124c751bd6aab1b96dbe1a0eb8e38831d305871ce0787a6536d1a4d8a8ab8aaf03aca3e48e3f
languageName: node
linkType: hard
"@ai-sdk/azure@npm:^2.0.53":
version: 2.0.53
resolution: "@ai-sdk/azure@npm:2.0.53"
"@ai-sdk/azure@npm:^2.0.49":
version: 2.0.49
resolution: "@ai-sdk/azure@npm:2.0.49"
dependencies:
"@ai-sdk/openai": "npm:2.0.52"
"@ai-sdk/openai": "npm:2.0.48"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/39346f50434c3568b40bb57aa64010261ae767d9aa49b4477999ca78431326275b111879b9c5431ce35ca4ca376c47455618c8bf528c54402b0dad1b03e10487
checksum: 10c0/d4dc5a8e0cbe0cefc8db987c4a7b784a9898d40cc55ef38618c71eba7f40dbef77b754aec1d507559f643fed49e538ffe2b677b327f001a2efc0474f6b544ba9
languageName: node
linkType: hard
@@ -128,55 +128,55 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/gateway@npm:2.0.0":
version: 2.0.0
resolution: "@ai-sdk/gateway@npm:2.0.0"
"@ai-sdk/gateway@npm:1.0.39":
version: 1.0.39
resolution: "@ai-sdk/gateway@npm:1.0.39"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
"@vercel/oidc": "npm:3.0.3"
"@vercel/oidc": "npm:3.0.2"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/720cfb827bc64f3eb6bb86d17e7e7947c54bdc7d74db7f6e9e162be0973a45368c05829e4b257704182ca9c4886e7f3c74f6b64841e88359930f48f288aa958a
checksum: 10c0/1b6eedf12ac641c96a1eb75e48e43474694b60eb7dca273f76a636a4e2bfc89efda1d9855d5abf9cc464e23cdbf5a3119fed65c3d22cec726e29a2bad3c3318b
languageName: node
linkType: hard
"@ai-sdk/google-vertex@npm:^3.0.48":
version: 3.0.48
resolution: "@ai-sdk/google-vertex@npm:3.0.48"
"@ai-sdk/google-vertex@npm:^3.0.40":
version: 3.0.40
resolution: "@ai-sdk/google-vertex@npm:3.0.40"
dependencies:
"@ai-sdk/anthropic": "npm:2.0.32"
"@ai-sdk/google": "npm:2.0.23"
"@ai-sdk/anthropic": "npm:2.0.27"
"@ai-sdk/google": "npm:2.0.20"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
google-auth-library: "npm:^9.15.0"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/79f0ccb78c4930ea57a41e81f31a1935531d8f02b738d0aae13fa865272f4dac6b1c31b2e1c8b8ca65671a96b90cd4f14fabaa9d60ab0252c6c0e6a1828e7f09
checksum: 10c0/680a06e1b80bc036744e2f13e1a55b57661c3674000ab82b863d6536730edfc3696b1b0b2235f6354de11fa323c4ef817d8edbd2dbf94dc4037ea882e560c9ea
languageName: node
linkType: hard
"@ai-sdk/google@npm:2.0.23":
version: 2.0.23
resolution: "@ai-sdk/google@npm:2.0.23"
"@ai-sdk/google@npm:2.0.20":
version: 2.0.20
resolution: "@ai-sdk/google@npm:2.0.20"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/402b78f392196c3e23c75cc35fc1d701f9521b57aace2fb1bbae6a0d57bbb3894a778b0485305bd6674998403e44c3883dca2416f2d48377722351debead9f11
checksum: 10c0/9c73bb67061673b16f0996c85bf4e79ab9968c8a203c4f9731bf569e45960db88950dfc227aca69661ea805d381b285697ba1763faa03a38c01b86e6d2e90629
languageName: node
linkType: hard
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch":
version: 2.0.23
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch::version=2.0.23&hash=df67ed"
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch":
version: 2.0.20
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch::version=2.0.20&hash=1f2ccb"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/e7fda169f04190b3ef37937e61219dcf8dade735cf76a9af8f1a1def83a43846659a361835814f0b68a2c392bc840a457a693cb69fed42af375771dd210ebdbe
checksum: 10c0/2d567361d533a4e2be83aa135cb5f01f09ea54c255d7751171855ef4244cfaeff73fe7b3c7b044b384a9c170e89d053160a26933176ad68dcaf03bd3c69c0be3
languageName: node
linkType: hard
@@ -242,39 +242,27 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/openai@npm:2.0.52":
version: 2.0.52
resolution: "@ai-sdk/openai@npm:2.0.52"
"@ai-sdk/openai@npm:2.0.48, @ai-sdk/openai@npm:^2.0.48":
version: 2.0.48
resolution: "@ai-sdk/openai@npm:2.0.48"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/253125303235dc677e272eaffbcd5c788373e12f897e42da7cce827bcc952f31e4bb11b72ba06931f37d49a2588f6cba8526127d539025bbd58d78d7bcfc691d
checksum: 10c0/6c584d7ffb80025da6b7253106a83f8c7a023e8ca322fd32e6858453782d6a0a6d268d7afa7145e3ea743a9c6cbc882932bb59eb1a659750f5205639c414fb49
languageName: node
linkType: hard
"@ai-sdk/openai@npm:^2.0.42":
version: 2.0.53
resolution: "@ai-sdk/openai@npm:2.0.53"
version: 2.0.47
resolution: "@ai-sdk/openai@npm:2.0.47"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
"@ai-sdk/provider-utils": "npm:3.0.11"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/acb014c7e4d99be0502fe2190c3b91c76ee86ade25e80dad939ffd113a5f013f29a81f06e13fa0e6a76b49fcb8cc524aab180fc1a622ceb8d3dac58fd655de1c
languageName: node
linkType: hard
"@ai-sdk/openai@patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch":
version: 2.0.52
resolution: "@ai-sdk/openai@patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch::version=2.0.52&hash=c7ceb9"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/a3ac267a645ffd50952c312318d0ea6190e1ca961f910f9e3067211df731ac4ba0eb89face21b5cc195770b643326b295a6fece91f07b60db8aef32f45d4664e
checksum: 10c0/7fabcdda707134971bcc2b285705d4595f8bf419285dbdd9266b3b0858ea11b6ac200e63dd2eeb1822f99571910093d64d4a76154a365331cf184f56452933c6
languageName: node
linkType: hard
@@ -303,6 +291,19 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/provider-utils@npm:3.0.11":
version: 3.0.11
resolution: "@ai-sdk/provider-utils@npm:3.0.11"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@standard-schema/spec": "npm:^1.0.0"
eventsource-parser: "npm:^3.0.5"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/31081b127b48f3eefb448eaca59574b4631da9577aa0778622d28669c71bbde0361c9b37962c5edbb1d0c163ed1479755fc889da9251a03e906b1e27d0d2eb24
languageName: node
linkType: hard
"@ai-sdk/provider-utils@npm:3.0.12, @ai-sdk/provider-utils@npm:^3.0.12":
version: 3.0.12
resolution: "@ai-sdk/provider-utils@npm:3.0.12"
@@ -2563,10 +2564,10 @@ __metadata:
version: 0.0.0-use.local
resolution: "@cherrystudio/ai-core@workspace:packages/aiCore"
dependencies:
"@ai-sdk/anthropic": "npm:^2.0.32"
"@ai-sdk/azure": "npm:^2.0.53"
"@ai-sdk/anthropic": "npm:^2.0.27"
"@ai-sdk/azure": "npm:^2.0.49"
"@ai-sdk/deepseek": "npm:^1.0.23"
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch"
"@ai-sdk/openai": "npm:^2.0.48"
"@ai-sdk/openai-compatible": "npm:^1.0.22"
"@ai-sdk/provider": "npm:^2.0.0"
"@ai-sdk/provider-utils": "npm:^3.0.12"
@@ -16865,10 +16866,10 @@ __metadata:
languageName: node
linkType: hard
"@vercel/oidc@npm:3.0.3":
version: 3.0.3
resolution: "@vercel/oidc@npm:3.0.3"
checksum: 10c0/c8eecb1324559435f4ab8a955f5ef44f74f546d11c2ddcf28151cb636d989bd4b34e0673fd8716cb21bb21afb34b3de663bacc30c9506036eeecbcbf2fd86241
"@vercel/oidc@npm:3.0.2":
version: 3.0.2
resolution: "@vercel/oidc@npm:3.0.2"
checksum: 10c0/8d4c8553baa5aed339ab7614d775139bc124a6d443b76877ab17e98c156daa4dbeb3cf2f3bf21fabfae2ac0dd3ff462ab43b9398708e02483e5923d302a1c4c8
languageName: node
linkType: hard
@@ -17175,8 +17176,8 @@ __metadata:
"@agentic/exa": "npm:^7.3.3"
"@agentic/searxng": "npm:^7.3.3"
"@agentic/tavily": "npm:^7.3.3"
"@ai-sdk/amazon-bedrock": "npm:^3.0.42"
"@ai-sdk/google-vertex": "npm:^3.0.48"
"@ai-sdk/amazon-bedrock": "npm:^3.0.35"
"@ai-sdk/google-vertex": "npm:^3.0.40"
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch"
"@ai-sdk/mistral": "npm:^2.0.19"
"@ai-sdk/perplexity": "npm:^2.0.13"
@@ -17304,7 +17305,7 @@ __metadata:
"@viz-js/lang-dot": "npm:^1.0.5"
"@viz-js/viz": "npm:^3.14.0"
"@xyflow/react": "npm:^12.4.4"
ai: "npm:^5.0.76"
ai: "npm:^5.0.68"
antd: "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch"
archiver: "npm:^7.0.1"
async-mutex: "npm:^0.5.0"
@@ -17571,17 +17572,17 @@ __metadata:
languageName: node
linkType: hard
"ai@npm:^5.0.76":
version: 5.0.76
resolution: "ai@npm:5.0.76"
"ai@npm:^5.0.68":
version: 5.0.68
resolution: "ai@npm:5.0.68"
dependencies:
"@ai-sdk/gateway": "npm:2.0.0"
"@ai-sdk/gateway": "npm:1.0.39"
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.12"
"@opentelemetry/api": "npm:1.9.0"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/167a191354b72106b1af6cfc8b53975637ca43919b8f48db81c0cf542ef0172f55958ed9331adcd08d017a608a98cb0b4a253c62732038322c78091e32595771
checksum: 10c0/0c042cd58c7193a47b06b3074a9e62790c4d5a8134e8e12bbb750714151e9aa217c641ee60c8cbe59d9869bade52ccbb283f9fcbf6d79711ebf1f774fa3feee3
languageName: node
linkType: hard