Compare commits
24 Commits
refactor/o
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c8b6b5b4 | ||
|
|
f5136a0adb | ||
|
|
99873a0767 | ||
|
|
34affb4533 | ||
|
|
cf008ca22e | ||
|
|
851ff8992f | ||
|
|
91f9088436 | ||
|
|
c971daf23c | ||
|
|
0c7cee2700 | ||
|
|
dfbfc2869c | ||
|
|
1575e97168 | ||
|
|
e0a2ed0481 | ||
|
|
3e9d9f16d6 | ||
|
|
f3a279d8de | ||
|
|
5790c12011 | ||
|
|
352ecbc506 | ||
|
|
fc4f30feab | ||
|
|
888a183328 | ||
|
|
9a01e092f6 | ||
|
|
5986800c9d | ||
|
|
56d68276e1 | ||
|
|
29c1173365 | ||
|
|
c7ceb3035d | ||
|
|
7bcae6fba2 |
6
.github/workflows/issue-management.yml
vendored
6
.github/workflows/issue-management.yml
vendored
@@ -29,8 +29,10 @@ jobs:
|
||||
days-before-close: 0 # Close immediately after stale
|
||||
stale-issue-label: 'inactive'
|
||||
close-issue-label: 'closed:no-response'
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: true
|
||||
stale-issue-message: |
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
It will be closed now due to lack of additional information.
|
||||
|
||||
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
|
||||
@@ -46,6 +48,8 @@ jobs:
|
||||
days-before-stale: ${{ env.daysBeforeStale }}
|
||||
days-before-close: ${{ env.daysBeforeClose }}
|
||||
stale-issue-label: 'inactive'
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: true
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
|
||||
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
26
.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch
vendored
Normal file
26
.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
76
.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch
vendored
Normal file
76
.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
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" });
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
|
||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
}
|
||||
|
||||
|
||||
// ../src/transport/ProcessTransport.ts
|
||||
-import { spawn } from "child_process";
|
||||
+import { fork } from "child_process";
|
||||
import { createInterface } from "readline";
|
||||
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6473,14 +6473,11 @@ class ProcessTransport {
|
||||
@@ -6487,14 +6487,11 @@ class ProcessTransport {
|
||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
||||
throw new ReferenceError(errorMessage);
|
||||
}
|
||||
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
|
||||
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
|
||||
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
||||
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
|
||||
- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
||||
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`);
|
||||
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
|
||||
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
|
||||
- this.child = spawn(spawnCommand, spawnArgs, {
|
||||
@@ -10,8 +10,9 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
|
||||
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
|
||||
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
|
||||
- **Seek review**: Ask a human developer to review substantial changes before merging.
|
||||
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
|
||||
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
|
||||
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
|
||||
📝 docs:`).
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
},
|
||||
"files": { "ignoreUnknown": false },
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**"],
|
||||
"maxSize": 2097152
|
||||
},
|
||||
"formatter": {
|
||||
"attributePosition": "auto",
|
||||
"bracketSameLine": false,
|
||||
|
||||
191
docs/technical/ShortcutSystemRefactor.md
Normal file
191
docs/technical/ShortcutSystemRefactor.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 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` 优化等补充说明。
|
||||
@@ -67,6 +67,10 @@ asarUnpack:
|
||||
extraResources:
|
||||
- from: "migrations/sqlite-drizzle"
|
||||
to: "migrations/sqlite-drizzle"
|
||||
# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso
|
||||
- from: "./node_modules/claude-code-plugins/plugins/"
|
||||
to: "claude-code-plugins"
|
||||
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
|
||||
15
package.json
15
package.json
@@ -81,7 +81,7 @@
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
@@ -89,6 +89,8 @@
|
||||
"express": "^5.1.0",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
@@ -104,8 +106,8 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.35",
|
||||
"@ai-sdk/google-vertex": "^3.0.40",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.42",
|
||||
"@ai-sdk/google-vertex": "^3.0.48",
|
||||
"@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",
|
||||
@@ -198,6 +200,7 @@
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/he": "^1",
|
||||
"@types/html-to-text": "^9",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
@@ -227,7 +230,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.68",
|
||||
"ai": "^5.0.76",
|
||||
"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",
|
||||
@@ -237,6 +240,7 @@
|
||||
"check-disk-space": "3.4.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"claude-code-plugins": "1.0.1",
|
||||
"cli-progress": "^3.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
@@ -389,7 +393,8 @@
|
||||
"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.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.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",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
"@img/sharp-linux-arm": "0.34.3",
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.27",
|
||||
"@ai-sdk/azure": "^2.0.49",
|
||||
"@ai-sdk/anthropic": "^2.0.32",
|
||||
"@ai-sdk/azure": "^2.0.53",
|
||||
"@ai-sdk/deepseek": "^1.0.23",
|
||||
"@ai-sdk/openai": "^2.0.48",
|
||||
"@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-compatible": "^1.0.22",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.12",
|
||||
|
||||
@@ -96,6 +96,10 @@ export enum IpcChannel {
|
||||
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
||||
AgentMessage_GetHistory = 'agent-message:get-history',
|
||||
|
||||
AgentToolPermission_Request = 'agent-tool-permission:request',
|
||||
AgentToolPermission_Response = 'agent-tool-permission:response',
|
||||
AgentToolPermission_Result = 'agent-tool-permission:result',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
@@ -382,5 +386,14 @@ export enum IpcChannel {
|
||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||
|
||||
// CherryAI
|
||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||
Cherryai_GetSignature = 'cherryai:get-signature',
|
||||
|
||||
// Claude Code Plugins
|
||||
ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available',
|
||||
ClaudeCodePlugin_Install = 'claudeCodePlugin:install',
|
||||
ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall',
|
||||
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
|
||||
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
|
||||
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
|
||||
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
|
||||
}
|
||||
|
||||
@@ -373,6 +373,8 @@ 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
|
||||
@@ -383,6 +385,10 @@ export interface PreferenceSchemas {
|
||||
'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
|
||||
@@ -638,6 +644,12 @@ 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,
|
||||
@@ -648,6 +660,18 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
'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',
|
||||
|
||||
148
packages/shared/shortcuts/definitions.ts
Normal file
148
packages/shared/shortcuts/definitions.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { ShortcutCategory, ShortcutDefinition } from './types'
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
|
||||
// ==================== 应用级快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.app.show_main_window',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'A'],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
persistOnBlur: true
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.show_mini_window',
|
||||
defaultKey: ['CommandOrControl', 'E'],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
persistOnBlur: true,
|
||||
enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled')
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.show_settings',
|
||||
defaultKey: ['CommandOrControl', ','],
|
||||
scope: 'both',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.toggle_show_assistants',
|
||||
defaultKey: ['CommandOrControl', '['],
|
||||
scope: 'renderer',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.exit_fullscreen',
|
||||
defaultKey: ['Escape'],
|
||||
scope: 'renderer',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.zoom_in',
|
||||
defaultKey: ['CommandOrControl', '='],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
variants: [['CommandOrControl', 'numadd']]
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.zoom_out',
|
||||
defaultKey: ['CommandOrControl', '-'],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
variants: [['CommandOrControl', 'numsub']]
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.zoom_reset',
|
||||
defaultKey: ['CommandOrControl', '0'],
|
||||
scope: 'main',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.search_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'F'],
|
||||
scope: 'renderer',
|
||||
category: 'app'
|
||||
},
|
||||
// ==================== 聊天相关快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.chat.clear',
|
||||
defaultKey: ['CommandOrControl', 'L'],
|
||||
scope: 'renderer',
|
||||
category: 'chat'
|
||||
},
|
||||
{
|
||||
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',
|
||||
defaultKey: ['CommandOrControl', 'N'],
|
||||
scope: 'renderer',
|
||||
category: 'topic'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.topic.rename',
|
||||
defaultKey: ['CommandOrControl', 'T'],
|
||||
scope: 'renderer',
|
||||
category: 'topic'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.topic.toggle_show_topics',
|
||||
defaultKey: ['CommandOrControl', ']'],
|
||||
scope: 'renderer',
|
||||
category: 'topic'
|
||||
},
|
||||
// ==================== 划词助手快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.selection.toggle_enabled',
|
||||
defaultKey: [],
|
||||
scope: 'main',
|
||||
category: 'selection',
|
||||
persistOnBlur: true
|
||||
},
|
||||
{
|
||||
key: 'shortcut.selection.get_text',
|
||||
defaultKey: [],
|
||||
scope: 'main',
|
||||
category: 'selection',
|
||||
persistOnBlur: 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)
|
||||
}
|
||||
40
packages/shared/shortcuts/types.ts
Normal file
40
packages/shared/shortcuts/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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 interface ShortcutDefinition {
|
||||
key: ShortcutPreferenceKey
|
||||
defaultKey: 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 type ShortcutHandler = (window?: BrowserWindow) => void | Promise<void>
|
||||
137
packages/shared/shortcuts/utils.ts
Normal file
137
packages/shared/shortcuts/utils.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
368
packages/ui/DESIGN_SYSTEM.md
Normal file
368
packages/ui/DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# 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-` 品牌类
|
||||
@@ -1,4 +1,26 @@
|
||||
# UI Component Library Migration Status
|
||||
# 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
|
||||
|
||||
## Usage Example
|
||||
|
||||
@@ -24,115 +46,68 @@ function MyComponent() {
|
||||
@packages/ui/
|
||||
├── src/
|
||||
│ ├── components/ # Main components directory
|
||||
│ │ ├── 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)
|
||||
│ │ ├── 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.)
|
||||
│ ├── hooks/ # Custom React Hooks
|
||||
│ └── types/ # TypeScript type definitions
|
||||
│ ├── styles/ # Global styles and CSS variables
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── utils/ # Utility functions
|
||||
│ └── index.ts # Main export file
|
||||
```
|
||||
|
||||
### Component Classification Guide
|
||||
|
||||
When submitting PRs, please place components in the correct directory based on their function:
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
- **icons**: All icon-related components
|
||||
- **interactive**: Components requiring user interaction like modals, drawers, tooltips, dropdowns, etc.
|
||||
- **composite**: Composite components made from multiple base 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.)
|
||||
|
||||
## Migration Overview
|
||||
## Component Extraction Criteria
|
||||
|
||||
- **Total Components**: 236
|
||||
- **Migrated**: 34
|
||||
- **Refactored**: 18
|
||||
- **Pending Migration**: 184
|
||||
### Extraction Standards
|
||||
|
||||
## Component Status Table
|
||||
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
|
||||
|
||||
| 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) |
|
||||
### 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
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 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
|
||||
| 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 |
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -143,9 +118,27 @@ 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 HeroUI later)
|
||||
- Components using antd (replace with shadcn/ui later)
|
||||
- Components using HeroUI (replace with shadcn/ui later)
|
||||
|
||||
3. **Submission Guidelines**:
|
||||
- Each PR should focus on one category of components
|
||||
- Ensure all migrated components are exported
|
||||
- Update migration status in this document
|
||||
- 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
|
||||
870
packages/ui/todocss.css
Normal file
870
packages/ui/todocss.css
Normal file
@@ -0,0 +1,870 @@
|
||||
: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;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
setupAppImageDeepLink
|
||||
} from './services/ProtocolClient'
|
||||
import selectionService, { initSelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { shortcutService } 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()) {
|
||||
}
|
||||
})
|
||||
|
||||
registerShortcuts(mainWindow)
|
||||
shortcutService.registerForWindow(mainWindow)
|
||||
registerIpc(mainWindow, app)
|
||||
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
|
||||
140
src/main/ipc.ts
140
src/main/ipc.ts
@@ -19,8 +19,8 @@ import type {
|
||||
FileMetadata,
|
||||
Notification,
|
||||
OcrProvider,
|
||||
PluginError,
|
||||
Provider,
|
||||
Shortcut,
|
||||
SupportedOcrFile
|
||||
} from '@types'
|
||||
import checkDiskSpace from 'check-disk-space'
|
||||
@@ -34,7 +34,6 @@ import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { codeToolsService } from './services/CodeToolsService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@@ -49,12 +48,12 @@ import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import OvmsManager from './services/OvmsManager'
|
||||
import { PluginService } from './services/PluginService'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import {
|
||||
addEndMessage,
|
||||
addStreamMessage,
|
||||
@@ -95,6 +94,18 @@ const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
const ovmsManager = new OvmsManager()
|
||||
const pluginService = PluginService.getInstance()
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function extractPluginError(error: unknown): PluginError | null {
|
||||
if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') {
|
||||
return error as PluginError
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
@@ -567,16 +578,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
await shell.openPath(path)
|
||||
})
|
||||
|
||||
// shortcuts
|
||||
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
|
||||
configManager.setShortcuts(shortcuts)
|
||||
// Refresh shortcuts registration
|
||||
if (mainWindow) {
|
||||
unregisterAllShortcuts()
|
||||
registerShortcuts(mainWindow)
|
||||
}
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -894,6 +895,119 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// CherryAI
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
// Claude Code Plugins
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => {
|
||||
try {
|
||||
const data = await pluginService.listAvailable()
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to list available plugins', pluginError)
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to list available plugins', err)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation: 'list-available',
|
||||
reason: err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => {
|
||||
try {
|
||||
const data = await pluginService.install(options)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
logger.error('Failed to install plugin', { options, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => {
|
||||
try {
|
||||
await pluginService.uninstall(options)
|
||||
return { success: true, data: undefined }
|
||||
} catch (error) {
|
||||
logger.error('Failed to uninstall plugin', { options, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => {
|
||||
try {
|
||||
const data = await pluginService.listInstalled(agentId)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to list installed plugins', { agentId, error: err })
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation: 'list-installed',
|
||||
reason: err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => {
|
||||
try {
|
||||
pluginService.invalidateCache()
|
||||
return { success: true, data: undefined }
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to invalidate plugin cache', pluginError)
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to invalidate plugin cache', err)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation: 'invalidate-cache',
|
||||
reason: err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => {
|
||||
try {
|
||||
const data = await pluginService.readContent(sourcePath)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
logger.error('Failed to read plugin content', { sourcePath, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => {
|
||||
try {
|
||||
await pluginService.writeContent(options.agentId, options.filename, options.type, options.content)
|
||||
return { success: true, data: undefined }
|
||||
} catch (error) {
|
||||
logger.error('Failed to write plugin content', { options, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
// Preference handlers
|
||||
PreferenceService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -9,7 +8,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, apiVersion, baseURL } = embedApiClient
|
||||
const { model, provider, apiKey, baseURL } = embedApiClient
|
||||
if (provider === 'voyageai') {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
@@ -38,16 +37,7 @@ export default class EmbeddingsFactory {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (apiVersion !== undefined) {
|
||||
return new AzureOpenAiEmbeddings({
|
||||
azureOpenAIApiKey: apiKey,
|
||||
azureOpenAIApiVersion: apiVersion,
|
||||
azureOpenAIApiDeploymentName: model,
|
||||
azureOpenAIEndpoint: baseURL,
|
||||
dimensions,
|
||||
batchSize
|
||||
})
|
||||
}
|
||||
// NOTE: Azure OpenAI 也走 OpenAIEmbeddings, baseURL是https://xxxx.openai.azure.com/openai/v1
|
||||
return new OpenAiEmbeddings({
|
||||
model,
|
||||
apiKey,
|
||||
|
||||
199
src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts
Normal file
199
src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import type { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { net } from 'electron'
|
||||
import FormData from 'form-data'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
const logger = loggerService.withContext('MineruPreprocessProvider')
|
||||
|
||||
export default class OpenMineruPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider, userId?: string) {
|
||||
super(provider, userId)
|
||||
}
|
||||
|
||||
public async parseFile(
|
||||
sourceId: string,
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||
try {
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`Open MinerU preprocess processing started: ${filePath}`)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 1. Update progress
|
||||
await this.sendPreprocessProgress(sourceId, 50)
|
||||
logger.info(`File ${file.name} is starting processing...`)
|
||||
|
||||
// 2. Upload file and extract
|
||||
const { path: outputPath } = await this.uploadFileAndExtract(file)
|
||||
|
||||
// 3. Check quota
|
||||
const quota = await this.checkQuota()
|
||||
|
||||
// 4. Create processed file info
|
||||
return {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath),
|
||||
quota
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Open MinerU preprocess processing failed for:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuota() {
|
||||
// self-hosted version always has enough quota
|
||||
return Infinity
|
||||
}
|
||||
|
||||
private async validateFile(filePath: string): Promise<void> {
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const doc = await this.readPdf(pdfBuffer)
|
||||
|
||||
// File page count must be less than 600 pages
|
||||
if (doc.numPages >= 600) {
|
||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
||||
}
|
||||
// File size must be less than 200MB
|
||||
if (pdfBuffer.length >= 200 * 1024 * 1024) {
|
||||
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
|
||||
}
|
||||
}
|
||||
|
||||
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||
// Find the main file after extraction
|
||||
let finalPath = ''
|
||||
let finalName = file.origin_name.replace('.pdf', '.md')
|
||||
// Find the corresponding folder by file name
|
||||
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
|
||||
try {
|
||||
const files = fs.readdirSync(outputPath)
|
||||
|
||||
const mdFile = files.find((f) => f.endsWith('.md'))
|
||||
if (mdFile) {
|
||||
const originalMdPath = path.join(outputPath, mdFile)
|
||||
const newMdPath = path.join(outputPath, finalName)
|
||||
|
||||
// Rename file to original file name
|
||||
try {
|
||||
fs.renameSync(originalMdPath, newMdPath)
|
||||
finalPath = newMdPath
|
||||
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
|
||||
} catch (renameError) {
|
||||
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
|
||||
// If rename fails, use the original file
|
||||
finalPath = originalMdPath
|
||||
finalName = mdFile
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to read output directory ${outputPath}:`, error as Error)
|
||||
finalPath = path.join(outputPath, `${file.id}.md`)
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
name: finalName,
|
||||
path: finalPath,
|
||||
ext: '.md',
|
||||
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFileAndExtract(
|
||||
file: FileMetadata,
|
||||
maxRetries: number = 5,
|
||||
intervalMs: number = 5000
|
||||
): Promise<{ path: string }> {
|
||||
let retries = 0
|
||||
|
||||
const endpoint = `${this.provider.apiHost}/file_parse`
|
||||
|
||||
// Get file stream
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
const fileBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('return_md', 'true')
|
||||
formData.append('response_format_zip', 'true')
|
||||
formData.append('files', fileBuffer, {
|
||||
filename: file.origin_name
|
||||
})
|
||||
|
||||
while (retries < maxRetries) {
|
||||
let zipPath: string | undefined
|
||||
|
||||
try {
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: this.userId ?? '',
|
||||
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
|
||||
...formData.getHeaders()
|
||||
},
|
||||
body: formData.getBuffer()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Check if response header is application/zip
|
||||
if (response.headers.get('content-type') !== 'application/zip') {
|
||||
throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`)
|
||||
}
|
||||
|
||||
const dirPath = this.storageDir
|
||||
|
||||
zipPath = path.join(dirPath, `${file.id}.zip`)
|
||||
const extractPath = path.join(dirPath, `${file.id}`)
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
|
||||
logger.info(`Downloaded ZIP file: ${zipPath}`)
|
||||
|
||||
// Ensure extraction directory exists
|
||||
if (!fs.existsSync(extractPath)) {
|
||||
fs.mkdirSync(extractPath, { recursive: true })
|
||||
}
|
||||
|
||||
// Extract files
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(extractPath, true)
|
||||
logger.info(`Extracted files to: ${extractPath}`)
|
||||
|
||||
return { path: extractPath }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}`
|
||||
)
|
||||
if (retries === maxRetries - 1) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
// Delete temporary ZIP file
|
||||
if (zipPath && fs.existsSync(zipPath)) {
|
||||
try {
|
||||
fs.unlinkSync(zipPath)
|
||||
logger.info(`Deleted temporary ZIP file: ${zipPath}`)
|
||||
} catch (deleteError) {
|
||||
logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retries++
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||
}
|
||||
|
||||
throw new Error(`Processing timeout for file: ${file.id}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import DefaultPreprocessProvider from './DefaultPreprocessProvider'
|
||||
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
|
||||
import MineruPreprocessProvider from './MineruPreprocessProvider'
|
||||
import MistralPreprocessProvider from './MistralPreprocessProvider'
|
||||
import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider'
|
||||
export default class PreprocessProviderFactory {
|
||||
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
|
||||
switch (provider.id) {
|
||||
@@ -14,6 +15,8 @@ export default class PreprocessProviderFactory {
|
||||
return new MistralPreprocessProvider(provider)
|
||||
case 'mineru':
|
||||
return new MineruPreprocessProvider(provider, userId)
|
||||
case 'open-mineru':
|
||||
return new OpenMineruPreprocessProvider(provider, userId)
|
||||
default:
|
||||
return new DefaultPreprocessProvider(provider)
|
||||
}
|
||||
|
||||
1171
src/main/services/PluginService.ts
Normal file
1171
src/main/services/PluginService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,298 +1,307 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { loggerService } from '@logger'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import type { Shortcut } from '@types'
|
||||
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 type { BrowserWindow } from 'electron'
|
||||
import { globalShortcut } from 'electron'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('ShortcutService')
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
let showMiniWindowAccelerator: string | null = null
|
||||
let selectionAssistantToggleAccelerator: string | null = null
|
||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
||||
const toAccelerator = (keys: string[]): string => keys.join('+')
|
||||
|
||||
//indicate if the shortcuts are registered on app boot time
|
||||
let isRegisterOnBoot = true
|
||||
const relevantDefinitions = SHORTCUT_DEFINITIONS.filter((definition) => definition.scope !== 'renderer')
|
||||
|
||||
// store the focus and blur handlers for each window to unregister them later
|
||||
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
|
||||
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> = []
|
||||
|
||||
function getShortcutHandler(shortcut: Shortcut) {
|
||||
switch (shortcut.key) {
|
||||
case 'zoom_in':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
|
||||
case 'zoom_out':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
|
||||
case 'zoom_reset':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
|
||||
case 'show_app':
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
}
|
||||
case 'mini_window':
|
||||
return () => {
|
||||
windowService.toggleMiniWindow()
|
||||
}
|
||||
case 'selection_assistant_toggle':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
}
|
||||
}
|
||||
case 'selection_assistant_select_text':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.processSelectTextByShortcut()
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
constructor() {
|
||||
this.registerBuiltInHandlers()
|
||||
this.subscribeToPreferenceChanges()
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortcutKey(shortcut: string[]): string {
|
||||
return shortcut.join('+')
|
||||
}
|
||||
|
||||
// convert the shortcut recorded by JS keyboard event key value to electron global shortcut format
|
||||
// see: https://www.electronjs.org/zh/docs/latest/api/accelerator
|
||||
const convertShortcutFormat = (shortcut: string | string[]): string => {
|
||||
const accelerator = (() => {
|
||||
if (Array.isArray(shortcut)) {
|
||||
return shortcut
|
||||
} else {
|
||||
return shortcut.split('+').map((key) => key.trim())
|
||||
public registerHandler(key: ShortcutPreferenceKey, handler: ShortcutHandler): void {
|
||||
if (this.handlers.has(key)) {
|
||||
logger.warn(`Handler for ${key} is being overwritten`)
|
||||
}
|
||||
})()
|
||||
|
||||
return accelerator
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
|
||||
// case 'Command':
|
||||
// return 'CommandOrControl'
|
||||
// case 'Control':
|
||||
// return 'Control'
|
||||
// case 'Ctrl':
|
||||
// return 'Control'
|
||||
|
||||
// NEW WAY FOR MODIFIER KEYS
|
||||
// you can see all the modifier keys in the same
|
||||
case 'CommandOrControl':
|
||||
return 'CommandOrControl'
|
||||
case 'Ctrl':
|
||||
return 'Ctrl'
|
||||
case 'Alt':
|
||||
return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms.
|
||||
case 'Meta':
|
||||
return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS.
|
||||
case 'Shift':
|
||||
return 'Shift'
|
||||
|
||||
// For backward compatibility with old data
|
||||
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 '='
|
||||
default:
|
||||
return key
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
if (isRegisterOnBoot) {
|
||||
window.once('ready-to-show', () => {
|
||||
if (preferenceService.get('app.tray.on_launch')) {
|
||||
registerOnlyUniversalShortcuts()
|
||||
}
|
||||
})
|
||||
isRegisterOnBoot = false
|
||||
this.handlers.set(key, handler)
|
||||
logger.debug(`Registered handler for ${key}`)
|
||||
}
|
||||
|
||||
//only for clearer code
|
||||
const registerOnlyUniversalShortcuts = () => {
|
||||
register(true)
|
||||
public registerForWindow(window: BrowserWindow): void {
|
||||
if (this.windowLifecycleHandlers.has(window)) {
|
||||
logger.warn(`Window ${window.id} already registered for shortcuts`)
|
||||
return
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
|
||||
//onlyUniversalShortcuts is needed when we launch to tray
|
||||
const register = (onlyUniversalShortcuts: boolean = false) => {
|
||||
if (window.isDestroyed()) return
|
||||
public unregisterWindow(window: BrowserWindow): void {
|
||||
const lifecycle = this.windowLifecycleHandlers.get(window)
|
||||
if (!lifecycle) {
|
||||
return
|
||||
}
|
||||
|
||||
const shortcuts = configManager.getShortcuts()
|
||||
if (!shortcuts) return
|
||||
window.off('focus', lifecycle.onFocus)
|
||||
window.off('blur', lifecycle.onBlur)
|
||||
window.off('closed', lifecycle.onClosed)
|
||||
|
||||
shortcuts.forEach((shortcut) => {
|
||||
try {
|
||||
if (shortcut.shortcut.length === 0) {
|
||||
this.windowLifecycleHandlers.delete(window)
|
||||
|
||||
if (this.currentWindow === window) {
|
||||
this.currentWindow = null
|
||||
globalShortcut.unregisterAll()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private registerAllShortcuts(window: BrowserWindow): void {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
relevantDefinitions.forEach((definition) => {
|
||||
const runtimeConfig = this.getRuntimeConfig(definition)
|
||||
if (!runtimeConfig.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
|
||||
logger.debug(`Skipping ${definition.key} - enabledWhen condition not met`)
|
||||
return
|
||||
}
|
||||
|
||||
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 unregisterTransientShortcuts(window: BrowserWindow): void {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
relevantDefinitions
|
||||
.filter((definition) => definition.persistOnBlur)
|
||||
.forEach((definition) => {
|
||||
const runtimeConfig = this.getRuntimeConfig(definition)
|
||||
if (!runtimeConfig.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
//if not enabled, exit early from the process.
|
||||
if (!shortcut.enabled) {
|
||||
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
// only register universal shortcuts when needed
|
||||
if (
|
||||
onlyUniversalShortcuts &&
|
||||
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
|
||||
shortcut.key
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const handler = getShortcutHandler(shortcut)
|
||||
const handler = this.handlers.get(definition.key)
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (shortcut.key) {
|
||||
case 'show_app':
|
||||
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
|
||||
|
||||
case 'mini_window':
|
||||
//available only when QuickAssistant enabled
|
||||
if (!preferenceService.get('feature.quick_assistant.enabled')) {
|
||||
return
|
||||
}
|
||||
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_toggle':
|
||||
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_select_text':
|
||||
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
//the following ZOOMs will register shortcuts separately, so will return
|
||||
case 'zoom_in':
|
||||
globalShortcut.register('CommandOrControl+=', () => handler(window))
|
||||
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
|
||||
return
|
||||
|
||||
case 'zoom_out':
|
||||
globalShortcut.register('CommandOrControl+-', () => handler(window))
|
||||
globalShortcut.register('CommandOrControl+numsub', () => handler(window))
|
||||
return
|
||||
|
||||
case 'zoom_reset':
|
||||
globalShortcut.register('CommandOrControl+0', () => handler(window))
|
||||
return
|
||||
if (definition.variants) {
|
||||
definition.variants.forEach((variant) => {
|
||||
this.registerSingleShortcut(variant, handler, window)
|
||||
})
|
||||
}
|
||||
|
||||
const accelerator = convertShortcutFormat(shortcut.shortcut)
|
||||
|
||||
globalShortcut.register(accelerator, () => handler(window))
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to register shortcut ${shortcut.key}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const unregister = () => {
|
||||
if (window.isDestroyed()) return
|
||||
private registerSingleShortcut(keys: string[], handler: ShortcutHandler, window: BrowserWindow): void {
|
||||
if (!keys.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const accelerator = toAccelerator(keys)
|
||||
|
||||
try {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
if (showAppAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showAppAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showMiniWindowAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantToggleAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantSelectTextAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
globalShortcut.register(accelerator, () => {
|
||||
logger.debug(`Shortcut triggered: ${accelerator}`)
|
||||
const targetWindow = window?.isDestroyed?.() ? undefined : window
|
||||
handler(targetWindow)
|
||||
})
|
||||
logger.verbose(`Registered shortcut: ${accelerator}`)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to unregister shortcuts')
|
||||
logger.error(`Failed to register shortcut ${accelerator}:`, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// only register the event handlers once
|
||||
if (undefined === windowOnHandlers.get(window)) {
|
||||
// pass register() directly to listener, the func will receive Event as argument, it's not expected
|
||||
const registerHandler = () => {
|
||||
register()
|
||||
private getRuntimeConfig(definition: ShortcutDefinition): ShortcutRuntimeConfig {
|
||||
const preference = this.getPreference(definition)
|
||||
return {
|
||||
...definition,
|
||||
binding: preference.binding,
|
||||
enabled: preference.enabled,
|
||||
editable: preference.editable,
|
||||
system: preference.system
|
||||
}
|
||||
window.on('focus', registerHandler)
|
||||
window.on('blur', unregister)
|
||||
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
|
||||
}
|
||||
|
||||
if (!window.isDestroyed() && window.isFocused()) {
|
||||
register()
|
||||
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
|
||||
}
|
||||
|
||||
if (this.currentWindow.isFocused()) {
|
||||
this.registerAllShortcuts(this.currentWindow)
|
||||
return
|
||||
}
|
||||
|
||||
this.unregisterTransientShortcuts(this.currentWindow)
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
showMiniWindowAccelerator = null
|
||||
selectionAssistantToggleAccelerator = null
|
||||
selectionAssistantSelectTextAccelerator = null
|
||||
windowOnHandlers.forEach((handlers, window) => {
|
||||
window.off('focus', handlers.onFocusHandler)
|
||||
window.off('blur', handlers.onBlurHandler)
|
||||
})
|
||||
windowOnHandlers.clear()
|
||||
globalShortcut.unregisterAll()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to unregister all shortcuts')
|
||||
}
|
||||
}
|
||||
export const shortcutService = new ShortcutService()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import type { McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { config as apiConfigService } from '@main/apiServer/config'
|
||||
@@ -12,10 +12,23 @@ import { app } from 'electron'
|
||||
|
||||
import type { GetAgentSessionResponse } from '../..'
|
||||
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||
import { promptForToolApproval } from './tool-permissions'
|
||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
||||
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
|
||||
type UserInputMessage = {
|
||||
type: 'user'
|
||||
parent_tool_use_id: string | null
|
||||
session_id: string
|
||||
message: {
|
||||
role: 'user'
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
class ClaudeCodeStream extends EventEmitter implements AgentStream {
|
||||
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
|
||||
@@ -100,6 +113,41 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
const errorChunks: string[] = []
|
||||
|
||||
const sessionAllowedTools = new Set<string>(session.allowed_tools ?? [])
|
||||
const autoAllowTools = new Set<string>([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools])
|
||||
const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name)
|
||||
|
||||
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
||||
logger.info('Handling tool permission check', {
|
||||
toolName,
|
||||
suggestionCount: options.suggestions?.length ?? 0
|
||||
})
|
||||
|
||||
if (shouldAutoApproveTools) {
|
||||
logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName })
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
if (options.signal.aborted) {
|
||||
logger.debug('Permission request signal already aborted; denying tool', { toolName })
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Tool request was cancelled before prompting the user'
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedToolName = normalizeToolName(toolName)
|
||||
if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) {
|
||||
logger.debug('Auto-allowing tool from allowed list', {
|
||||
toolName,
|
||||
normalizedToolName
|
||||
})
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
return promptForToolApproval(toolName, input, options)
|
||||
}
|
||||
|
||||
// Build SDK options from parameters
|
||||
const options: Options = {
|
||||
abortController,
|
||||
@@ -122,7 +170,8 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
includePartialMessages: true,
|
||||
permissionMode: session.configuration?.permission_mode,
|
||||
maxTurns: session.configuration?.max_turns,
|
||||
allowedTools: session.allowed_tools
|
||||
allowedTools: session.allowed_tools,
|
||||
canUseTool
|
||||
}
|
||||
|
||||
if (session.accessible_paths.length > 1) {
|
||||
@@ -161,9 +210,14 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
resume: options.resume
|
||||
})
|
||||
|
||||
const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream(
|
||||
prompt,
|
||||
abortController.signal
|
||||
)
|
||||
|
||||
// Start async processing on the next tick so listeners can subscribe first
|
||||
setImmediate(() => {
|
||||
this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => {
|
||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
||||
logger.error('Unhandled Claude Code stream error', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
@@ -177,17 +231,90 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
return aiStream
|
||||
}
|
||||
|
||||
private async *userMessages(prompt: string) {
|
||||
{
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
message: {
|
||||
role: 'user' as const,
|
||||
content: prompt
|
||||
private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) {
|
||||
const queue: Array<UserInputMessage | null> = []
|
||||
const waiters: Array<(value: UserInputMessage | null) => void> = []
|
||||
let closed = false
|
||||
|
||||
const flushWaiters = (value: UserInputMessage | null) => {
|
||||
const resolve = waiters.shift()
|
||||
if (resolve) {
|
||||
resolve(value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const enqueue = (value: UserInputMessage | null) => {
|
||||
if (closed) return
|
||||
if (value === null) {
|
||||
closed = true
|
||||
}
|
||||
if (!flushWaiters(value)) {
|
||||
queue.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
enqueue(null)
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
close()
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
close()
|
||||
} else {
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
const iterator = (async function* () {
|
||||
try {
|
||||
while (true) {
|
||||
let value: UserInputMessage | null
|
||||
if (queue.length > 0) {
|
||||
value = queue.shift() ?? null
|
||||
} else if (closed) {
|
||||
break
|
||||
} else {
|
||||
// Wait for next message or close signal
|
||||
value = await new Promise<UserInputMessage | null>((resolve) => {
|
||||
waiters.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
break
|
||||
}
|
||||
|
||||
yield value
|
||||
}
|
||||
} finally {
|
||||
closed = true
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
while (waiters.length > 0) {
|
||||
const resolve = waiters.shift()
|
||||
resolve?.(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
enqueue({
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: initialPrompt
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
stream: iterator,
|
||||
enqueue,
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +322,8 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
* Process SDK query and emit stream events
|
||||
*/
|
||||
private async processSDKQuery(
|
||||
prompt: string,
|
||||
promptStream: AsyncIterable<UserInputMessage>,
|
||||
closePromptStream: () => void,
|
||||
options: Options,
|
||||
stream: ClaudeCodeStream,
|
||||
errorChunks: string[]
|
||||
@@ -203,14 +331,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
const jsonOutput: SDKMessage[] = []
|
||||
let hasCompleted = false
|
||||
const startTime = Date.now()
|
||||
|
||||
const streamState = new ClaudeStreamState()
|
||||
|
||||
try {
|
||||
// Process streaming responses using SDK query
|
||||
for await (const message of query({
|
||||
prompt: this.userMessages(prompt),
|
||||
options
|
||||
})) {
|
||||
for await (const message of query({ prompt: promptStream, options })) {
|
||||
if (hasCompleted) break
|
||||
|
||||
jsonOutput.push(message)
|
||||
@@ -221,10 +345,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
content: JSON.stringify(message.message.content)
|
||||
})
|
||||
} else if (message.type === 'stream_event') {
|
||||
logger.silly('Claude stream event', {
|
||||
message,
|
||||
event: JSON.stringify(message.event)
|
||||
})
|
||||
// logger.silly('Claude stream event', {
|
||||
// message,
|
||||
// event: JSON.stringify(message.event)
|
||||
// })
|
||||
} else {
|
||||
logger.silly('Claude response', {
|
||||
message,
|
||||
@@ -232,7 +356,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
})
|
||||
}
|
||||
|
||||
// Transform SDKMessage to UIMessageChunks
|
||||
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
||||
for (const chunk of chunks) {
|
||||
stream.emit('data', {
|
||||
@@ -242,7 +365,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully completed
|
||||
hasCompleted = true
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
@@ -251,7 +373,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
messageCount: jsonOutput.length
|
||||
})
|
||||
|
||||
// Emit completion event
|
||||
stream.emit('data', {
|
||||
type: 'complete'
|
||||
})
|
||||
@@ -260,8 +381,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
hasCompleted = true
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Check if this is an abort error
|
||||
const errorObj = error as any
|
||||
const isAborted =
|
||||
errorObj?.name === 'AbortError' ||
|
||||
@@ -270,7 +389,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
if (isAborted) {
|
||||
logger.info('SDK query aborted by client disconnect', { duration })
|
||||
// Simply cleanup and return - don't emit error events
|
||||
stream.emit('data', {
|
||||
type: 'cancelled',
|
||||
error: new Error('Request aborted by client')
|
||||
@@ -285,11 +403,13 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
|
||||
stderr: errorChunks
|
||||
})
|
||||
// Emit error event
|
||||
|
||||
stream.emit('data', {
|
||||
type: 'error',
|
||||
error: new Error(errorMessage)
|
||||
})
|
||||
} finally {
|
||||
closePromptStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
323
src/main/services/agents/services/claudecode/tool-permissions.ts
Normal file
323
src/main/services/agents/services/claudecode/tool-permissions.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { windowService } from '../../../WindowService'
|
||||
import { builtinTools } from './tools'
|
||||
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = 30_000
|
||||
const MAX_PREVIEW_LENGTH = 2_000
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
|
||||
type ToolPermissionBehavior = 'allow' | 'deny'
|
||||
|
||||
type ToolPermissionResponsePayload = {
|
||||
requestId: string
|
||||
behavior: ToolPermissionBehavior
|
||||
updatedInput?: unknown
|
||||
message?: string
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}
|
||||
|
||||
type PendingPermissionRequest = {
|
||||
fulfill: (update: PermissionResult) => void
|
||||
timeout: NodeJS.Timeout
|
||||
signal?: AbortSignal
|
||||
abortListener?: () => void
|
||||
originalInput: Record<string, unknown>
|
||||
toolName: string
|
||||
}
|
||||
|
||||
type RendererPermissionRequestPayload = {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
description?: string
|
||||
requiresPermissions: boolean
|
||||
input: Record<string, unknown>
|
||||
inputPreview: string
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
suggestions: PermissionUpdate[]
|
||||
}
|
||||
|
||||
type RendererPermissionResultPayload = {
|
||||
requestId: string
|
||||
behavior: ToolPermissionBehavior
|
||||
message?: string
|
||||
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, PendingPermissionRequest>()
|
||||
let ipcHandlersInitialized = false
|
||||
|
||||
const jsonReplacer = (_key: string, value: unknown) => {
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (value instanceof Map) return Object.fromEntries(value.entries())
|
||||
if (value instanceof Set) return Array.from(value.values())
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
if (typeof value === 'function') return undefined
|
||||
if (value === undefined) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
const sanitizeStructuredData = <T>(value: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value, jsonReplacer)) as T
|
||||
} catch (error) {
|
||||
logger.warn('Failed to sanitize structured data for tool permission payload', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const buildInputPreview = (value: unknown): string => {
|
||||
let preview: string
|
||||
|
||||
try {
|
||||
preview = JSON.stringify(value, null, 2)
|
||||
} catch (error) {
|
||||
preview = typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
if (preview.length > MAX_PREVIEW_LENGTH) {
|
||||
preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...`
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
const broadcastToRenderer = (
|
||||
channel: IpcChannel,
|
||||
payload: RendererPermissionRequestPayload | RendererPermissionResultPayload
|
||||
): boolean => {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
if (!mainWindow) {
|
||||
logger.warn('Unable to send agent tool permission payload – main window unavailable', {
|
||||
channel,
|
||||
requestId: 'requestId' in payload ? payload.requestId : undefined
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(channel, payload)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const finalizeRequest = (
|
||||
requestId: string,
|
||||
update: PermissionResult,
|
||||
reason: RendererPermissionResultPayload['reason']
|
||||
) => {
|
||||
const pending = pendingRequests.get(requestId)
|
||||
|
||||
if (!pending) {
|
||||
logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason })
|
||||
return false
|
||||
}
|
||||
|
||||
logger.debug('Finalizing tool permission request', {
|
||||
requestId,
|
||||
toolName: pending.toolName,
|
||||
behavior: update.behavior,
|
||||
reason
|
||||
})
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
if (pending.signal && pending.abortListener) {
|
||||
pending.signal.removeEventListener('abort', pending.abortListener)
|
||||
}
|
||||
|
||||
pending.fulfill(update)
|
||||
|
||||
const resultPayload: RendererPermissionResultPayload = {
|
||||
requestId,
|
||||
behavior: update.behavior,
|
||||
message: update.behavior === 'deny' ? update.message : undefined,
|
||||
reason
|
||||
}
|
||||
|
||||
const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload)
|
||||
|
||||
logger.debug('Sent tool permission result to renderer', {
|
||||
requestId,
|
||||
dispatched
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const ensureIpcHandlersRegistered = () => {
|
||||
if (ipcHandlersInitialized) return
|
||||
|
||||
ipcHandlersInitialized = true
|
||||
|
||||
ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => {
|
||||
logger.debug('main received AgentToolPermission_Response', payload)
|
||||
const { requestId, behavior, updatedInput, message } = payload
|
||||
const pending = pendingRequests.get(requestId)
|
||||
|
||||
if (!pending) {
|
||||
logger.warn('Received renderer tool permission response for unknown request', { requestId })
|
||||
return { success: false, error: 'unknown-request' }
|
||||
}
|
||||
|
||||
logger.debug('Received renderer response for tool permission', {
|
||||
requestId,
|
||||
toolName: pending.toolName,
|
||||
behavior,
|
||||
hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0
|
||||
})
|
||||
|
||||
const maybeUpdatedInput =
|
||||
updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput)
|
||||
? (updatedInput as Record<string, unknown>)
|
||||
: pending.originalInput
|
||||
|
||||
const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions)
|
||||
? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm))
|
||||
: undefined
|
||||
|
||||
const finalUpdate: PermissionResult =
|
||||
behavior === 'allow'
|
||||
? {
|
||||
behavior: 'allow',
|
||||
updatedInput: sanitizeStructuredData(maybeUpdatedInput),
|
||||
updatedPermissions: sanitizedUpdatedPermissions
|
||||
}
|
||||
: {
|
||||
behavior: 'deny',
|
||||
message: message ?? 'User denied permission for this tool'
|
||||
}
|
||||
|
||||
finalizeRequest(requestId, finalUpdate, 'response')
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
export async function promptForToolApproval(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
|
||||
): Promise<PermissionResult> {
|
||||
if (shouldAutoApproveTools) {
|
||||
logger.debug('promptForToolApproval auto-approving tool for test', {
|
||||
toolName
|
||||
})
|
||||
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
ensureIpcHandlersRegistered()
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName })
|
||||
return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' }
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
if (!mainWindow) {
|
||||
logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName })
|
||||
return { behavior: 'deny', message: 'Unable to request approval – renderer not ready' }
|
||||
}
|
||||
|
||||
const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName)
|
||||
const sanitizedInput = sanitizeStructuredData(input)
|
||||
const inputPreview = buildInputPreview(sanitizedInput)
|
||||
const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion))
|
||||
|
||||
const requestId = randomUUID()
|
||||
const createdAt = Date.now()
|
||||
const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS
|
||||
|
||||
logger.info('Requesting user approval for tool usage', {
|
||||
requestId,
|
||||
toolName,
|
||||
description: toolMetadata?.description
|
||||
})
|
||||
|
||||
const requestPayload: RendererPermissionRequestPayload = {
|
||||
requestId,
|
||||
toolName,
|
||||
toolId: toolMetadata?.id ?? toolName,
|
||||
description: toolMetadata?.description,
|
||||
requiresPermissions: toolMetadata?.requirePermissions ?? false,
|
||||
input: sanitizedInput,
|
||||
inputPreview,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
suggestions: sanitizedSuggestions
|
||||
}
|
||||
|
||||
const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' }
|
||||
|
||||
logger.debug('Registering tool permission request', {
|
||||
requestId,
|
||||
toolName,
|
||||
requiresPermissions: requestPayload.requiresPermissions,
|
||||
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
|
||||
suggestionCount: sanitizedSuggestions.length
|
||||
})
|
||||
|
||||
return new Promise<PermissionResult>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.info('User tool permission request timed out', { requestId, toolName })
|
||||
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
|
||||
}, TOOL_APPROVAL_TIMEOUT_MS)
|
||||
|
||||
const pending: PendingPermissionRequest = {
|
||||
fulfill: resolve,
|
||||
timeout,
|
||||
originalInput: sanitizedInput,
|
||||
toolName,
|
||||
signal: options?.signal
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
const abortListener = () => {
|
||||
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
|
||||
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
|
||||
}
|
||||
|
||||
pending.abortListener = abortListener
|
||||
options.signal.addEventListener('abort', abortListener, { once: true })
|
||||
}
|
||||
|
||||
pendingRequests.set(requestId, pending)
|
||||
|
||||
logger.debug('Pending tool permission request count', {
|
||||
count: pendingRequests.size
|
||||
})
|
||||
|
||||
const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload)
|
||||
|
||||
logger.debug('Broadcasted tool permission request to renderer', {
|
||||
requestId,
|
||||
toolName,
|
||||
sent
|
||||
})
|
||||
|
||||
if (!sent) {
|
||||
finalizeRequest(
|
||||
requestId,
|
||||
{
|
||||
behavior: 'deny',
|
||||
message: 'Unable to request approval because the renderer window is unavailable'
|
||||
},
|
||||
'no-window'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
223
src/main/utils/fileOperations.ts
Normal file
223
src/main/utils/fileOperations.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
import { isPathInside } from './file'
|
||||
|
||||
const logger = loggerService.withContext('Utils:FileOperations')
|
||||
|
||||
const MAX_RECURSION_DEPTH = 1000
|
||||
|
||||
/**
|
||||
* Recursively copy a directory and all its contents
|
||||
* @param source - Source directory path (must be absolute)
|
||||
* @param destination - Destination directory path (must be absolute)
|
||||
* @param options - Copy options
|
||||
* @param depth - Current recursion depth (internal use)
|
||||
* @throws If copy operation fails or paths are invalid
|
||||
*/
|
||||
export async function copyDirectoryRecursive(
|
||||
source: string,
|
||||
destination: string,
|
||||
options?: { allowedBasePath?: string },
|
||||
depth = 0
|
||||
): Promise<void> {
|
||||
// Input validation
|
||||
if (!source || !destination) {
|
||||
throw new TypeError('Source and destination paths are required')
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(source) || !path.isAbsolute(destination)) {
|
||||
throw new Error('Source and destination paths must be absolute')
|
||||
}
|
||||
|
||||
// Depth limit to prevent stack overflow
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
|
||||
}
|
||||
|
||||
// Path validation - ensure operations stay within allowed boundaries
|
||||
if (options?.allowedBasePath) {
|
||||
if (!isPathInside(source, options.allowedBasePath)) {
|
||||
throw new Error(`Source path is outside allowed directory: ${source}`)
|
||||
}
|
||||
if (!isPathInside(destination, options.allowedBasePath)) {
|
||||
throw new Error(`Destination path is outside allowed directory: ${destination}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify source exists and is a directory
|
||||
const sourceStats = await fs.promises.lstat(source)
|
||||
if (!sourceStats.isDirectory()) {
|
||||
throw new Error(`Source is not a directory: ${source}`)
|
||||
}
|
||||
|
||||
// Create destination directory
|
||||
await fs.promises.mkdir(destination, { recursive: true })
|
||||
logger.debug('Created destination directory', { destination })
|
||||
|
||||
// Read source directory
|
||||
const entries = await fs.promises.readdir(source, { withFileTypes: true })
|
||||
|
||||
// Copy each entry
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(source, entry.name)
|
||||
const destPath = path.join(destination, entry.name)
|
||||
|
||||
// Use lstat to detect symlinks and prevent following them
|
||||
const entryStats = await fs.promises.lstat(sourcePath)
|
||||
|
||||
if (entryStats.isSymbolicLink()) {
|
||||
logger.warn('Skipping symlink for security', { path: sourcePath })
|
||||
continue
|
||||
}
|
||||
|
||||
if (entryStats.isDirectory()) {
|
||||
// Recursively copy subdirectory
|
||||
await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1)
|
||||
} else if (entryStats.isFile()) {
|
||||
// Copy file with error handling for race conditions
|
||||
try {
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
// Preserve file permissions
|
||||
await fs.promises.chmod(destPath, entryStats.mode)
|
||||
logger.debug('Copied file', { from: sourcePath, to: destPath })
|
||||
} catch (error) {
|
||||
// Handle race condition where file was deleted during copy
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn('File disappeared during copy', { sourcePath })
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
// Skip special files (pipes, sockets, devices, etc.)
|
||||
logger.debug('Skipping special file', { path: sourcePath })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Directory copied successfully', { from: source, to: destination, depth })
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy directory', { source, destination, depth, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory and all its contents
|
||||
* @param dirPath - Directory path to delete (must be absolute)
|
||||
* @param options - Delete options
|
||||
* @throws If deletion fails or path is invalid
|
||||
*/
|
||||
export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise<void> {
|
||||
// Input validation
|
||||
if (!dirPath) {
|
||||
throw new TypeError('Directory path is required')
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(dirPath)) {
|
||||
throw new Error('Directory path must be absolute')
|
||||
}
|
||||
|
||||
// Path validation - ensure operations stay within allowed boundaries
|
||||
if (options?.allowedBasePath) {
|
||||
if (!isPathInside(dirPath, options.allowedBasePath)) {
|
||||
throw new Error(`Path is outside allowed directory: ${dirPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify path exists before attempting deletion
|
||||
try {
|
||||
const stats = await fs.promises.lstat(dirPath)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${dirPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn('Directory already deleted', { dirPath })
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Node.js 14.14+ has fs.rm with recursive option
|
||||
await fs.promises.rm(dirPath, { recursive: true, force: true })
|
||||
logger.info('Directory deleted successfully', { dirPath })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete directory', { dirPath, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total size of a directory (in bytes)
|
||||
* @param dirPath - Directory path (must be absolute)
|
||||
* @param options - Size calculation options
|
||||
* @param depth - Current recursion depth (internal use)
|
||||
* @returns Total size in bytes
|
||||
* @throws If size calculation fails or path is invalid
|
||||
*/
|
||||
export async function getDirectorySize(
|
||||
dirPath: string,
|
||||
options?: { allowedBasePath?: string },
|
||||
depth = 0
|
||||
): Promise<number> {
|
||||
// Input validation
|
||||
if (!dirPath) {
|
||||
throw new TypeError('Directory path is required')
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(dirPath)) {
|
||||
throw new Error('Directory path must be absolute')
|
||||
}
|
||||
|
||||
// Depth limit to prevent stack overflow
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
|
||||
}
|
||||
|
||||
// Path validation - ensure operations stay within allowed boundaries
|
||||
if (options?.allowedBasePath) {
|
||||
if (!isPathInside(dirPath, options.allowedBasePath)) {
|
||||
throw new Error(`Path is outside allowed directory: ${dirPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
let totalSize = 0
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dirPath, entry.name)
|
||||
|
||||
// Use lstat to detect symlinks and prevent following them
|
||||
const entryStats = await fs.promises.lstat(entryPath)
|
||||
|
||||
if (entryStats.isSymbolicLink()) {
|
||||
logger.debug('Skipping symlink in size calculation', { path: entryPath })
|
||||
continue
|
||||
}
|
||||
|
||||
if (entryStats.isDirectory()) {
|
||||
// Recursively get size of subdirectory
|
||||
totalSize += await getDirectorySize(entryPath, options, depth + 1)
|
||||
} else if (entryStats.isFile()) {
|
||||
// Get file size from lstat (already have it)
|
||||
totalSize += entryStats.size
|
||||
} else {
|
||||
// Skip special files
|
||||
logger.debug('Skipping special file in size calculation', { path: entryPath })
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Calculated directory size', { dirPath, size: totalSize, depth })
|
||||
return totalSize
|
||||
} catch (error) {
|
||||
logger.error('Failed to calculate directory size', { dirPath, depth, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
309
src/main/utils/markdownParser.ts
Normal file
309
src/main/utils/markdownParser.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PluginError, PluginMetadata } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import * as yaml from 'js-yaml'
|
||||
import * as path from 'path'
|
||||
|
||||
import { getDirectorySize } from './fileOperations'
|
||||
|
||||
const logger = loggerService.withContext('Utils:MarkdownParser')
|
||||
|
||||
/**
|
||||
* Parse plugin metadata from a markdown file with frontmatter
|
||||
* @param filePath Absolute path to the markdown file
|
||||
* @param sourcePath Relative source path from plugins directory
|
||||
* @param category Category name derived from parent folder
|
||||
* @param type Plugin type (agent or command)
|
||||
* @returns PluginMetadata object with parsed frontmatter and file info
|
||||
*/
|
||||
export async function parsePluginMetadata(
|
||||
filePath: string,
|
||||
sourcePath: string,
|
||||
category: string,
|
||||
type: 'agent' | 'command'
|
||||
): Promise<PluginMetadata> {
|
||||
const content = await fs.promises.readFile(filePath, 'utf8')
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
|
||||
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
|
||||
const { data } = matter(content, {
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate content hash for integrity checking
|
||||
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
|
||||
|
||||
// Extract filename
|
||||
const filename = path.basename(filePath)
|
||||
|
||||
// Parse allowed_tools - handle both array and comma-separated string
|
||||
let allowedTools: string[] | undefined
|
||||
if (data['allowed-tools'] || data.allowed_tools) {
|
||||
const toolsData = data['allowed-tools'] || data.allowed_tools
|
||||
if (Array.isArray(toolsData)) {
|
||||
allowedTools = toolsData
|
||||
} else if (typeof toolsData === 'string') {
|
||||
allowedTools = toolsData
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tools - similar handling
|
||||
let tools: string[] | undefined
|
||||
if (data.tools) {
|
||||
if (Array.isArray(data.tools)) {
|
||||
tools = data.tools
|
||||
} else if (typeof data.tools === 'string') {
|
||||
tools = data.tools
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
let tags: string[] | undefined
|
||||
if (data.tags) {
|
||||
if (Array.isArray(data.tags)) {
|
||||
tags = data.tags
|
||||
} else if (typeof data.tags === 'string') {
|
||||
tags = data.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath,
|
||||
filename,
|
||||
name: data.name || filename.replace(/\.md$/, ''),
|
||||
description: data.description,
|
||||
allowed_tools: allowedTools,
|
||||
tools,
|
||||
category,
|
||||
type,
|
||||
tags,
|
||||
version: data.version,
|
||||
author: data.author,
|
||||
size: stats.size,
|
||||
contentHash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all directories containing SKILL.md
|
||||
*
|
||||
* @param dirPath - Directory to search in
|
||||
* @param basePath - Base path for calculating relative source paths
|
||||
* @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops)
|
||||
* @param currentDepth - Current search depth (used internally)
|
||||
* @returns Array of objects with absolute folder path and relative source path
|
||||
*/
|
||||
export async function findAllSkillDirectories(
|
||||
dirPath: string,
|
||||
basePath: string,
|
||||
maxDepth = 10,
|
||||
currentDepth = 0
|
||||
): Promise<Array<{ folderPath: string; sourcePath: string }>> {
|
||||
const results: Array<{ folderPath: string; sourcePath: string }> = []
|
||||
|
||||
// Prevent excessive recursion
|
||||
if (currentDepth > maxDepth) {
|
||||
return results
|
||||
}
|
||||
|
||||
// Check if current directory contains SKILL.md
|
||||
const skillMdPath = path.join(dirPath, 'SKILL.md')
|
||||
|
||||
try {
|
||||
await fs.promises.stat(skillMdPath)
|
||||
// Found SKILL.md in this directory
|
||||
const relativePath = path.relative(basePath, dirPath)
|
||||
results.push({
|
||||
folderPath: dirPath,
|
||||
sourcePath: relativePath
|
||||
})
|
||||
return results
|
||||
} catch {
|
||||
// SKILL.md not in current directory
|
||||
}
|
||||
|
||||
// Only search subdirectories if current directory doesn't have SKILL.md
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const subDirPath = path.join(dirPath, entry.name)
|
||||
const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1)
|
||||
results.push(...subResults)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ignore errors when reading subdirectories (e.g., permission denied)
|
||||
logger.debug('Failed to read subdirectory during skill search', {
|
||||
dirPath,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse metadata from SKILL.md within a skill folder
|
||||
*
|
||||
* @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md)
|
||||
* @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill")
|
||||
* @param category - Category name (typically "skills" for flat structure)
|
||||
* @returns PluginMetadata with folder name as filename (no extension)
|
||||
* @throws PluginError if SKILL.md not found or parsing fails
|
||||
*/
|
||||
export async function parseSkillMetadata(
|
||||
skillFolderPath: string,
|
||||
sourcePath: string,
|
||||
category: string
|
||||
): Promise<PluginMetadata> {
|
||||
// Input validation
|
||||
if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) {
|
||||
throw {
|
||||
type: 'INVALID_METADATA',
|
||||
reason: 'Skill folder path must be absolute',
|
||||
path: skillFolderPath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Look for SKILL.md directly in this folder (no recursion)
|
||||
const skillMdPath = path.join(skillFolderPath, 'SKILL.md')
|
||||
|
||||
// Check if SKILL.md exists
|
||||
try {
|
||||
await fs.promises.stat(skillMdPath)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.error('SKILL.md not found in skill folder', { skillMdPath })
|
||||
throw {
|
||||
type: 'FILE_NOT_FOUND',
|
||||
path: skillMdPath,
|
||||
message: 'SKILL.md not found in skill folder'
|
||||
} as PluginError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Read SKILL.md content
|
||||
let content: string
|
||||
try {
|
||||
content = await fs.promises.readFile(skillMdPath, 'utf8')
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to read SKILL.md', { skillMdPath, error })
|
||||
throw {
|
||||
type: 'READ_FAILED',
|
||||
path: skillMdPath,
|
||||
reason: error.message || 'Unknown error'
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
|
||||
let data: any
|
||||
try {
|
||||
const parsed = matter(content, {
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
|
||||
}
|
||||
})
|
||||
data = parsed.data
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error })
|
||||
throw {
|
||||
type: 'INVALID_METADATA',
|
||||
reason: `Failed to parse frontmatter: ${error.message}`,
|
||||
path: skillMdPath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Calculate hash of SKILL.md only (not entire folder)
|
||||
// Note: This means changes to other files in the skill won't trigger cache invalidation
|
||||
// This is intentional - only SKILL.md metadata changes should trigger updates
|
||||
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
|
||||
|
||||
// Get folder name as identifier (NO EXTENSION)
|
||||
const folderName = path.basename(skillFolderPath)
|
||||
|
||||
// Get total folder size
|
||||
let folderSize: number
|
||||
try {
|
||||
folderSize = await getDirectorySize(skillFolderPath)
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to calculate skill folder size', { skillFolderPath, error })
|
||||
// Use 0 as fallback instead of failing completely
|
||||
folderSize = 0
|
||||
}
|
||||
|
||||
// Parse tools (skills use 'tools', not 'allowed_tools')
|
||||
let tools: string[] | undefined
|
||||
if (data.tools) {
|
||||
if (Array.isArray(data.tools)) {
|
||||
// Validate all elements are strings
|
||||
tools = data.tools.filter((t) => typeof t === 'string')
|
||||
} else if (typeof data.tools === 'string') {
|
||||
tools = data.tools
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
let tags: string[] | undefined
|
||||
if (data.tags) {
|
||||
if (Array.isArray(data.tags)) {
|
||||
// Validate all elements are strings
|
||||
tags = data.tags.filter((t) => typeof t === 'string')
|
||||
} else if (typeof data.tags === 'string') {
|
||||
tags = data.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and sanitize name
|
||||
const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName
|
||||
|
||||
// Validate and sanitize description
|
||||
const description =
|
||||
typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined
|
||||
|
||||
// Validate version and author
|
||||
const version = typeof data.version === 'string' ? data.version : undefined
|
||||
const author = typeof data.author === 'string' ? data.author : undefined
|
||||
|
||||
logger.debug('Successfully parsed skill metadata', {
|
||||
skillFolderPath,
|
||||
folderName,
|
||||
size: folderSize
|
||||
})
|
||||
|
||||
return {
|
||||
sourcePath, // e.g., "skills/my-skill"
|
||||
filename: folderName, // e.g., "my-skill" (folder name, NO .md extension)
|
||||
name,
|
||||
description,
|
||||
tools,
|
||||
category, // "skills" for flat structure
|
||||
type: 'skill',
|
||||
tags,
|
||||
version,
|
||||
author,
|
||||
size: folderSize,
|
||||
contentHash // Hash of SKILL.md content only
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import type { SpanContext } from '@opentelemetry/api'
|
||||
@@ -42,6 +43,16 @@ import type { OpenDialogOptions } from 'electron'
|
||||
import { contextBridge, ipcRenderer, shell, webUtils } from 'electron'
|
||||
import type { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
InstallPluginOptions,
|
||||
ListAvailablePluginsResult,
|
||||
PluginMetadata,
|
||||
PluginResult,
|
||||
UninstallPluginOptions,
|
||||
WritePluginContentOptions
|
||||
} from '../renderer/src/types/plugin'
|
||||
|
||||
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
|
||||
if (spanContext) {
|
||||
const data = { type: 'trace', context: spanContext }
|
||||
@@ -426,6 +437,15 @@ const api = {
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
agentTools: {
|
||||
respondToPermission: (payload: {
|
||||
requestId: string
|
||||
behavior: 'allow' | 'deny'
|
||||
updatedInput?: Record<string, unknown>
|
||||
message?: string
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
|
||||
// setDisableHardwareAcceleration: (isDisable: boolean) =>
|
||||
// ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
|
||||
@@ -548,6 +568,21 @@ const api = {
|
||||
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
|
||||
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
|
||||
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
|
||||
},
|
||||
claudeCodePlugin: {
|
||||
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable),
|
||||
install: (options: InstallPluginOptions): Promise<PluginResult<PluginMetadata>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options),
|
||||
uninstall: (options: UninstallPluginOptions): Promise<PluginResult<void>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options),
|
||||
listInstalled: (agentId: string): Promise<PluginResult<InstalledPlugin[]>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId),
|
||||
invalidateCache: (): Promise<PluginResult<void>> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache),
|
||||
readContent: (sourcePath: string): Promise<PluginResult<string>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
|
||||
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
|
||||
import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
|
||||
import type {
|
||||
BaseTool,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
MCPToolResultContent,
|
||||
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'
|
||||
@@ -255,6 +262,7 @@ export class ToolCallChunkHandler {
|
||||
type: 'tool-result'
|
||||
} & TypedToolResult<ToolSet>
|
||||
): void {
|
||||
// TODO: 基于AI SDK为供应商内置工具做更好的展示和类型安全处理
|
||||
const { toolCallId, output, input } = chunk
|
||||
|
||||
if (!toolCallId) {
|
||||
@@ -300,12 +308,7 @@ export class ToolCallChunkHandler {
|
||||
responses: [toolResponse]
|
||||
})
|
||||
|
||||
const images: string[] = []
|
||||
for (const content of toolResponse.response?.content || []) {
|
||||
if (content.type === 'image' && content.data) {
|
||||
images.push(`data:${content.mimeType};base64,${content.data}`)
|
||||
}
|
||||
}
|
||||
const images = extractImagesFromToolOutput(toolResponse.response)
|
||||
|
||||
if (images.length) {
|
||||
this.onChunk({
|
||||
@@ -352,3 +355,41 @@ 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'
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
|
||||
@@ -78,7 +79,7 @@ export default class ModernAiProvider {
|
||||
return this.actualProvider
|
||||
}
|
||||
|
||||
public async completions(modelId: string, params: StreamTextParams, config: ModernAiProviderConfig) {
|
||||
public async completions(modelId: string, params: StreamTextParams, providerConfig: ModernAiProviderConfig) {
|
||||
// 检查model是否存在
|
||||
if (!this.model) {
|
||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||
@@ -86,7 +87,10 @@ 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)
|
||||
|
||||
@@ -97,12 +101,13 @@ export default class ModernAiProvider {
|
||||
|
||||
// 提前构建中间件
|
||||
const middlewares = buildAiSdkMiddlewares({
|
||||
...config,
|
||||
provider: this.actualProvider
|
||||
...providerConfig,
|
||||
provider: this.actualProvider,
|
||||
assistant: providerConfig.assistant
|
||||
})
|
||||
logger.debug('Built middlewares in completions', {
|
||||
middlewareCount: middlewares.length,
|
||||
isImageGeneration: config.isImageGenerationEndpoint
|
||||
isImageGeneration: providerConfig.isImageGenerationEndpoint
|
||||
})
|
||||
if (!this.localProvider) {
|
||||
throw new Error('Local provider not created')
|
||||
@@ -110,7 +115,7 @@ export default class ModernAiProvider {
|
||||
|
||||
// 根据endpoint类型创建对应的模型
|
||||
let model: AiSdkModel | undefined
|
||||
if (config.isImageGenerationEndpoint) {
|
||||
if (providerConfig.isImageGenerationEndpoint) {
|
||||
model = this.localProvider.imageModel(modelId)
|
||||
} else {
|
||||
model = this.localProvider.languageModel(modelId)
|
||||
@@ -126,15 +131,15 @@ export default class ModernAiProvider {
|
||||
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
|
||||
}
|
||||
|
||||
if (config.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
|
||||
if (providerConfig.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
|
||||
// TypeScript类型窄化:确保topicId是string类型
|
||||
const traceConfig = {
|
||||
...config,
|
||||
topicId: config.topicId
|
||||
...providerConfig,
|
||||
topicId: providerConfig.topicId
|
||||
}
|
||||
return await this._completionsForTrace(model, params, traceConfig)
|
||||
} else {
|
||||
return await this._completionsOrImageGeneration(model, params, config)
|
||||
return await this._completionsOrImageGeneration(model, params, providerConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Provider } from '@renderer/types'
|
||||
import { isOpenAIProvider } from '@renderer/utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
|
||||
@@ -202,36 +201,4 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { loggerService } from '@logger'
|
||||
import { type MCPTool, type Message, type Model, type Provider } from '@renderer/types'
|
||||
import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
|
||||
import { isSupportEnableThinkingProvider } from '@renderer/config/providers'
|
||||
import type { MCPTool } from '@renderer/types'
|
||||
import { type Assistant, type Message, type Model, type Provider } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import type { LanguageModelMiddleware } from 'ai'
|
||||
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
|
||||
import { noThinkMiddleware } from './noThinkMiddleware'
|
||||
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
|
||||
import { qwenThinkingMiddleware } from './qwenThinkingMiddleware'
|
||||
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
|
||||
|
||||
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
|
||||
@@ -20,6 +25,7 @@ export interface AiSdkMiddlewareConfig {
|
||||
onChunk?: (chunk: Chunk) => void
|
||||
model?: Model
|
||||
provider?: Provider
|
||||
assistant?: Assistant
|
||||
enableReasoning: boolean
|
||||
// 是否开启提示词工具调用
|
||||
isPromptToolUse: boolean
|
||||
@@ -128,7 +134,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
|
||||
const builder = new AiSdkMiddlewareBuilder()
|
||||
|
||||
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
|
||||
if (config.knowledgeRecognition === 'off') {
|
||||
if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') {
|
||||
builder.add({
|
||||
name: 'force-knowledge-first',
|
||||
middleware: toolChoiceMiddleware('builtin_knowledge_search')
|
||||
@@ -219,6 +225,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
|
||||
function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
|
||||
if (!config.model || !config.provider) return
|
||||
|
||||
// Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA)
|
||||
// Use /think or /no_think suffix to control thinking mode
|
||||
if (
|
||||
config.provider &&
|
||||
isSupportedThinkingTokenQwenModel(config.model) &&
|
||||
!isSupportEnableThinkingProvider(config.provider)
|
||||
) {
|
||||
const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined
|
||||
builder.add({
|
||||
name: 'qwen-thinking-control',
|
||||
middleware: qwenThinkingMiddleware(enableThinking)
|
||||
})
|
||||
logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`)
|
||||
}
|
||||
|
||||
// 可以根据模型ID或特性添加特定中间件
|
||||
// 例如:图像生成模型、多模态模型等
|
||||
if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) {
|
||||
|
||||
39
src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts
Normal file
39
src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LanguageModelMiddleware } from 'ai'
|
||||
|
||||
/**
|
||||
* Qwen Thinking Middleware
|
||||
* Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama)
|
||||
* Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting
|
||||
* @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined)
|
||||
* @returns LanguageModelMiddleware
|
||||
*/
|
||||
export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware {
|
||||
const suffix = enableThinking ? ' /think' : ' /no_think'
|
||||
|
||||
return {
|
||||
middlewareVersion: 'v2',
|
||||
|
||||
transformParams: async ({ params }) => {
|
||||
const transformedParams = { ...params }
|
||||
// Process messages in prompt
|
||||
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
|
||||
transformedParams.prompt = transformedParams.prompt.map((message) => {
|
||||
// Only process user messages
|
||||
if (message.role === 'user') {
|
||||
// Process content array
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) {
|
||||
part.text += suffix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
return transformedParams
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AiPlugin } from '@cherrystudio/ai-core'
|
||||
import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { createPromptToolUsePlugin, 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) {
|
||||
plugins.push(googleToolsPlugin({ urlContext: true }))
|
||||
}
|
||||
// if (middlewareConfig.enableUrlContext && middlewareConfig.) {
|
||||
// plugins.push(googleToolsPlugin({ urlContext: true }))
|
||||
// }
|
||||
|
||||
logger.debug(
|
||||
'Final plugin list:',
|
||||
|
||||
@@ -114,7 +114,7 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model):
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OpenAI大文件上传
|
||||
* 处理OpenAI兼容大文件上传
|
||||
*/
|
||||
export async function handleOpenAILargeFileUpload(
|
||||
file: FileMetadata,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 构建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'
|
||||
@@ -97,10 +99,6 @@ 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,
|
||||
@@ -143,12 +141,34 @@ export async function buildStreamTextParams(
|
||||
}
|
||||
}
|
||||
|
||||
// google-vertex
|
||||
if (enableUrlContext && aiSdkProviderId === 'google-vertex') {
|
||||
if (enableUrlContext) {
|
||||
if (!tools) {
|
||||
tools = {}
|
||||
}
|
||||
tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 构建基础参数
|
||||
|
||||
@@ -32,7 +32,8 @@ const AIHUBMIX_RULES: RuleSet = {
|
||||
match: (model) =>
|
||||
(startsWith('gemini')(model) || startsWith('imagen')(model)) &&
|
||||
!model.id.endsWith('-nothink') &&
|
||||
!model.id.endsWith('-search'),
|
||||
!model.id.endsWith('-search') &&
|
||||
!model.id.includes('embedding'),
|
||||
provider: (provider: Provider) => {
|
||||
return extraProviderConfig({
|
||||
...provider,
|
||||
|
||||
@@ -7,24 +7,27 @@ import {
|
||||
} from '@cherrystudio/ai-core/provider'
|
||||
import { cacheService } from '@data/CacheService'
|
||||
import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
|
||||
import { isNewApiProvider } from '@renderer/config/providers'
|
||||
import {
|
||||
isAnthropicProvider,
|
||||
isAzureOpenAIProvider,
|
||||
isGeminiProvider,
|
||||
isNewApiProvider
|
||||
} from '@renderer/config/providers'
|
||||
import {
|
||||
getAwsBedrockAccessKeyId,
|
||||
getAwsBedrockRegion,
|
||||
getAwsBedrockSecretAccessKey
|
||||
} from '@renderer/hooks/useAwsBedrock'
|
||||
import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI'
|
||||
import { createVertexProvider, isVertexAIConfigured, isVertexProvider } 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 } from '@renderer/types'
|
||||
import { formatApiHost } from '@renderer/utils/api'
|
||||
import { cloneDeep, trim } from 'lodash'
|
||||
import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types'
|
||||
import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api'
|
||||
import { cloneDeep } 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
|
||||
@@ -56,13 +59,6 @@ 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)
|
||||
}
|
||||
@@ -79,43 +75,30 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化provider的API Host
|
||||
* 主要用来对齐AISdk的BaseURL格式
|
||||
* @param provider
|
||||
* @returns
|
||||
*/
|
||||
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 = formatAnthropicApiHost(formatted.anthropicApiHost)
|
||||
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
|
||||
}
|
||||
|
||||
if (formatted.type === 'anthropic') {
|
||||
if (isAnthropicProvider(provider)) {
|
||||
const baseHost = formatted.anthropicApiHost || formatted.apiHost
|
||||
formatted.apiHost = formatAnthropicApiHost(baseHost)
|
||||
formatted.apiHost = formatApiHost(baseHost)
|
||||
if (!formatted.anthropicApiHost) {
|
||||
formatted.anthropicApiHost = formatted.apiHost
|
||||
}
|
||||
} 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 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 {
|
||||
formatted.apiHost = formatApiHost(formatted.apiHost)
|
||||
}
|
||||
@@ -149,15 +132,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: trim(actualProvider.apiHost),
|
||||
baseURL: baseURL,
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
}
|
||||
|
||||
const isCopilotProvider = actualProvider.id === 'copilot'
|
||||
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
|
||||
if (isCopilotProvider) {
|
||||
const storedHeaders = store.getState().copilot.defaultHeaders ?? {}
|
||||
const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, {
|
||||
@@ -178,6 +161,7 @@ export function providerToAiSdkConfig(
|
||||
|
||||
// 处理OpenAI模式
|
||||
const extraOptions: any = {}
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai') {
|
||||
@@ -199,13 +183,11 @@ export function providerToAiSdkConfig(
|
||||
}
|
||||
// azure
|
||||
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
||||
extraOptions.apiVersion = actualProvider.apiVersion
|
||||
baseConfig.baseURL += '/openai'
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview') {
|
||||
extraOptions.mode = 'responses'
|
||||
} else {
|
||||
extraOptions.mode = 'chat'
|
||||
extraOptions.useDeploymentBasedUrls = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,22 +209,7 @@ export function providerToAiSdkConfig(
|
||||
...googleCredentials,
|
||||
privateKey: formatPrivateKey(googleCredentials.privateKey)
|
||||
}
|
||||
// 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`
|
||||
}
|
||||
baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models'
|
||||
}
|
||||
|
||||
if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') {
|
||||
|
||||
@@ -5,6 +5,16 @@ 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 {
|
||||
|
||||
@@ -3,6 +3,16 @@ 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@cherrystudio/ui'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -20,18 +20,12 @@ const ExpandableText = ({
|
||||
setIsExpanded((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const button = useMemo(() => {
|
||||
return (
|
||||
<Button variant="ghost" onClick={toggleExpand} className="self-end">
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</Button>
|
||||
)
|
||||
}, [isExpanded, t, toggleExpand])
|
||||
|
||||
return (
|
||||
<Container ref={ref} style={style} $expanded={isExpanded}>
|
||||
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
|
||||
{button}
|
||||
<Button variant="ghost" onClick={toggleExpand} className="self-end">
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -48,4 +42,4 @@ const TextContainer = styled.div<{ $expanded?: boolean }>`
|
||||
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
|
||||
`
|
||||
|
||||
export default memo(ExpandableText)
|
||||
export default ExpandableText
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-
|
||||
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
|
||||
import { restoreFromLocal } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Modal, Table } from 'antd'
|
||||
import { Modal, Space, Table } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -221,6 +221,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
}
|
||||
}
|
||||
|
||||
const footerContent = (
|
||||
<Space align="center">
|
||||
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
{t('settings.data.local.backup.manager.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
key="delete"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}>
|
||||
<DeleteOutlined />
|
||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.local.backup.manager.title')}
|
||||
@@ -229,24 +249,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
width={800}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
classNames={{ footer: 'flex justify-end gap-1' }}
|
||||
footer={[
|
||||
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
{t('settings.data.local.backup.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}>
|
||||
<DeleteOutlined />
|
||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
]}>
|
||||
footer={footerContent}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -12,8 +12,8 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
// Mock ImageToolButton
|
||||
vi.mock('../ImageToolButton', () => ({
|
||||
default: vi.fn(({ tooltip, onPress, icon }) => (
|
||||
<button type="button" onClick={onPress} role="button" aria-label={tooltip}>
|
||||
default: vi.fn(({ tooltip, onClick, icon }) => (
|
||||
<button type="button" onClick={onClick} role="button" aria-label={tooltip}>
|
||||
{icon}
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -4,8 +4,8 @@ exports[`ImageToolButton > should match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-label="Test tooltip"
|
||||
class="rounded-full"
|
||||
data-testid="button"
|
||||
radius="full"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils'
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Tooltip } from '@cherrystudio/ui'
|
||||
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||
import type { S3Config } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Modal, Space, Table } from 'antd'
|
||||
import { Modal, Table } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -18,6 +18,7 @@ interface BaseSelectorProps<V = string | number> {
|
||||
options: SelectorOption<V>[]
|
||||
placeholder?: string
|
||||
placement?: 'topLeft' | 'topCenter' | 'topRight' | 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'top' | 'bottom'
|
||||
style?: React.CSSProperties
|
||||
/** 字体大小 */
|
||||
size?: number
|
||||
/** 是否禁用 */
|
||||
@@ -45,6 +46,7 @@ const Selector = <V extends string | number>({
|
||||
placement = 'bottomRight',
|
||||
size = 13,
|
||||
placeholder,
|
||||
style,
|
||||
disabled = false,
|
||||
multiple = false
|
||||
}: SelectorProps<V>) => {
|
||||
@@ -137,7 +139,7 @@ const Selector = <V extends string | number>({
|
||||
placement={placement}
|
||||
open={open && !disabled}
|
||||
onOpenChange={handleOpenChange}>
|
||||
<Label $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
|
||||
<Label style={style} $size={size} $open={open} $disabled={disabled} $isPlaceholder={label === placeholder}>
|
||||
{label}
|
||||
<LabelIcon size={size + 3} />
|
||||
</Label>
|
||||
|
||||
@@ -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 { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { Modal } from 'antd'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -35,8 +35,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
elementsRef.current = elements
|
||||
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const { shortcuts } = useShortcuts()
|
||||
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
|
||||
const shortcuts = useAllShortcuts()
|
||||
const enableQuitFullScreen = shortcuts.find((item) => item.definition.key === 'shortcut.app.exit_fullscreen')
|
||||
?.preference.enabled
|
||||
|
||||
useAppInit()
|
||||
|
||||
|
||||
@@ -9,6 +9,15 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k })
|
||||
}))
|
||||
|
||||
// mock @cherrystudio/ui Button component
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Button: ({ children, onPress, ...props }: any) => (
|
||||
<button type="button" onClick={onPress} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('ExpandableText', () => {
|
||||
const TEXT = 'This is a long text for testing.'
|
||||
|
||||
|
||||
@@ -23,6 +23,16 @@ 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 }>> = ({
|
||||
|
||||
@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
|
||||
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
|
||||
max-height: var(--navbar-height);
|
||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0};
|
||||
padding-left: ${({ $isFullScreen }) =>
|
||||
|
||||
@@ -18,6 +18,15 @@ 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)
|
||||
|
||||
@@ -2,6 +2,16 @@ 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: () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'> = {
|
||||
@@ -19,3 +20,47 @@ 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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1741,6 +1741,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
id: 'DeepSeek-R1',
|
||||
provider: 'cephalon',
|
||||
name: 'DeepSeek-R1满血版',
|
||||
capabilities: [{ type: 'reasoning' }],
|
||||
group: 'DeepSeek'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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'
|
||||
@@ -65,12 +67,16 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
|
||||
// 不管哪个供应商都判断了
|
||||
if (isAnthropicModel(model)) {
|
||||
// bedrock和vertex不支持
|
||||
if (
|
||||
isAnthropicModel(model) &&
|
||||
(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
|
||||
) {
|
||||
return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
if (provider.type === 'openai-response') {
|
||||
// TODO: 当其他供应商采用Response端点时,这个地方逻辑需要改进
|
||||
if (isOpenAIProvider(provider)) {
|
||||
if (isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
@@ -78,11 +84,11 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider.id === 'perplexity') {
|
||||
if (provider.id === SystemProviderIds.perplexity) {
|
||||
return PERPLEXITY_SEARCH_MODELS.includes(modelId)
|
||||
}
|
||||
|
||||
if (provider.id === 'aihubmix') {
|
||||
if (provider.id === SystemProviderIds.aihubmix) {
|
||||
// modelId 不以-search结尾
|
||||
if (!modelId.endsWith('-search') && GEMINI_SEARCH_REGEX.test(modelId)) {
|
||||
return true
|
||||
@@ -95,13 +101,13 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (isOpenAICompatibleProvider(provider) || isNewApiProvider(provider)) {
|
||||
if (GEMINI_SEARCH_REGEX.test(modelId) || isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.id === 'gemini' || provider?.type === 'gemini' || provider.type === 'vertexai') {
|
||||
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
|
||||
return GEMINI_SEARCH_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
|
||||
return MistralLogo
|
||||
case 'mineru':
|
||||
return MinerULogo
|
||||
case 'open-mineru':
|
||||
return MinerULogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -36,5 +38,11 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess
|
||||
official: 'https://mineru.net/',
|
||||
apiKey: 'https://mineru.net/apiManage'
|
||||
}
|
||||
},
|
||||
'open-mineru': {
|
||||
websites: {
|
||||
official: 'https://github.com/opendatalab/MinerU/',
|
||||
apiKey: 'https://github.com/opendatalab/MinerU/'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,14 @@ 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, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types'
|
||||
import type {
|
||||
AtLeast,
|
||||
AzureOpenAIProvider,
|
||||
Provider,
|
||||
ProviderType,
|
||||
SystemProvider,
|
||||
SystemProviderId
|
||||
} from '@renderer/types'
|
||||
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
|
||||
|
||||
import { TOKENFLUX_HOST } from './constant'
|
||||
@@ -348,7 +355,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
name: 'VertexAI',
|
||||
type: 'vertexai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://aiplatform.googleapis.com',
|
||||
apiHost: '',
|
||||
models: SYSTEM_MODELS.vertexai,
|
||||
isSystem: true,
|
||||
enabled: false,
|
||||
@@ -412,7 +419,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
|
||||
anthropicApiHost: 'https://dashscope.aliyuncs.com/apps/anthropic',
|
||||
models: SYSTEM_MODELS.dashscope,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@@ -1288,7 +1295,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
vertexai: {
|
||||
api: {
|
||||
url: 'https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/overview'
|
||||
url: ''
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cloud.google.com/vertex-ai',
|
||||
@@ -1368,7 +1375,8 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
|
||||
'baichuan',
|
||||
'minimax',
|
||||
'xirang',
|
||||
'poe'
|
||||
'poe',
|
||||
'cephalon'
|
||||
] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
@@ -1433,10 +1441,15 @@ export const isSupportServiceTierProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES = ['gemini', 'vertexai'] as const satisfies ProviderType[]
|
||||
const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
|
||||
'gemini',
|
||||
'vertexai',
|
||||
'anthropic',
|
||||
'new-api'
|
||||
] as const satisfies ProviderType[]
|
||||
|
||||
export const isSupportUrlContextProvider = (provider: Provider) => {
|
||||
return SUPPORT_GEMINI_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
|
||||
return SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
|
||||
@@ -1449,3 +1462,37 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
10
src/renderer/src/env.d.ts
vendored
10
src/renderer/src/env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { ToastUtilities } from '@cherrystudio/ui'
|
||||
import type { HookAPI } from 'antd/es/modal/useModal'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
@@ -19,5 +20,14 @@ declare global {
|
||||
store: any
|
||||
navigate: NavigateFunction
|
||||
toast: ToastUtilities
|
||||
agentTools: {
|
||||
respondToPermission: (payload: {
|
||||
requestId: string
|
||||
behavior: 'allow' | 'deny'
|
||||
updatedInput?: Record<string, unknown>
|
||||
message?: string
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}) => Promise<{ success: boolean }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useShortcut } 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 showSettingsShortcutEnabled = useAppSelector(
|
||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'meta+, ! ctrl+,',
|
||||
function () {
|
||||
useShortcut(
|
||||
'shortcut.app.show_settings',
|
||||
() => {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
return
|
||||
}
|
||||
navigate('/settings/provider')
|
||||
},
|
||||
{
|
||||
splitKey: '!',
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
enabled: showSettingsShortcutEnabled
|
||||
enableOnContentEditable: true
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
49
src/renderer/src/hooks/agents/useCreateDefaultSession.ts
Normal file
49
src/renderer/src/hooks/agents/useCreateDefaultSession.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||
import type { CreateSessionForm } from '@renderer/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Returns a stable callback that creates a default agent session and updates UI state.
|
||||
*/
|
||||
export const useCreateDefaultSession = (agentId: string | null) => {
|
||||
const { agent } = useAgent(agentId)
|
||||
const { createSession } = useSessions(agentId)
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
const [creatingSession, setCreatingSession] = useState(false)
|
||||
|
||||
const createDefaultSession = useCallback(async () => {
|
||||
if (!agentId || !agent || creatingSession) {
|
||||
return null
|
||||
}
|
||||
|
||||
setCreatingSession(true)
|
||||
try {
|
||||
const session = {
|
||||
...agent,
|
||||
id: undefined,
|
||||
name: t('common.unnamed')
|
||||
} satisfies CreateSessionForm
|
||||
|
||||
const created = await createSession(session)
|
||||
|
||||
if (created) {
|
||||
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
|
||||
dispatch(setActiveTopicOrSessionAction('session'))
|
||||
}
|
||||
|
||||
return created
|
||||
} finally {
|
||||
setCreatingSession(false)
|
||||
}
|
||||
}, [agentId, agent, createSession, creatingSession, dispatch, t])
|
||||
|
||||
return {
|
||||
createDefaultSession,
|
||||
creatingSession
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,18 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
import { selectMemoryConfig } from '@renderer/store/memory'
|
||||
import {
|
||||
type ToolPermissionRequestPayload,
|
||||
type ToolPermissionResultPayload,
|
||||
toolPermissionsActions
|
||||
} from '@renderer/store/toolPermissions'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
import { checkDataLimit } from '@renderer/utils'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import useFullScreenNotice from './useFullScreenNotice'
|
||||
@@ -27,6 +33,7 @@ import { useNavbarPosition } from './useNavbar'
|
||||
const logger = loggerService.withContext('useAppInit')
|
||||
|
||||
export function useAppInit() {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const [language] = usePreference('app.language')
|
||||
const [windowStyle] = usePreference('ui.window_style')
|
||||
@@ -148,6 +155,64 @@ export function useAppInit() {
|
||||
}
|
||||
}, [customCss])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron?.ipcRenderer) return
|
||||
|
||||
const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
|
||||
logger.debug('Renderer received tool permission request', {
|
||||
requestId: payload.requestId,
|
||||
toolName: payload.toolName,
|
||||
expiresAt: payload.expiresAt,
|
||||
suggestionCount: payload.suggestions.length
|
||||
})
|
||||
dispatch(toolPermissionsActions.requestReceived(payload))
|
||||
}
|
||||
|
||||
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
|
||||
logger.debug('Renderer received tool permission result', {
|
||||
requestId: payload.requestId,
|
||||
behavior: payload.behavior,
|
||||
reason: payload.reason
|
||||
})
|
||||
dispatch(toolPermissionsActions.requestResolved(payload))
|
||||
|
||||
if (payload.behavior === 'deny') {
|
||||
const message =
|
||||
payload.reason === 'timeout'
|
||||
? (payload.message ?? t('agent.toolPermission.toast.timeout'))
|
||||
: (payload.message ?? t('agent.toolPermission.toast.denied'))
|
||||
|
||||
if (payload.reason === 'no-window') {
|
||||
logger.debug('Displaying deny toast for tool permission', {
|
||||
requestId: payload.requestId,
|
||||
behavior: payload.behavior,
|
||||
reason: payload.reason
|
||||
})
|
||||
window.toast?.error?.(message)
|
||||
} else if (payload.reason === 'timeout') {
|
||||
logger.debug('Displaying timeout toast for tool permission', {
|
||||
requestId: payload.requestId
|
||||
})
|
||||
window.toast?.warning?.(message)
|
||||
} else {
|
||||
logger.debug('Displaying info toast for tool permission deny', {
|
||||
requestId: payload.requestId,
|
||||
reason: payload.reason
|
||||
})
|
||||
window.toast?.info?.(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener)
|
||||
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
|
||||
|
||||
return () => {
|
||||
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Request, requestListener)
|
||||
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Result, resultListener)
|
||||
}
|
||||
}, [dispatch, t])
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: init data collection
|
||||
}, [enableDataCollection])
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
|
||||
label: t('settings.tool.preprocess.provider'),
|
||||
title: t('settings.tool.preprocess.provider'),
|
||||
options: preprocessProviders
|
||||
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
|
||||
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru'].includes(p.id))
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
}
|
||||
return [preprocessOptions]
|
||||
|
||||
163
src/renderer/src/hooks/usePlugins.ts
Normal file
163
src/renderer/src/hooks/usePlugins.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { InstalledPlugin, PluginError, PluginMetadata } from '@renderer/types/plugin'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Helper to extract error message from PluginError union type
|
||||
*/
|
||||
function getPluginErrorMessage(error: PluginError, defaultMessage: string): string {
|
||||
if ('message' in error && error.message) return error.message
|
||||
if ('reason' in error) return error.reason
|
||||
if ('path' in error) return `Error with file: ${error.path}`
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and cache available plugins from the resources directory
|
||||
* @returns Object containing available agents, commands, skills, loading state, and error
|
||||
*/
|
||||
export function useAvailablePlugins() {
|
||||
const [agents, setAgents] = useState<PluginMetadata[]>([])
|
||||
const [commands, setCommands] = useState<PluginMetadata[]>([])
|
||||
const [skills, setSkills] = useState<PluginMetadata[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvailablePlugins = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.listAvailable()
|
||||
|
||||
if (result.success) {
|
||||
setAgents(result.data.agents)
|
||||
setCommands(result.data.commands)
|
||||
setSkills(result.data.skills)
|
||||
} else {
|
||||
setError(getPluginErrorMessage(result.error, 'Failed to load available plugins'))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAvailablePlugins()
|
||||
}, [])
|
||||
|
||||
return { agents, commands, skills, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch installed plugins for a specific agent
|
||||
* @param agentId - The ID of the agent to fetch plugins for
|
||||
* @returns Object containing installed plugins, loading state, error, and refresh function
|
||||
*/
|
||||
export function useInstalledPlugins(agentId: string | undefined) {
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!agentId) {
|
||||
setPlugins([])
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.listInstalled(agentId)
|
||||
|
||||
if (result.success) {
|
||||
setPlugins(result.data)
|
||||
} else {
|
||||
setError(getPluginErrorMessage(result.error, 'Failed to load installed plugins'))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
return { plugins, loading, error, refresh }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to provide install and uninstall actions for plugins
|
||||
* @param agentId - The ID of the agent to perform actions for
|
||||
* @param onSuccess - Optional callback to be called on successful operations
|
||||
* @returns Object containing install, uninstall functions and their loading states
|
||||
*/
|
||||
export function usePluginActions(agentId: string, onSuccess?: () => void) {
|
||||
const [installing, setInstalling] = useState<boolean>(false)
|
||||
const [uninstalling, setUninstalling] = useState<boolean>(false)
|
||||
|
||||
const install = useCallback(
|
||||
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
|
||||
setInstalling(true)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.install({
|
||||
agentId,
|
||||
sourcePath,
|
||||
type
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.()
|
||||
return { success: true as const, data: result.data }
|
||||
} else {
|
||||
const errorMessage = getPluginErrorMessage(result.error, 'Failed to install plugin')
|
||||
return { success: false as const, error: errorMessage }
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
return { success: false as const, error: errorMessage }
|
||||
} finally {
|
||||
setInstalling(false)
|
||||
}
|
||||
},
|
||||
[agentId, onSuccess]
|
||||
)
|
||||
|
||||
const uninstall = useCallback(
|
||||
async (filename: string, type: 'agent' | 'command' | 'skill') => {
|
||||
setUninstalling(true)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.uninstall({
|
||||
agentId,
|
||||
filename,
|
||||
type
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.()
|
||||
return { success: true as const }
|
||||
} else {
|
||||
const errorMessage = getPluginErrorMessage(result.error, 'Failed to uninstall plugin')
|
||||
return { success: false as const, error: errorMessage }
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
return { success: false as const, error: errorMessage }
|
||||
} finally {
|
||||
setUninstalling(false)
|
||||
}
|
||||
},
|
||||
[agentId, onSuccess]
|
||||
)
|
||||
|
||||
return { install, uninstall, installing, uninstalling }
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { orderBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
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 { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
interface UseShortcutOptions {
|
||||
@@ -9,85 +17,175 @@ interface UseShortcutOptions {
|
||||
enableOnFormTags?: boolean
|
||||
enabled?: boolean
|
||||
description?: string
|
||||
enableOnContentEditable?: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: UseShortcutOptions = {
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
enableOnContentEditable: false
|
||||
}
|
||||
|
||||
const resolvePreferenceValue = (
|
||||
definition: ShortcutDefinition | undefined,
|
||||
preference: PreferenceShortcutType | Record<string, unknown> | undefined
|
||||
): ShortcutPreferenceValue | null => {
|
||||
if (!definition) {
|
||||
return null
|
||||
}
|
||||
return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined)
|
||||
}
|
||||
|
||||
export const useShortcut = (
|
||||
shortcutKey: string,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
shortcutKey: ShortcutPreferenceKey,
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
options: UseShortcutOptions = defaultOptions
|
||||
) => {
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
|
||||
const [preference] = usePreference(shortcutKey)
|
||||
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
|
||||
|
||||
const formatShortcut = useCallback((shortcut: string[]) => {
|
||||
return shortcut
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'command':
|
||||
return 'meta'
|
||||
case 'commandorcontrol':
|
||||
return isMac ? 'meta' : 'ctrl'
|
||||
default:
|
||||
return key.toLowerCase()
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}, [])
|
||||
const hotkey = useMemo(() => {
|
||||
if (!definition || !preferenceState) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
|
||||
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])
|
||||
|
||||
useHotkeys(
|
||||
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
|
||||
(e) => {
|
||||
hotkey,
|
||||
(event) => {
|
||||
if (options.preventDefault) {
|
||||
e.preventDefault()
|
||||
event.preventDefault()
|
||||
}
|
||||
if (options.enabled !== false) {
|
||||
callback(e)
|
||||
callback(event)
|
||||
}
|
||||
},
|
||||
{
|
||||
enableOnFormTags: options.enableOnFormTags,
|
||||
description: options.description || shortcutConfig?.key,
|
||||
enabled: !!shortcutConfig?.enabled
|
||||
}
|
||||
description: options.description ?? shortcutKey,
|
||||
enabled: hotkey !== 'none',
|
||||
enableOnContentEditable: options.enableOnContentEditable
|
||||
},
|
||||
[hotkey, callback, options]
|
||||
)
|
||||
}
|
||||
|
||||
export function useShortcuts() {
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
|
||||
export const useShortcutDisplay = (shortcutKey: ShortcutPreferenceKey): string => {
|
||||
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
|
||||
const [preference] = usePreference(shortcutKey)
|
||||
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
|
||||
|
||||
if (!definition || !preferenceState || !preferenceState.enabled) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
|
||||
|
||||
if (!displayBinding.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatShortcutDisplay(displayBinding, isMac)
|
||||
}
|
||||
|
||||
export function useShortcutDisplay(key: string) {
|
||||
const formatShortcut = useCallback((shortcut: string[]) => {
|
||||
return shortcut
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
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'
|
||||
default:
|
||||
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}, [])
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
const shortcutConfig = shortcuts.find((s) => s.key === key)
|
||||
return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : ''
|
||||
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>>)
|
||||
}
|
||||
|
||||
return {
|
||||
definition,
|
||||
preference,
|
||||
defaultPreference,
|
||||
updatePreference
|
||||
}
|
||||
}),
|
||||
[buildNextPreference, setValues, values]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function getVertexAIServiceAccount() {
|
||||
* 类型守卫:检查 Provider 是否为 VertexProvider
|
||||
*/
|
||||
export function isVertexProvider(provider: Provider): provider is VertexProvider {
|
||||
return provider.type === 'vertexai' && 'googleCredentials' in provider
|
||||
return provider.type === 'vertexai'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Advanced Settings"
|
||||
},
|
||||
"essential": "Essential Settings",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Available Plugins"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Are you sure you want to uninstall this plugin?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "No plugins found matching your filters. Try adjusting your search or category filters."
|
||||
},
|
||||
"error": {
|
||||
"install": "Failed to install plugin",
|
||||
"load": "Failed to load plugins",
|
||||
"uninstall": "Failed to uninstall plugin"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All Categories"
|
||||
},
|
||||
"install": "Install",
|
||||
"installed": {
|
||||
"empty": "No plugins installed yet. Browse available plugins to get started.",
|
||||
"title": "Installed Plugins"
|
||||
},
|
||||
"installing": "Installing...",
|
||||
"results": "{{count}} plugin(s) found",
|
||||
"search": {
|
||||
"placeholder": "Search plugins..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin installed successfully",
|
||||
"uninstall": "Plugin uninstalled successfully"
|
||||
},
|
||||
"tab": "Plugins",
|
||||
"type": {
|
||||
"agent": "Agent",
|
||||
"agents": "Agents",
|
||||
"all": "All",
|
||||
"command": "Command",
|
||||
"commands": "Commands",
|
||||
"skills": "Skills"
|
||||
},
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling..."
|
||||
},
|
||||
"prompt": "Prompt Settings",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Allow tool request",
|
||||
"denyRequest": "Deny tool request",
|
||||
"hideDetails": "Hide tool details",
|
||||
"runWithOptions": "Run with additional options",
|
||||
"showDetails": "Show tool details"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Cancel",
|
||||
"run": "Run"
|
||||
},
|
||||
"confirmation": "Are you sure you want to run this Claude tool?",
|
||||
"defaultDenyMessage": "User denied permission for this tool.",
|
||||
"defaultDescription": "Executes code or system actions in your environment. Make sure the command looks safe before running it.",
|
||||
"error": {
|
||||
"sendFailed": "Failed to send your decision. Please try again."
|
||||
},
|
||||
"expired": "Expired",
|
||||
"inputPreview": "Tool input preview",
|
||||
"pending": "Pending ({{seconds}}s)",
|
||||
"permissionExpired": "Permission request expired. Waiting for new instructions...",
|
||||
"requiresElevatedPermissions": "This tool requires elevated permissions.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Approving may update multiple session permissions if you chose to always allow this tool.",
|
||||
"permissionUpdateSingle": "Approving may update your session permissions if you chose to always allow this tool."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Tool request was denied.",
|
||||
"timeout": "Tool request timed out before receiving approval."
|
||||
},
|
||||
"waiting": "Waiting for tool permission decision..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Agent Type",
|
||||
"unknown": "Unknown Type"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Controls upscaling randomness"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Actions",
|
||||
"agents": "Agents",
|
||||
"all_categories": "All Categories",
|
||||
"all_types": "All",
|
||||
"category": "Category",
|
||||
"commands": "Commands",
|
||||
"confirm_uninstall": "Are you sure you want to uninstall {{name}}?",
|
||||
"install": "Install",
|
||||
"install_plugins_from_browser": "Browse available plugins to get started",
|
||||
"installing": "Installing...",
|
||||
"name": "Name",
|
||||
"no_description": "No description available",
|
||||
"no_installed_plugins": "No plugins installed yet",
|
||||
"no_results": "No plugins found",
|
||||
"search_placeholder": "Search plugins...",
|
||||
"showing_results": "Showing {{count}} plugin",
|
||||
"showing_results_one": "Showing {{count}} plugin",
|
||||
"showing_results_other": "Showing {{count}} plugins",
|
||||
"showing_results_plural": "Showing {{count}} plugins",
|
||||
"skills": "Skills",
|
||||
"try_different_search": "Try adjusting your search or category filters",
|
||||
"type": "Type",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copy as image"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Preview: {{url}}",
|
||||
"reset": "Reset",
|
||||
"tip": "Ending with / ignores v1, ending with # forces use of input address"
|
||||
"tip": "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": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "高级设置"
|
||||
},
|
||||
"essential": "基础设置",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "可用插件"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "确定要卸载此插件吗?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "未找到匹配的插件。请尝试调整搜索或类别筛选。"
|
||||
},
|
||||
"error": {
|
||||
"install": "安装插件失败",
|
||||
"load": "加载插件失败",
|
||||
"uninstall": "卸载插件失败"
|
||||
},
|
||||
"filter": {
|
||||
"all": "所有类别"
|
||||
},
|
||||
"install": "安装",
|
||||
"installed": {
|
||||
"empty": "尚未安装任何插件。浏览可用插件以开始使用。",
|
||||
"title": "已安装插件"
|
||||
},
|
||||
"installing": "安装中...",
|
||||
"results": "找到 {{count}} 个插件",
|
||||
"search": {
|
||||
"placeholder": "搜索插件..."
|
||||
},
|
||||
"success": {
|
||||
"install": "插件安装成功",
|
||||
"uninstall": "插件卸载成功"
|
||||
},
|
||||
"tab": "插件",
|
||||
"type": {
|
||||
"agent": "代理",
|
||||
"agents": "代理",
|
||||
"all": "全部",
|
||||
"command": "命令",
|
||||
"commands": "命令",
|
||||
"skills": "技能"
|
||||
},
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中..."
|
||||
},
|
||||
"prompt": "提示词设置",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "允许工具请求",
|
||||
"denyRequest": "拒绝工具请求",
|
||||
"hideDetails": "隐藏工具详情",
|
||||
"runWithOptions": "带选项运行",
|
||||
"showDetails": "显示工具详情"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "取消",
|
||||
"run": "运行"
|
||||
},
|
||||
"confirmation": "确定要运行此 Claude 工具吗?",
|
||||
"defaultDenyMessage": "用户拒绝了该工具的权限。",
|
||||
"defaultDescription": "在您的环境中执行代码或系统操作。运行前请确保命令安全。",
|
||||
"error": {
|
||||
"sendFailed": "发送您的决定失败,请重试。"
|
||||
},
|
||||
"expired": "已过期",
|
||||
"inputPreview": "工具输入预览",
|
||||
"pending": "等待中 ({{seconds}}秒)",
|
||||
"permissionExpired": "权限请求已过期。等待新指令...",
|
||||
"requiresElevatedPermissions": "此工具需要更高权限。",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "如果您选择总是允许此工具,批准可能会更新多个会话权限。",
|
||||
"permissionUpdateSingle": "如果您选择总是允许此工具,批准可能会更新您的会话权限。"
|
||||
},
|
||||
"toast": {
|
||||
"denied": "工具请求已被拒绝。",
|
||||
"timeout": "工具请求在收到批准前超时。"
|
||||
},
|
||||
"waiting": "等待工具权限决定..."
|
||||
},
|
||||
"type": {
|
||||
"label": "智能体类型",
|
||||
"unknown": "未知类型"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "控制放大结果的随机性"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "操作",
|
||||
"agents": "代理",
|
||||
"all_categories": "所有类别",
|
||||
"all_types": "全部",
|
||||
"category": "类别",
|
||||
"commands": "命令",
|
||||
"confirm_uninstall": "确定要卸载 {{name}} 吗?",
|
||||
"install": "安装",
|
||||
"install_plugins_from_browser": "浏览可用插件以开始使用",
|
||||
"installing": "安装中...",
|
||||
"name": "名称",
|
||||
"no_description": "无描述",
|
||||
"no_installed_plugins": "尚未安装任何插件",
|
||||
"no_results": "未找到插件",
|
||||
"search_placeholder": "搜索插件...",
|
||||
"showing_results": "显示 {{count}} 个插件",
|
||||
"showing_results_one": "显示 {{count}} 个插件",
|
||||
"showing_results_other": "显示 {{count}} 个插件",
|
||||
"showing_results_plural": "显示 {{count}} 个插件",
|
||||
"skills": "技能",
|
||||
"try_different_search": "请尝试调整搜索或类别筛选",
|
||||
"type": "类型",
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "复制为图片"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic API 地址",
|
||||
"anthropic_api_host_preview": "Anthropic 预览:{{url}}",
|
||||
"anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1,以 # 结尾则强制使用原始地址。",
|
||||
"anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "预览: {{url}}",
|
||||
"reset": "重置",
|
||||
"tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址"
|
||||
"tip": "# 结尾强制使用输入地址"
|
||||
}
|
||||
},
|
||||
"api_host": "API 地址",
|
||||
"api_host_no_valid": "API 地址不合法",
|
||||
"api_host_preview": "预览:{{url}}",
|
||||
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
|
||||
"api_key": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "進階設定"
|
||||
},
|
||||
"essential": "必要設定",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "可用外掛"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "確定要解除安裝此外掛嗎?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "未找到符合的外掛。請嘗試調整搜尋或類別篩選。"
|
||||
},
|
||||
"error": {
|
||||
"install": "安裝外掛失敗",
|
||||
"load": "載入外掛失敗",
|
||||
"uninstall": "解除安裝外掛失敗"
|
||||
},
|
||||
"filter": {
|
||||
"all": "所有類別"
|
||||
},
|
||||
"install": "安裝",
|
||||
"installed": {
|
||||
"empty": "尚未安裝任何外掛。瀏覽可用外掛以開始使用。",
|
||||
"title": "已安裝外掛"
|
||||
},
|
||||
"installing": "安裝中...",
|
||||
"results": "找到 {{count}} 個外掛",
|
||||
"search": {
|
||||
"placeholder": "搜尋外掛..."
|
||||
},
|
||||
"success": {
|
||||
"install": "外掛安裝成功",
|
||||
"uninstall": "外掛解除安裝成功"
|
||||
},
|
||||
"tab": "外掛",
|
||||
"type": {
|
||||
"agent": "代理",
|
||||
"agents": "代理",
|
||||
"all": "全部",
|
||||
"command": "指令",
|
||||
"commands": "指令",
|
||||
"skills": "技能"
|
||||
},
|
||||
"uninstall": "解除安裝",
|
||||
"uninstalling": "解除安裝中..."
|
||||
},
|
||||
"prompt": "提示設定",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "允許工具請求",
|
||||
"denyRequest": "拒絕工具請求",
|
||||
"hideDetails": "隱藏工具詳情",
|
||||
"runWithOptions": "帶選項執行",
|
||||
"showDetails": "顯示工具詳情"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "取消",
|
||||
"run": "執行"
|
||||
},
|
||||
"confirmation": "確定要執行此 Claude 工具嗎?",
|
||||
"defaultDenyMessage": "使用者拒絕了該工具的權限。",
|
||||
"defaultDescription": "在您的環境中執行程式碼或系統操作。執行前請確保指令安全。",
|
||||
"error": {
|
||||
"sendFailed": "傳送您的決定失敗,請重試。"
|
||||
},
|
||||
"expired": "已過期",
|
||||
"inputPreview": "工具輸入預覽",
|
||||
"pending": "等待中 ({{seconds}}秒)",
|
||||
"permissionExpired": "權限請求已過期。等待新指令...",
|
||||
"requiresElevatedPermissions": "此工具需要提升的權限。",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "如果您選擇總是允許此工具,核准可能會更新多個工作階段權限。",
|
||||
"permissionUpdateSingle": "如果您選擇總是允許此工具,核准可能會更新您的工作階段權限。"
|
||||
},
|
||||
"toast": {
|
||||
"denied": "工具請求已被拒絕。",
|
||||
"timeout": "工具請求在收到核准前逾時。"
|
||||
},
|
||||
"waiting": "等待工具權限決定..."
|
||||
},
|
||||
"type": {
|
||||
"label": "代理類型",
|
||||
"unknown": "未知類型"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "控制放大結果的隨機性"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "操作",
|
||||
"agents": "代理",
|
||||
"all_categories": "所有類別",
|
||||
"all_types": "全部",
|
||||
"category": "類別",
|
||||
"commands": "指令",
|
||||
"confirm_uninstall": "確定要解除安裝 {{name}} 嗎?",
|
||||
"install": "安裝",
|
||||
"install_plugins_from_browser": "瀏覽可用外掛以開始使用",
|
||||
"installing": "安裝中...",
|
||||
"name": "名稱",
|
||||
"no_description": "無描述",
|
||||
"no_installed_plugins": "尚未安裝任何外掛",
|
||||
"no_results": "未找到外掛",
|
||||
"search_placeholder": "搜尋外掛...",
|
||||
"showing_results": "顯示 {{count}} 個外掛",
|
||||
"showing_results_one": "顯示 {{count}} 個外掛",
|
||||
"showing_results_other": "顯示 {{count}} 個外掛",
|
||||
"showing_results_plural": "顯示 {{count}} 個外掛",
|
||||
"skills": "技能",
|
||||
"try_different_search": "請嘗試調整搜尋或類別篩選",
|
||||
"type": "類型",
|
||||
"uninstall": "解除安裝",
|
||||
"uninstalling": "解除安裝中..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "複製為圖片"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic API 主機地址",
|
||||
"anthropic_api_host_preview": "Anthropic 預覽:{{url}}",
|
||||
"anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1,以 # 結尾則強制使用原始地址。",
|
||||
"anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "預覽:{{url}}",
|
||||
"reset": "重設",
|
||||
"tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址"
|
||||
"tip": "# 結尾強制使用輸入位址"
|
||||
}
|
||||
},
|
||||
"api_host": "API 主機地址",
|
||||
"api_host_no_valid": "API 位址不合法",
|
||||
"api_host_preview": "預覽:{{url}}",
|
||||
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
|
||||
"api_key": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Erweiterte Einstellungen"
|
||||
},
|
||||
"essential": "Grundeinstellungen",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Verfügbare Plugins"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Sind Sie sicher, dass Sie dieses Plugin deinstallieren möchten?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Keine Plugins gefunden, die deinen Filtern entsprechen. Versuche, deine Such- oder Kategoriefilter anzupassen."
|
||||
},
|
||||
"error": {
|
||||
"install": "Fehler beim Installieren des Plugins",
|
||||
"load": "Fehler beim Laden der Plugins",
|
||||
"uninstall": "Fehler beim Deinstallieren des Plugins"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Alle Kategorien"
|
||||
},
|
||||
"install": "Installieren",
|
||||
"installed": {
|
||||
"empty": "Noch keine Plugins installiert. Durchsuche verfügbare Plugins, um loszulegen.",
|
||||
"title": "Installierte Plugins"
|
||||
},
|
||||
"installing": "Wird installiert...",
|
||||
"results": "{{count}} Plugin(s) gefunden",
|
||||
"search": {
|
||||
"placeholder": "Such-Plugins..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin erfolgreich installiert",
|
||||
"uninstall": "Plugin erfolgreich deinstalliert"
|
||||
},
|
||||
"tab": "Plugins",
|
||||
"type": {
|
||||
"agent": "Agent",
|
||||
"agents": "Agenten",
|
||||
"all": "Alle",
|
||||
"command": "Befehl",
|
||||
"commands": "Befehle",
|
||||
"skills": "Fähigkeiten"
|
||||
},
|
||||
"uninstall": "Deinstallieren",
|
||||
"uninstalling": "Deinstallation läuft..."
|
||||
},
|
||||
"prompt": "Prompt-Einstellungen",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Werkzeuganfrage zulassen",
|
||||
"denyRequest": "Werkzeuganfrage ablehnen",
|
||||
"hideDetails": "Werkzeugdetails ausblenden",
|
||||
"runWithOptions": "Mit zusätzlichen Optionen ausführen",
|
||||
"showDetails": "Zeige Werkzeugdetails"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Abbrechen",
|
||||
"run": "Laufen"
|
||||
},
|
||||
"confirmation": "Bist du sicher, dass du dieses Claude-Tool ausführen möchtest?",
|
||||
"defaultDenyMessage": "Der Benutzer hat die Berechtigung für dieses Tool verweigert.",
|
||||
"defaultDescription": "Führt Code oder Systemaktionen in Ihrer Umgebung aus. Vergewissern Sie sich, dass der Befehl sicher aussieht, bevor Sie ihn ausführen.",
|
||||
"error": {
|
||||
"sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"expired": "Abgelaufen",
|
||||
"inputPreview": "Vorschau der Werkzeugeingabe",
|
||||
"pending": "Ausstehend ({{seconds}}s)",
|
||||
"permissionExpired": "Berechtigungsanfrage abgelaufen. Warte auf neue Anweisungen...",
|
||||
"requiresElevatedPermissions": "Dieses Tool erfordert erhöhte Berechtigungen.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Das Genehmigen kann mehrere Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen.",
|
||||
"permissionUpdateSingle": "Das Genehmigen kann Ihre Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Tool-Anfrage wurde abgelehnt.",
|
||||
"timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist."
|
||||
},
|
||||
"waiting": "Warten auf Entscheidung über Tool-Berechtigung..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Agent-Typ",
|
||||
"unknown": "Unbekannter Typ"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Aktionen",
|
||||
"agents": "Agenten",
|
||||
"all_categories": "Alle Kategorien",
|
||||
"all_types": "Alle",
|
||||
"category": "Kategorie",
|
||||
"commands": "Befehle",
|
||||
"confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?",
|
||||
"install": "Installieren",
|
||||
"install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen",
|
||||
"installing": "Installiere…",
|
||||
"name": "Name",
|
||||
"no_description": "Keine Beschreibung verfügbar",
|
||||
"no_installed_plugins": "Noch keine Plugins installiert",
|
||||
"no_results": "Keine Plugins gefunden",
|
||||
"search_placeholder": "Such-Plugins...",
|
||||
"showing_results": "{{count}} Plugin anzeigen",
|
||||
"showing_results_one": "{{count}} Plugin anzeigen",
|
||||
"showing_results_other": "Zeige {{count}} Plugins",
|
||||
"showing_results_plural": "{{count}} Plugins anzeigen",
|
||||
"skills": "Fähigkeiten",
|
||||
"try_different_search": "Versuchen Sie, Ihre Suche oder die Kategoriefilter anzupassen.",
|
||||
"type": "Typ",
|
||||
"uninstall": "Deinstallieren",
|
||||
"uninstalling": "Deinstallation läuft..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Als Bild kopieren"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Vorschau: {{url}}",
|
||||
"reset": "Zurücksetzen",
|
||||
"tip": "/ am Ende ignorieren v1-Version, # am Ende erzwingt die Verwendung der Eingabe-Adresse"
|
||||
"tip": "# 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": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Ρυθμίσεις για προχωρημένους"
|
||||
},
|
||||
"essential": "Βασικές Ρυθμίσεις",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Διαθέσιμα πρόσθετα"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Είστε βέβαιοι ότι θέλετε να απεγκαταστήσετε αυτό το πρόσθετο;"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Δεν βρέθηκε συμβατό πρόσθετο. Δοκιμάστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών."
|
||||
},
|
||||
"error": {
|
||||
"install": "Η εγκατάσταση του πρόσθετου απέτυχε",
|
||||
"load": "Η φόρτωση του πρόσθετου απέτυχε",
|
||||
"uninstall": "Η απεγκατάσταση του πρόσθετου απέτυχε"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Όλες οι κατηγορίες"
|
||||
},
|
||||
"install": "εγκατάσταση",
|
||||
"installed": {
|
||||
"empty": "Δεν έχει εγκατασταθεί κανένα πρόσθετο. Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε.",
|
||||
"title": "Έχει εγκατασταθεί το πρόσθετο"
|
||||
},
|
||||
"installing": "Εγκατάσταση...",
|
||||
"results": "Βρέθηκαν {{count}} πρόσθετα",
|
||||
"search": {
|
||||
"placeholder": "Αναζήτηση πρόσθετου..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Η εγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία",
|
||||
"uninstall": "Η απεγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία"
|
||||
},
|
||||
"tab": "Πρόσθετο",
|
||||
"type": {
|
||||
"agent": "αντιπρόσωπος",
|
||||
"agents": "αντιπρόσωπος",
|
||||
"all": "όλα",
|
||||
"command": "εντολή",
|
||||
"commands": "εντολή",
|
||||
"skills": "δεξιότητα"
|
||||
},
|
||||
"uninstall": "απεγκατάσταση",
|
||||
"uninstalling": "Απεγκατάσταση..."
|
||||
},
|
||||
"prompt": "Ρυθμίσεις Προτροπής",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Επίτρεψη αίτησης εργαλείου",
|
||||
"denyRequest": "Απόρριψη αιτήματος εργαλείου",
|
||||
"hideDetails": "Απόκρυψη λεπτομερειών εργαλείου",
|
||||
"runWithOptions": "Εκτέλεση με επιπλέον επιλογές",
|
||||
"showDetails": "Εμφάνιση λεπτομερειών εργαλείου"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Ακύρωση",
|
||||
"run": "Τρέξε"
|
||||
},
|
||||
"confirmation": "Είσαι σίγουρος ότι θέλεις να εκτελέσεις αυτό το εργαλείο Claude;",
|
||||
"defaultDenyMessage": "Ο χρήστης αρνήθηκε την άδεια για αυτό το εργαλείο.",
|
||||
"defaultDescription": "Εκτελεί κώδικα ή ενέργειες συστήματος στο περιβάλλον σας. Βεβαιωθείτε ότι η εντολή φαίνεται ασφαλής πριν την εκτελέσετε.",
|
||||
"error": {
|
||||
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
|
||||
},
|
||||
"expired": "Ληγμένο",
|
||||
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
|
||||
"pending": "Εκκρεμεί ({{seconds}}δ)",
|
||||
"permissionExpired": "Το αίτημα άδειας έληξε. Αναμονή για νέες οδηγίες...",
|
||||
"requiresElevatedPermissions": "Αυτό το εργαλείο απαιτεί αυξημένα δικαιώματα.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Η έγκριση μπορεί να ενημερώσει πολλές άδειες συνεδρίας αν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο.",
|
||||
"permissionUpdateSingle": "Η έγκριση ενδέχεται να ενημερώσει τα δικαιώματα περιόδου σύνδεσής σας, εάν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Το αίτημα για εργαλείο απορρίφθηκε.",
|
||||
"timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση."
|
||||
},
|
||||
"waiting": "Αναμονή για απόφαση άδειας εργαλείου..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Τύπος Πράκτορα",
|
||||
"unknown": "Άγνωστος Τύπος"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Λειτουργία",
|
||||
"agents": "αντιπρόσωπος",
|
||||
"all_categories": "Όλες οι κατηγορίες",
|
||||
"all_types": "ολόκληρο",
|
||||
"category": "Κατηγορία",
|
||||
"commands": "εντολή",
|
||||
"confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};",
|
||||
"install": "εγκατάσταση",
|
||||
"install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε",
|
||||
"installing": "Εγκατάσταση...",
|
||||
"name": "Όνομα",
|
||||
"no_description": "Χωρίς περιγραφή",
|
||||
"no_installed_plugins": "Δεν έχει εγκατασταθεί κανένα πρόσθετο",
|
||||
"no_results": "Δεν βρέθηκε πρόσθετο",
|
||||
"search_placeholder": "Πρόσθετο αναζήτησης...",
|
||||
"showing_results": "Εμφάνιση {{count}} προσθέτων",
|
||||
"showing_results_one": "Εμφάνιση {{count}} προσθέτων",
|
||||
"showing_results_other": "Εμφάνιση {{count}} προσθέτων",
|
||||
"showing_results_plural": "Εμφάνιση {{count}} πρόσθετων",
|
||||
"skills": "δεξιότητα",
|
||||
"try_different_search": "Προσπαθήστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών",
|
||||
"type": "τύπος",
|
||||
"uninstall": "κατάργηση εγκατάστασης",
|
||||
"uninstalling": "Απεγκατάσταση..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Αντιγραφή ως εικόνα"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Διεύθυνση API Anthropic",
|
||||
"anthropic_api_host_preview": "Προεπισκόπηση Anthropic: {{url}}",
|
||||
"anthropic_api_host_tip": "Συμπληρώστε μόνο εάν ο πάροχος προσφέρει συμβατή με Anthropic διεύθυνση. Η λήξη με / αγνοεί το v1 που προστίθεται αυτόματα, η λήξη με # επιβάλλει τη χρήση της αρχικής διεύθυνσης.",
|
||||
"anthropic_api_host_tooltip": "Συμπληρώστε μόνο όταν ο πάροχος παρέχει βασική διεύθυνση συμβατή με Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Προεπισκόπηση: {{url}}",
|
||||
"reset": "Επαναφορά",
|
||||
"tip": "/τέλος αγνόηση v1 έκδοσης, #τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
}
|
||||
},
|
||||
"api_host": "Διεύθυνση API",
|
||||
"api_host_no_valid": "Η διεύθυνση API δεν είναι έγκυρη",
|
||||
"api_host_preview": "Προεπισκόπηση: {{url}}",
|
||||
"api_host_tooltip": "Αντικατάσταση μόνο όταν ο πάροχος απαιτεί προσαρμοσμένη διεύθυνση συμβατή με OpenAI.",
|
||||
"api_key": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Configuración avanzada"
|
||||
},
|
||||
"essential": "Configuraciones esenciales",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Complementos disponibles"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "¿Estás seguro de que quieres desinstalar este complemento?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "No se encontró ningún complemento que coincida. Intenta ajustar la búsqueda o los filtros de categoría."
|
||||
},
|
||||
"error": {
|
||||
"install": "Error al instalar el complemento",
|
||||
"load": "Error al cargar el complemento",
|
||||
"uninstall": "Error al desinstalar el complemento"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Todas las categorías"
|
||||
},
|
||||
"install": "instalación",
|
||||
"installed": {
|
||||
"empty": "Aún no se ha instalado ningún complemento. Explora los complementos disponibles para comenzar.",
|
||||
"title": "Complemento instalado"
|
||||
},
|
||||
"installing": "Instalando...",
|
||||
"results": "Encontrados {{count}} complementos",
|
||||
"search": {
|
||||
"placeholder": "Buscar complemento..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Complemento instalado con éxito",
|
||||
"uninstall": "Complemento desinstalado correctamente"
|
||||
},
|
||||
"tab": "complemento",
|
||||
"type": {
|
||||
"agent": "agente",
|
||||
"agents": "Agente",
|
||||
"all": "todo",
|
||||
"command": "comando",
|
||||
"commands": "comando",
|
||||
"skills": "habilidad"
|
||||
},
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"prompt": "Configuración de indicaciones",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Permitir solicitud de herramienta",
|
||||
"denyRequest": "Denegar solicitud de herramienta",
|
||||
"hideDetails": "Ocultar detalles de la herramienta",
|
||||
"runWithOptions": "Ejecutar con opciones adicionales",
|
||||
"showDetails": "Mostrar detalles de la herramienta"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"run": "Correr"
|
||||
},
|
||||
"confirmation": "¿Estás seguro de que quieres ejecutar esta herramienta de Claude?",
|
||||
"defaultDenyMessage": "El usuario denegó el permiso para esta herramienta.",
|
||||
"defaultDescription": "Ejecuta código o acciones del sistema en tu entorno. Asegúrate de que el comando parezca seguro antes de ejecutarlo.",
|
||||
"error": {
|
||||
"sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo."
|
||||
},
|
||||
"expired": "Caducado",
|
||||
"inputPreview": "Vista previa de entrada de herramienta",
|
||||
"pending": "Pendiente ({{seconds}}s)",
|
||||
"permissionExpired": "Solicitud de permiso expirada. Esperando nuevas instrucciones...",
|
||||
"requiresElevatedPermissions": "Esta herramienta requiere permisos elevados.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Aprobar puede actualizar varios permisos de sesión si elegiste permitir siempre esta herramienta.",
|
||||
"permissionUpdateSingle": "Aprobar puede actualizar los permisos de tu sesión si elegiste permitir siempre esta herramienta."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "La solicitud de herramienta fue denegada.",
|
||||
"timeout": "La solicitud de herramienta expiró antes de recibir la aprobación."
|
||||
},
|
||||
"waiting": "Esperando la decisión de permiso de la herramienta..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo de Agente",
|
||||
"unknown": "Tipo desconocido"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Controla la aleatoriedad del resultado de la ampliación"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Operación",
|
||||
"agents": "Agente",
|
||||
"all_categories": "Todas las categorías",
|
||||
"all_types": "todo",
|
||||
"category": "Categoría",
|
||||
"commands": "comando",
|
||||
"confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?",
|
||||
"install": "instalación",
|
||||
"install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar",
|
||||
"installing": "Instalando...",
|
||||
"name": "Nombre",
|
||||
"no_description": "Sin descripción",
|
||||
"no_installed_plugins": "Aún no se ha instalado ningún complemento",
|
||||
"no_results": "No se encontró el complemento",
|
||||
"search_placeholder": "Buscar complemento...",
|
||||
"showing_results": "Mostrar {{count}} complementos",
|
||||
"showing_results_one": "Mostrar {{count}} complementos",
|
||||
"showing_results_other": "Mostrar {{count}} complementos",
|
||||
"showing_results_plural": "Mostrar {{count}} complementos",
|
||||
"skills": "habilidad",
|
||||
"try_different_search": "Por favor, intenta ajustar la búsqueda o los filtros de categoría.",
|
||||
"type": "tipo",
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copiar como imagen"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Vista previa: {{url}}",
|
||||
"reset": "Restablecer",
|
||||
"tip": "Ignorar v1 al final con /, forzar uso de dirección de entrada con # al final"
|
||||
"tip": "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": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Paramètres avancés"
|
||||
},
|
||||
"essential": "Paramètres essentiels",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Plugins disponibles"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Êtes-vous sûr de vouloir désinstaller ce plugin ?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Aucun plugin correspondant trouvé. Veuillez essayer d’ajuster la recherche ou les filtres de catégorie."
|
||||
},
|
||||
"error": {
|
||||
"install": "Échec de l'installation du plugin",
|
||||
"load": "Échec du chargement du plugin",
|
||||
"uninstall": "Échec de la désinstallation du plugin"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Toutes les catégories"
|
||||
},
|
||||
"install": "Installation",
|
||||
"installed": {
|
||||
"empty": "Aucun plugin n'est encore installé. Parcourez les plugins disponibles pour commencer.",
|
||||
"title": "Extension installée"
|
||||
},
|
||||
"installing": "Installation en cours...",
|
||||
"results": "{{count}} modules complémentaires trouvés",
|
||||
"search": {
|
||||
"placeholder": "Recherche de plug-ins..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Installation du plugin réussie",
|
||||
"uninstall": "Désinstallation du plugin réussie"
|
||||
},
|
||||
"tab": "Module d'extension",
|
||||
"type": {
|
||||
"agent": "mandataire",
|
||||
"agents": "mandataire",
|
||||
"all": "Tout",
|
||||
"command": "commande",
|
||||
"commands": "commande",
|
||||
"skills": "compétence"
|
||||
},
|
||||
"uninstall": "Désinstaller",
|
||||
"uninstalling": "Désinstallation en cours..."
|
||||
},
|
||||
"prompt": "Paramètres de l'invite",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Autoriser la demande d'outil",
|
||||
"denyRequest": "Refuser la demande d'outil",
|
||||
"hideDetails": "Masquer les détails de l'outil",
|
||||
"runWithOptions": "Exécuter avec des options supplémentaires",
|
||||
"showDetails": "Afficher les détails de l'outil"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Annuler",
|
||||
"run": "Courir"
|
||||
},
|
||||
"confirmation": "Êtes-vous sûr de vouloir exécuter cet outil Claude ?",
|
||||
"defaultDenyMessage": "L'utilisateur a refusé l'autorisation pour cet outil.",
|
||||
"defaultDescription": "Exécute du code ou des actions système dans votre environnement. Assurez-vous que la commande semble sûre avant de l’exécuter.",
|
||||
"error": {
|
||||
"sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer."
|
||||
},
|
||||
"expired": "Expiré",
|
||||
"inputPreview": "Aperçu de l'entrée de l'outil",
|
||||
"pending": "En attente ({{seconds}}s)",
|
||||
"permissionExpired": "Demande de permission expirée. En attente de nouvelles instructions...",
|
||||
"requiresElevatedPermissions": "Cet outil nécessite des autorisations élevées.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Approuver peut mettre à jour plusieurs autorisations de session si vous avez choisi de toujours autoriser cet outil.",
|
||||
"permissionUpdateSingle": "Approuver peut mettre à jour vos permissions de session si vous avez choisi de toujours autoriser cet outil."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "La demande d'outil a été refusée.",
|
||||
"timeout": "La demande d'outil a expiré avant d'obtenir l'approbation."
|
||||
},
|
||||
"waiting": "En attente de la décision d'autorisation de l'outil..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Type d'agent",
|
||||
"unknown": "Type inconnu"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Contrôle la randomisation du résultat d'agrandissement"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Opération",
|
||||
"agents": "mandataire",
|
||||
"all_categories": "Toutes les catégories",
|
||||
"all_types": "Tout",
|
||||
"category": "Catégorie",
|
||||
"commands": "commande",
|
||||
"confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?",
|
||||
"install": "Installation",
|
||||
"install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer",
|
||||
"installing": "Installation en cours...",
|
||||
"name": "Nom",
|
||||
"no_description": "Sans description",
|
||||
"no_installed_plugins": "Aucun plugin n’est encore installé",
|
||||
"no_results": "Aucun plugin trouvé",
|
||||
"search_placeholder": "Rechercher des modules d'extension...",
|
||||
"showing_results": "Afficher {{count}} extensions",
|
||||
"showing_results_one": "Afficher {{count}} modules d’extension",
|
||||
"showing_results_other": "Afficher {{count}} modules d'extension",
|
||||
"showing_results_plural": "Afficher {{count}} modules d'extension",
|
||||
"skills": "compétence",
|
||||
"try_different_search": "Veuillez essayer d’ajuster la recherche ou le filtre de catégorie.",
|
||||
"type": "type",
|
||||
"uninstall": "Désinstaller",
|
||||
"uninstalling": "Désinstallation en cours..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copier en tant qu'image"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Aperçu : {{url}}",
|
||||
"reset": "Réinitialiser",
|
||||
"tip": "Ignorer la version v1 si terminé par /, forcer l'utilisation de l'adresse d'entrée si terminé par #"
|
||||
"tip": "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": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "高級設定"
|
||||
},
|
||||
"essential": "必須設定",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "利用可能なプラグイン"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "このプラグインをアンインストールしてもよろしいですか?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "一致するプラグインが見つかりませんでした。検索キーワードやカテゴリフィルターを調整してみてください。"
|
||||
},
|
||||
"error": {
|
||||
"install": "プラグインのインストールに失敗しました",
|
||||
"load": "プラグインの読み込みに失敗しました",
|
||||
"uninstall": "プラグインのアンインストールに失敗しました"
|
||||
},
|
||||
"filter": {
|
||||
"all": "すべてのカテゴリー"
|
||||
},
|
||||
"install": "インストール",
|
||||
"installed": {
|
||||
"empty": "まだプラグインがインストールされていません。利用可能なプラグインを見てみましょう。",
|
||||
"title": "インストール済みプラグイン"
|
||||
},
|
||||
"installing": "インストール中...",
|
||||
"results": "{{count}} 個のプラグインが見つかりました",
|
||||
"search": {
|
||||
"placeholder": "検索プラグイン..."
|
||||
},
|
||||
"success": {
|
||||
"install": "プラグインのインストールが成功しました",
|
||||
"uninstall": "プラグインのアンインストールが成功しました"
|
||||
},
|
||||
"tab": "プラグイン",
|
||||
"type": {
|
||||
"agent": "代理",
|
||||
"agents": "代理",
|
||||
"all": "全部",
|
||||
"command": "命令",
|
||||
"commands": "命令",
|
||||
"skills": "技能"
|
||||
},
|
||||
"uninstall": "アンインストール",
|
||||
"uninstalling": "アンインストール中..."
|
||||
},
|
||||
"prompt": "プロンプト設定",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "ツールリクエストを許可",
|
||||
"denyRequest": "ツールリクエストを拒否",
|
||||
"hideDetails": "ツールの詳細を非表示",
|
||||
"runWithOptions": "追加オプションで実行",
|
||||
"showDetails": "ツールの詳細を表示"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "キャンセル",
|
||||
"run": "走る"
|
||||
},
|
||||
"confirmation": "このClaudeツールを実行してもよろしいですか?",
|
||||
"defaultDenyMessage": "ユーザーはこのツールの使用を拒否しました。",
|
||||
"defaultDescription": "環境内でコードまたはシステムアクションを実行します。実行前にコマンドが安全であることを確認してください。",
|
||||
"error": {
|
||||
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"expired": "期限切れ",
|
||||
"inputPreview": "ツール入力プレビュー",
|
||||
"pending": "保留中({{seconds}}秒)",
|
||||
"permissionExpired": "許可リクエストの期限が切れました。新しい指示を待っています...",
|
||||
"requiresElevatedPermissions": "このツールは昇格した権限が必要です。",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "承認すると、このツールを常に許可することを選択した場合、複数のセッション権限が更新されることがあります。",
|
||||
"permissionUpdateSingle": "承認すると、このツールを常に許可することを選択した場合、セッションの権限が更新されることがあります。"
|
||||
},
|
||||
"toast": {
|
||||
"denied": "ツールリクエストは拒否されました。",
|
||||
"timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。"
|
||||
},
|
||||
"waiting": "ツールの許可決定を待っています..."
|
||||
},
|
||||
"type": {
|
||||
"label": "エージェントタイプ",
|
||||
"unknown": "不明なタイプ"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "拡大結果のランダム性を制御します"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "操作",
|
||||
"agents": "代理",
|
||||
"all_categories": "すべてのカテゴリー",
|
||||
"all_types": "全部",
|
||||
"category": "カテゴリー",
|
||||
"commands": "命令",
|
||||
"confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?",
|
||||
"install": "インストール",
|
||||
"install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください",
|
||||
"installing": "インストール中...",
|
||||
"name": "名称",
|
||||
"no_description": "説明なし",
|
||||
"no_installed_plugins": "まだプラグインがインストールされていません",
|
||||
"no_results": "プラグインが見つかりません",
|
||||
"search_placeholder": "検索プラグイン...",
|
||||
"showing_results": "{{count}} 個のプラグインを表示",
|
||||
"showing_results_one": "{{count}} 個のプラグインを表示",
|
||||
"showing_results_other": "{{count}} 個のプラグインを表示",
|
||||
"showing_results_plural": "{{count}} 個のプラグインを表示",
|
||||
"skills": "スキル",
|
||||
"try_different_search": "検索またはカテゴリフィルターを調整してみてください",
|
||||
"type": "タイプ",
|
||||
"uninstall": "アンインストール",
|
||||
"uninstalling": "アンインストール中..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "画像としてコピー"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Anthropic APIアドレス",
|
||||
"anthropic_api_host_preview": "Anthropic プレビュー:{{url}}",
|
||||
"anthropic_api_host_tip": "サービスプロバイダーがAnthropic互換のアドレスを提供する場合のみ入力してください。/で終わる場合は自動追加されるv1を無視し、#で終わる場合は元のアドレスを強制的に使用します。",
|
||||
"anthropic_api_host_tooltip": "サービスプロバイダーがClaude互換のベースアドレスを提供する場合のみ入力してください。",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "プレビュー: {{url}}",
|
||||
"reset": "リセット",
|
||||
"tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
}
|
||||
},
|
||||
"api_host": "APIホスト",
|
||||
"api_host_no_valid": "APIアドレスが無効です",
|
||||
"api_host_preview": "プレビュー:{{url}}",
|
||||
"api_host_tooltip": "サービスプロバイダーがカスタムOpenAI互換アドレスを必要とする場合のみ上書きしてください。",
|
||||
"api_key": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Configurações avançadas"
|
||||
},
|
||||
"essential": "Configurações Essenciais",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Plugins disponíveis"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Tem certeza de que deseja desinstalar este plugin?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Nenhum plugin correspondente encontrado. Tente ajustar a pesquisa ou os filtros de categoria."
|
||||
},
|
||||
"error": {
|
||||
"install": "Falha na instalação do plugin",
|
||||
"load": "Falha ao carregar o plugin",
|
||||
"uninstall": "Falha ao desinstalar o plug-in"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Todas as categorias"
|
||||
},
|
||||
"install": "Instalação",
|
||||
"installed": {
|
||||
"empty": "Nenhum plugin foi instalado ainda. Explore os plugins disponíveis para começar.",
|
||||
"title": "Plugin instalado"
|
||||
},
|
||||
"installing": "Instalando...",
|
||||
"results": "Encontrados {{count}} plugins",
|
||||
"search": {
|
||||
"placeholder": "Pesquisar extensão..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin instalado com sucesso",
|
||||
"uninstall": "插件 desinstalado com sucesso"
|
||||
},
|
||||
"tab": "plug-in",
|
||||
"type": {
|
||||
"agent": "agente",
|
||||
"agents": "agente",
|
||||
"all": "tudo",
|
||||
"command": "comando",
|
||||
"commands": "comando",
|
||||
"skills": "habilidade"
|
||||
},
|
||||
"uninstall": "desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"prompt": "Configurações de Prompt",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Permitir solicitação de ferramenta",
|
||||
"denyRequest": "Negar solicitação de ferramenta",
|
||||
"hideDetails": "Ocultar detalhes da ferramenta",
|
||||
"runWithOptions": "Executar com opções adicionais",
|
||||
"showDetails": "Mostrar detalhes da ferramenta"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"run": "Correr"
|
||||
},
|
||||
"confirmation": "Tem certeza de que quer executar esta ferramenta Claude?",
|
||||
"defaultDenyMessage": "Usuário negou permissão para esta ferramenta.",
|
||||
"defaultDescription": "Executa código ou ações do sistema no seu ambiente. Certifique-se de que o comando parece seguro antes de executá-lo.",
|
||||
"error": {
|
||||
"sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente."
|
||||
},
|
||||
"expired": "Expirado",
|
||||
"inputPreview": "Pré-visualização da entrada da ferramenta",
|
||||
"pending": "Pendente ({{seconds}}s)",
|
||||
"permissionExpired": "Solicitação de permissão expirou. Aguardando novas instruções...",
|
||||
"requiresElevatedPermissions": "Esta ferramenta requer permissões elevadas.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Aprovar pode atualizar várias permissões de sessão se você escolheu sempre permitir esta ferramenta.",
|
||||
"permissionUpdateSingle": "Aprovar pode atualizar as permissões da sua sessão se você escolheu sempre permitir esta ferramenta."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Solicitação de ferramenta foi negada.",
|
||||
"timeout": "A solicitação da ferramenta expirou antes de receber aprovação."
|
||||
},
|
||||
"waiting": "Aguardando decisão de permissão da ferramenta..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo de Agente",
|
||||
"unknown": "Tipo Desconhecido"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Controla a aleatoriedade do resultado de ampliação"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Operação",
|
||||
"agents": "agente",
|
||||
"all_categories": "Todas as categorias",
|
||||
"all_types": "Tudo",
|
||||
"category": "categoria",
|
||||
"commands": "comando",
|
||||
"confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?",
|
||||
"install": "Instalação",
|
||||
"install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar",
|
||||
"installing": "Instalando...",
|
||||
"name": "Nome",
|
||||
"no_description": "Sem descrição",
|
||||
"no_installed_plugins": "Nenhum plugin foi instalado ainda",
|
||||
"no_results": "Plugin não encontrado",
|
||||
"search_placeholder": "Pesquisar plugin...",
|
||||
"showing_results": "Exibir {{count}} extensões",
|
||||
"showing_results_one": "Mostrar {{count}} extensões",
|
||||
"showing_results_other": "Exibir {{count}} extensões",
|
||||
"showing_results_plural": "Exibir {{count}} extensões",
|
||||
"skills": "habilidade",
|
||||
"try_different_search": "Por favor, tente ajustar a pesquisa ou os filtros de categoria.",
|
||||
"type": "tipo",
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copiar como imagem"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Pré-visualização: {{url}}",
|
||||
"reset": "Redefinir",
|
||||
"tip": "Ignorar v1 na versão finalizada com /, usar endereço de entrada forçado se terminar com #"
|
||||
"tip": "e forçar o uso do endereço original quando 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": {
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Расширенные настройки"
|
||||
},
|
||||
"essential": "Основные настройки",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Доступные плагины"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Вы уверены, что хотите удалить этот плагин?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Совпадающие плагины не найдены. Попробуйте изменить поиск или фильтр категорий."
|
||||
},
|
||||
"error": {
|
||||
"install": "Ошибка установки плагина",
|
||||
"load": "Ошибка загрузки плагина",
|
||||
"uninstall": "Не удалось удалить плагин"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Все категории"
|
||||
},
|
||||
"install": "установка",
|
||||
"installed": {
|
||||
"empty": "Плагины ещё не установлены. Просмотрите доступные плагины, чтобы начать.",
|
||||
"title": "Установленный плагин"
|
||||
},
|
||||
"installing": "Установка...",
|
||||
"results": "Найдено {{count}} плагинов",
|
||||
"search": {
|
||||
"placeholder": "Поиск плагинов..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Плагин успешно установлен",
|
||||
"uninstall": "Плагин успешно удалён"
|
||||
},
|
||||
"tab": "плагин",
|
||||
"type": {
|
||||
"agent": "агент",
|
||||
"agents": "Прокси",
|
||||
"all": "всё",
|
||||
"command": "команда",
|
||||
"commands": "команда",
|
||||
"skills": "навык"
|
||||
},
|
||||
"uninstall": "Удаление",
|
||||
"uninstalling": "Удаление..."
|
||||
},
|
||||
"prompt": "Настройки подсказки",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Разрешить запрос инструмента",
|
||||
"denyRequest": "Отклонить запрос на инструмент",
|
||||
"hideDetails": "Скрыть сведения об инструменте",
|
||||
"runWithOptions": "Запустить с дополнительными параметрами",
|
||||
"showDetails": "Показать сведения об инструменте"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Отмена",
|
||||
"run": "Беги"
|
||||
},
|
||||
"confirmation": "Вы уверены, что хотите запустить этот инструмент Claude?",
|
||||
"defaultDenyMessage": "Пользователь отказал в разрешении на использование этого инструмента.",
|
||||
"defaultDescription": "Выполняет код или системные действия в вашей среде. Убедитесь, что команда выглядит безопасно, прежде чем запускать её.",
|
||||
"error": {
|
||||
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
|
||||
},
|
||||
"expired": "Истёк",
|
||||
"inputPreview": "Предварительный просмотр ввода инструмента",
|
||||
"pending": "Ожидание ({{seconds}}с)",
|
||||
"permissionExpired": "Срок действия запроса на разрешение истёк. Ожидание новых инструкций...",
|
||||
"requiresElevatedPermissions": "Этому инструменту требуются повышенные разрешения.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Одобрение может обновить разрешения для нескольких сеансов, если вы выбрали всегда разрешать использование этого инструмента.",
|
||||
"permissionUpdateSingle": "Одобрение может обновить разрешения вашей сессии, если вы выбрали всегда разрешать использование этого инструмента."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Запрос на инструмент был отклонён.",
|
||||
"timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения."
|
||||
},
|
||||
"waiting": "Ожидание решения о разрешении на использование инструмента..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Тип агента",
|
||||
"unknown": "Неизвестный тип"
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Операция",
|
||||
"agents": "агент",
|
||||
"all_categories": "Все категории",
|
||||
"all_types": "всё",
|
||||
"category": "категория",
|
||||
"commands": "команда",
|
||||
"confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?",
|
||||
"install": "установка",
|
||||
"install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу",
|
||||
"installing": "Установка...",
|
||||
"name": "название",
|
||||
"no_description": "Без описания",
|
||||
"no_installed_plugins": "Плагины ещё не установлены",
|
||||
"no_results": "Плагин не найден",
|
||||
"search_placeholder": "Поиск плагинов...",
|
||||
"showing_results": "Отображено {{count}} плагинов",
|
||||
"showing_results_one": "Отображено {{count}} плагинов",
|
||||
"showing_results_other": "Отображено {{count}} плагинов",
|
||||
"showing_results_plural": "Отображение {{count}} плагинов",
|
||||
"skills": "навык",
|
||||
"try_different_search": "Пожалуйста, попробуйте изменить поиск или фильтры категорий",
|
||||
"type": "тип",
|
||||
"uninstall": "Удаление",
|
||||
"uninstalling": "Удаление..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Скопировать как изображение"
|
||||
@@ -4045,7 +4148,6 @@
|
||||
},
|
||||
"anthropic_api_host": "Адрес API Anthropic",
|
||||
"anthropic_api_host_preview": "Предпросмотр Anthropic: {{url}}",
|
||||
"anthropic_api_host_tip": "Заполняйте только если провайдер предоставляет совместимый с Anthropic адрес. Окончание на / игнорирует автоматически добавляемое v1, окончание на # принудительно использует оригинальный адрес.",
|
||||
"anthropic_api_host_tooltip": "Заполняйте только когда провайдер предоставляет базовый адрес, совместимый с Claude.",
|
||||
"api": {
|
||||
"key": {
|
||||
@@ -4090,10 +4192,11 @@
|
||||
"url": {
|
||||
"preview": "Предпросмотр: {{url}}",
|
||||
"reset": "Сброс",
|
||||
"tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес"
|
||||
"tip": "заканчивая на # принудительно использует введенный адрес"
|
||||
}
|
||||
},
|
||||
"api_host": "Хост API",
|
||||
"api_host_no_valid": "Недопустимый адрес API",
|
||||
"api_host_preview": "Предпросмотр: {{url}}",
|
||||
"api_host_tooltip": "Переопределяйте только когда провайдер требует пользовательский адрес, совместимый с OpenAI.",
|
||||
"api_key": {
|
||||
|
||||
@@ -72,7 +72,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
||||
},
|
||||
dashscope: {
|
||||
anthropic: {
|
||||
api_base_url: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
|
||||
api_base_url: 'https://dashscope.aliyuncs.com/apps/anthropic'
|
||||
}
|
||||
},
|
||||
modelscope: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ContentSearch } from '@renderer/components/ContentSearch'
|
||||
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
|
||||
@@ -56,6 +57,8 @@ const Chat: FC<Props> = (props) => {
|
||||
const { chat } = useRuntime()
|
||||
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat
|
||||
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
|
||||
const sessionAgentId = activeTopicOrSession === 'session' ? activeAgentId : null
|
||||
const { createDefaultSession } = useCreateDefaultSession(sessionAgentId)
|
||||
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
@@ -67,7 +70,7 @@ const Chat: FC<Props> = (props) => {
|
||||
contentSearchRef.current?.disable()
|
||||
})
|
||||
|
||||
useShortcut('search_message_in_chat', () => {
|
||||
useShortcut('shortcut.chat.search_message', () => {
|
||||
try {
|
||||
const selectedText = window.getSelection()?.toString().trim()
|
||||
contentSearchRef.current?.enable(selectedText)
|
||||
@@ -76,7 +79,7 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('rename_topic', async () => {
|
||||
useShortcut('shortcut.topic.rename', async () => {
|
||||
const topic = props.activeTopic
|
||||
if (!topic) return
|
||||
|
||||
@@ -94,6 +97,21 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut(
|
||||
'shortcut.topic.new',
|
||||
() => {
|
||||
if (activeTopicOrSession !== 'session' || !activeAgentId) {
|
||||
return
|
||||
}
|
||||
void createDefaultSession()
|
||||
},
|
||||
{
|
||||
enabled: activeTopicOrSession === 'session',
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
}
|
||||
)
|
||||
|
||||
const contentSearchFilter: NodeFilter = {
|
||||
acceptNode(node) {
|
||||
const container = node.parentElement?.closest('.message-content-container')
|
||||
|
||||
@@ -37,9 +37,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
useShortcut('toggle_show_assistants', toggleShowAssistants)
|
||||
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
|
||||
|
||||
useShortcut('toggle_show_topics', () => {
|
||||
useShortcut('shortcut.topic.toggle_show_topics', () => {
|
||||
if (topicPosition === 'right') {
|
||||
toggleShowTopics()
|
||||
} else {
|
||||
@@ -47,7 +47,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('search_message', () => {
|
||||
useShortcut('shortcut.app.search_message', () => {
|
||||
SearchPopup.show()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ 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'
|
||||
import { getModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { pauseTrace } from '@renderer/services/SpanManagerService'
|
||||
@@ -22,7 +23,7 @@ import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/
|
||||
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
|
||||
import TextArea, { type TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { CirclePause } from 'lucide-react'
|
||||
import { CirclePause, MessageSquareDiff } from 'lucide-react'
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -45,8 +46,9 @@ 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 { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
@@ -88,6 +90,22 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
}, [topicMessages])
|
||||
|
||||
const canAbort = loading && streamingAskIds.length > 0
|
||||
const createSessionDisabled = creatingSession || !apiServer.enabled
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (createSessionDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createDefaultSession()
|
||||
if (created) {
|
||||
focusTextarea()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create agent session via toolbar:', error as Error)
|
||||
}
|
||||
}, [createDefaultSession, createSessionDisabled, focusTextarea])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
//to check if the SendMessage key is pressed
|
||||
@@ -165,7 +183,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] = agent?.model.split(':') ?? [undefined, undefined]
|
||||
const [providerId, actualModelId] = session?.model?.split(':') ?? [undefined, undefined]
|
||||
|
||||
// Try to find the actual model from providers
|
||||
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
|
||||
@@ -211,7 +229,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
logger.warn('Failed to send message:', error as Error)
|
||||
}
|
||||
}, [
|
||||
agent?.model,
|
||||
session?.model,
|
||||
agentId,
|
||||
dispatch,
|
||||
sendDisabled,
|
||||
@@ -287,8 +305,16 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
}}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
/>
|
||||
<div className="flex justify-end px-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Toolbar>
|
||||
<ToolbarGroup>
|
||||
<Tooltip placement="top" content={t('chat.input.new_topic', { Command: newTopicShortcut })} delay={0}>
|
||||
<ActionIconButton
|
||||
onClick={handleCreateSession}
|
||||
disabled={createSessionDisabled}
|
||||
icon={<MessageSquareDiff size={19} />}></ActionIconButton>
|
||||
</Tooltip>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup>
|
||||
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
|
||||
{canAbort && (
|
||||
<Tooltip placement="top" content={t('chat.input.pause')}>
|
||||
@@ -299,8 +325,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
</InputBarContainer>
|
||||
</Container>
|
||||
</NarrowLayout>
|
||||
@@ -346,6 +372,25 @@ const InputBarContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 8px;
|
||||
height: 40px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const ToolbarGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '6px 15px 0px' // 减小顶部padding
|
||||
|
||||
@@ -723,13 +723,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [onPaste])
|
||||
|
||||
useShortcut('new_topic', () => {
|
||||
useShortcut('shortcut.topic.new', () => {
|
||||
addNewTopic()
|
||||
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
||||
focusTextarea()
|
||||
})
|
||||
|
||||
useShortcut('clear_topic', clearTopic)
|
||||
useShortcut('shortcut.chat.clear', clearTopic)
|
||||
|
||||
useEffect(() => {
|
||||
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import {
|
||||
isAnthropicModel,
|
||||
isGeminiModel,
|
||||
isGenerateImageModel,
|
||||
isMandatoryWebSearchModel,
|
||||
@@ -193,8 +194,8 @@ const InputbarTools = ({
|
||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}, [assistant.enableGenerateImage, updateAssistant])
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
|
||||
const clearTopicShortcut = useShortcutDisplay('shortcut.chat.clear')
|
||||
|
||||
const toggleToolVisibility = useCallback(
|
||||
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
|
||||
@@ -391,7 +392,7 @@ const InputbarTools = ({
|
||||
label: t('chat.input.url_context'),
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
||||
condition:
|
||||
isGeminiModel(model) &&
|
||||
(isGeminiModel(model) || isAnthropicModel(model)) &&
|
||||
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,10 +9,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const NewContextButton: FC<Props> = ({ onNewContext }) => {
|
||||
const newContextShortcut = useShortcutDisplay('toggle_new_context')
|
||||
const newContextShortcut = useShortcutDisplay('shortcut.chat.toggle_new_context')
|
||||
const { t } = useTranslation()
|
||||
|
||||
useShortcut('toggle_new_context', onNewContext)
|
||||
useShortcut('shortcut.chat.toggle_new_context', onNewContext)
|
||||
|
||||
return (
|
||||
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })} closeDelay={0}>
|
||||
|
||||
@@ -268,7 +268,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
)
|
||||
}, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer])
|
||||
|
||||
useShortcut('copy_last_message', () => {
|
||||
useShortcut('shortcut.chat.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('edit_last_user_message', () => {
|
||||
useShortcut('shortcut.chat.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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NormalToolResponse } from '@renderer/types'
|
||||
export * from './types'
|
||||
|
||||
// 导入所有渲染器
|
||||
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
|
||||
import { BashOutputTool } from './BashOutputTool'
|
||||
import { BashTool } from './BashTool'
|
||||
import { EditTool } from './EditTool'
|
||||
@@ -78,12 +79,16 @@ function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?:
|
||||
|
||||
// 统一的组件渲染入口
|
||||
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const { arguments: args, response, tool } = toolResponse
|
||||
const { arguments: args, response, tool, status } = toolResponse
|
||||
logger.info('Rendering agent tool response', {
|
||||
tool: tool,
|
||||
arguments: args,
|
||||
response
|
||||
})
|
||||
|
||||
if (status === 'pending') {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
}
|
||||
|
||||
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('ToolPermissionRequestCard')
|
||||
|
||||
interface Props {
|
||||
toolResponse: NormalToolResponse
|
||||
}
|
||||
|
||||
export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const request = useAppSelector((state) =>
|
||||
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
|
||||
)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return
|
||||
|
||||
logger.debug('Rendering inline tool permission card', {
|
||||
requestId: request.requestId,
|
||||
toolName: request.toolName,
|
||||
expiresAt: request.expiresAt
|
||||
})
|
||||
|
||||
setNow(Date.now())
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!request) return 0
|
||||
return Math.max(0, request.expiresAt - now)
|
||||
}, [request, now])
|
||||
|
||||
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
|
||||
const isExpired = remainingMs <= 0
|
||||
|
||||
const isSubmittingAllow = request?.status === 'submitting-allow'
|
||||
const isSubmittingDeny = request?.status === 'submitting-deny'
|
||||
const isSubmitting = isSubmittingAllow || isSubmittingDeny
|
||||
const hasSuggestions = (request?.suggestions?.length ?? 0) > 0
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (
|
||||
behavior: 'allow' | 'deny',
|
||||
extra?: {
|
||||
updatedInput?: Record<string, unknown>
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
message?: string
|
||||
}
|
||||
) => {
|
||||
if (!request) return
|
||||
|
||||
logger.debug('Submitting inline tool permission decision', {
|
||||
requestId: request.requestId,
|
||||
toolName: request.toolName,
|
||||
behavior
|
||||
})
|
||||
|
||||
dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior }))
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
requestId: request.requestId,
|
||||
behavior,
|
||||
...(behavior === 'allow'
|
||||
? {
|
||||
updatedInput: extra?.updatedInput ?? request.input,
|
||||
updatedPermissions: extra?.updatedPermissions
|
||||
}
|
||||
: {
|
||||
message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage')
|
||||
})
|
||||
}
|
||||
|
||||
const response = await window.api.agentTools.respondToPermission(payload)
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error('Renderer response rejected by main process')
|
||||
}
|
||||
|
||||
logger.debug('Tool permission decision acknowledged by main process', {
|
||||
requestId: request.requestId,
|
||||
behavior
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send tool permission response', error as Error)
|
||||
window.toast?.error?.(t('agent.toolPermission.error.sendFailed'))
|
||||
dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId }))
|
||||
}
|
||||
},
|
||||
[dispatch, request, t]
|
||||
)
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm">
|
||||
{t('agent.toolPermission.waiting')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
|
||||
<div className="text-default-500 text-xs">
|
||||
{request.description?.trim() || t('agent.toolPermission.defaultDescription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Chip color={isExpired ? 'danger' : 'warning'} size="sm" variant="flat">
|
||||
{isExpired
|
||||
? t('agent.toolPermission.expired')
|
||||
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
|
||||
</Chip>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.denyRequest')}
|
||||
className="h-8"
|
||||
color="danger"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingDeny}
|
||||
onPress={() => handleDecision('deny')}
|
||||
startContent={<CircleX size={16} />}
|
||||
variant="bordered">
|
||||
{t('agent.toolPermission.button.cancel')}
|
||||
</Button>
|
||||
|
||||
{hasSuggestions ? (
|
||||
<ButtonGroup className="h-8">
|
||||
<Button
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.runWithOptions')}
|
||||
className="h-8 rounded-l-none"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isIconOnly
|
||||
variant="solid"></Button>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.allowRequest')}
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8"
|
||||
isIconOnly
|
||||
onPress={() => setShowDetails((value) => !value)}
|
||||
variant="light">
|
||||
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
|
||||
<div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm">
|
||||
{t('agent.toolPermission.confirmation')}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-default-200 bg-default-100 p-3">
|
||||
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
|
||||
{t('agent.toolPermission.inputPreview')}
|
||||
</p>
|
||||
<ScrollShadow className="max-h-48 font-mono text-xs" hideScrollBar>
|
||||
<pre className="whitespace-pre-wrap break-all text-left">{request.inputPreview}</pre>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
|
||||
{request.requiresPermissions && (
|
||||
<div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs">
|
||||
{t('agent.toolPermission.requiresElevatedPermissions')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{request.suggestions.length > 0 && (
|
||||
<div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs">
|
||||
{request.suggestions.length === 1
|
||||
? t('agent.toolPermission.suggestion.permissionUpdateSingle')
|
||||
: t('agent.toolPermission.suggestion.permissionUpdateMultiple')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && !isSubmitting && (
|
||||
<div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolPermissionRequestCard
|
||||
@@ -38,9 +38,9 @@ const HeaderNavbar: FC<Props> = ({
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
|
||||
useShortcut('toggle_show_assistants', toggleShowAssistants)
|
||||
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
|
||||
|
||||
useShortcut('toggle_show_topics', () => {
|
||||
useShortcut('shortcut.topic.toggle_show_topics', () => {
|
||||
if (topicPosition === 'right') {
|
||||
toggleShowTopics()
|
||||
} else {
|
||||
@@ -48,7 +48,7 @@ const HeaderNavbar: FC<Props> = ({
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('search_message', () => {
|
||||
useShortcut('shortcut.app.search_message', () => {
|
||||
SearchPopup.show()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, Spinner } from '@heroui/react'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
setActiveTopicOrSessionAction,
|
||||
setSessionWaitingAction
|
||||
} from '@renderer/store/runtime'
|
||||
import type { CreateSessionForm } from '@renderer/types'
|
||||
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||
import { motion } from 'framer-motion'
|
||||
import { memo, useCallback, useEffect } from 'react'
|
||||
@@ -27,11 +26,11 @@ interface SessionsProps {
|
||||
|
||||
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { agent } = useAgent(agentId)
|
||||
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
|
||||
const { sessions, isLoading, error, deleteSession } = useSessions(agentId)
|
||||
const { chat } = useRuntime()
|
||||
const { activeSessionIdMap } = chat
|
||||
const dispatch = useAppDispatch()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
|
||||
const setActiveSessionId = useCallback(
|
||||
(agentId: string, sessionId: string | null) => {
|
||||
@@ -41,19 +40,6 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (!agent) return
|
||||
const session = {
|
||||
...agent,
|
||||
id: undefined,
|
||||
name: t('common.unnamed')
|
||||
} satisfies CreateSessionForm
|
||||
const created = await createSession(session)
|
||||
if (created) {
|
||||
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
|
||||
}
|
||||
}, [agent, agentId, createSession, dispatch, t])
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (id: string) => {
|
||||
if (sessions.length === 1) {
|
||||
@@ -110,7 +96,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
|
||||
return (
|
||||
<div className="sessions-tab flex h-full w-full flex-col p-2">
|
||||
<AddButton onClick={handleCreateSession} className="mb-2">
|
||||
<AddButton onClick={createDefaultSession} className="mb-2" disabled={creatingSession}>
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
{/* h-9 */}
|
||||
|
||||
@@ -93,7 +93,7 @@ const KnowledgePage: FC = () => {
|
||||
[deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t]
|
||||
)
|
||||
|
||||
useShortcut('search_message', () => {
|
||||
useShortcut('shortcut.app.search_message', () => {
|
||||
if (selectedBase) {
|
||||
KnowledgeSearchPopup.show({ base: selectedBase }).then()
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
|
||||
return
|
||||
}
|
||||
try {
|
||||
target.findInPage(text, options)
|
||||
target.findInPage(text, options || {})
|
||||
} catch (error) {
|
||||
logger.error('findInPage failed', { error })
|
||||
window.toast?.error(t('common.error'))
|
||||
|
||||
@@ -19,6 +19,15 @@ vi.mock('react-i18next', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// mock @cherrystudio/ui Button component to handle onClick
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
const createWebviewMock = () => {
|
||||
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
|
||||
const findInPageMock = vi.fn()
|
||||
@@ -255,7 +264,7 @@ describe('WebviewSearch', () => {
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
@@ -307,7 +316,7 @@ describe('WebviewSearch', () => {
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
|
||||
})
|
||||
findInPageMock.mockClear()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AdvancedSettings from './AdvancedSettings'
|
||||
import EssentialSettings from './EssentialSettings'
|
||||
import PluginSettings from './PluginSettings'
|
||||
import PromptSettings from './PromptSettings'
|
||||
import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared'
|
||||
import ToolingSettings from './ToolingSettings'
|
||||
@@ -20,7 +21,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
|
||||
resolve: () => void
|
||||
}
|
||||
|
||||
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps'
|
||||
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
|
||||
|
||||
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
@@ -56,6 +57,10 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
key: 'tooling',
|
||||
label: t('agent.settings.tooling.tab', 'Tooling & permissions')
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
label: t('agent.settings.plugins.tab', 'Plugins')
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('agent.settings.advance.title', 'Advanced Settings')
|
||||
@@ -75,6 +80,9 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex w-full flex-1">
|
||||
<LeftMenu>
|
||||
@@ -90,6 +98,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
{menu === 'essential' && <EssentialSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'plugins' && <PluginSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
|
||||
</Settings>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
|
||||
import type { AgentEntity, UpdateAgentForm } from '@renderer/types'
|
||||
import { isAgentType } from '@renderer/types'
|
||||
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -20,13 +20,11 @@ export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update })
|
||||
|
||||
const updateAvatar = useCallback(
|
||||
(avatar: string) => {
|
||||
const parsedConfiguration = AgentConfigurationSchema.parse(agent.configuration ?? {})
|
||||
const payload = {
|
||||
id: agent.id,
|
||||
// hard-encoded default values. better to implement incremental update for configuration
|
||||
configuration: {
|
||||
...agent.configuration,
|
||||
permission_mode: agent.configuration?.permission_mode ?? 'default',
|
||||
max_turns: agent.configuration?.max_turns ?? 100,
|
||||
...parsedConfiguration,
|
||||
avatar
|
||||
}
|
||||
} satisfies UpdateAgentForm
|
||||
|
||||
115
src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx
Normal file
115
src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Card, CardBody, Tab, Tabs } from '@heroui/react'
|
||||
import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
|
||||
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { InstalledPluginsList } from './components/InstalledPluginsList'
|
||||
import { PluginBrowser } from './components/PluginBrowser'
|
||||
import { SettingsContainer } from './shared'
|
||||
|
||||
interface PluginSettingsProps {
|
||||
agentBase: GetAgentResponse | GetAgentSessionResponse
|
||||
update: (partial: UpdateAgentBaseForm) => Promise<void>
|
||||
}
|
||||
|
||||
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Fetch available plugins
|
||||
const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins()
|
||||
|
||||
// Fetch installed plugins
|
||||
const { plugins, loading: loadingInstalled, error: errorInstalled, refresh } = useInstalledPlugins(agentBase.id)
|
||||
|
||||
// Plugin actions
|
||||
const { install, uninstall, installing, uninstalling } = usePluginActions(agentBase.id, refresh)
|
||||
|
||||
// Handle install action
|
||||
const handleInstall = useCallback(
|
||||
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
|
||||
const result = await install(sourcePath, type)
|
||||
|
||||
if (result.success) {
|
||||
window.toast.success(t('agent.settings.plugins.success.install'))
|
||||
} else {
|
||||
window.toast.error(t('agent.settings.plugins.error.install') + (result.error ? ': ' + result.error : ''))
|
||||
}
|
||||
},
|
||||
[install, t]
|
||||
)
|
||||
|
||||
// Handle uninstall action
|
||||
const handleUninstall = useCallback(
|
||||
async (filename: string, type: 'agent' | 'command' | 'skill') => {
|
||||
const result = await uninstall(filename, type)
|
||||
|
||||
if (result.success) {
|
||||
window.toast.success(t('agent.settings.plugins.success.uninstall'))
|
||||
} else {
|
||||
window.toast.error(t('agent.settings.plugins.error.uninstall') + (result.error ? ': ' + result.error : ''))
|
||||
}
|
||||
},
|
||||
[uninstall, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<Tabs
|
||||
aria-label="Plugin settings tabs"
|
||||
classNames={{
|
||||
base: 'w-full',
|
||||
tabList: 'w-full',
|
||||
panel: 'w-full flex-1 overflow-hidden'
|
||||
}}>
|
||||
<Tab key="available" title={t('agent.settings.plugins.available.title')}>
|
||||
<div className="flex h-full flex-col overflow-y-auto pt-4">
|
||||
{errorAvailable ? (
|
||||
<Card className="bg-danger-50 dark:bg-danger-900/20">
|
||||
<CardBody>
|
||||
<p className="text-danger">
|
||||
{t('agent.settings.plugins.error.load')}: {errorAvailable}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<PluginBrowser
|
||||
agentId={agentBase.id}
|
||||
agents={agents}
|
||||
commands={commands}
|
||||
skills={skills}
|
||||
installedPlugins={plugins}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
loading={loadingAvailable || installing || uninstalling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab key="installed" title={t('agent.settings.plugins.installed.title')}>
|
||||
<div className="flex h-full flex-col overflow-y-auto pt-4">
|
||||
{errorInstalled ? (
|
||||
<Card className="bg-danger-50 dark:bg-danger-900/20">
|
||||
<CardBody>
|
||||
<p className="text-danger">
|
||||
{t('agent.settings.plugins.error.load')}: {errorInstalled}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<InstalledPluginsList
|
||||
plugins={plugins}
|
||||
onUninstall={handleUninstall}
|
||||
loading={loadingInstalled || uninstalling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</SettingsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginSettings
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Alert, Card, CardBody, CardHeader, Chip, Input, Switch } from '@heroui/react'
|
||||
import { permissionModeCards } from '@renderer/constants/permissionModes'
|
||||
import { useAgentClient } from '@renderer/hooks/agents/useAgentClient'
|
||||
import { permissionModeCards } from '@renderer/config/agent'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import type {
|
||||
@@ -17,9 +16,8 @@ import { AgentConfigurationSchema } from '@renderer/types'
|
||||
import { Modal } from 'antd'
|
||||
import { ShieldAlert, ShieldCheck, Wrench } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mutate } from 'swr'
|
||||
|
||||
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
|
||||
|
||||
@@ -37,6 +35,13 @@ 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) {
|
||||
@@ -66,52 +71,34 @@ 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, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
|
||||
const [selectedMode, setSelectedMode] = useState<PermissionMode>(defaultConfiguration.permission_mode)
|
||||
const [autoToolIds, setAutoToolIds] = useState<string[]>([])
|
||||
const [approvedToolIds, setApprovedToolIds] = useState<string[]>([])
|
||||
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 [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
|
||||
@@ -147,10 +134,6 @@ 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)
|
||||
}
|
||||
@@ -212,33 +195,25 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
|
||||
)
|
||||
|
||||
const handleToggleTool = useCallback(
|
||||
(toolId: string, isApproved: boolean) => {
|
||||
async (toolId: string, isApproved: boolean) => {
|
||||
if (!agentBase || isUpdatingTools) {
|
||||
return
|
||||
}
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
[agentBase, isUpdatingTools, availableTools, autoToolIds, update]
|
||||
[agentBase, isUpdatingTools, approvedToolIds, autoToolIds, availableTools, update]
|
||||
)
|
||||
|
||||
const { agentSummary, autoCount, customCount } = useMemo(() => {
|
||||
@@ -258,31 +233,24 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
|
||||
}, [selectedMode, autoToolIds, userAddedIds, availableTools.length, selectedMcpIds.length])
|
||||
|
||||
const handleToggleMcp = useCallback(
|
||||
(serverId: string, enabled: boolean) => {
|
||||
async (serverId: string, enabled: boolean) => {
|
||||
if (!agentBase || isUpdatingMcp) {
|
||||
return
|
||||
}
|
||||
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
|
||||
})
|
||||
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)
|
||||
}
|
||||
},
|
||||
[agentBase, isUpdatingMcp, client, update]
|
||||
[agentBase, isUpdatingMcp, selectedMcpIds, update]
|
||||
)
|
||||
|
||||
if (!agentBase) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user