Compare commits

..

4 Commits

Author SHA1 Message Date
beyondkmp
03c8b6b5b4 format imports 2025-10-30 13:18:01 +08:00
beyondkmp
f5136a0adb format code 2025-10-30 13:09:58 +08:00
beyondkmp
99873a0767 📝 docs: refresh shortcut refactor design 2025-10-30 11:35:00 +08:00
beyondkmp
34affb4533 ♻️ refactor: migrate shortcuts to preferences 2025-10-30 11:28:41 +08:00
141 changed files with 3995 additions and 7197 deletions

View File

@@ -1,4 +1,4 @@
name: 🐛 Bug Report
name: 🐛 Bug Report (English)
description: Create a report to help us improve
title: '[Bug]: '
labels: ['BUG']

View File

@@ -1,4 +1,4 @@
name: 💡 Feature Request
name: 💡 Feature Request (English)
description: Suggest an idea for this project
title: '[Feature]: '
labels: ['feature']

View File

@@ -1,4 +1,4 @@
name: 🤔 Other Questions
name: 🤔 Other Questions (English)
description: Submit questions that don't fit into bug reports or feature requests
title: '[Other]: '
body:

View File

@@ -0,0 +1,71 @@
diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644
--- a/dist/utils/tiktoken.cjs
+++ b/dist/utils/tiktoken.cjs
@@ -1,25 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.encodingForModel = exports.getEncoding = void 0;
-const lite_1 = require("js-tiktoken/lite");
const async_caller_js_1 = require("./async_caller.cjs");
const cache = {};
const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({});
async function getEncoding(encoding) {
- if (!(encoding in cache)) {
- cache[encoding] = caller
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
- .then((res) => res.json())
- .then((data) => new lite_1.Tiktoken(data))
- .catch((e) => {
- delete cache[encoding];
- throw e;
- });
- }
- return await cache[encoding];
+ throw new Error("TikToken Not implemented");
}
exports.getEncoding = getEncoding;
async function encodingForModel(model) {
- return getEncoding((0, lite_1.getEncodingNameForModel)(model));
+ throw new Error("TikToken Not implemented");
}
exports.encodingForModel = encodingForModel;
diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644
--- a/dist/utils/tiktoken.js
+++ b/dist/utils/tiktoken.js
@@ -1,20 +1,9 @@
-import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite";
import { AsyncCaller } from "./async_caller.js";
const cache = {};
const caller = /* #__PURE__ */ new AsyncCaller({});
export async function getEncoding(encoding) {
- if (!(encoding in cache)) {
- cache[encoding] = caller
- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`)
- .then((res) => res.json())
- .then((data) => new Tiktoken(data))
- .catch((e) => {
- delete cache[encoding];
- throw e;
- });
- }
- return await cache[encoding];
+ throw new Error("TikToken Not implemented");
}
export async function encodingForModel(model) {
- return getEncoding(getEncodingNameForModel(model));
+ throw new Error("TikToken Not implemented");
}
diff --git a/package.json b/package.json
index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,6 @@
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
- "js-tiktoken": "^1.0.12",
"langsmith": ">=0.2.8 <0.4.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",

View File

@@ -1,68 +0,0 @@
diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs
index c5b41f121d2e3d24c3a4969e31fa1acffdcad3b9..ec724489dcae79ee6c61acf2d4d84bd19daef036 100644
--- a/dist/utils/tiktoken.cjs
+++ b/dist/utils/tiktoken.cjs
@@ -1,6 +1,5 @@
const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
const require_utils_async_caller = require('./async_caller.cjs');
-const js_tiktoken_lite = require_rolldown_runtime.__toESM(require("js-tiktoken/lite"));
//#region src/utils/tiktoken.ts
var tiktoken_exports = {};
@@ -11,14 +10,10 @@ require_rolldown_runtime.__export(tiktoken_exports, {
const cache = {};
const caller = /* @__PURE__ */ new require_utils_async_caller.AsyncCaller({});
async function getEncoding(encoding) {
- if (!(encoding in cache)) cache[encoding] = caller.fetch(`https://tiktoken.pages.dev/js/${encoding}.json`).then((res) => res.json()).then((data) => new js_tiktoken_lite.Tiktoken(data)).catch((e) => {
- delete cache[encoding];
- throw e;
- });
- return await cache[encoding];
+ throw new Error("TikToken Not implemented");
}
async function encodingForModel(model) {
- return getEncoding((0, js_tiktoken_lite.getEncodingNameForModel)(model));
+ throw new Error("TikToken Not implemented");
}
//#endregion
diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js
index 641acca03cb92f04a6fa5c9c31f1880ce635572e..707389970ad957aa0ff20ef37fa8dd2875be737c 100644
--- a/dist/utils/tiktoken.js
+++ b/dist/utils/tiktoken.js
@@ -1,6 +1,5 @@
import { __export } from "../_virtual/rolldown_runtime.js";
import { AsyncCaller } from "./async_caller.js";
-import { Tiktoken, getEncodingNameForModel } from "js-tiktoken/lite";
//#region src/utils/tiktoken.ts
var tiktoken_exports = {};
@@ -11,14 +10,10 @@ __export(tiktoken_exports, {
const cache = {};
const caller = /* @__PURE__ */ new AsyncCaller({});
async function getEncoding(encoding) {
- if (!(encoding in cache)) cache[encoding] = caller.fetch(`https://tiktoken.pages.dev/js/${encoding}.json`).then((res) => res.json()).then((data) => new Tiktoken(data)).catch((e) => {
- delete cache[encoding];
- throw e;
- });
- return await cache[encoding];
+ throw new Error("TikToken Not implemented");
}
async function encodingForModel(model) {
- return getEncoding(getEncodingNameForModel(model));
+ throw new Error("TikToken Not implemented");
}
//#endregion
diff --git a/package.json b/package.json
index a24f8fc61de58526051999260f2ebee5f136354b..e885359e8966e7730c51772533ce37e01edb3046 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,6 @@
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
- "js-tiktoken": "^1.0.12",
"langsmith": "^0.3.64",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",

View File

@@ -0,0 +1,19 @@
diff --git a/dist/embeddings.js b/dist/embeddings.js
index 1f8154be3e9c22442a915eb4b85fa6d2a21b0d0c..dc13ef4a30e6c282824a5357bcee9bd0ae222aab 100644
--- a/dist/embeddings.js
+++ b/dist/embeddings.js
@@ -214,10 +214,12 @@ export class OpenAIEmbeddings extends Embeddings {
* @returns Promise that resolves to an embedding for the document.
*/
async embedQuery(text) {
+ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com')
+ const input = this.stripNewLines ? text.replace(/\n/g, ' ') : text
const params = {
model: this.model,
- input: this.stripNewLines ? text.replace(/\n/g, " ") : text,
- };
+ input: isBaiduCloud ? [input] : input
+ }
if (this.dimensions) {
params.dimensions = this.dimensions;
}

View File

@@ -1,17 +0,0 @@
diff --git a/dist/embeddings.js b/dist/embeddings.js
index 6f4b928d3e4717309382e1b5c2e31ab5bc6c5af0..bc79429c88a6d27d4997a2740c4d8ae0707f5991 100644
--- a/dist/embeddings.js
+++ b/dist/embeddings.js
@@ -94,9 +94,11 @@ var OpenAIEmbeddings = class extends Embeddings {
* @returns Promise that resolves to an embedding for the document.
*/
async embedQuery(text) {
+ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com');
+ const input = this.stripNewLines ? text.replace(/\n/g, " ") : text
const params = {
model: this.model,
- input: this.stripNewLines ? text.replace(/\n/g, " ") : text
+ input: isBaiduCloud ? [input] : input
};
if (this.dimensions) params.dimensions = this.dimensions;
if (this.encodingFormat) params.encoding_format = this.encodingFormat;

View File

@@ -77,7 +77,7 @@ Please review the following critical information before submitting your Pull Req
Our core team is currently focused on significant architectural updates that involve these data structures. To ensure stability and focus during this period, contributions of this nature will be temporarily managed internally.
* **PRs that require changes to Redux state shape or IndexedDB schemas will be closed.**
* **This restriction is temporary and will be lifted with the release of `v2.0.0`.** You can track the progress of `v2.0.0` and its related discussions on issue [#10162](https://github.com/CherryHQ/cherry-studio/pull/10162).
* **This restriction is temporary and will be lifted with the release of `v2.0.0`.** You can track the progress of `v2.0.0` and its related discussions on issue [#10162](https://github.com/YOUR_ORG/YOUR_REPO/issues/10162) (please replace with your actual repo link).
We highly encourage contributions for:
* Bug fixes 🐞

View File

@@ -23,7 +23,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/.claude/**"],
"includes": ["**"],
"maxSize": 2097152
},
"formatter": {

View File

@@ -81,7 +81,7 @@ git commit --signoff -m "Your commit message"
我们的核心团队目前正专注于涉及这些数据结构的关键架构更新和基础工作。为确保在此期间的稳定性与专注,此类贡献将暂时由内部进行管理。
* **需要更改 Redux 状态结构或 IndexedDB schema 的 PR 将会被关闭。**
* **此限制是临时性的,并将在 `v2.0.0` 版本发布后解除。** 您可以通过 Issue [#10162](https://github.com/CherryHQ/cherry-studio/pull/10162) 跟踪 `v2.0.0` 的进展及相关讨论。
* **此限制是临时性的,并将在 `v2.0.0` 版本发布后解除。** 您可以通过 Issue [#10162](https://github.com/YOUR_ORG/YOUR_REPO/issues/10162) (请替换为您的实际仓库链接) 跟踪 `v2.0.0` 的进展及相关讨论。
我们非常鼓励以下类型的贡献:
* 错误修复 🐞

View 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` 优化等补充说明。

View File

@@ -134,116 +134,60 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.7.0-beta.3
What's New in v1.7.0-beta.2
New Features:
- Enhanced Tool Permission System: Real-time tool approval interface with improved UX
- Plugin Management System: Support for Claude Agent plugins (agents, commands, skills)
- Skill Tool: Add skill execution capabilities for agents
- Mobile App Data Restore: Support restoring data to mobile applications
- OpenMinerU Preprocessor: Knowledge base now supports open-source MinerU for document processing
- HuggingFace Provider: Added HuggingFace as AI provider
- Claude Haiku 4.5: Support for the latest Claude Haiku 4.5 model
- Ling Series Models: Added support for Ling-1T and related models
- Intel OVMS Painting: New painting provider using Intel OpenVINO Model Server
- Automatic Update Checks: Implement periodic update checking with configurable intervals
- HuggingChat Mini App: New mini app for HuggingChat integration
- Session Settings: Manage session-specific settings and model configurations independently
- Notes Full-Text Search: Search across all notes with match highlighting
- Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only)
- Intel OV OCR: Hardware-accelerated OCR using Intel NPU
- Auto-start API Server: Automatically starts when agents exist
Improvements:
- Agent Creation: New agents are now automatically activated upon creation
- Lazy Loading: Optimize page load performance with route lazy loading
- UI Enhancements: Improved agent item styling and layout consistency
- Navigation: Better navbar layout for fullscreen mode on macOS
- Settings Tab: Enhanced context slider consistency
- Backup Manager: Unified footer layout for local and S3 backup managers
- Menu System: Enhanced application menu with improved help section
- Proxy Rules: Comprehensive proxy bypass rule matching
- German Language: Added German language support
- MCP Confirmation: Added confirmation modal when activating protocol-installed MCP servers
- Translation: Enhanced translation script with concurrency and validation
- Electron & Vite: Updated to Electron 38 and Vite 4.0.1
Claude Code Tool Improvements:
- GlobTool: Now counts lines instead of files in output for better clarity
- ReadTool: Automatically removes system reminder tags from output
- TodoWriteTool: Improved rendering behavior
- Environment Variables: Updated model-related environment variable names
- Agent model selection now requires explicit user choice
- Added Mistral AI provider support
- Added NewAPI generic provider support
- Improved navbar layout consistency across different modes
- Enhanced chat component responsiveness
- Better code block display on small screens
- Updated OVMS to 2025.3 official release
- Added Greek language support
Bug Fixes:
- Fixed session model not being used when sending messages
- Fixed tool approval UI and shared workspace plugin inconsistencies
- Fixed API server readiness notification to renderer
- Fixed grouped items not respecting saved tag order
- Fixed assistant/agent activation when creating new ones
- Fixed Dashscope Anthropic API host and migrated old configs
- Fixed Qwen3 thinking mode control for Ollama
- Fixed disappeared MCP button
- Fixed create assistant causing blank screen
- Fixed up-down button visibility in some cases
- Fixed hooks preventing save on composing enter key
- Fixed Azure GPT-image-1 and OpenRouter Gemini-image
- Fixed Silicon reasoning issues
- Fixed topic branch incomplete copy with two-pass ID mapping
- Fixed deep research model search context restrictions
- Fixed model capability checking logic
- Fixed reranker API error response capture
- Fixed right-click paste file content into inputbar
- Fixed minimax-m2 support in aiCore
- Fixed GitHub Copilot gpt-5-codex streaming issues
- Fixed assistant creation failures
- Fixed translate auto-copy functionality
- Fixed miniapps external link opening
- Fixed message layout and overflow issues
- Fixed API key parsing to preserve spaces
- Fixed agent display in different navbar layouts
<!--LANG:zh-CN-->
v1.7.0-beta.3 新特性
v1.7.0-beta.2 新特性
新功能:
- 增强工具权限系统:实时工具审批界面,改进用户体验
- 插件管理系统:支持 Claude Agent 插件agents、commands、skills
- 技能工具:为 Agent 添加技能执行能力
- 移动应用数据恢复:支持将数据恢复到移动应用程序
- OpenMinerU 预处理器:知识库现支持使用开源 MinerU 处理文档
- HuggingFace 提供商:添加 HuggingFace 作为 AI 提供商
- Claude Haiku 4.5:支持最新的 Claude Haiku 4.5 模型
- Ling 系列模型:添加 Ling-1T 及相关模型支持
- Intel OVMS 绘图:使用 Intel OpenVINO 模型服务器的新绘图提供商
- 自动更新检查:实现可配置间隔的定期更新检查
- HuggingChat 小程序:新增 HuggingChat 集成小程序
- 会话设置:独立管理会话特定的设置和模型配置
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
- 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区)
- Intel OV OCR使用 Intel NPU 的硬件加速 OCR
- 自动启动 API 服务器:当存在 Agent 时自动启动
改进:
- Agent 创建:新创建的 Agent 现在会自动激活
- 懒加载:通过路由懒加载优化页面加载性能
- UI 增强:改进 Agent 项目样式和布局一致性
- 导航:改进 macOS 全屏模式下的导航栏布局
- 设置选项卡:增强上下文滑块一致性
- 备份管理器:统一本地和 S3 备份管理器的页脚布局
- 菜单系统:增强应用菜单,改进帮助部分
- 代理规则:全面的代理绕过规则匹配
- 德语支持:添加德语语言支持
- MCP 确认:添加激活协议安装的 MCP 服务器时的确认模态框
- 翻译:增强翻译脚本的并发和验证功能
- Electron & Vite更新至 Electron 38 和 Vite 4.0.1
Claude Code 工具改进:
- GlobTool现在计算行数而不是文件数提供更清晰的输出
- ReadTool自动从输出中移除系统提醒标签
- TodoWriteTool改进渲染行为
- 环境变量:更新模型相关的环境变量名称
- Agent 模型选择现在需要用户显式选择
- 添加 Mistral AI 提供商支持
- 添加 NewAPI 通用提供商支持
- 改进不同模式下的导航栏布局一致性
- 增强聊天组件响应式设计
- 优化小屏幕代码块显示
- 更新 OVMS 至 2025.3 正式版
- 添加希腊语支持
问题修复:
- 修复发送消息时未使用会话模型
- 修复工具审批 UI 和共享工作区插件不一致
- 修复 API 服务器就绪通知到渲染器
- 修复分组项目不遵守已保存标签顺序
- 修复创建新的助手/Agent 时的激活问题
- 修复 Dashscope Anthropic API 主机并迁移旧配置
- 修复 Ollama 的 Qwen3 思考模式控制
- 修复 MCP 按钮消失
- 修复创建助手导致空白屏幕
- 修复某些情况下上下按钮可见性
- 修复钩子在输入法输入时阻止保存
- 修复 Azure GPT-image-1 和 OpenRouter Gemini-image
- 修复 Silicon 推理问题
- 修复主题分支不完整复制,采用两阶段 ID 映射
- 修复深度研究模型搜索上下文限制
- 修复模型能力检查逻辑
- 修复 reranker API 错误响应捕获
- 修复右键粘贴文件内容到输入栏
- 修复 aiCore 中的 minimax-m2 支持
- 修复 GitHub Copilot gpt-5-codex 流式传输问题
- 修复助手创建失败
- 修复翻译自动复制功能
- 修复小程序外部链接打开
- 修复消息布局和溢出问题
- 修复 API 密钥解析以保留空格
- 修复不同导航栏布局中的 Agent 显示
<!--LANG:END-->

View File

@@ -95,10 +95,8 @@
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"qrcode.react": "^4.2.0",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
@@ -151,9 +149,7 @@
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@hello-pangea/dnd": "^18.0.1",
"@heroui/react": "^2.8.3",
"@langchain/community": "^1.0.0",
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/community": "^0.3.50",
"@mistralai/mistralai": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.17.5",
"@mozilla/readability": "^0.6.0",
@@ -379,7 +375,9 @@
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
@@ -403,10 +401,7 @@
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch"
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -354,7 +354,6 @@ export enum IpcChannel {
ApiServer_Stop = 'api-server:stop',
ApiServer_Restart = 'api-server:restart',
ApiServer_GetStatus = 'api-server:get-status',
ApiServer_Ready = 'api-server:ready',
// NOTE: This api is not be used.
ApiServer_GetConfig = 'api-server:get-config',
@@ -396,12 +395,5 @@ export enum IpcChannel {
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content',
// WebSocket
WebSocket_Start = 'webSocket:start',
WebSocket_Stop = 'webSocket:stop',
WebSocket_Status = 'webSocket:status',
WebSocket_SendFile = 'webSocket:send-file',
WebSocket_GetAllCandidates = 'webSocket:get-all-candidates'
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
}

View File

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

View 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)
}

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

View 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)
}
}

View 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-` 品牌类

View File

@@ -9,16 +9,16 @@ This document outlines the detailed plan for migrating Cherry Studio from antd +
### Target Tech Stack
- **UI Component Library**: shadcn/ui (replacing antd and previously migrated HeroUI)
- **Styling Solution**: Tailwind CSS v4 (replacing styled-components)
- **Design System**: Custom CSS variable system (`--cs-*` namespace)
- **Theme System**: CSS variables + Tailwind CSS theme
- **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 [README.md](./README.md))
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
@@ -105,7 +105,7 @@ When submitting PRs, please place components in the correct directory based on t
| Phase | Status | Main Tasks | Description |
| --- | --- | --- | --- |
| **Phase 1** | **Completed** | **Design System Integration** | • Converted design tokens from todocss.css to tokens.css with `--cs-*` namespace<br>• Created theme.css mapping all design tokens to standard Tailwind classes<br>• Extended Tailwind with semantic spacing (5xs~8xl) and radius (4xs~3xl) systems<br>• Established two usage modes: full override and selective override<br>• Cleaned up main package's conflicting Shadcn theme definitions |
| **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 |
@@ -129,22 +129,16 @@ When submitting PRs, please place components in the correct directory based on t
## Design System Integration
### CSS Variable System
- All design tokens use `--cs-*` namespace (e.g., `--cs-primary`, `--cs-red-500`)
- Complete color palette: 17 colors × 11 shades each
- Semantic spacing system: `5xs` through `8xl` (16 levels)
- Semantic radius system: `4xs` through `3xl` plus `round` (11 levels)
- Full light/dark mode support
- See [README.md](./README.md) for usage documentation
- 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
- New components must comply with design system specifications

View File

@@ -2,120 +2,54 @@
Cherry Studio UI 组件库 - 为 Cherry Studio 设计的 React 组件集合
## 特性
## 特性
- 🎨 **设计系统**: 完整的 CherryStudio 设计令牌17种颜色 × 11个色阶 + 语义化主题)
- 🌓 **Dark Mode**: 开箱即用的深色模式支持
- 🚀 **Tailwind v4**: 基于最新 Tailwind CSS v4 构建
- 📦 **灵活导入**: 2种样式导入方式满足不同使用场景
- 🔷 **TypeScript**: 完整的类型定义和智能提示
- 🎯 **零冲突**: CSS 变量隔离,不覆盖用户主题
- 🎨 基于 Tailwind CSS 的现代化设计
- 📦 支持 ESM 和 CJS 格式
- 🔷 完整的 TypeScript 支持
- 🚀 可以作为 npm 包发布
- 🔧 开箱即用的常用 hooks 和工具函数
---
## 🚀 快速开始
### 安装
## 安装
```bash
# 安装组件库
npm install @cherrystudio/ui
# peer dependencies
# 安装必需的 peer dependencies
npm install @heroui/react framer-motion react react-dom tailwindcss
```
### 两种使用方式
## 配置
#### 方式 1完整覆盖 ✨
### 1. Tailwind CSS v4 配置
使用完整的 CherryStudio 设计系统,所有 Tailwind 类名映射到设计系统。
本组件库使用 Tailwind CSS v4配置方式已改变。在你的主 CSS 文件(如 `src/styles/tailwind.css`)中:
```css
/* app.css */
@import '@cherrystudio/ui/styles/theme.css';
```
**特点**
- ✅ 直接使用标准 Tailwind 类名(`bg-primary``bg-red-500``p-md``rounded-lg`
- ✅ 所有颜色使用设计师定义的值
- ✅ 扩展的 Spacing 系统(`p-5xs` ~ `p-8xl`,共 16 个语义化尺寸)
- ✅ 扩展的 Radius 系统(`rounded-4xs` ~ `rounded-3xl`,共 11 个圆角)
- ⚠️ 会完全覆盖 Tailwind 默认主题
**示例**
```tsx
<Button className="bg-primary text-red-500 p-md rounded-lg">
{/* bg-primary → 品牌色lime-500 */}
{/* text-red-500 → 设计师定义的红色 */}
{/* p-md → 2.5remspacing-md */}
{/* rounded-lg → 2.5remradius-lg */}
</Button>
{/* 扩展的工具类 */}
<div className="p-5xs"> (0.5rem)</div>
<div className="p-xs"> (1rem)</div>
<div className="p-sm"> (1.5rem)</div>
<div className="p-md"> (2.5rem)</div>
<div className="p-lg"> (3.5rem)</div>
<div className="p-xl"> (5rem)</div>
<div className="p-8xl"> (15rem)</div>
<div className="rounded-4xs"> (0.25rem)</div>
<div className="rounded-xs"> (1rem)</div>
<div className="rounded-md"> (2rem)</div>
<div className="rounded-xl"> (3rem)</div>
<div className="rounded-round"> (999px)</div>
```
#### 方式 2选择性覆盖 🎯
只导入设计令牌CSS 变量),手动选择要覆盖的部分。
```css
/* app.css */
@import 'tailwindcss';
@import '@cherrystudio/ui/styles/tokens.css';
/* 只使用部分设计系统 */
/* 必须扫描组件库文件以提取类名 */
@source '../node_modules/@cherrystudio/ui/dist/**/*.{js,mjs}';
/* 你的应用源文件 */
@source './src/**/*.{js,ts,jsx,tsx}';
/*
* 如果你的应用直接使用 HeroUI 组件,需要添加:
* @source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
* @plugin '@heroui/react/plugin';
*/
/* 自定义主题配置(可选) */
@theme {
--color-primary: var(--cs-primary); /* 使用 CS 的主色 */
--color-red-500: oklch(...); /* 使用自己的红色 */
--spacing-md: var(--cs-size-md); /* 使用 CS 的间距 */
--radius-lg: 1rem; /* 使用自己的圆角 */
/* 你的主题扩展 */
}
```
**特点**
注意Tailwind CSS v4 不再使用 `tailwind.config.js` 文件,所有配置都在 CSS 中完成。
- ✅ 不覆盖任何 Tailwind 默认主题
- ✅ 通过 CSS 变量访问所有设计令牌(`var(--cs-primary)``var(--cs-red-500)`
- ✅ 精细控制哪些使用 CS、哪些保持原样
- ✅ 适合有自己设计系统但想借用部分 CS 设计令牌的场景
**示例**
```tsx
{/* 通过 CSS 变量使用 CS 设计令牌 */}
<button style={{ backgroundColor: 'var(--cs-primary)' }}>
使 CherryStudio
</button>
{/* 保持原有的 Tailwind 类名不受影响 */}
<div className="bg-red-500">
使 Tailwind
</div>
{/* 可用的 CSS 变量 */}
<div style={{
color: 'var(--cs-primary)', // 品牌色
backgroundColor: 'var(--cs-red-500)', // 红色-500
padding: 'var(--cs-size-md)', // 间距
borderRadius: 'var(--cs-radius-lg)' // 圆角
}} />
```
### Provider 配置
### 2. Provider 配置
在你的 App 根组件中添加 HeroUI Provider
@@ -160,6 +94,9 @@ function App() {
// 只导入组件
import { Button } from '@cherrystudio/ui/components'
// 只导入 hooks
import { useDebounce, useLocalStorage } from '@cherrystudio/ui/hooks'
// 只导入工具函数
import { cn, formatFileSize } from '@cherrystudio/ui/utils'
```

View File

@@ -13,7 +13,7 @@
"tailwind": {
"baseColor": "zinc",
"config": "",
"css": "src/styles/theme.css",
"css": "src/styles/globals.css",
"cssVariables": true,
"prefix": ""
},

View File

@@ -1,214 +0,0 @@
# todocss.css → design-tokens.css 转换日志
## ✅ 已转换的变量
### 基础颜色 (Primitive Colors)
- ✅ Neutral (50-950)
- ✅ Zinc (50-950)
- ✅ Red (50-950)
- ✅ Orange (50-950)
- ✅ Amber (50-950)
- ✅ Yellow (50-950)
- ✅ Lime (50-950) - 品牌主色
- ✅ Green (50-950)
- ✅ Emerald (50-950)
- ✅ Purple (50-950)
- ✅ Blue (50-950)
- ✅ Black & White
### 语义化颜色 (Semantic Colors)
-`--cs-primary` (Lime 500)
-`--cs-destructive` (Red 500)
-`--cs-success` (Green 500)
-`--cs-warning` (Amber 500)
-`--cs-background` (Zinc 50/900)
-`--cs-foreground` 系列 (main, secondary, muted)
-`--cs-border` 系列 (default, hover, active)
-`--cs-ring` (Focus)
### 容器颜色
-`--cs-card` (White/Black)
-`--cs-popover` (White/Black)
-`--cs-sidebar` (White/Black)
### UI 元素细分颜色 (新增补充)
-**Modal / Overlay**
- `--cs-modal-backdrop`
- `--cs-modal-thumb`
- `--cs-modal-thumb-hover`
-**Icon**
- `--cs-icon-default`
- `--cs-icon-hover`
-**Input / Select**
- `--cs-input-background`
- `--cs-input-border`
- `--cs-input-border-hover`
- `--cs-input-border-focus`
-**Primary Button**
- `--cs-primary-button-background`
- `--cs-primary-button-text`
- `--cs-primary-button-background-hover`
- `--cs-primary-button-background-active`
- `--cs-primary-button-background-2nd`
- `--cs-primary-button-background-3rd`
-**Secondary Button**
- `--cs-secondary-button-background`
- `--cs-secondary-button-text`
- `--cs-secondary-button-background-hover`
- `--cs-secondary-button-background-active`
- `--cs-secondary-button-border`
-**Ghost Button**
- `--cs-ghost-button-background`
- `--cs-ghost-button-text`
- `--cs-ghost-button-background-hover`
- `--cs-ghost-button-background-active`
### 尺寸系统
- ✅ Spacing/Sizing 合并为 `--cs-size-*` (5xs ~ 8xl)
- ✅ Border Radius (4xs ~ 3xl, round)
- ✅ Border Width (sm, md, lg)
### 字体排版
- ✅ Font Families (Heading, Body)
- ✅ Font Weights (修正单位错误: 400px → 400)
- ✅ Font Sizes (Body & Heading)
- ✅ Line Heights (Body & Heading)
- ✅ Paragraph Spacing
---
## ❌ 已废弃的变量
### Opacity 变量 (全部废弃)
使用 Tailwind 的 `/modifier` 语法替代:
| todocss.css | 替代方案 |
|-------------|---------|
| `--Opacity--Red--Red-80` | `bg-cs-destructive/80` |
| `--Opacity--Green--Green-60` | `bg-cs-success/60` |
| `--Opacity--White--White-10` | `bg-white/10` |
**原因**: Tailwind v4 原生支持透明度修饰符,无需单独定义变量。
---
## 🔧 关键修正
### 1. 单位错误
```css
/* ❌ todocss.css */
--Font_weight--Regular: 400px;
/* ✅ design-tokens.css */
--cs-font-weight-regular: 400;
```
### 2. px → rem 转换
```css
/* ❌ todocss.css */
--Spacing--md: 40px;
/* ✅ design-tokens.css */
--cs-size-md: 2.5rem; /* 40px / 16 = 2.5rem */
```
### 3. 变量合并
```css
/* ❌ todocss.css (冗余) */
--Spacing--md: 40px;
--Sizing--md: 40px;
/* ✅ design-tokens.css (合并) */
--cs-size-md: 2.5rem;
```
### 4. Dark Mode 分离
```css
/* ❌ todocss.css (Light 和 Dark 都在 :root) */
:root {
--Brand--Semantic_Colors--Background: var(--Primitive--Zinc--50);
--Brand--Semantic_Colors--Background: var(--Primitive--Zinc--900); /* 后面覆盖 */
}
/* ✅ design-tokens.css (正确分离) */
:root {
--cs-background: var(--cs-zinc-50);
}
.dark {
--cs-background: var(--cs-zinc-900);
}
```
---
## 📊 变量统计
| 分类 | todocss.css | design-tokens.css | 说明 |
|------|-------------|-------------------|------|
| Primitive Colors | ~250 | ~250 | 完整保留 |
| Semantic Colors | ~20 | ~20 | 完整转换 |
| UI Element Colors | ~30 | ~30 | ✅ 已补充完整 |
| Opacity Variables | ~50 | 0 | 废弃,用 `/modifier` |
| Spacing/Sizing | 32 | 16 | 合并去重 |
| Typography | ~50 | ~50 | 修正单位 |
| **总计** | ~430 | ~390 | 优化 40 个变量 |
---
## 🎨 Dark Mode 变量对比
| Light Mode | Dark Mode | 变量名 |
|-----------|-----------|-------|
| Zinc 50 | Zinc 900 | `--cs-background` |
| Black 90% | White 90% | `--cs-foreground` |
| Black 60% | White 60% | `--cs-foreground-secondary` |
| Black 10% | White 10% | `--cs-border` |
| White | Black | `--cs-card` |
| White | Black | `--cs-popover` |
| White | Black | `--cs-sidebar` |
| White | Black | `--cs-input-background` |
| Black 40% | Black 6% | `--cs-modal-backdrop` |
| Black 20% | White 20% | `--cs-modal-thumb` |
| Black 5% | White 10% | `--cs-secondary` |
| Black 0% | White 0% | `--cs-ghost-button-background` |
---
## ✅ 验证清单
- [x] 所有 Primitive 颜色已转换
- [x] 所有语义化颜色已转换
- [x] 所有 UI 元素颜色已转换
- [x] Dark Mode 变量完整
- [x] 尺寸单位统一为 rem
- [x] Font Weight 单位已修正
- [x] Opacity 变量已废弃
- [x] Spacing/Sizing 已合并
---
## 📝 使用指南
### 如果设计师更新 todocss.css
1. 对比此文档,找出新增/修改的变量
2. 按照转换规则更新 `design-tokens.css`
3. 验证 Light/Dark Mode 是否完整
4. 更新此日志
### 验证转换正确性
```bash
# 检查 Light Mode 变量数量
grep -c "^ --cs-" packages/ui/src/styles/design-tokens.css
# 检查 Dark Mode 覆盖数量
grep -c "^ --cs-" packages/ui/src/styles/design-tokens.css | grep -A 100 ".dark"
```

View File

@@ -1,26 +0,0 @@
# Design Reference
本文件夹包含设计师提供的原始设计令牌文件,仅作为参考使用。
## 文件说明
### todocss.css
- **来源**:设计师提供的原始设计令牌
- **状态**:已转换为 `src/styles/design-tokens.css`
- **用途**
- 追溯设计决策
- 验证转换正确性
- 设计师更新时作为对比基准
## 转换规则
原始文件 → 生产文件的转换规则参见:
- [DESIGN_SYSTEM.md](../DESIGN_SYSTEM.md)
- [USAGE_GUIDE.md](../USAGE_GUIDE.md)
## 注意事项
⚠️ **请勿直接使用此文件夹中的文件**
- 这些文件仅供参考
- 实际使用请导入 `src/styles/` 中的文件

View File

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

View File

@@ -124,11 +124,7 @@
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.js",
"default": "./dist/utils/index.js"
},
"./styles": "./src/styles/index.css",
"./styles/tokens.css": "./src/styles/tokens.css",
"./styles/theme.css": "./src/styles/theme.css",
"./styles/index.css": "./src/styles/index.css"
}
},
"packageManager": "yarn@4.9.1"
}

View File

@@ -0,0 +1,123 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,24 +0,0 @@
/**
* CherryStudio Styles - npm 包入口
*
* 用途:仅导出 CSS 变量,不生成工具类,不覆盖用户主题
* 使用场景:
* - npm 用户想使用设计系统的变量,但不想覆盖 Tailwind 默认主题
* - 可以通过 var(--cs-primary) 等方式使用
* - 不会生成 bg-primary 等工具类(用户自己的 bg-primary 不受影响)
*
* 示例:
* ```css
* @import '@cherrystudio/ui/styles/index.css';
*
* .my-button {
* background: var(--cs-primary); // 使用 CS 的品牌色
* padding: var(--cs-size-md); // 使用 CS 的间距
* }
* ```
*
* 如果想要完整的主题覆盖(生成 bg-primary 等类),请导入:
* @import '@cherrystudio/ui/styles/theme.css';
*/
@import './tokens.css';

View File

@@ -1,433 +0,0 @@
/**
* CherryStudio Theme - Tailwind CSS 主题配置
*
* 用途:映射设计师的设计系统(--cs-*)到 Tailwind 标准命名
* 使用场景:
* - 主包:完全使用设计系统
* - npm 用户:可选择导入,会覆盖 Tailwind 默认主题
*
* 生成的工具类bg-primary, bg-red-500, p-md 等(无前缀)
*/
@import 'tailwindcss';
@import './tokens.css';
@custom-variant dark (&:is(.dark *));
@theme {
/* ==================== */
/* 语义化颜色(覆盖 Tailwind 标准命名) */
/* ==================== */
--color-primary: var(--cs-primary);
--color-primary-foreground: var(--cs-white);
--color-destructive: var(--cs-destructive);
--color-destructive-foreground: var(--cs-white);
--color-success: var(--cs-success);
--color-success-foreground: var(--cs-white);
--color-warning: var(--cs-warning);
--color-warning-foreground: var(--cs-black);
--color-background: var(--cs-background);
--color-foreground: var(--cs-foreground);
--color-card: var(--cs-card);
--color-card-foreground: var(--cs-foreground);
--color-popover: var(--cs-popover);
--color-popover-foreground: var(--cs-foreground);
--color-secondary: var(--cs-secondary);
--color-secondary-foreground: var(--cs-foreground);
--color-muted: var(--cs-muted);
--color-muted-foreground: var(--cs-foreground-muted);
--color-accent: var(--cs-accent);
--color-accent-foreground: var(--cs-foreground);
--color-border: var(--cs-border);
--color-input: var(--cs-border);
--color-ring: var(--cs-ring);
/* 侧边栏 */
--color-sidebar: var(--cs-sidebar);
--color-sidebar-foreground: var(--cs-foreground);
--color-sidebar-primary: var(--cs-primary);
--color-sidebar-primary-foreground: var(--cs-white);
--color-sidebar-accent: var(--cs-sidebar-accent);
--color-sidebar-accent-foreground: var(--cs-foreground);
--color-sidebar-border: var(--cs-border);
--color-sidebar-ring: var(--cs-ring);
/* 图表颜色 */
--color-chart-1: var(--cs-blue-500);
--color-chart-2: var(--cs-emerald-500);
--color-chart-3: var(--cs-purple-500);
--color-chart-4: var(--cs-amber-500);
--color-chart-5: var(--cs-orange-500);
/* ==================== */
/* Primitive 颜色(覆盖 Tailwind 默认色板) */
/* ==================== */
/* Neutral */
--color-neutral-50: var(--cs-neutral-50);
--color-neutral-100: var(--cs-neutral-100);
--color-neutral-200: var(--cs-neutral-200);
--color-neutral-300: var(--cs-neutral-300);
--color-neutral-400: var(--cs-neutral-400);
--color-neutral-500: var(--cs-neutral-500);
--color-neutral-600: var(--cs-neutral-600);
--color-neutral-700: var(--cs-neutral-700);
--color-neutral-800: var(--cs-neutral-800);
--color-neutral-900: var(--cs-neutral-900);
--color-neutral-950: var(--cs-neutral-950);
/* Stone */
--color-stone-50: var(--cs-stone-50);
--color-stone-100: var(--cs-stone-100);
--color-stone-200: var(--cs-stone-200);
--color-stone-300: var(--cs-stone-300);
--color-stone-400: var(--cs-stone-400);
--color-stone-500: var(--cs-stone-500);
--color-stone-600: var(--cs-stone-600);
--color-stone-700: var(--cs-stone-700);
--color-stone-800: var(--cs-stone-800);
--color-stone-900: var(--cs-stone-900);
--color-stone-950: var(--cs-stone-950);
/* Zinc */
--color-zinc-50: var(--cs-zinc-50);
--color-zinc-100: var(--cs-zinc-100);
--color-zinc-200: var(--cs-zinc-200);
--color-zinc-300: var(--cs-zinc-300);
--color-zinc-400: var(--cs-zinc-400);
--color-zinc-500: var(--cs-zinc-500);
--color-zinc-600: var(--cs-zinc-600);
--color-zinc-700: var(--cs-zinc-700);
--color-zinc-800: var(--cs-zinc-800);
--color-zinc-900: var(--cs-zinc-900);
--color-zinc-950: var(--cs-zinc-950);
/* Slate */
--color-slate-50: var(--cs-slate-50);
--color-slate-100: var(--cs-slate-100);
--color-slate-200: var(--cs-slate-200);
--color-slate-300: var(--cs-slate-300);
--color-slate-400: var(--cs-slate-400);
--color-slate-500: var(--cs-slate-500);
--color-slate-600: var(--cs-slate-600);
--color-slate-700: var(--cs-slate-700);
--color-slate-800: var(--cs-slate-800);
--color-slate-900: var(--cs-slate-900);
--color-slate-950: var(--cs-slate-950);
/* Gray */
--color-gray-50: var(--cs-gray-50);
--color-gray-100: var(--cs-gray-100);
--color-gray-200: var(--cs-gray-200);
--color-gray-300: var(--cs-gray-300);
--color-gray-400: var(--cs-gray-400);
--color-gray-500: var(--cs-gray-500);
--color-gray-600: var(--cs-gray-600);
--color-gray-700: var(--cs-gray-700);
--color-gray-800: var(--cs-gray-800);
--color-gray-900: var(--cs-gray-900);
--color-gray-950: var(--cs-gray-950);
/* Red */
--color-red-50: var(--cs-red-50);
--color-red-100: var(--cs-red-100);
--color-red-200: var(--cs-red-200);
--color-red-300: var(--cs-red-300);
--color-red-400: var(--cs-red-400);
--color-red-500: var(--cs-red-500);
--color-red-600: var(--cs-red-600);
--color-red-700: var(--cs-red-700);
--color-red-800: var(--cs-red-800);
--color-red-900: var(--cs-red-900);
--color-red-950: var(--cs-red-950);
/* Orange */
--color-orange-50: var(--cs-orange-50);
--color-orange-100: var(--cs-orange-100);
--color-orange-200: var(--cs-orange-200);
--color-orange-300: var(--cs-orange-300);
--color-orange-400: var(--cs-orange-400);
--color-orange-500: var(--cs-orange-500);
--color-orange-600: var(--cs-orange-600);
--color-orange-700: var(--cs-orange-700);
--color-orange-800: var(--cs-orange-800);
--color-orange-900: var(--cs-orange-900);
--color-orange-950: var(--cs-orange-950);
/* Amber */
--color-amber-50: var(--cs-amber-50);
--color-amber-100: var(--cs-amber-100);
--color-amber-200: var(--cs-amber-200);
--color-amber-300: var(--cs-amber-300);
--color-amber-400: var(--cs-amber-400);
--color-amber-500: var(--cs-amber-500);
--color-amber-600: var(--cs-amber-600);
--color-amber-700: var(--cs-amber-700);
--color-amber-800: var(--cs-amber-800);
--color-amber-900: var(--cs-amber-900);
--color-amber-950: var(--cs-amber-950);
/* Yellow */
--color-yellow-50: var(--cs-yellow-50);
--color-yellow-100: var(--cs-yellow-100);
--color-yellow-200: var(--cs-yellow-200);
--color-yellow-300: var(--cs-yellow-300);
--color-yellow-400: var(--cs-yellow-400);
--color-yellow-500: var(--cs-yellow-500);
--color-yellow-600: var(--cs-yellow-600);
--color-yellow-700: var(--cs-yellow-700);
--color-yellow-800: var(--cs-yellow-800);
--color-yellow-900: var(--cs-yellow-900);
--color-yellow-950: var(--cs-yellow-950);
/* Lime (品牌主色) */
--color-lime-50: var(--cs-lime-50);
--color-lime-100: var(--cs-lime-100);
--color-lime-200: var(--cs-lime-200);
--color-lime-300: var(--cs-lime-300);
--color-lime-400: var(--cs-lime-400);
--color-lime-500: var(--cs-lime-500);
--color-lime-600: var(--cs-lime-600);
--color-lime-700: var(--cs-lime-700);
--color-lime-800: var(--cs-lime-800);
--color-lime-900: var(--cs-lime-900);
--color-lime-950: var(--cs-lime-950);
/* Green */
--color-green-50: var(--cs-green-50);
--color-green-100: var(--cs-green-100);
--color-green-200: var(--cs-green-200);
--color-green-300: var(--cs-green-300);
--color-green-400: var(--cs-green-400);
--color-green-500: var(--cs-green-500);
--color-green-600: var(--cs-green-600);
--color-green-700: var(--cs-green-700);
--color-green-800: var(--cs-green-800);
--color-green-900: var(--cs-green-900);
--color-green-950: var(--cs-green-950);
/* Emerald */
--color-emerald-50: var(--cs-emerald-50);
--color-emerald-100: var(--cs-emerald-100);
--color-emerald-200: var(--cs-emerald-200);
--color-emerald-300: var(--cs-emerald-300);
--color-emerald-400: var(--cs-emerald-400);
--color-emerald-500: var(--cs-emerald-500);
--color-emerald-600: var(--cs-emerald-600);
--color-emerald-700: var(--cs-emerald-700);
--color-emerald-800: var(--cs-emerald-800);
--color-emerald-900: var(--cs-emerald-900);
--color-emerald-950: var(--cs-emerald-950);
/* Teal */
--color-teal-50: var(--cs-teal-50);
--color-teal-100: var(--cs-teal-100);
--color-teal-200: var(--cs-teal-200);
--color-teal-300: var(--cs-teal-300);
--color-teal-400: var(--cs-teal-400);
--color-teal-500: var(--cs-teal-500);
--color-teal-600: var(--cs-teal-600);
--color-teal-700: var(--cs-teal-700);
--color-teal-800: var(--cs-teal-800);
--color-teal-900: var(--cs-teal-900);
--color-teal-950: var(--cs-teal-950);
/* Cyan */
--color-cyan-50: var(--cs-cyan-50);
--color-cyan-100: var(--cs-cyan-100);
--color-cyan-200: var(--cs-cyan-200);
--color-cyan-300: var(--cs-cyan-300);
--color-cyan-400: var(--cs-cyan-400);
--color-cyan-500: var(--cs-cyan-500);
--color-cyan-600: var(--cs-cyan-600);
--color-cyan-700: var(--cs-cyan-700);
--color-cyan-800: var(--cs-cyan-800);
--color-cyan-900: var(--cs-cyan-900);
--color-cyan-950: var(--cs-cyan-950);
/* Sky */
--color-sky-50: var(--cs-sky-50);
--color-sky-100: var(--cs-sky-100);
--color-sky-200: var(--cs-sky-200);
--color-sky-300: var(--cs-sky-300);
--color-sky-400: var(--cs-sky-400);
--color-sky-500: var(--cs-sky-500);
--color-sky-600: var(--cs-sky-600);
--color-sky-700: var(--cs-sky-700);
--color-sky-800: var(--cs-sky-800);
--color-sky-900: var(--cs-sky-900);
--color-sky-950: var(--cs-sky-950);
/* Blue */
--color-blue-50: var(--cs-blue-50);
--color-blue-100: var(--cs-blue-100);
--color-blue-200: var(--cs-blue-200);
--color-blue-300: var(--cs-blue-300);
--color-blue-400: var(--cs-blue-400);
--color-blue-500: var(--cs-blue-500);
--color-blue-600: var(--cs-blue-600);
--color-blue-700: var(--cs-blue-700);
--color-blue-800: var(--cs-blue-800);
--color-blue-900: var(--cs-blue-900);
--color-blue-950: var(--cs-blue-950);
/* Indigo */
--color-indigo-50: var(--cs-indigo-50);
--color-indigo-100: var(--cs-indigo-100);
--color-indigo-200: var(--cs-indigo-200);
--color-indigo-300: var(--cs-indigo-300);
--color-indigo-400: var(--cs-indigo-400);
--color-indigo-500: var(--cs-indigo-500);
--color-indigo-600: var(--cs-indigo-600);
--color-indigo-700: var(--cs-indigo-700);
--color-indigo-800: var(--cs-indigo-800);
--color-indigo-900: var(--cs-indigo-900);
--color-indigo-950: var(--cs-indigo-950);
/* Violet */
--color-violet-50: var(--cs-violet-50);
--color-violet-100: var(--cs-violet-100);
--color-violet-200: var(--cs-violet-200);
--color-violet-300: var(--cs-violet-300);
--color-violet-400: var(--cs-violet-400);
--color-violet-500: var(--cs-violet-500);
--color-violet-600: var(--cs-violet-600);
--color-violet-700: var(--cs-violet-700);
--color-violet-800: var(--cs-violet-800);
--color-violet-900: var(--cs-violet-900);
--color-violet-950: var(--cs-violet-950);
/* Purple */
--color-purple-50: var(--cs-purple-50);
--color-purple-100: var(--cs-purple-100);
--color-purple-200: var(--cs-purple-200);
--color-purple-300: var(--cs-purple-300);
--color-purple-400: var(--cs-purple-400);
--color-purple-500: var(--cs-purple-500);
--color-purple-600: var(--cs-purple-600);
--color-purple-700: var(--cs-purple-700);
--color-purple-800: var(--cs-purple-800);
--color-purple-900: var(--cs-purple-900);
--color-purple-950: var(--cs-purple-950);
/* Fuchsia */
--color-fuchsia-50: var(--cs-fuchsia-50);
--color-fuchsia-100: var(--cs-fuchsia-100);
--color-fuchsia-200: var(--cs-fuchsia-200);
--color-fuchsia-300: var(--cs-fuchsia-300);
--color-fuchsia-400: var(--cs-fuchsia-400);
--color-fuchsia-500: var(--cs-fuchsia-500);
--color-fuchsia-600: var(--cs-fuchsia-600);
--color-fuchsia-700: var(--cs-fuchsia-700);
--color-fuchsia-800: var(--cs-fuchsia-800);
--color-fuchsia-900: var(--cs-fuchsia-900);
--color-fuchsia-950: var(--cs-fuchsia-950);
/* Pink */
--color-pink-50: var(--cs-pink-50);
--color-pink-100: var(--cs-pink-100);
--color-pink-200: var(--cs-pink-200);
--color-pink-300: var(--cs-pink-300);
--color-pink-400: var(--cs-pink-400);
--color-pink-500: var(--cs-pink-500);
--color-pink-600: var(--cs-pink-600);
--color-pink-700: var(--cs-pink-700);
--color-pink-800: var(--cs-pink-800);
--color-pink-900: var(--cs-pink-900);
--color-pink-950: var(--cs-pink-950);
/* Rose */
--color-rose-50: var(--cs-rose-50);
--color-rose-100: var(--cs-rose-100);
--color-rose-200: var(--cs-rose-200);
--color-rose-300: var(--cs-rose-300);
--color-rose-400: var(--cs-rose-400);
--color-rose-500: var(--cs-rose-500);
--color-rose-600: var(--cs-rose-600);
--color-rose-700: var(--cs-rose-700);
--color-rose-800: var(--cs-rose-800);
--color-rose-900: var(--cs-rose-900);
--color-rose-950: var(--cs-rose-950);
/* Black & White */
--color-black: var(--cs-black);
--color-white: var(--cs-white);
/* ==================== */
/* Spacing & Sizing自定义尺寸额外扩展 */
/* ==================== */
--spacing-5xs: var(--cs-size-5xs);
--spacing-4xs: var(--cs-size-4xs);
--spacing-3xs: var(--cs-size-3xs);
--spacing-2xs: var(--cs-size-2xs);
--spacing-xs: var(--cs-size-xs);
--spacing-sm: var(--cs-size-sm);
--spacing-md: var(--cs-size-md);
--spacing-lg: var(--cs-size-lg);
--spacing-xl: var(--cs-size-xl);
--spacing-2xl: var(--cs-size-2xl);
--spacing-3xl: var(--cs-size-3xl);
--spacing-4xl: var(--cs-size-4xl);
--spacing-5xl: var(--cs-size-5xl);
--spacing-6xl: var(--cs-size-6xl);
--spacing-7xl: var(--cs-size-7xl);
--spacing-8xl: var(--cs-size-8xl);
/* ==================== */
/* Border Radius自定义圆角额外扩展 */
/* ==================== */
--radius-4xs: var(--cs-radius-4xs);
--radius-3xs: var(--cs-radius-3xs);
--radius-2xs: var(--cs-radius-2xs);
--radius-xs: var(--cs-radius-xs);
--radius-sm: var(--cs-radius-sm);
--radius-md: var(--cs-radius-md);
--radius-lg: var(--cs-radius-lg);
--radius-xl: var(--cs-radius-xl);
--radius-2xl: var(--cs-radius-2xl);
--radius-3xl: var(--cs-radius-3xl);
--radius-round: var(--cs-radius-round);
}
/* ==================== */
/* Dark Mode 覆盖 */
/* ==================== */
@theme dark {
--color-background: var(--cs-background);
--color-foreground: var(--cs-foreground);
--color-card: var(--cs-card);
--color-popover: var(--cs-popover);
--color-border: var(--cs-border);
--color-input: var(--cs-border);
--color-secondary: var(--cs-secondary);
--color-muted: var(--cs-muted);
--color-accent: var(--cs-accent);
--color-sidebar: var(--cs-sidebar);
--color-sidebar-accent: var(--cs-sidebar-accent);
}
/* ==================== */
/* Base Styles可选 */
/* ==================== */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,560 +0,0 @@
/**
* CherryStudio Design Tokens
*
* 基于 todocss.css 转换的设计令牌
* - 所有变量使用 --cs-* 前缀CherryStudio
* - 合并重复的 Spacing/Sizing
* - 修正错误(如 font-weight 单位)
* - 删除 Opacity 变量(使用 Tailwind 修饰符)
* - 正确分离 Light/Dark Mode
*/
:root {
/* ==================== */
/* Primitive Colors - Light Mode */
/* ==================== */
/* Neutral */
--cs-neutral-50: hsla(0, 0%, 98%, 1);
--cs-neutral-100: hsla(0, 0%, 96%, 1);
--cs-neutral-200: hsla(0, 0%, 90%, 1);
--cs-neutral-300: hsla(0, 0%, 83%, 1);
--cs-neutral-400: hsla(0, 0%, 64%, 1);
--cs-neutral-500: hsla(0, 0%, 45%, 1);
--cs-neutral-600: hsla(215, 14%, 34%, 1);
--cs-neutral-700: hsla(0, 0%, 25%, 1);
--cs-neutral-800: hsla(0, 0%, 15%, 1);
--cs-neutral-900: hsla(0, 0%, 9%, 1);
--cs-neutral-950: hsla(0, 0%, 4%, 1);
/* Stone */
--cs-stone-50: hsla(60, 9%, 98%, 1);
--cs-stone-100: hsla(60, 5%, 96%, 1);
--cs-stone-200: hsla(20, 6%, 90%, 1);
--cs-stone-300: hsla(24, 6%, 83%, 1);
--cs-stone-400: hsla(24, 5%, 64%, 1);
--cs-stone-500: hsla(25, 5%, 45%, 1);
--cs-stone-600: hsla(33, 5%, 32%, 1);
--cs-stone-700: hsla(30, 6%, 25%, 1);
--cs-stone-800: hsla(12, 6%, 15%, 1);
--cs-stone-900: hsla(24, 10%, 10%, 1);
--cs-stone-950: hsla(20, 14%, 4%, 1);
/* Zinc */
--cs-zinc-50: hsla(0, 0%, 98%, 1);
--cs-zinc-100: hsla(240, 5%, 96%, 1);
--cs-zinc-200: hsla(240, 6%, 90%, 1);
--cs-zinc-300: hsla(240, 5%, 84%, 1);
--cs-zinc-400: hsla(240, 5%, 65%, 1);
--cs-zinc-500: hsla(240, 4%, 46%, 1);
--cs-zinc-600: hsla(240, 5%, 34%, 1);
--cs-zinc-700: hsla(240, 5%, 26%, 1);
--cs-zinc-800: hsla(240, 4%, 16%, 1);
--cs-zinc-900: hsla(240, 6%, 10%, 1);
--cs-zinc-950: hsla(240, 10%, 4%, 1);
/* Slate */
--cs-slate-50: hsla(210, 40%, 98%, 1);
--cs-slate-100: hsla(210, 40%, 96%, 1);
--cs-slate-200: hsla(214, 32%, 91%, 1);
--cs-slate-300: hsla(213, 27%, 84%, 1);
--cs-slate-400: hsla(215, 20%, 65%, 1);
--cs-slate-500: hsla(215, 16%, 47%, 1);
--cs-slate-600: hsla(215, 19%, 35%, 1);
--cs-slate-700: hsla(215, 25%, 27%, 1);
--cs-slate-800: hsla(217, 33%, 17%, 1);
--cs-slate-900: hsla(222, 47%, 11%, 1);
--cs-slate-950: hsla(229, 84%, 5%, 1);
/* Gray */
--cs-gray-50: hsla(210, 20%, 98%, 1);
--cs-gray-100: hsla(220, 14%, 96%, 1);
--cs-gray-200: hsla(220, 13%, 91%, 1);
--cs-gray-300: hsla(216, 12%, 84%, 1);
--cs-gray-400: hsla(218, 11%, 65%, 1);
--cs-gray-500: hsla(220, 9%, 46%, 1);
--cs-gray-600: hsla(0, 0%, 32%, 1);
--cs-gray-700: hsla(217, 19%, 27%, 1);
--cs-gray-800: hsla(215, 28%, 17%, 1);
--cs-gray-900: hsla(221, 39%, 11%, 1);
--cs-gray-950: hsla(224, 71%, 4%, 1);
/* Red */
--cs-red-50: hsla(0, 86%, 97%, 1);
--cs-red-100: hsla(0, 93%, 94%, 1);
--cs-red-200: hsla(0, 96%, 89%, 1);
--cs-red-300: hsla(0, 94%, 82%, 1);
--cs-red-400: hsla(0, 91%, 71%, 1);
--cs-red-500: hsla(0, 84%, 60%, 1);
--cs-red-600: hsla(0, 72%, 51%, 1);
--cs-red-700: hsla(0, 74%, 42%, 1);
--cs-red-800: hsla(0, 70%, 35%, 1);
--cs-red-900: hsla(0, 63%, 31%, 1);
--cs-red-950: hsla(0, 75%, 15%, 1);
/* Orange */
--cs-orange-50: hsla(33, 100%, 96%, 1);
--cs-orange-100: hsla(34, 100%, 92%, 1);
--cs-orange-200: hsla(32, 98%, 83%, 1);
--cs-orange-300: hsla(31, 97%, 72%, 1);
--cs-orange-400: hsla(27, 96%, 61%, 1);
--cs-orange-500: hsla(25, 95%, 53%, 1);
--cs-orange-600: hsla(21, 90%, 48%, 1);
--cs-orange-700: hsla(17, 88%, 40%, 1);
--cs-orange-800: hsla(15, 79%, 34%, 1);
--cs-orange-900: hsla(15, 75%, 28%, 1);
--cs-orange-950: hsla(13, 81%, 15%, 1);
/* Amber */
--cs-amber-50: hsla(48, 100%, 96%, 1);
--cs-amber-100: hsla(48, 96%, 89%, 1);
--cs-amber-200: hsla(48, 97%, 77%, 1);
--cs-amber-300: hsla(46, 97%, 65%, 1);
--cs-amber-400: hsla(43, 96%, 56%, 1);
--cs-amber-500: hsla(38, 92%, 50%, 1);
--cs-amber-600: hsla(32, 95%, 44%, 1);
--cs-amber-700: hsla(26, 90%, 37%, 1);
--cs-amber-800: hsla(23, 83%, 31%, 1);
--cs-amber-900: hsla(22, 78%, 26%, 1);
--cs-amber-950: hsla(21, 92%, 14%, 1);
/* Yellow */
--cs-yellow-50: hsla(55, 92%, 95%, 1);
--cs-yellow-100: hsla(55, 97%, 88%, 1);
--cs-yellow-200: hsla(53, 98%, 77%, 1);
--cs-yellow-300: hsla(50, 98%, 64%, 1);
--cs-yellow-400: hsla(48, 96%, 53%, 1);
--cs-yellow-500: hsla(45, 93%, 47%, 1);
--cs-yellow-600: hsla(41, 96%, 40%, 1);
--cs-yellow-700: hsla(35, 92%, 33%, 1);
--cs-yellow-800: hsla(32, 81%, 29%, 1);
--cs-yellow-900: hsla(28, 73%, 26%, 1);
--cs-yellow-950: hsla(26, 83%, 14%, 1);
/* Lime (品牌主色) */
--cs-lime-50: hsla(78, 92%, 95%, 1);
--cs-lime-100: hsla(80, 89%, 89%, 1);
--cs-lime-200: hsla(81, 88%, 80%, 1);
--cs-lime-300: hsla(82, 85%, 67%, 1);
--cs-lime-400: hsla(83, 78%, 55%, 1);
--cs-lime-500: hsla(84, 81%, 44%, 1);
--cs-lime-600: hsla(85, 85%, 35%, 1);
--cs-lime-700: hsla(86, 78%, 27%, 1);
--cs-lime-800: hsla(86, 69%, 23%, 1);
--cs-lime-900: hsla(88, 61%, 20%, 1);
--cs-lime-950: hsla(89, 80%, 10%, 1);
/* Green */
--cs-green-50: hsla(138, 76%, 97%, 1);
--cs-green-100: hsla(141, 84%, 93%, 1);
--cs-green-200: hsla(141, 79%, 85%, 1);
--cs-green-300: hsla(142, 77%, 73%, 1);
--cs-green-400: hsla(142, 69%, 58%, 1);
--cs-green-500: hsla(142, 71%, 45%, 1);
--cs-green-600: hsla(142, 76%, 36%, 1);
--cs-green-700: hsla(142, 72%, 29%, 1);
--cs-green-800: hsla(143, 64%, 24%, 1);
--cs-green-900: hsla(144, 61%, 20%, 1);
--cs-green-950: hsla(145, 80%, 10%, 1);
/* Emerald */
--cs-emerald-50: hsla(152, 81%, 96%, 1);
--cs-emerald-100: hsla(149, 80%, 90%, 1);
--cs-emerald-200: hsla(152, 76%, 80%, 1);
--cs-emerald-300: hsla(156, 72%, 67%, 1);
--cs-emerald-400: hsla(158, 64%, 52%, 1);
--cs-emerald-500: hsla(160, 84%, 39%, 1);
--cs-emerald-600: hsla(161, 94%, 30%, 1);
--cs-emerald-700: hsla(163, 94%, 24%, 1);
--cs-emerald-800: hsla(163, 88%, 20%, 1);
--cs-emerald-900: hsla(164, 86%, 16%, 1);
--cs-emerald-950: hsla(166, 91%, 9%, 1);
/* Teal */
--cs-teal-50: hsla(166, 76%, 97%, 1);
--cs-teal-100: hsla(167, 85%, 89%, 1);
--cs-teal-200: hsla(168, 84%, 78%, 1);
--cs-teal-300: hsla(171, 77%, 64%, 1);
--cs-teal-400: hsla(172, 66%, 50%, 1);
--cs-teal-500: hsla(173, 80%, 40%, 1);
--cs-teal-600: hsla(175, 84%, 32%, 1);
--cs-teal-700: hsla(175, 77%, 26%, 1);
--cs-teal-800: hsla(176, 69%, 22%, 1);
--cs-teal-900: hsla(176, 61%, 19%, 1);
--cs-teal-950: hsla(179, 84%, 10%, 1);
/* Cyan */
--cs-cyan-50: hsla(183, 100%, 96%, 1);
--cs-cyan-100: hsla(185, 96%, 90%, 1);
--cs-cyan-200: hsla(186, 94%, 82%, 1);
--cs-cyan-300: hsla(187, 92%, 69%, 1);
--cs-cyan-400: hsla(188, 86%, 53%, 1);
--cs-cyan-500: hsla(189, 94%, 43%, 1);
--cs-cyan-600: hsla(192, 91%, 36%, 1);
--cs-cyan-700: hsla(193, 82%, 31%, 1);
--cs-cyan-800: hsla(194, 70%, 27%, 1);
--cs-cyan-900: hsla(196, 64%, 24%, 1);
--cs-cyan-950: hsla(197, 79%, 15%, 1);
/* Sky */
--cs-sky-50: hsla(204, 100%, 97%, 1);
--cs-sky-100: hsla(204, 94%, 94%, 1);
--cs-sky-200: hsla(201, 94%, 86%, 1);
--cs-sky-300: hsla(199, 95%, 74%, 1);
--cs-sky-400: hsla(198, 93%, 60%, 1);
--cs-sky-500: hsla(199, 89%, 48%, 1);
--cs-sky-600: hsla(200, 98%, 39%, 1);
--cs-sky-700: hsla(201, 96%, 32%, 1);
--cs-sky-800: hsla(201, 90%, 27%, 1);
--cs-sky-900: hsla(202, 80%, 24%, 1);
--cs-sky-950: hsla(204, 80%, 16%, 1);
/* Blue */
--cs-blue-50: hsla(214, 100%, 97%, 1);
--cs-blue-100: hsla(214, 95%, 93%, 1);
--cs-blue-200: hsla(213, 97%, 87%, 1);
--cs-blue-300: hsla(212, 96%, 78%, 1);
--cs-blue-400: hsla(213, 94%, 68%, 1);
--cs-blue-500: hsla(217, 91%, 60%, 1);
--cs-blue-600: hsla(221, 83%, 53%, 1);
--cs-blue-700: hsla(224, 76%, 48%, 1);
--cs-blue-800: hsla(226, 71%, 40%, 1);
--cs-blue-900: hsla(224, 64%, 33%, 1);
--cs-blue-950: hsla(226, 57%, 21%, 1);
/* Indigo */
--cs-indigo-50: hsla(226, 100%, 97%, 1);
--cs-indigo-100: hsla(226, 100%, 94%, 1);
--cs-indigo-200: hsla(228, 96%, 89%, 1);
--cs-indigo-300: hsla(230, 94%, 82%, 1);
--cs-indigo-400: hsla(234, 89%, 74%, 1);
--cs-indigo-500: hsla(239, 84%, 67%, 1);
--cs-indigo-600: hsla(243, 75%, 59%, 1);
--cs-indigo-700: hsla(245, 58%, 51%, 1);
--cs-indigo-800: hsla(244, 55%, 41%, 1);
--cs-indigo-900: hsla(242, 47%, 34%, 1);
--cs-indigo-950: hsla(244, 47%, 20%, 1);
/* Violet */
--cs-violet-50: hsla(250, 100%, 98%, 1);
--cs-violet-100: hsla(251, 91%, 95%, 1);
--cs-violet-200: hsla(251, 95%, 92%, 1);
--cs-violet-300: hsla(253, 95%, 85%, 1);
--cs-violet-400: hsla(255, 92%, 76%, 1);
--cs-violet-500: hsla(258, 90%, 66%, 1);
--cs-violet-600: hsla(262, 83%, 58%, 1);
--cs-violet-700: hsla(263, 70%, 50%, 1);
--cs-violet-800: hsla(263, 69%, 42%, 1);
--cs-violet-900: hsla(264, 67%, 35%, 1);
--cs-violet-950: hsla(262, 78%, 23%, 1);
/* Purple */
--cs-purple-50: hsla(270, 100%, 98%, 1);
--cs-purple-100: hsla(269, 100%, 95%, 1);
--cs-purple-200: hsla(269, 100%, 92%, 1);
--cs-purple-300: hsla(269, 97%, 85%, 1);
--cs-purple-400: hsla(270, 95%, 75%, 1);
--cs-purple-500: hsla(271, 91%, 65%, 1);
--cs-purple-600: hsla(271, 81%, 56%, 1);
--cs-purple-700: hsla(272, 72%, 47%, 1);
--cs-purple-800: hsla(273, 67%, 39%, 1);
--cs-purple-900: hsla(274, 66%, 32%, 1);
--cs-purple-950: hsla(274, 87%, 21%, 1);
/* Fuchsia */
--cs-fuchsia-50: hsla(289, 100%, 98%, 1);
--cs-fuchsia-100: hsla(287, 100%, 95%, 1);
--cs-fuchsia-200: hsla(288, 96%, 91%, 1);
--cs-fuchsia-300: hsla(291, 93%, 83%, 1);
--cs-fuchsia-400: hsla(292, 91%, 73%, 1);
--cs-fuchsia-500: hsla(292, 84%, 61%, 1);
--cs-fuchsia-600: hsla(293, 69%, 49%, 1);
--cs-fuchsia-700: hsla(295, 72%, 40%, 1);
--cs-fuchsia-800: hsla(295, 70%, 33%, 1);
--cs-fuchsia-900: hsla(297, 64%, 28%, 1);
--cs-fuchsia-950: hsla(297, 90%, 16%, 1);
/* Pink */
--cs-pink-50: hsla(327, 73%, 97%, 1);
--cs-pink-100: hsla(326, 78%, 95%, 1);
--cs-pink-200: hsla(326, 85%, 90%, 1);
--cs-pink-300: hsla(327, 87%, 82%, 1);
--cs-pink-400: hsla(329, 86%, 70%, 1);
--cs-pink-500: hsla(330, 81%, 60%, 1);
--cs-pink-600: hsla(333, 71%, 51%, 1);
--cs-pink-700: hsla(335, 78%, 42%, 1);
--cs-pink-800: hsla(336, 74%, 35%, 1);
--cs-pink-900: hsla(336, 69%, 30%, 1);
--cs-pink-950: hsla(336, 84%, 17%, 1);
/* Rose */
--cs-rose-50: hsla(356, 100%, 97%, 1);
--cs-rose-100: hsla(356, 100%, 95%, 1);
--cs-rose-200: hsla(353, 96%, 90%, 1);
--cs-rose-300: hsla(353, 96%, 82%, 1);
--cs-rose-400: hsla(351, 95%, 71%, 1);
--cs-rose-500: hsla(350, 89%, 60%, 1);
--cs-rose-600: hsla(347, 77%, 50%, 1);
--cs-rose-700: hsla(345, 83%, 41%, 1);
--cs-rose-800: hsla(343, 80%, 35%, 1);
--cs-rose-900: hsla(342, 75%, 30%, 1);
--cs-rose-950: hsla(343, 88%, 16%, 1);
/* Black & White */
--cs-black: hsla(0, 0%, 0%, 1);
--cs-white: hsla(0, 0%, 100%, 1);
/* ==================== */
/* Semantic Tokens - Light Mode */
/* ==================== */
/* Brand Colors */
--cs-primary: var(--cs-lime-500);
--cs-destructive: var(--cs-red-500);
--cs-success: var(--cs-green-500);
--cs-warning: var(--cs-amber-500);
/* Background & Foreground */
--cs-background: var(--cs-zinc-50);
--cs-background-subtle: hsla(0, 0%, 0%, 0.02);
--cs-foreground: hsla(0, 0%, 0%, 0.9);
--cs-foreground-secondary: hsla(0, 0%, 0%, 0.6);
--cs-foreground-muted: hsla(0, 0%, 0%, 0.4);
/* Card & Popover */
--cs-card: var(--cs-white);
--cs-popover: var(--cs-white);
/* Border */
--cs-border: hsla(0, 0%, 0%, 0.1);
--cs-border-hover: hsla(0, 0%, 0%, 0.2);
--cs-border-active: hsla(0, 0%, 0%, 0.3);
/* Ring (Focus) */
--cs-ring: hsla(84, 81%, 44%, 0.4);
/* UI Element Colors */
--cs-secondary: hsla(0, 0%, 0%, 0.05); /* Secondary Button Background */
--cs-secondary-hover: hsla(0, 0%, 0%, 0.85);
--cs-secondary-active: hsla(0, 0%, 0%, 0.7);
--cs-muted: hsla(0, 0%, 0%, 0.05); /* Muted/Subtle Background */
--cs-accent: hsla(0, 0%, 0%, 0.05); /* Accent Background */
--cs-ghost-hover: hsla(0, 0%, 0%, 0.05); /* Ghost Button Hover */
--cs-ghost-active: hsla(0, 0%, 0%, 0.1); /* Ghost Button Active */
/* Sidebar */
--cs-sidebar: var(--cs-white);
--cs-sidebar-accent: hsla(0, 0%, 0%, 0.05);
/* ==================== */
/* UI 元素细分颜色 */
/* ==================== */
/* Modal / Overlay */
--cs-modal-backdrop: hsla(0, 0%, 0%, 0.4);
--cs-modal-thumb: hsla(0, 0%, 0%, 0.2);
--cs-modal-thumb-hover: hsla(0, 0%, 0%, 0.3);
/* Icon */
--cs-icon-default: var(--cs-foreground-secondary);
--cs-icon-hover: var(--cs-foreground);
/* Input / Select */
--cs-input-background: var(--cs-white);
--cs-input-border: var(--cs-border);
--cs-input-border-hover: var(--cs-border-hover);
--cs-input-border-focus: var(--cs-primary);
/* Primary Button */
--cs-primary-button-background: var(--cs-primary);
--cs-primary-button-text: var(--cs-white);
--cs-primary-button-background-hover: hsla(84, 81%, 44%, 0.85);
--cs-primary-button-background-active: hsla(84, 81%, 44%, 0.7);
--cs-primary-button-background-2nd: hsla(84, 81%, 44%, 0.1);
--cs-primary-button-background-3rd: hsla(84, 81%, 44%, 0.05);
/* Secondary Button */
--cs-secondary-button-background: var(--cs-secondary);
--cs-secondary-button-text: var(--cs-foreground);
--cs-secondary-button-background-hover: hsla(0, 0%, 0%, 0.85);
--cs-secondary-button-background-active: hsla(0, 0%, 0%, 0.7);
--cs-secondary-button-border: var(--cs-border);
/* Ghost Button */
--cs-ghost-button-background: hsla(0, 0%, 0%, 0);
--cs-ghost-button-text: var(--cs-foreground);
--cs-ghost-button-background-hover: var(--cs-ghost-hover);
--cs-ghost-button-background-active: var(--cs-ghost-active);
/* ==================== */
/* Spacing & Sizing (合并) */
/* ==================== */
--cs-size-5xs: 0.25rem; /* 4px */
--cs-size-4xs: 0.5rem; /* 8px */
--cs-size-3xs: 0.75rem; /* 12px */
--cs-size-2xs: 1rem; /* 16px */
--cs-size-xs: 1.5rem; /* 24px */
--cs-size-sm: 2rem; /* 32px */
--cs-size-md: 2.5rem; /* 40px */
--cs-size-lg: 3rem; /* 48px */
--cs-size-xl: 3.5rem; /* 56px */
--cs-size-2xl: 4rem; /* 64px */
--cs-size-3xl: 4.5rem; /* 72px */
--cs-size-4xl: 5rem; /* 80px */
--cs-size-5xl: 5.5rem; /* 88px */
--cs-size-6xl: 6rem; /* 96px */
--cs-size-7xl: 6.5rem; /* 104px */
--cs-size-8xl: 7rem; /* 112px */
/* ==================== */
/* Border Radius */
/* ==================== */
--cs-radius-4xs: 4px; /* 4px */
--cs-radius-3xs: 8px; /* 8px */
--cs-radius-2xs: 12px; /* 12px */
--cs-radius-xs: 16px; /* 16px */
--cs-radius-sm: 24px; /* 24px */
--cs-radius-md: 32px; /* 32px */
--cs-radius-lg: 2.5rem; /* 40px */
--cs-radius-xl: 48px; /* 48px */
--cs-radius-2xl: 56px; /* 56px */
--cs-radius-3xl: 64px; /* 64px */
--cs-radius-round: 999px; /* 保持 px因为是特殊值 */
/* Border Width */
--cs-border-width-sm: 1px; /* 1px */
--cs-border-width-md: 2px; /* 2px */
--cs-border-width-lg: 3px; /* 3px */
/* ==================== */
/* Typography */
/* ==================== */
/* Font Families */
--cs-font-family-heading: Inter;
--cs-font-family-body: Inter;
/* Font Weights (修正单位错误) */
--cs-font-weight-regular: 400;
--cs-font-weight-medium: 500;
--cs-font-weight-bold: 700;
/* Font Sizes - Body */
--cs-font-size-body-xs: 0.75rem; /* 12px */
--cs-font-size-body-sm: 0.875rem; /* 14px */
--cs-font-size-body-md: 1rem; /* 16px */
--cs-font-size-body-lg: 1.125rem; /* 18px */
/* Font Sizes - Heading */
--cs-font-size-heading-xs: 1.25rem; /* 20px */
--cs-font-size-heading-sm: 1.5rem; /* 24px */
--cs-font-size-heading-md: 2rem; /* 32px */
--cs-font-size-heading-lg: 2.5rem; /* 40px */
--cs-font-size-heading-xl: 3rem; /* 48px */
--cs-font-size-heading-2xl: 3.75rem; /* 60px */
/* Line Heights - Body */
--cs-line-height-body-xs: 1.25rem; /* 20px */
--cs-line-height-body-sm: 1.5rem; /* 24px */
--cs-line-height-body-md: 1.5rem; /* 24px */
--cs-line-height-body-lg: 1.75rem; /* 28px */
/* Line Heights - Heading */
--cs-line-height-heading-xs: 2rem; /* 32px */
--cs-line-height-heading-sm: 2.5rem; /* 40px */
--cs-line-height-heading-md: 3rem; /* 48px */
--cs-line-height-heading-lg: 3.75rem; /* 60px */
--cs-line-height-heading-xl: 5rem; /* 80px */
/* Paragraph Spacing */
--cs-paragraph-spacing-body-xs: 0.75rem; /* 12px */
--cs-paragraph-spacing-body-sm: 0.875rem; /* 14px */
--cs-paragraph-spacing-body-md: 1rem; /* 16px */
--cs-paragraph-spacing-body-lg: 1.125rem; /* 18px */
--cs-paragraph-spacing-heading-xs: 1.25rem; /* 20px */
--cs-paragraph-spacing-heading-sm: 1.5rem; /* 24px */
--cs-paragraph-spacing-heading-md: 2rem; /* 32px */
--cs-paragraph-spacing-heading-lg: 2.5rem; /* 40px */
--cs-paragraph-spacing-heading-xl: 3rem; /* 48px */
--cs-paragraph-spacing-heading-2xl: 3.75rem; /* 60px */
}
/* ==================== */
/* Dark Mode */
/* ==================== */
.dark {
/* Background & Foreground */
--cs-background: var(--cs-zinc-900);
--cs-background-subtle: hsla(0, 0%, 100%, 0.02);
--cs-foreground: hsla(0, 0%, 100%, 0.9);
--cs-foreground-secondary: hsla(0, 0%, 100%, 0.6);
--cs-foreground-muted: hsla(0, 0%, 100%, 0.4);
/* Card & Popover */
--cs-card: var(--cs-black);
--cs-popover: var(--cs-black);
/* Border */
--cs-border: hsla(0, 0%, 100%, 0.1);
--cs-border-hover: hsla(0, 0%, 100%, 0.2);
--cs-border-active: hsla(0, 0%, 100%, 0.3);
/* Ring (Focus) - 保持不变 */
--cs-ring: hsla(84, 81%, 44%, 0.4);
/* UI Element Colors - Dark Mode */
--cs-secondary: hsla(0, 0%, 100%, 0.1); /* Secondary Button Background */
--cs-secondary-hover: hsla(0, 0%, 100%, 0.2);
--cs-secondary-active: hsla(0, 0%, 100%, 0.25);
--cs-muted: hsla(0, 0%, 100%, 0.1); /* Muted/Subtle Background */
--cs-accent: hsla(0, 0%, 100%, 0.1); /* Accent Background */
--cs-ghost-hover: hsla(0, 0%, 100%, 0.1); /* Ghost Button Hover */
--cs-ghost-active: hsla(0, 0%, 100%, 0.15); /* Ghost Button Active */
/* Sidebar */
--cs-sidebar: var(--cs-black);
--cs-sidebar-accent: hsla(0, 0%, 100%, 0.1);
/* ==================== */
/* UI 元素细分颜色 (Dark Mode) */
/* ==================== */
/* Modal / Overlay */
--cs-modal-backdrop: hsla(0, 0%, 0%, 0.06);
--cs-modal-thumb: hsla(0, 0%, 100%, 0.2);
--cs-modal-thumb-hover: hsla(0, 0%, 100%, 0.3);
/* Icon */
--cs-icon-default: var(--cs-foreground-secondary);
--cs-icon-hover: var(--cs-foreground);
/* Input / Select */
--cs-input-background: var(--cs-black);
--cs-input-border: var(--cs-border);
--cs-input-border-hover: var(--cs-border-hover);
--cs-input-border-focus: var(--cs-primary);
/* Primary Button - 保持不变 */
--cs-primary-button-background: var(--cs-primary);
--cs-primary-button-text: var(--cs-white);
--cs-primary-button-background-hover: hsla(84, 81%, 44%, 0.85);
--cs-primary-button-background-active: hsla(84, 81%, 44%, 0.7);
--cs-primary-button-background-2nd: hsla(84, 81%, 44%, 0.1);
--cs-primary-button-background-3rd: hsla(84, 81%, 44%, 0.05);
/* Secondary Button */
--cs-secondary-button-background: var(--cs-secondary);
--cs-secondary-button-text: var(--cs-foreground);
--cs-secondary-button-background-hover: hsla(0, 0%, 100%, 0.2);
--cs-secondary-button-background-active: hsla(0, 0%, 100%, 0.25);
--cs-secondary-button-border: var(--cs-border);
/* Ghost Button */
--cs-ghost-button-background: hsla(0, 0%, 100%, 0);
--cs-ghost-button-text: var(--cs-foreground);
--cs-ghost-button-background-hover: var(--cs-ghost-hover);
--cs-ghost-button-background-active: var(--cs-ghost-active);
}

View File

@@ -1,10 +1,8 @@
import { createServer } from 'node:http'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { agentService } from '../services/agents'
import { windowService } from '../services/WindowService'
import { app } from './app'
import { config } from './config'
@@ -45,13 +43,6 @@ export class ApiServer {
return new Promise((resolve, reject) => {
this.server!.listen(port, host, () => {
logger.info('API server started', { host, port })
// Notify renderer that API server is ready
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.ApiServer_Ready)
}
resolve()
})

View File

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

View File

@@ -21,7 +21,6 @@ import type {
OcrProvider,
PluginError,
Provider,
Shortcut,
SupportedOcrFile
} from '@types'
import checkDiskSpace from 'check-disk-space'
@@ -30,13 +29,11 @@ import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webC
import fontList from 'font-list'
import { agentMessageRepository } from './services/agents/database'
import { PluginService } from './services/agents/plugins/PluginService'
import { apiServerService } from './services/ApiServerService'
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'
@@ -51,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,
@@ -72,7 +69,6 @@ import {
} from './services/SpanCacheService'
import storeSyncService from './services/StoreSyncService'
import VertexAIService from './services/VertexAIService'
import WebSocketService from './services/WebSocketService'
import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
@@ -582,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))
@@ -1022,13 +1008,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}
})
// WebSocket
ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start)
ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop)
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
// Preference handlers
PreferenceService.registerIpcHandler()
}

View File

@@ -2,7 +2,6 @@ import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
import type { ApiClient } from '@types'
import { net } from 'electron'
import { VoyageEmbeddings } from './VoyageEmbeddings'
@@ -44,7 +43,7 @@ export default class EmbeddingsFactory {
apiKey,
dimensions,
batchSize,
configuration: { baseURL, fetch: net.fetch as typeof fetch }
configuration: { baseURL }
})
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,369 +0,0 @@
import { loggerService } from '@logger'
import * as fs from 'fs'
import { networkInterfaces } from 'os'
import * as path from 'path'
import type { Socket } from 'socket.io'
import { Server } from 'socket.io'
import { windowService } from './WindowService'
const logger = loggerService.withContext('WebSocketService')
class WebSocketService {
private io: Server | null = null
private isStarted = false
private port = 7017
private connectedClients = new Set<string>()
private getLocalIpAddress(): string | undefined {
const interfaces = networkInterfaces()
// 按优先级排序的网络接口名称模式
const interfacePriority = [
// macOS: 以太网/Wi-Fi 优先
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
/^(en|eth)[0-9]+$/, // 以太网接口
/^wlan[0-9]+$/, // 无线接口
// Windows: 以太网/Wi-Fi 优先
/^(Ethernet|Wi-Fi|Local Area Connection)/,
/^(Wi-Fi|无线网络连接)/,
// Linux: 以太网/Wi-Fi 优先
/^(eth|enp|wlp|wlan)[0-9]+/,
// 虚拟化接口(低优先级)
/^bridge[0-9]+$/, // Docker bridge
/^veth[0-9]+$/, // Docker veth
/^docker[0-9]+/, // Docker interfaces
/^br-[0-9a-f]+/, // Docker bridge
/^vmnet[0-9]+$/, // VMware
/^vboxnet[0-9]+$/, // VirtualBox
// VPN 隧道接口(低优先级)
/^utun[0-9]+$/, // macOS VPN
/^tun[0-9]+$/, // Linux/Unix VPN
/^tap[0-9]+$/, // TAP interfaces
/^tailscale[0-9]*$/, // Tailscale VPN
/^wg[0-9]+$/ // WireGuard VPN
]
const candidates: Array<{ interface: string; address: string; priority: number }> = []
for (const [name, ifaces] of Object.entries(interfaces)) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
// 计算接口优先级
let priority = 999 // 默认最低优先级
for (let i = 0; i < interfacePriority.length; i++) {
if (interfacePriority[i].test(name)) {
priority = i
break
}
}
candidates.push({
interface: name,
address: iface.address,
priority
})
}
}
}
if (candidates.length === 0) {
logger.warn('无法获取局域网 IP使用默认 IP: 127.0.0.1')
return '127.0.0.1'
}
// 按优先级排序,选择优先级最高的
candidates.sort((a, b) => a.priority - b.priority)
const best = candidates[0]
logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`)
return best.address
}
public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => {
if (this.isStarted && this.io) {
return { success: true, port: this.port }
}
try {
this.io = new Server(this.port, {
cors: {
origin: '*',
methods: ['GET', 'POST']
},
transports: ['websocket', 'polling'],
allowEIO3: true,
pingTimeout: 60000,
pingInterval: 25000
})
this.io.on('connection', (socket: Socket) => {
this.connectedClients.add(socket.id)
const mainWindow = windowService.getMainWindow()
if (!mainWindow) {
logger.error('Main window is null, cannot send connection event')
} else {
mainWindow.webContents.send('websocket-client-connected', {
connected: true,
clientId: socket.id
})
logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`)
}
socket.on('message', (data) => {
logger.info('Received message from mobile:', data)
mainWindow?.webContents.send('websocket-message-received', data)
socket.emit('message_received', { success: true })
})
socket.on('disconnect', () => {
logger.info(`Client disconnected: ${socket.id}`)
this.connectedClients.delete(socket.id)
if (this.connectedClients.size === 0) {
mainWindow?.webContents.send('websocket-client-connected', {
connected: false,
clientId: socket.id
})
}
})
})
// Engine 层面的事件监听
this.io.engine.on('connection_error', (err) => {
logger.error('Engine connection error:', err)
})
this.io.engine.on('connection', (rawSocket) => {
const remoteAddr = rawSocket.request.connection.remoteAddress
logger.info(`[Engine] Raw connection from: ${remoteAddr}`)
logger.info(`[Engine] Transport: ${rawSocket.transport.name}`)
rawSocket.on('packet', (packet: { type: string; data?: any }) => {
logger.info(
`[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`,
packet.data ? { data: packet.data } : {}
)
})
rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => {
logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`)
})
rawSocket.on('close', (reason: string) => {
logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`)
})
rawSocket.on('error', (error: Error) => {
logger.error(`[Engine] Connection error from ${remoteAddr}:`, error)
})
})
// Socket.IO 握手失败监听
this.io.on('connection_error', (err) => {
logger.error('[Socket.IO] Connection error during handshake:', err)
})
this.isStarted = true
logger.info(`WebSocket server started on port ${this.port}`)
return { success: true, port: this.port }
} catch (error) {
logger.error('Failed to start WebSocket server:', error as Error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
public stop = async (): Promise<{ success: boolean }> => {
if (!this.isStarted || !this.io) {
return { success: true }
}
try {
await new Promise<void>((resolve) => {
this.io!.close(() => {
resolve()
})
})
this.io = null
this.isStarted = false
this.connectedClients.clear()
logger.info('WebSocket server stopped')
return { success: true }
} catch (error) {
logger.error('Failed to stop WebSocket server:', error as Error)
return { success: false }
}
}
public getStatus = async (): Promise<{
isRunning: boolean
port?: number
ip?: string
clientConnected: boolean
}> => {
return {
isRunning: this.isStarted,
port: this.isStarted ? this.port : undefined,
ip: this.isStarted ? this.getLocalIpAddress() : undefined,
clientConnected: this.connectedClients.size > 0
}
}
public getAllCandidates = async (): Promise<
Array<{
host: string
interface: string
priority: number
}>
> => {
const interfaces = networkInterfaces()
// 按优先级排序的网络接口名称模式
const interfacePriority = [
// macOS: 以太网/Wi-Fi 优先
/^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi)
/^(en|eth)[0-9]+$/, // 以太网接口
/^wlan[0-9]+$/, // 无线接口
// Windows: 以太网/Wi-Fi 优先
/^(Ethernet|Wi-Fi|Local Area Connection)/,
/^(Wi-Fi|无线网络连接)/,
// Linux: 以太网/Wi-Fi 优先
/^(eth|enp|wlp|wlan)[0-9]+/,
// 虚拟化接口(低优先级)
/^bridge[0-9]+$/, // Docker bridge
/^veth[0-9]+$/, // Docker veth
/^docker[0-9]+/, // Docker interfaces
/^br-[0-9a-f]+/, // Docker bridge
/^vmnet[0-9]+$/, // VMware
/^vboxnet[0-9]+$/, // VirtualBox
// VPN 隧道接口(低优先级)
/^utun[0-9]+$/, // macOS VPN
/^tun[0-9]+$/, // Linux/Unix VPN
/^tap[0-9]+$/, // TAP interfaces
/^tailscale[0-9]*$/, // Tailscale VPN
/^wg[0-9]+$/ // WireGuard VPN
]
const candidates: Array<{ host: string; interface: string; priority: number }> = []
for (const [name, ifaces] of Object.entries(interfaces)) {
for (const iface of ifaces || []) {
if (iface.family === 'IPv4' && !iface.internal) {
// 计算接口优先级
let priority = 999 // 默认最低优先级
for (let i = 0; i < interfacePriority.length; i++) {
if (interfacePriority[i].test(name)) {
priority = i
break
}
}
candidates.push({
host: iface.address,
interface: name,
priority
})
logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`)
}
}
}
// 按优先级排序返回
candidates.sort((a, b) => a.priority - b.priority)
logger.info(
`Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}`
)
return candidates
}
public sendFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string
): Promise<{ success: boolean; error?: string }> => {
if (!this.isStarted || !this.io) {
const errorMsg = 'WebSocket server is not running.'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
if (this.connectedClients.size === 0) {
const errorMsg = 'No client connected.'
logger.error(errorMsg)
return { success: false, error: errorMsg }
}
const mainWindow = windowService.getMainWindow()
return new Promise((resolve, reject) => {
const stats = fs.statSync(filePath)
const totalSize = stats.size
const filename = path.basename(filePath)
const stream = fs.createReadStream(filePath)
let bytesSent = 0
const startTime = Date.now()
logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`)
// 向客户端发送文件开始的信号,包含文件名和总大小
this.io!.emit('zip-file-start', { filename, totalSize })
stream.on('data', (chunk) => {
bytesSent += chunk.length
const progress = (bytesSent / totalSize) * 100
// 向客户端发送文件块
this.io!.emit('zip-file-chunk', chunk)
// 向渲染进程发送进度更新
mainWindow?.webContents.send('file-send-progress', { progress })
// 每10%记录一次进度
if (Math.floor(progress) % 10 === 0) {
const elapsed = (Date.now() - startTime) / 1000
const speed = elapsed > 0 ? bytesSent / elapsed : 0
logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`)
}
})
stream.on('end', () => {
const totalTime = (Date.now() - startTime) / 1000
const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0
logger.info(
`File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)`
)
// 确保发送100%的进度
mainWindow?.webContents.send('file-send-progress', { progress: 100 })
// 向客户端发送文件结束的信号
this.io!.emit('zip-file-end')
resolve({ success: true })
})
stream.on('error', (error) => {
logger.error(`File transfer failed: ${filename}`, error)
reject({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
})
})
})
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
}
export default new WebSocketService()

View File

@@ -1,426 +0,0 @@
import { loggerService } from '@logger'
import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser'
import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types'
import { CachedPluginsDataSchema } from '@types'
import * as fs from 'fs'
import * as path from 'path'
const logger = loggerService.withContext('PluginCacheStore')
interface PluginCacheStoreDeps {
allowedExtensions: string[]
getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills'
getClaudeBasePath: (workdir: string) => string
getClaudePluginDirectory: (workdir: string, type: PluginType) => string
getPluginsBasePath: () => string
}
export class PluginCacheStore {
constructor(private readonly deps: PluginCacheStoreDeps) {}
async listAvailableFilePlugins(type: 'agent' | 'command'): Promise<PluginMetadata[]> {
const basePath = this.deps.getPluginsBasePath()
const directory = path.join(basePath, this.deps.getPluginDirectoryName(type))
try {
await fs.promises.access(directory, fs.constants.R_OK)
} catch (error) {
logger.warn(`Plugin directory not accessible: ${directory}`, {
error: error instanceof Error ? error.message : String(error)
})
return []
}
const plugins: PluginMetadata[] = []
const categories = await fs.promises.readdir(directory, { withFileTypes: true })
for (const categoryEntry of categories) {
if (!categoryEntry.isDirectory()) {
continue
}
const category = categoryEntry.name
const categoryPath = path.join(directory, category)
const files = await fs.promises.readdir(categoryPath, { withFileTypes: true })
for (const file of files) {
if (!file.isFile()) {
continue
}
const ext = path.extname(file.name).toLowerCase()
if (!this.deps.allowedExtensions.includes(ext)) {
continue
}
try {
const filePath = path.join(categoryPath, file.name)
const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name)
const metadata = await parsePluginMetadata(filePath, sourcePath, category, type)
plugins.push(metadata)
} catch (error) {
logger.warn(`Failed to parse plugin: ${file.name}`, {
category,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
return plugins
}
async listAvailableSkills(): Promise<PluginMetadata[]> {
const basePath = this.deps.getPluginsBasePath()
const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill'))
const skills: PluginMetadata[] = []
try {
await fs.promises.access(skillsPath)
} catch {
logger.warn('Skills directory not found', { skillsPath })
return []
}
try {
const skillDirectories = await findAllSkillDirectories(skillsPath, basePath)
logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath })
for (const { folderPath, sourcePath } of skillDirectories) {
try {
const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills')
skills.push(metadata)
} catch (error) {
logger.warn(`Failed to parse skill folder: ${sourcePath}`, {
folderPath,
error: error instanceof Error ? error.message : String(error)
})
}
}
} catch (error) {
logger.error('Failed to scan skill directory', {
skillsPath,
error: error instanceof Error ? error.message : String(error)
})
}
return skills
}
async readSourceContent(sourcePath: string): Promise<string> {
const absolutePath = this.resolveSourcePath(sourcePath)
try {
await fs.promises.access(absolutePath, fs.constants.R_OK)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: sourcePath
} as PluginError
}
try {
return await fs.promises.readFile(absolutePath, 'utf-8')
} catch (error) {
throw {
type: 'READ_FAILED',
path: sourcePath,
reason: error instanceof Error ? error.message : String(error)
} as PluginError
}
}
resolveSourcePath(sourcePath: string): string {
const normalized = path.normalize(sourcePath)
if (normalized.includes('..')) {
throw {
type: 'PATH_TRAVERSAL',
message: 'Path traversal detected',
path: sourcePath
} as PluginError
}
const basePath = this.deps.getPluginsBasePath()
const absolutePath = path.join(basePath, normalized)
const resolvedPath = path.resolve(absolutePath)
if (!resolvedPath.startsWith(path.resolve(basePath))) {
throw {
type: 'PATH_TRAVERSAL',
message: 'Path outside plugins directory',
path: sourcePath
} as PluginError
}
return resolvedPath
}
async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise<void> {
let stats: fs.Stats
try {
stats = await fs.promises.stat(sourceAbsolutePath)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: sourceAbsolutePath
} as PluginError
}
if (!stats.isDirectory()) {
throw {
type: 'INVALID_METADATA',
reason: 'Skill source is not a directory',
path: sourcePath
} as PluginError
}
}
async validatePluginFile(filePath: string, maxFileSize: number): Promise<void> {
let stats: fs.Stats
try {
stats = await fs.promises.stat(filePath)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: filePath
} as PluginError
}
if (stats.size > maxFileSize) {
throw {
type: 'FILE_TOO_LARGE',
size: stats.size,
max: maxFileSize
} as PluginError
}
const ext = path.extname(filePath).toLowerCase()
if (!this.deps.allowedExtensions.includes(ext)) {
throw {
type: 'INVALID_FILE_TYPE',
extension: ext
} as PluginError
}
try {
const basePath = this.deps.getPluginsBasePath()
const relativeSourcePath = path.relative(basePath, filePath)
const segments = relativeSourcePath.split(path.sep)
const rootDir = segments[0]
const agentDir = this.deps.getPluginDirectoryName('agent')
const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command'
const category = path.basename(path.dirname(filePath))
await parsePluginMetadata(filePath, relativeSourcePath, category, type)
} catch (error) {
throw {
type: 'INVALID_METADATA',
reason: 'Failed to parse frontmatter',
path: filePath
} as PluginError
}
}
async listInstalled(workdir: string): Promise<InstalledPlugin[]> {
const claudePath = this.deps.getClaudeBasePath(workdir)
const cacheData = await this.readCacheFile(claudePath)
if (cacheData) {
logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir })
return cacheData.plugins
}
logger.info('Cache read failed, rebuilding from filesystem', { workdir })
return await this.rebuild(workdir)
}
async upsert(workdir: string, plugin: InstalledPlugin): Promise<void> {
const claudePath = this.deps.getClaudeBasePath(workdir)
let cacheData = await this.readCacheFile(claudePath)
let plugins = cacheData?.plugins
if (!plugins) {
plugins = await this.rebuild(workdir)
cacheData = {
version: 1,
lastUpdated: Date.now(),
plugins
}
}
const updatedPlugin: InstalledPlugin = {
...plugin,
metadata: {
...plugin.metadata,
installedAt: plugin.metadata.installedAt ?? Date.now()
}
}
const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type)
if (index >= 0) {
plugins[index] = updatedPlugin
} else {
plugins.push(updatedPlugin)
}
const data: CachedPluginsData = {
version: cacheData?.version ?? 1,
lastUpdated: Date.now(),
plugins
}
await fs.promises.mkdir(claudePath, { recursive: true })
await this.writeCacheFile(claudePath, data)
}
async remove(workdir: string, filename: string, type: PluginType): Promise<void> {
const claudePath = this.deps.getClaudeBasePath(workdir)
let cacheData = await this.readCacheFile(claudePath)
let plugins = cacheData?.plugins
if (!plugins) {
plugins = await this.rebuild(workdir)
cacheData = {
version: 1,
lastUpdated: Date.now(),
plugins
}
}
const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type))
const data: CachedPluginsData = {
version: cacheData?.version ?? 1,
lastUpdated: Date.now(),
plugins: filtered
}
await fs.promises.mkdir(claudePath, { recursive: true })
await this.writeCacheFile(claudePath, data)
}
async rebuild(workdir: string): Promise<InstalledPlugin[]> {
logger.info('Rebuilding plugin cache from filesystem', { workdir })
const claudePath = this.deps.getClaudeBasePath(workdir)
try {
await fs.promises.access(claudePath, fs.constants.R_OK)
} catch {
logger.warn('.claude directory not found, returning empty plugin list', { claudePath })
return []
}
const plugins: InstalledPlugin[] = []
await Promise.all([
this.collectFilePlugins(workdir, 'agent', plugins),
this.collectFilePlugins(workdir, 'command', plugins),
this.collectSkillPlugins(workdir, plugins)
])
try {
const cacheData: CachedPluginsData = {
version: 1,
lastUpdated: Date.now(),
plugins
}
await this.writeCacheFile(claudePath, cacheData)
logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir })
} catch (error) {
logger.error('Failed to write cache file after rebuild', {
error: error instanceof Error ? error.message : String(error)
})
}
return plugins
}
private async collectFilePlugins(
workdir: string,
type: Exclude<PluginType, 'skill'>,
plugins: InstalledPlugin[]
): Promise<void> {
const directory = this.deps.getClaudePluginDirectory(workdir, type)
try {
await fs.promises.access(directory, fs.constants.R_OK)
} catch {
logger.debug(`${type} directory not found or not accessible`, { directory })
return
}
const files = await fs.promises.readdir(directory, { withFileTypes: true })
for (const file of files) {
if (!file.isFile()) {
continue
}
const ext = path.extname(file.name).toLowerCase()
if (!this.deps.allowedExtensions.includes(ext)) {
continue
}
try {
const filePath = path.join(directory, file.name)
const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name)
const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type)
plugins.push({ filename: file.name, type, metadata })
} catch (error) {
logger.warn(`Failed to parse ${type} plugin: ${file.name}`, {
error: error instanceof Error ? error.message : String(error)
})
}
}
}
private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise<void> {
const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill')
const claudePath = this.deps.getClaudeBasePath(workdir)
try {
await fs.promises.access(skillsPath, fs.constants.R_OK)
} catch {
logger.debug('Skills directory not found or not accessible', { skillsPath })
return
}
const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath)
for (const { folderPath, sourcePath } of skillDirectories) {
try {
const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills')
plugins.push({ filename: metadata.filename, type: 'skill', metadata })
} catch (error) {
logger.warn(`Failed to parse skill plugin: ${sourcePath}`, {
error: error instanceof Error ? error.message : String(error)
})
}
}
}
private async readCacheFile(claudePath: string): Promise<CachedPluginsData | null> {
const cachePath = path.join(claudePath, 'plugins.json')
try {
const content = await fs.promises.readFile(cachePath, 'utf-8')
const data = JSON.parse(content)
return CachedPluginsDataSchema.parse(data)
} catch (err) {
logger.warn(`Failed to read cache file at ${cachePath}`, {
error: err instanceof Error ? err.message : String(err)
})
return null
}
}
private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise<void> {
const cachePath = path.join(claudePath, 'plugins.json')
const tempPath = `${cachePath}.tmp`
const content = JSON.stringify(data, null, 2)
await fs.promises.writeFile(tempPath, content, 'utf-8')
await fs.promises.rename(tempPath, cachePath)
}
}

View File

@@ -1,149 +0,0 @@
import { loggerService } from '@logger'
import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations'
import type { PluginError } from '@types'
import * as crypto from 'crypto'
import * as fs from 'fs'
const logger = loggerService.withContext('PluginInstaller')
export class PluginInstaller {
async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise<void> {
const tempPath = `${destPath}.tmp`
let fileCopied = false
try {
await fs.promises.copyFile(sourceAbsolutePath, tempPath)
fileCopied = true
logger.debug('File copied to temp location', { agentId, tempPath })
await fs.promises.rename(tempPath, destPath)
logger.debug('File moved to final location', { agentId, destPath })
} catch (error) {
if (fileCopied) {
await this.safeUnlink(tempPath, 'temp file')
}
throw this.toPluginError('install', error)
}
}
async uninstallFilePlugin(
agentId: string,
filename: string,
type: 'agent' | 'command',
filePath: string
): Promise<void> {
try {
await fs.promises.unlink(filePath)
logger.debug('Plugin file deleted', { agentId, filename, type, filePath })
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw this.toPluginError('uninstall', error)
}
logger.warn('Plugin file already deleted', { agentId, filename, type, filePath })
}
}
async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise<string> {
try {
await fs.promises.access(filePath, fs.constants.W_OK)
} catch {
throw {
type: 'FILE_NOT_FOUND',
path: filePath
} as PluginError
}
try {
await fs.promises.writeFile(filePath, content, 'utf8')
logger.debug('Plugin content written successfully', {
agentId,
filePath,
size: Buffer.byteLength(content, 'utf8')
})
} catch (error) {
throw {
type: 'WRITE_FAILED',
path: filePath,
reason: error instanceof Error ? error.message : String(error)
} as PluginError
}
return crypto.createHash('sha256').update(content).digest('hex')
}
async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise<void> {
const logContext = logger.withContext('installSkill')
let folderCopied = false
const tempPath = `${destPath}.tmp`
try {
try {
await fs.promises.access(destPath)
await deleteDirectoryRecursive(destPath)
logContext.info('Removed existing skill folder', { agentId, destPath })
} catch {
// No existing folder
}
await copyDirectoryRecursive(sourceAbsolutePath, tempPath)
folderCopied = true
logContext.info('Skill folder copied to temp location', { agentId, tempPath })
await fs.promises.rename(tempPath, destPath)
logContext.info('Skill folder moved to final location', { agentId, destPath })
} catch (error) {
if (folderCopied) {
await this.safeRemoveDirectory(tempPath, 'temp folder')
}
throw this.toPluginError('install-skill', error)
}
}
async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise<void> {
const logContext = logger.withContext('uninstallSkill')
try {
await deleteDirectoryRecursive(skillPath)
logContext.info('Skill folder deleted', { agentId, folderName, skillPath })
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw this.toPluginError('uninstall-skill', error)
}
logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath })
}
}
private toPluginError(operation: string, error: unknown): PluginError {
return {
type: 'TRANSACTION_FAILED',
operation,
reason: error instanceof Error ? error.message : String(error)
}
}
private async safeUnlink(targetPath: string, label: string): Promise<void> {
try {
await fs.promises.unlink(targetPath)
logger.debug(`Rolled back ${label}`, { targetPath })
} catch (unlinkError) {
logger.error(`Failed to rollback ${label}`, {
targetPath,
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
})
}
}
private async safeRemoveDirectory(targetPath: string, label: string): Promise<void> {
try {
await deleteDirectoryRecursive(targetPath)
logger.info(`Rolled back ${label}`, { targetPath })
} catch (unlinkError) {
logger.error(`Failed to rollback ${label}`, {
targetPath,
error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError)
})
}
}
}

View File

@@ -1,614 +0,0 @@
import { loggerService } from '@logger'
import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser'
import type {
GetAgentResponse,
InstalledPlugin,
InstallPluginOptions,
ListAvailablePluginsResult,
PluginError,
PluginMetadata,
PluginType,
UninstallPluginOptions
} from '@types'
import { app } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import { AgentService } from '../services/AgentService'
import { PluginCacheStore } from './PluginCacheStore'
import { PluginInstaller } from './PluginInstaller'
const logger = loggerService.withContext('PluginService')
interface PluginServiceConfig {
maxFileSize: number // bytes
cacheTimeout: number // milliseconds
}
/**
* PluginService manages agent and command plugins from resources directory.
*
* Features:
* - Singleton pattern for consistent state management
* - Caching of available plugins for performance
* - Security validation (path traversal, file size, extensions)
* - Transactional install/uninstall operations
* - Integration with AgentService for metadata persistence
*/
export class PluginService {
private static instance: PluginService | null = null
private availablePluginsCache: ListAvailablePluginsResult | null = null
private cacheTimestamp = 0
private config: PluginServiceConfig
private readonly cacheStore: PluginCacheStore
private readonly installer: PluginInstaller
private readonly agentService: AgentService
private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown']
private constructor(config?: Partial<PluginServiceConfig>) {
this.config = {
maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default
cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default
}
this.agentService = AgentService.getInstance()
this.cacheStore = new PluginCacheStore({
allowedExtensions: this.ALLOWED_EXTENSIONS,
getPluginDirectoryName: this.getPluginDirectoryName.bind(this),
getClaudeBasePath: this.getClaudeBasePath.bind(this),
getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this),
getPluginsBasePath: this.getPluginsBasePath.bind(this)
})
this.installer = new PluginInstaller()
logger.info('PluginService initialized', {
maxFileSize: this.config.maxFileSize,
cacheTimeout: this.config.cacheTimeout
})
}
/**
* Get singleton instance
*/
static getInstance(config?: Partial<PluginServiceConfig>): PluginService {
if (!PluginService.instance) {
PluginService.instance = new PluginService(config)
}
return PluginService.instance
}
/**
* List all available plugins from resources directory (with caching)
*/
async listAvailable(): Promise<ListAvailablePluginsResult> {
const now = Date.now()
// Return cached data if still valid
if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) {
logger.debug('Returning cached plugin list', {
cacheAge: now - this.cacheTimestamp
})
return this.availablePluginsCache
}
logger.info('Scanning available plugins')
// Scan all plugin types
const [agents, commands, skills] = await Promise.all([
this.cacheStore.listAvailableFilePlugins('agent'),
this.cacheStore.listAvailableFilePlugins('command'),
this.cacheStore.listAvailableSkills()
])
const result: ListAvailablePluginsResult = {
agents,
commands,
skills, // NEW: include skills
total: agents.length + commands.length + skills.length
}
// Update cache
this.availablePluginsCache = result
this.cacheTimestamp = now
logger.info('Available plugins scanned', {
agentsCount: agents.length,
commandsCount: commands.length,
skillsCount: skills.length,
total: result.total
})
return result
}
/**
* Install plugin with validation and transactional safety
*/
async install(options: InstallPluginOptions): Promise<PluginMetadata> {
logger.info('Installing plugin', options)
const context = await this.prepareInstallContext(options)
if (options.type === 'skill') {
return await this.installSkillPlugin(options, context)
}
return await this.installFilePlugin(options, context)
}
private async prepareInstallContext(options: InstallPluginOptions): Promise<{
agent: GetAgentResponse
workdir: string
sourceAbsolutePath: string
}> {
const agent = await this.getAgentOrThrow(options.agentId)
const workdir = this.getWorkdirOrThrow(agent, options.agentId)
await this.validateWorkdir(agent, workdir)
const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath)
return { agent, workdir, sourceAbsolutePath }
}
private async installSkillPlugin(
options: InstallPluginOptions,
context: {
agent: GetAgentResponse
workdir: string
sourceAbsolutePath: string
}
): Promise<PluginMetadata> {
const { agent, workdir, sourceAbsolutePath } = context
await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath)
const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills')
const sanitizedFolderName = this.sanitizeFolderName(metadata.filename)
await this.ensureClaudeDirectory(workdir, 'skill')
const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName)
metadata.filename = sanitizedFolderName
await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath)
const installedAt = Date.now()
const metadataWithInstall: PluginMetadata = {
...metadata,
filename: sanitizedFolderName,
installedAt,
updatedAt: metadata.updatedAt ?? installedAt,
type: 'skill'
}
const installedPlugin: InstalledPlugin = {
filename: sanitizedFolderName,
type: 'skill',
metadata: metadataWithInstall
}
await this.cacheStore.upsert(workdir, installedPlugin)
this.upsertAgentPlugin(agent, installedPlugin)
logger.info('Skill installed successfully', {
agentId: options.agentId,
sourcePath: options.sourcePath,
folderName: sanitizedFolderName
})
return metadataWithInstall
}
private async installFilePlugin(
options: InstallPluginOptions,
context: {
agent: GetAgentResponse
workdir: string
sourceAbsolutePath: string
}
): Promise<PluginMetadata> {
const { agent, workdir, sourceAbsolutePath } = context
if (options.type === 'skill') {
throw {
type: 'INVALID_FILE_TYPE',
extension: options.type
} as PluginError
}
const filePluginType: 'agent' | 'command' = options.type
await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize)
const category = path.basename(path.dirname(options.sourcePath))
const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType)
const sanitizedFilename = this.sanitizeFilename(metadata.filename)
metadata.filename = sanitizedFilename
await this.ensureClaudeDirectory(workdir, filePluginType)
const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename)
await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath)
const installedAt = Date.now()
const metadataWithInstall: PluginMetadata = {
...metadata,
filename: sanitizedFilename,
installedAt,
updatedAt: metadata.updatedAt ?? installedAt,
type: filePluginType
}
const installedPlugin: InstalledPlugin = {
filename: sanitizedFilename,
type: filePluginType,
metadata: metadataWithInstall
}
await this.cacheStore.upsert(workdir, installedPlugin)
this.upsertAgentPlugin(agent, installedPlugin)
logger.info('Plugin installed successfully', {
agentId: options.agentId,
filename: sanitizedFilename,
type: filePluginType
})
return metadataWithInstall
}
/**
* Uninstall plugin with cleanup
*/
async uninstall(options: UninstallPluginOptions): Promise<void> {
logger.info('Uninstalling plugin', options)
const agent = await this.getAgentOrThrow(options.agentId)
const workdir = this.getWorkdirOrThrow(agent, options.agentId)
await this.validateWorkdir(agent, workdir)
if (options.type === 'skill') {
const sanitizedFolderName = this.sanitizeFolderName(options.filename)
const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName)
await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath)
await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill')
this.removeAgentPlugin(agent, sanitizedFolderName, 'skill')
logger.info('Skill uninstalled successfully', {
agentId: options.agentId,
folderName: sanitizedFolderName
})
return
}
const sanitizedFilename = this.sanitizeFilename(options.filename)
const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename)
await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath)
await this.cacheStore.remove(workdir, sanitizedFilename, options.type)
this.removeAgentPlugin(agent, sanitizedFilename, options.type)
logger.info('Plugin uninstalled successfully', {
agentId: options.agentId,
filename: sanitizedFilename,
type: options.type
})
}
/**
* List installed plugins for an agent (from database + filesystem validation)
*/
async listInstalled(agentId: string): Promise<InstalledPlugin[]> {
logger.debug('Listing installed plugins', { agentId })
const agent = await this.getAgentOrThrow(agentId)
const workdir = agent.accessible_paths?.[0]
if (!workdir) {
logger.warn('Agent has no accessible paths', { agentId })
return []
}
const plugins = await this.listInstalledFromCache(workdir)
logger.debug('Listed installed plugins from cache', {
agentId,
count: plugins.length
})
return plugins
}
/**
* Invalidate plugin cache (for development/testing)
*/
invalidateCache(): void {
this.availablePluginsCache = null
this.cacheTimestamp = 0
logger.info('Plugin cache invalidated')
}
// ============================================================================
// Cache File Management (for installed plugins)
// ============================================================================
/**
* Read cache file from .claude/plugins.json
* Returns null if cache doesn't exist or is invalid
*/
/**
* List installed plugins from cache file
* Falls back to filesystem scan if cache is missing or corrupt
*/
async listInstalledFromCache(workdir: string): Promise<InstalledPlugin[]> {
logger.debug('Listing installed plugins from cache', { workdir })
return await this.cacheStore.listInstalled(workdir)
}
/**
* Read plugin content from source (resources directory)
*/
async readContent(sourcePath: string): Promise<string> {
logger.info('Reading plugin content', { sourcePath })
const content = await this.cacheStore.readSourceContent(sourcePath)
logger.debug('Plugin content read successfully', {
sourcePath,
size: content.length
})
return content
}
/**
* Write plugin content to installed plugin (in agent's .claude directory)
* Note: Only works for file-based plugins (agents/commands), not skills
*/
async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise<void> {
logger.info('Writing plugin content', { agentId, filename, type })
const agent = await this.getAgentOrThrow(agentId)
const workdir = this.getWorkdirOrThrow(agent, agentId)
await this.validateWorkdir(agent, workdir)
// Check if plugin is installed
let installedPlugins = agent.installed_plugins ?? []
if (installedPlugins.length === 0) {
installedPlugins = await this.cacheStore.listInstalled(workdir)
agent.installed_plugins = installedPlugins
}
const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type)
if (!installedPlugin) {
throw {
type: 'PLUGIN_NOT_INSTALLED',
filename,
agentId
} as PluginError
}
if (type === 'skill') {
throw {
type: 'INVALID_FILE_TYPE',
extension: type
} as PluginError
}
const filePluginType = type as 'agent' | 'command'
const filePath = this.getClaudePluginPath(workdir, filePluginType, filename)
const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content)
const updatedMetadata: PluginMetadata = {
...installedPlugin.metadata,
contentHash: newContentHash,
size: Buffer.byteLength(content, 'utf8'),
updatedAt: Date.now(),
filename,
type: filePluginType
}
const updatedPlugin: InstalledPlugin = {
filename,
type: filePluginType,
metadata: updatedMetadata
}
await this.cacheStore.upsert(workdir, updatedPlugin)
this.upsertAgentPlugin(agent, updatedPlugin)
logger.info('Plugin content updated successfully', {
agentId,
filename,
type: filePluginType,
newContentHash
})
}
// ============================================================================
// Private Helper Methods
// ============================================================================
/**
* Resolve plugin type to directory name under .claude
*/
private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' {
if (type === 'agent') {
return 'agents'
}
if (type === 'command') {
return 'commands'
}
return 'skills'
}
/**
* Get the base .claude directory for a workdir
*/
private getClaudeBasePath(workdir: string): string {
return path.join(workdir, '.claude')
}
/**
* Get the directory for a specific plugin type inside .claude
*/
private getClaudePluginDirectory(workdir: string, type: PluginType): string {
return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type))
}
/**
* Get the absolute path for a plugin file/folder inside .claude
*/
private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string {
return path.join(this.getClaudePluginDirectory(workdir, type), filename)
}
/**
* Get absolute path to plugins directory (handles packaged vs dev)
*/
private getPluginsBasePath(): string {
// Use the utility function which handles both dev and production correctly
if (app.isPackaged) {
return path.join(process.resourcesPath, 'claude-code-plugins')
}
return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins')
}
/**
* Validate source path to prevent path traversal attacks
*/
private async getAgentOrThrow(agentId: string): Promise<GetAgentResponse> {
const agent = await this.agentService.getAgent(agentId)
if (!agent) {
throw {
type: 'INVALID_WORKDIR',
agentId,
workdir: '',
message: 'Agent not found'
} as PluginError
}
return agent
}
private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string {
const workdir = agent.accessible_paths?.[0]
if (!workdir) {
throw {
type: 'INVALID_WORKDIR',
agentId,
workdir: '',
message: 'Agent has no accessible paths'
} as PluginError
}
return workdir
}
/**
* Validate workdir against agent's accessible paths
*/
private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise<void> {
// Verify workdir is in agent's accessible_paths
if (!agent.accessible_paths?.includes(workdir)) {
throw {
type: 'INVALID_WORKDIR',
workdir,
agentId: agent.id,
message: 'Workdir not in agent accessible paths'
} as PluginError
}
// Verify workdir exists and is accessible
try {
await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK)
} catch (error) {
throw {
type: 'WORKDIR_NOT_FOUND',
workdir,
message: 'Workdir does not exist or is not accessible'
} as PluginError
}
}
private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void {
const existing = agent.installed_plugins ?? []
const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type))
agent.installed_plugins = [...filtered, plugin]
}
private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void {
if (!agent.installed_plugins) {
agent.installed_plugins = []
return
}
agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type))
}
/**
* Sanitize filename to remove unsafe characters (for agents/commands)
*/
private sanitizeFilename(filename: string): string {
// Remove path separators
let sanitized = filename.replace(/[/\\]/g, '_')
// Remove null bytes using String method to avoid control-regex lint error
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
// Limit to safe characters (alphanumeric, dash, underscore, dot)
sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_')
// Ensure .md extension
if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) {
sanitized += '.md'
}
return sanitized
}
/**
* Sanitize folder name for skills (different rules than file names)
* NO dots allowed to avoid confusion with file extensions
*/
private sanitizeFolderName(folderName: string): string {
// Remove path separators
let sanitized = folderName.replace(/[/\\]/g, '_')
// Remove null bytes using String method to avoid control-regex lint error
sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '')
// Limit to safe characters (alphanumeric, dash, underscore)
// NOTE: No dots allowed to avoid confusion with file extensions
sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_')
// Validate no extension was provided
if (folderName.includes('.')) {
logger.warn('Skill folder name contained dots, sanitized', {
original: folderName,
sanitized
})
}
return sanitized
}
/**
* Ensure .claude subdirectory exists for the given plugin type
*/
private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise<void> {
const typeDir = this.getClaudePluginDirectory(workdir, type)
try {
await fs.promises.mkdir(typeDir, { recursive: true })
logger.debug('Ensured directory exists', { typeDir })
} catch (error) {
logger.error('Failed to create directory', {
typeDir,
error: error instanceof Error ? error.message : String(error)
})
throw {
type: 'PERMISSION_DENIED',
path: typeDir
} as PluginError
}
}
}
export const pluginService = PluginService.getInstance()

View File

@@ -1,7 +1,5 @@
import path from 'node:path'
import { loggerService } from '@logger'
import { pluginService } from '@main/services/agents/plugins/PluginService'
import { getDataPath } from '@main/utils'
import type {
AgentEntity,
@@ -19,8 +17,6 @@ import { BaseService } from '../BaseService'
import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema'
import type { AgentModelField } from '../errors'
const logger = loggerService.withContext('AgentService')
export class AgentService extends BaseService {
private static instance: AgentService | null = null
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
@@ -96,24 +92,6 @@ export class AgentService extends BaseService {
const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
agent.tools = await this.listMcpTools(agent.type, agent.mcps)
// Load installed_plugins from cache file instead of database
const workdir = agent.accessible_paths?.[0]
if (workdir) {
try {
agent.installed_plugins = await pluginService.listInstalledFromCache(workdir)
} catch (error) {
// Log error but don't fail the request
logger.warn(`Failed to load installed plugins for agent ${id}`, {
workdir,
error: error instanceof Error ? error.message : String(error)
})
agent.installed_plugins = []
}
} else {
agent.installed_plugins = []
}
return agent
}

View File

@@ -106,10 +106,7 @@ class ClaudeCodeService implements AgentServiceInterface {
ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey,
ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost,
ANTHROPIC_MODEL: modelInfo.modelId,
ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId,
ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId,
// TODO: support set small model in UI
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId,
ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId,
ELECTRON_RUN_AS_NODE: '1',
ELECTRON_NO_ATTACH_CONSOLE: '1'
}

View File

@@ -73,15 +73,6 @@ const emptyUsage: LanguageModelUsage = {
*/
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
/**
* Filters out command-* tags from text content to prevent internal command
* messages from appearing in the user-facing UI.
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
*/
const filterCommandTags = (text: string): string => {
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
}
/**
* Extracts provider metadata from the raw Claude message so we can surface it
* on every emitted stream part for observability and debugging purposes.
@@ -279,17 +270,12 @@ function handleUserMessage(
const chunks: AgentStreamPart[] = []
const providerMetadata = sdkMessageToProviderMetadata(message)
const content = message.message.content
const isSynthetic = message.isSynthetic ?? false
if (typeof content === 'string') {
if (!content) {
return chunks
}
const filteredContent = filterCommandTags(content)
if (!filteredContent) {
return chunks
}
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
type: 'text-start',
@@ -299,7 +285,7 @@ function handleUserMessage(
chunks.push({
type: 'text-delta',
id,
text: filteredContent,
text: content,
providerMetadata
})
chunks.push({
@@ -337,30 +323,24 @@ function handleUserMessage(
providerExecuted: true
})
}
} else if (block.type === 'text' && !isSynthetic) {
const rawText = (block as { text: string }).text
const filteredText = filterCommandTags(rawText)
// Only push text chunks if there's content after filtering
if (filteredText) {
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
type: 'text-start',
id,
providerMetadata
})
chunks.push({
type: 'text-delta',
id,
text: filteredText,
providerMetadata
})
chunks.push({
type: 'text-end',
id,
providerMetadata
})
}
} else if (block.type === 'text') {
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
type: 'text-start',
id,
providerMetadata
})
chunks.push({
type: 'text-delta',
id,
text: (block as { text: string }).text,
providerMetadata
})
chunks.push({
type: 'text-end',
id,
providerMetadata
})
} else {
logger.warn('Unhandled user content block', { type: (block as any).type })
}

View File

@@ -9,20 +9,13 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl')
function installMCPServer(server: MCPServer) {
const mainWindow = windowService.getMainWindow()
const now = Date.now()
const payload: MCPServer = {
...server,
id: server.id ?? nanoid(),
installSource: 'protocol',
isTrusted: false,
isActive: false,
trustedAt: undefined,
installedAt: server.installedAt ?? now
if (!server.id) {
server.id = nanoid()
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload)
mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server)
}
}

View File

@@ -567,16 +567,7 @@ const api = {
getStatus: (): Promise<GetApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus),
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop),
onReady: (callback: () => void): (() => void) => {
const listener = () => {
callback()
}
ipcRenderer.on(IpcChannel.ApiServer_Ready, listener)
return () => {
ipcRenderer.removeListener(IpcChannel.ApiServer_Ready, listener)
}
}
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
},
claudeCodePlugin: {
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
@@ -592,13 +583,6 @@ const api = {
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
},
webSocket: {
start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start),
stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop),
status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status),
sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath),
getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates)
}
}

View File

@@ -10,12 +10,12 @@ import type { EndpointType, Model, Provider } from '@renderer/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@renderer/config/models', () => ({
DEFAULT_MODEL_MAP: {
assistant: { id: 'gpt-4', name: 'GPT-4' },
quick: { id: 'gpt-4', name: 'GPT-4' },
translate: { id: 'gpt-4', name: 'GPT-4' }
},
SYSTEM_MODELS: {
defaultModel: [
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-4', name: 'GPT-4' }
],
zhipu: [],
silicon: [],
openai: [],

View File

@@ -1,6 +1,7 @@
@import './color.css';
@import './font.css';
@import './markdown.css';
@import './ant.css';
@import './scrollbar.css';
@import './container.css';
@import './animation.css';

View File

@@ -1,12 +1,10 @@
@import 'tailwindcss';
@import 'tailwindcss' source('../../../../renderer');
@import 'tw-animate-css';
@import '../../../../../packages/ui/src/styles/theme.css';
/* TODO heroui 迁移完成后即可删除 */
/* heroui */
/* @plugin '../../hero.ts'; */
@source '../../../../../packages/ui/src/components/**/*.{js,ts,jsx,tsx}';
@plugin '../../hero.ts';
@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@source '../../../../../packages/ui/src/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));
@@ -23,24 +21,117 @@
4. Put the new custom utility class into the utilities layer.
*/
/* 应用特定的原始变量 */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--icon: #00000099;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--icon: #ffffff99;
}
/* 应用特定的变量和动画(不与 UI 库冲突) */
@theme inline {
/* Icon 颜色 - 应用特定变量 */
--color-icon: var(--icon);
/* 跑马灯动画 - 应用特定 */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--color-icon: var(--icon);
@keyframes marquee {
from {
transform: translateX(0);

View File

@@ -20,7 +20,6 @@ export interface EditableNumberProps {
suffix?: string
prefix?: string
align?: 'start' | 'center' | 'end'
formatter?: (value: number | null) => string | number
}
const EditableNumber: FC<EditableNumberProps> = ({
@@ -37,8 +36,7 @@ const EditableNumber: FC<EditableNumberProps> = ({
style,
className,
size = 'middle',
align = 'end',
formatter
align = 'end'
}) => {
const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value)
@@ -92,7 +90,7 @@ const EditableNumber: FC<EditableNumberProps> = ({
changeOnBlur={changeOnBlur}
/>
<DisplayText style={style} className={className} $align={align} $isEditing={isEditing}>
{formatter ? formatter(value ?? null) : (value ?? placeholder)}
{value ?? placeholder}
</DisplayText>
</Container>
)

View File

@@ -1,4 +1,4 @@
import { Button } from '@cherrystudio/ui'
import { Button } from '@heroui/button'
import { formatErrorMessage } from '@renderer/utils/error'
import { Alert, Space } from 'antd'
import type { ComponentType, ReactNode } from 'react'

View File

@@ -1,119 +0,0 @@
import { cn } from '@heroui/react'
import { TopView } from '@renderer/components/TopView'
import { Modal } from 'antd'
import { Bot, MessageSquare } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
type OptionType = 'assistant' | 'agent'
interface ShowParams {
onSelect: (type: OptionType) => void
}
interface Props extends ShowParams {
resolve: (data: { type?: OptionType }) => void
}
const PopupContainer: React.FC<Props> = ({ onSelect, resolve }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
const [hoveredOption, setHoveredOption] = useState<OptionType | null>(null)
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const handleSelect = (type: OptionType) => {
setOpen(false)
onSelect(type)
resolve({ type })
}
AddAssistantOrAgentPopup.hide = onCancel
return (
<Modal
title={t('chat.add.option.title')}
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
footer={null}
width={560}>
<div className="grid grid-cols-2 gap-4 py-4">
{/* Assistant Option */}
<button
type="button"
onClick={() => handleSelect('assistant')}
className="group flex flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
onMouseEnter={() => setHoveredOption('assistant')}
onMouseLeave={() => setHoveredOption(null)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors">
<MessageSquare
size={24}
className={cn(
'transition-colors',
hoveredOption === 'assistant' ? 'text-[var(--color-primary)]' : 'text-[var(--color-icon-white)]'
)}
/>
</div>
<div className="text-center">
<h3 className="mb-1 font-semibold text-[var(--color-text-1)] text-base">{t('chat.add.assistant.title')}</h3>
<p className="text-[var(--color-text-2)] text-sm">{t('chat.add.assistant.description')}</p>
</div>
</button>
{/* Agent Option */}
<button
onClick={() => handleSelect('agent')}
type="button"
className="group flex flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
onMouseEnter={() => setHoveredOption('agent')}
onMouseLeave={() => setHoveredOption(null)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors">
<Bot
size={24}
className={cn(
'transition-colors',
hoveredOption === 'agent' ? 'text-[var(--color-primary)]' : 'text-[var(--color-icon-white)]'
)}
/>
</div>
<div className="text-center">
<h3 className="mb-1 font-semibold text-[var(--color-text-1)] text-base">{t('agent.add.title')}</h3>
<p className="text-[var(--color-text-2)] text-sm">{t('agent.add.description')}</p>
</div>
</button>
</div>
</Modal>
)
}
const TopViewKey = 'AddAssistantOrAgentPopup'
export default class AddAssistantOrAgentPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<{ type?: OptionType }>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -1,591 +0,0 @@
import { Button } from '@heroui/button'
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
import { Progress } from '@heroui/progress'
import { Spinner } from '@heroui/spinner'
import { loggerService } from '@logger'
import { SettingHelpText, SettingRow } from '@renderer/pages/settings'
import { QRCodeSVG } from 'qrcode.react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TopView } from '../TopView'
const logger = loggerService.withContext('ExportToPhoneLanPopup')
interface Props {
resolve: (data: any) => void
}
type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error'
type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error'
const LoadingQRCode: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<Spinner />
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.generating_qr')}
</span>
</div>
)
}
const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<QRCodeSVG
marginSize={2}
value={qrCodeValue}
level="Q"
size={160}
imageSettings={{
src: '/src/assets/images/logo.png',
width: 40,
height: 40,
excavate: true
}}
/>
<span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.scan_qr')}
</span>
</div>
)
}
const ConnectingAnimation: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '160px',
height: '160px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--color-status-warning)',
borderRadius: '12px',
backgroundColor: 'var(--color-status-warning)'
}}>
<Spinner size="lg" color="warning" />
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
{t('settings.data.export_to_phone.lan.status.connecting')}
</span>
</div>
</div>
)
}
const ConnectedDisplay: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '160px',
height: '160px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed var(--color-status-success)',
borderRadius: '12px',
backgroundColor: 'var(--color-status-success)'
}}>
<span style={{ fontSize: '48px' }}>📱</span>
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '8px' }}>
{t('settings.data.export_to_phone.lan.connected')}
</span>
</div>
</div>
)
}
const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => {
const { t } = useTranslation()
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
padding: '20px',
border: `1px solid var(--color-error)`,
borderRadius: '8px',
backgroundColor: 'var(--color-error)'
}}>
<span style={{ fontSize: '48px' }}></span>
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}>
{t('settings.data.export_to_phone.lan.connection_failed')}
</span>
{error && <span style={{ fontSize: '12px', color: 'var(--color-text-2)' }}>{error}</span>}
</div>
)
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [isOpen, setIsOpen] = useState(true)
const [connectionPhase, setConnectionPhase] = useState<ConnectionPhase>('initializing')
const [transferPhase, setTransferPhase] = useState<TransferPhase>('idle')
const [qrCodeValue, setQrCodeValue] = useState('')
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
const { t } = useTranslation()
// 派生状态
const isConnected = connectionPhase === 'connected'
const canSend = isConnected && selectedFolderPath && transferPhase === 'idle'
const isSending = transferPhase === 'preparing' || transferPhase === 'sending'
// 状态文本映射
const connectionStatusText = useMemo(() => {
const statusMap = {
initializing: t('settings.data.export_to_phone.lan.status.initializing'),
waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'),
connecting: t('settings.data.export_to_phone.lan.status.connecting'),
connected: t('settings.data.export_to_phone.lan.status.connected'),
disconnected: t('settings.data.export_to_phone.lan.status.disconnected'),
error: t('settings.data.export_to_phone.lan.status.error')
}
return statusMap[connectionPhase]
}, [connectionPhase, t])
const transferStatusText = useMemo(() => {
const statusMap = {
idle: '',
preparing: t('settings.data.export_to_phone.lan.status.preparing'),
sending: t('settings.data.export_to_phone.lan.status.sending'),
completed: t('settings.data.export_to_phone.lan.status.completed'),
error: t('settings.data.export_to_phone.lan.status.error')
}
return statusMap[transferPhase]
}, [transferPhase, t])
// 状态样式映射
const connectionStatusStyles = useMemo(() => {
const styleMap = {
initializing: {
bg: 'var(--color-background-mute)',
border: 'var(--color-border-mute)'
},
waiting_qr_scan: {
bg: 'var(--color-primary-mute)',
border: 'var(--color-primary-soft)'
},
connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' },
connected: {
bg: 'var(--color-status-success)',
border: 'var(--color-status-success)'
},
disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' },
error: { bg: 'var(--color-error)', border: 'var(--color-error)' }
}
return styleMap[connectionPhase]
}, [connectionPhase])
const initWebSocket = useCallback(async () => {
try {
setConnectionPhase('initializing')
await window.api.webSocket.start()
const { port, ip } = await window.api.webSocket.status()
if (ip && port) {
const candidates = await window.api.webSocket.getAllCandidates()
const connectionInfo = {
type: 'cherry-studio-app',
candidates,
selectedHost: ip,
port,
timestamp: Date.now()
}
setQrCodeValue(JSON.stringify(connectionInfo))
setConnectionPhase('waiting_qr_scan')
logger.info(`QR code generated: ${ip}:${port} with ${candidates.length} IP candidates`)
} else {
setError(t('settings.data.export_to_phone.lan.error.no_ip'))
setConnectionPhase('error')
}
} catch (error) {
setError(
`${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}`
)
setConnectionPhase('error')
logger.error('Failed to initialize WebSocket:', error as Error)
}
}, [t])
const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => {
logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`)
if (data.connected) {
setConnectionPhase('connected')
setError(null)
} else {
setConnectionPhase('disconnected')
}
}, [])
const handleMessageReceived = useCallback((_event: any, data: any) => {
logger.info(`Received message from mobile: ${JSON.stringify(data)}`)
}, [])
const handleSendProgress = useCallback(
(_event: any, data: { progress: number }) => {
const progress = data.progress
setSendProgress(progress)
if (transferPhase === 'preparing' && progress > 0) {
setTransferPhase('sending')
}
if (progress >= 100) {
setTransferPhase('completed')
// 启动 3 秒倒计时自动关闭
setAutoCloseCountdown(3)
}
},
[transferPhase]
)
const handleSelectZip = useCallback(async () => {
const result = await window.api.file.select()
if (result) {
setSelectedFolderPath(result[0].path)
}
}, [])
const handleSendZip = useCallback(async () => {
if (!selectedFolderPath) {
setError(t('settings.data.export_to_phone.lan.error.no_file'))
return
}
setTransferPhase('preparing')
setError(null)
setSendProgress(0)
try {
logger.info(`Starting file transfer: ${selectedFolderPath}`)
await window.api.webSocket.sendFile(selectedFolderPath)
} catch (error) {
setError(
`${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}`
)
setTransferPhase('error')
logger.error('Failed to send file:', error as Error)
}
}, [selectedFolderPath, t])
// 尝试关闭弹窗 - 如果正在传输则显示确认
const handleCancel = useCallback(() => {
if (isSending) {
setShowCloseConfirm(true)
} else {
setIsOpen(false)
}
}, [isSending])
// 确认强制关闭
const handleForceClose = useCallback(() => {
logger.info('Force closing popup during transfer')
setIsOpen(false)
}, [])
// 取消关闭确认
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false)
}, [])
// 清理并关闭
const handleClose = useCallback(async () => {
try {
// 主动断开 WebSocket 连接
if (isConnected || connectionPhase !== 'disconnected') {
logger.info('Closing popup, stopping WebSocket')
await window.api.webSocket.stop()
}
} catch (error) {
logger.error('Failed to stop WebSocket on close:', error as Error)
}
resolve({})
}, [resolve, isConnected, connectionPhase])
useEffect(() => {
initWebSocket()
const removeClientConnectedListener = window.electron.ipcRenderer.on(
'websocket-client-connected',
handleClientConnected
)
const removeMessageReceivedListener = window.electron.ipcRenderer.on(
'websocket-message-received',
handleMessageReceived
)
const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress)
return () => {
removeClientConnectedListener()
removeMessageReceivedListener()
removeSendProgressListener()
window.api.webSocket.stop()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 自动关闭倒计时
useEffect(() => {
if (autoCloseCountdown === null) return
if (autoCloseCountdown <= 0) {
logger.debug('Auto-closing popup after transfer completion')
setIsOpen(false)
return
}
const timer = setTimeout(() => {
setAutoCloseCountdown(autoCloseCountdown - 1)
}, 1000)
return () => clearTimeout(timer)
}, [autoCloseCountdown])
// 状态指示器组件
const StatusIndicator = useCallback(
() => (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '8px',
backgroundColor: connectionStatusStyles.bg,
border: `1px solid ${connectionStatusStyles.border}`
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
</div>
),
[connectionStatusStyles, connectionStatusText]
)
// 二维码显示组件 - 使用显式条件渲染以避免类型不匹配
const QRCodeDisplay = useCallback(() => {
switch (connectionPhase) {
case 'waiting_qr_scan':
case 'disconnected':
return <ScanQRCode qrCodeValue={qrCodeValue} />
case 'initializing':
return <LoadingQRCode />
case 'connecting':
return <ConnectingAnimation />
case 'connected':
return <ConnectedDisplay />
case 'error':
return <ErrorQRCode error={error} />
default:
return null
}
}, [connectionPhase, qrCodeValue, error])
// 传输进度组件
const TransferProgress = useCallback(() => {
if (!isSending && transferPhase !== 'completed') return null
return (
<div style={{ paddingTop: '8px' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
border: `1px solid var(--color-border)`,
borderRadius: '8px',
backgroundColor: 'var(--color-background-mute)'
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '14px',
fontWeight: '500'
}}>
<span style={{ color: 'var(--color-text)' }}>
{t('settings.data.export_to_phone.lan.transfer_progress')}
</span>
<span
style={{ color: transferPhase === 'completed' ? 'var(--color-status-success)' : 'var(--color-primary)' }}>
{transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`}
</span>
</div>
<Progress
value={Math.round(sendProgress)}
size="md"
color={transferPhase === 'completed' ? 'success' : 'primary'}
showValueLabel={false}
aria-label="Send progress"
/>
</div>
</div>
)
}, [isSending, transferPhase, sendProgress, t])
const AutoCloseCountdown = useCallback(() => {
if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null
return (
<div
style={{
fontSize: '12px',
color: 'var(--color-text-2)',
textAlign: 'center',
paddingTop: '4px'
}}>
{t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })}
</div>
)
}, [transferPhase, autoCloseCountdown, t])
// 错误显示组件
const ErrorDisplay = useCallback(() => {
if (!error || transferPhase !== 'error') return null
return (
<div
style={{
padding: '12px',
border: `1px solid var(--color-error)`,
borderRadius: '8px',
backgroundColor: 'var(--color-error)',
textAlign: 'center'
}}>
<span style={{ fontSize: '14px', color: 'var(--color-text)' }}> {error}</span>
</div>
)
}, [error, transferPhase])
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
handleCancel()
}
}}
isDismissable={false}
isKeyboardDismissDisabled={false}
placement="center"
onClose={handleClose}>
<ModalContent>
{() => (
<>
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
<ModalBody>
<SettingRow>
<StatusIndicator />
</SettingRow>
<SettingRow>
<div>{t('settings.data.export_to_phone.lan.content')}</div>
</SettingRow>
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
<QRCodeDisplay />
</SettingRow>
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')}
</Button>
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button>
</div>
</SettingRow>
<SettingHelpText
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'center'
}}>
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
</SettingHelpText>
<TransferProgress />
<AutoCloseCountdown />
<ErrorDisplay />
</ModalBody>
{showCloseConfirm && (
<ModalFooter>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '12px',
padding: '8px',
borderRadius: '8px',
backgroundColor: 'var(--color-status-warning)',
border: '1px solid var(--color-status-warning)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '20px' }}></span>
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
{t('settings.data.export_to_phone.lan.confirm_close_title')}
</span>
</div>
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
{t('settings.data.export_to_phone.lan.confirm_close_message')}
</span>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
{t('common.cancel')}
</Button>
<Button size="sm" color="danger" onPress={handleForceClose}>
{t('settings.data.export_to_phone.lan.force_close')}
</Button>
</div>
</div>
</ModalFooter>
)}
</>
)}
</ModalContent>
</Modal>
)
}
const TopViewKey = 'ExportToPhoneLanPopup'
export default class ExportToPhoneLanPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@@ -68,7 +68,6 @@ type Props = {
agent?: AgentWithTools
isOpen: boolean
onClose: () => void
afterSubmit?: (a: AgentEntity) => void
}
/**
@@ -80,7 +79,7 @@ type Props = {
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose, afterSubmit }) => {
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
@@ -303,13 +302,8 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
configuration: form.configuration ? { ...form.configuration } : undefined
} satisfies UpdateAgentForm
const result = await updateAgent(updatePayload)
if (result) {
logger.debug('Updated agent', result)
afterSubmit?.(result)
} else {
logger.error('Update failed.')
}
updateAgent(updatePayload)
logger.debug('Updated agent', updatePayload)
} else {
const newAgent = {
type: form.type,
@@ -322,13 +316,12 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
configuration: form.configuration ? { ...form.configuration } : undefined
} satisfies AddAgentForm
const result = await addAgent(newAgent)
if (!result.success) {
loadingRef.current = false
throw result.error
}
afterSubmit?.(result.data)
}
loadingRef.current = false
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
@@ -337,17 +330,16 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
[
form.type,
form.model,
form.accessible_paths,
form.name,
form.description,
form.instructions,
form.accessible_paths,
form.allowed_tools,
form.configuration,
agent,
onClose,
t,
updateAgent,
afterSubmit,
addAgent
]
)

View File

@@ -3,7 +3,7 @@ import { Box } from '@cherrystudio/ui'
import { getToastUtilities } from '@cherrystudio/ui'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { 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()

View File

@@ -14,13 +14,17 @@ export const qwen38bModel: Model = {
group: 'Qwen'
}
export const DEFAULT_MODEL_MAP = {
assistant: glm45FlashModel,
quick: qwen38bModel,
translate: glm45FlashModel
} as const
export const SYSTEM_MODELS: Record<SystemProviderId, Model[]> = {
export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> = {
defaultModel: [
// Default assistant model
glm45FlashModel,
// Default topic naming model
qwen38bModel,
// Default translation model
glm45FlashModel,
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
vertexai: [],
'302ai': [

View File

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

View File

@@ -0,0 +1,4 @@
export type UpdateAgentBaseOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}

View File

@@ -10,10 +10,6 @@ export const useAgent = (id: string | null) => {
const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null
const { apiServerConfig, apiServerRunning } = useApiServer()
// Disable SWR fetching when server is not running by setting key to null
const swrKey = apiServerRunning && id ? key : null
const fetcher = useCallback(async () => {
if (!id) {
throw new Error(t('agent.get.error.null_id'))
@@ -21,10 +17,13 @@ export const useAgent = (id: string | null) => {
if (!apiServerConfig.enabled) {
throw new Error(t('apiServer.messages.notEnabled'))
}
if (!apiServerRunning) {
throw new Error(t('agent.server.error.not_running'))
}
const result = await client.getAgent(id)
return result
}, [apiServerConfig.enabled, client, id, t])
const { data, error, isLoading } = useSWR(swrKey, fetcher)
}, [apiServerConfig.enabled, apiServerRunning, client, id, t])
const { data, error, isLoading } = useSWR(key, id ? fetcher : null)
return {
agent: data,

View File

@@ -25,10 +25,6 @@ export const useAgents = () => {
const client = useAgentClient()
const key = client.agentPaths.base
const { apiServerConfig, apiServerRunning } = useApiServer()
// Disable SWR fetching when server is not running by setting key to null
const swrKey = apiServerRunning ? key : null
const fetcher = useCallback(async () => {
// API server will start on startup if enabled OR there are agents
if (!apiServerConfig.enabled && !apiServerRunning) {
@@ -41,7 +37,7 @@ export const useAgents = () => {
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data
}, [apiServerConfig.enabled, apiServerRunning, client, t])
const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher)
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const { chat } = useRuntime()
const { activeAgentId } = chat
const dispatch = useAppDispatch()

View File

@@ -1,10 +1,10 @@
import type { AgentEntity, ListAgentsResponse, UpdateAgentForm } from '@renderer/types'
import type { UpdateAgentBaseOptions, UpdateAgentFunction } from '@renderer/types/agent'
import type { ListAgentsResponse, UpdateAgentForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import type { UpdateAgentBaseOptions } from './types'
import { useAgentClient } from './useAgentClient'
export const useUpdateAgent = () => {
@@ -12,8 +12,8 @@ export const useUpdateAgent = () => {
const client = useAgentClient()
const listKey = client.agentPaths.base
const updateAgent: UpdateAgentFunction = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions): Promise<AgentEntity | undefined> => {
const updateAgent = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentBaseOptions) => {
try {
const itemKey = client.agentPaths.withId(form.id)
// may change to optimistic update
@@ -23,10 +23,8 @@ export const useUpdateAgent = () => {
if (options?.showSuccessToast ?? true) {
window.toast.success(t('common.update_success'))
}
return result
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
return undefined
}
},
[client, listKey, t]

View File

@@ -1,18 +1,18 @@
import type { AgentSessionEntity, ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import type { UpdateAgentBaseOptions, UpdateAgentSessionFunction } from '@renderer/types/agent'
import type { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import type { UpdateAgentBaseOptions } from './types'
import { useAgentClient } from './useAgentClient'
export const useUpdateSession = (agentId: string | null) => {
const { t } = useTranslation()
const client = useAgentClient()
const updateSession: UpdateAgentSessionFunction = useCallback(
async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions): Promise<AgentSessionEntity | undefined> => {
const updateSession = useCallback(
async (form: UpdateSessionForm, options?: UpdateAgentBaseOptions) => {
if (!agentId) return
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
@@ -29,10 +29,8 @@ export const useUpdateSession = (agentId: string | null) => {
if (options?.showSuccessToast ?? true) {
window.toast.success(t('common.update_success'))
}
return result
} catch (error) {
window.toast.error({ title: t('agent.session.update.error.failed'), description: getErrorMessage(error) })
return undefined
}
},
[agentId, client, t]

View File

@@ -14,8 +14,8 @@ export const useApiServer = () => {
const apiServerConfig = useAppSelector((state) => state.settings.apiServer)
const dispatch = useAppDispatch()
// Initial state - no longer optimistic, wait for actual status
const [apiServerRunning, setApiServerRunning] = useState(false)
// Optimistic initial state.
const [apiServerRunning, setApiServerRunning] = useState(apiServerConfig.enabled)
const [apiServerLoading, setApiServerLoading] = useState(true)
const setApiServerEnabled = useCallback(
@@ -99,16 +99,6 @@ export const useApiServer = () => {
checkApiServerStatus()
}, [checkApiServerStatus])
// Listen for API server ready event
useEffect(() => {
const cleanup = window.api.apiServer.onReady(() => {
logger.info('API server ready event received, checking status')
checkApiServerStatus()
})
return cleanup
}, [checkApiServerStatus])
return {
apiServerConfig,
apiServerRunning,

View File

@@ -80,34 +80,17 @@ export function useAppInit() {
useEffect(() => {
savedAvatar?.value && cacheService.set('avatar', savedAvatar.value)
}, [savedAvatar])
}, [savedAvatar, dispatch])
useEffect(() => {
const checkForUpdates = async () => {
const { isPackaged } = await window.api.getAppInfo()
if (!isPackaged || !autoCheckUpdate) {
return
}
const { updateInfo } = await window.api.checkForUpdate()
updateAppUpdateState({ info: updateInfo })
}
// Initial check with delay
runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo()
if (isPackaged && autoCheckUpdate) {
await delay(2)
await checkForUpdates()
const { updateInfo } = await window.api.checkForUpdate()
updateAppUpdateState({ info: updateInfo })
}
})
// Set up 4-hour interval check
const FOUR_HOURS = 4 * 60 * 60 * 1000
const intervalId = setInterval(checkForUpdates, FOUR_HOURS)
return () => clearInterval(intervalId)
}, [autoCheckUpdate, updateAppUpdateState])
useEffect(() => {
@@ -152,7 +135,7 @@ export function useAppInit() {
cacheService.set('filesPath', info.filesPath)
cacheService.set('resourcesPath', info.resourcesPath)
})
}, [])
}, [dispatch])
useEffect(() => {
KnowledgeQueue.checkAllBases()

View File

@@ -1,6 +1,5 @@
import { loggerService } from '@logger'
import {
DEFAULT_MODEL_MAP,
getThinkModelType,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
@@ -25,11 +24,7 @@ import {
updateTopic,
updateTopics
} from '@renderer/store/assistants'
import {
setDefaultModel as setDefaultModelAction,
setQuickModel as setQuickModelAction,
setTranslateModel as setTranslateModelAction
} from '@renderer/store/llm'
import { setDefaultModel, setQuickModel, setTranslateModel } from '@renderer/store/llm'
import type { Assistant, AssistantSettings, Model, ThinkingOption, Topic } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -203,31 +198,12 @@ export function useDefaultModel() {
const { defaultModel, quickModel, translateModel } = useAppSelector((state) => state.llm)
const dispatch = useAppDispatch()
const setDefaultModel = useCallback((model: Model) => dispatch(setDefaultModelAction({ model })), [dispatch])
const setQuickModel = useCallback((model: Model) => dispatch(setQuickModelAction({ model })), [dispatch])
const setTranslateModel = useCallback((model: Model) => dispatch(setTranslateModelAction({ model })), [dispatch])
const resetDefaultAssistantModel = useCallback(() => {
setDefaultModel(DEFAULT_MODEL_MAP.assistant)
}, [setDefaultModel])
const resetTranslateModel = useCallback(() => {
setTranslateModel(DEFAULT_MODEL_MAP.translate)
}, [setTranslateModel])
const resetQuickModel = useCallback(() => {
setQuickModel(DEFAULT_MODEL_MAP.quick)
}, [setQuickModel])
return {
defaultModel,
setDefaultModel,
resetDefaultAssistantModel,
quickModel,
resetQuickModel,
setQuickModel,
translateModel,
setTranslateModel,
resetTranslateModel
setDefaultModel: (model: Model) => dispatch(setDefaultModel({ model })),
setQuickModel: (model: Model) => dispatch(setQuickModel({ model })),
setTranslateModel: (model: Model) => dispatch(setTranslateModel({ model }))
}
}

View File

@@ -1,57 +0,0 @@
import ProtocolInstallWarningContent from '@renderer/pages/settings/MCPSettings/ProtocolInstallWarning'
import {
ensureServerTrusted as ensureServerTrustedCore,
getCommandPreview
} from '@renderer/pages/settings/MCPSettings/utils'
import type { MCPServer } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMCPServers } from './useMCPServers'
/**
* Hook for handling MCP server trust verification
* Binds UI (modal dialog) to the core trust verification logic
*/
export const useMCPServerTrust = () => {
const { updateMCPServer } = useMCPServers()
const { t } = useTranslation()
/**
* Request user confirmation to trust a server
* Shows a warning modal with server command preview
*/
const requestConfirm = useCallback(
async (server: MCPServer): Promise<boolean> => {
const commandPreview = getCommandPreview(server)
return modalConfirm({
title: t('settings.mcp.protocolInstallWarning.title'),
content: (
<ProtocolInstallWarningContent
message={t('settings.mcp.protocolInstallWarning.message')}
commandLabel={t('settings.mcp.protocolInstallWarning.command')}
commandPreview={commandPreview}
/>
),
okText: t('settings.mcp.protocolInstallWarning.run'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true }
})
},
[t]
)
/**
* Ensures a server is trusted before proceeding
* Combines core logic with UI confirmation
*/
const ensureServerTrusted = useCallback(
async (server: MCPServer): Promise<MCPServer | null> => {
return ensureServerTrustedCore(server, requestConfirm, updateMCPServer)
},
[requestConfirm, updateMCPServer]
)
return { ensureServerTrusted }
}

View File

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

View File

@@ -11,12 +11,12 @@ import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Topic } from '@renderer/types'
import { findMainTextBlocks } from '@renderer/utils/messageUtils/find'
import { find, isEmpty } from 'lodash'
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useAssistant } from './useAssistant'
let _activeTopic: Topic
let _setActiveTopic: Dispatch<SetStateAction<Topic>>
let _setActiveTopic: (topic: Topic) => void
// const logger = loggerService.withContext('useTopic')

View File

@@ -14,7 +14,6 @@ export default function useUserTheme() {
const colorPrimary = Color(theme.colorPrimary)
document.body.style.setProperty('--color-primary', colorPrimary.toString())
document.body.style.setProperty('--color-primary-foreground', getForegroundColor(colorPrimary.hex()))
// overwrite hero UI primary color.
document.body.style.setProperty('--primary', colorPrimary.toString())
document.body.style.setProperty('--primary-foreground', getForegroundColor(colorPrimary.hex()))

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Handle complex tasks with various tools",
"error": {
"failed": "Failed to add a agent",
"invalid_agent": "Invalid Agent"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Daily conversations and quick Q&A",
"title": "Add Assistant"
},
"option": {
"title": "Select Type"
},
"topic": {
"title": "New Topic"
}
@@ -843,7 +838,7 @@
"label": "Context",
"tip": "The number of previous messages to keep in the context."
},
"max": "Unlimited",
"max": "Max",
"max_tokens": {
"confirm": "Set max tokens",
"confirm_content": "Set the maximum number of tokens the model can generate. Need to consider the context limit of the model, otherwise an error will be reported",
@@ -1052,12 +1047,10 @@
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"completed": "Completed",
"confirm": "Confirm",
"copied": "Copied",
"copy": "Copy",
"copy_failed": "Copy failed",
"current": "Current",
"cut": "Cut",
"default": "Default",
"delete": "Delete",
@@ -2928,14 +2921,15 @@
},
"description": "A powerful AI assistant for producer",
"downloading": "Downloading...",
"enterprise": {
"title": "Enterprise"
},
"feedback": {
"button": "Feedback",
"title": "Feedback"
},
"label": "About & Feedback",
"license": {
"button": "License",
"title": "License"
},
"releases": {
"button": "Releases",
"title": "Release Notes"
@@ -3043,46 +3037,6 @@
"title": "Export Menu Settings",
"yuque": "Export to Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Select backup file"
},
"content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.",
"lan": {
"auto_close_tip": "Auto-closing in {{seconds}} seconds...",
"confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?",
"confirm_close_title": "Confirm Close",
"connected": "Connected",
"connection_failed": "Connection failed",
"content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.",
"error": {
"init_failed": "Initialization failed",
"no_file": "No file selected",
"no_ip": "Unable to get IP address",
"send_failed": "Failed to send file"
},
"force_close": "Force Close",
"generating_qr": "Generating QR code...",
"noZipSelected": "No compressed file selected",
"scan_qr": "Please scan QR code with your phone",
"selectZip": "Select a compressed file",
"sendZip": "Begin data recovery",
"status": {
"completed": "Transfer completed",
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected",
"error": "Connection error",
"initializing": "Initializing connection...",
"preparing": "Preparing transfer...",
"sending": "Transferring {{progress}}%",
"waiting_qr_scan": "Please scan QR code to connect"
},
"title": "LAN transmission",
"transfer_progress": "Transfer progress"
},
"title": "Export to phone"
},
"hour_interval_one": "{{count}} hour",
"hour_interval_other": "{{count}} hours",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "No prompts available",
"requiredField": "Required Field"
},
"protocolInstallWarning": {
"command": "Startup command",
"message": "This MCP was installed from an external source via protocol. Running unknown tools may harm your computer.",
"run": "Run",
"title": "Run external MCP?"
},
"provider": "Provider",
"providerPlaceholder": "Provider name",
"providerUrl": "Provider URL",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "调用各种工具处理复杂任务",
"error": {
"failed": "添加 Agent 失败",
"invalid_agent": "无效的 Agent"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "日常对话和快速问答",
"title": "添加助手"
},
"option": {
"title": "选择添加类型"
},
"topic": {
"title": "新建话题"
}
@@ -1052,12 +1047,10 @@
"clear": "清除",
"close": "关闭",
"collapse": "折叠",
"completed": "完成",
"confirm": "确认",
"copied": "已复制",
"copy": "复制",
"copy_failed": "复制失败",
"current": "当前",
"cut": "剪切",
"default": "默认",
"delete": "删除",
@@ -2928,14 +2921,15 @@
},
"description": "一款为创造者而生的 AI 助手",
"downloading": "正在下载更新...",
"enterprise": {
"title": "企业版"
},
"feedback": {
"button": "反馈",
"title": "意见反馈"
},
"label": "关于我们",
"license": {
"button": "查看",
"title": "许可证"
},
"releases": {
"button": "查看",
"title": "更新日志"
@@ -3043,46 +3037,6 @@
"title": "导出菜单设置",
"yuque": "导出到语雀"
},
"export_to_phone": {
"confirm": {
"button": "选择备份文件"
},
"content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。",
"lan": {
"auto_close_tip": "{{seconds}} 秒后自动关闭...",
"confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?",
"confirm_close_title": "确认关闭",
"connected": "连接成功",
"connection_failed": "连接失败",
"content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。",
"error": {
"init_failed": "初始化失败",
"no_file": "未选择文件",
"no_ip": "无法获取 IP 地址",
"send_failed": "发送文件失败"
},
"force_close": "强制关闭",
"generating_qr": "正在生成二维码...",
"noZipSelected": "未选择压缩文件",
"scan_qr": "请使用手机扫码连接",
"selectZip": "选择压缩文件",
"sendZip": "开始恢复数据",
"status": {
"completed": "传输完成",
"connected": "连接成功",
"connecting": "正在连接中...",
"disconnected": "连接已断开",
"error": "连接出错",
"initializing": "正在初始化连接...",
"preparing": "准备传输中...",
"sending": "传输中 {{progress}}%",
"waiting_qr_scan": "请扫描二维码连接"
},
"title": "局域网传输",
"transfer_progress": "传输进度"
},
"title": "导出至手机"
},
"hour_interval_one": "{{count}} 小时",
"hour_interval_other": "{{count}} 小时",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "无可用提示",
"requiredField": "必填字段"
},
"protocolInstallWarning": {
"command": "启动命令",
"message": "该 MCP 是通过协议从外部来源安装的,运行来历不明的工具可能对您的计算机造成危害。",
"run": "运行",
"title": "运行外部 MCP"
},
"provider": "提供者",
"providerPlaceholder": "提供者名称",
"providerUrl": "提供者网址",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "調用各種工具處理複雜任務",
"error": {
"failed": "無法新增代理人",
"invalid_agent": "無效的 Agent"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "日常對話和快速問答",
"title": "新增助手"
},
"option": {
"title": "選擇新增類型"
},
"topic": {
"title": "新增話題"
}
@@ -843,7 +838,7 @@
"label": "上下文",
"tip": "在上下文中保留的前幾則訊息"
},
"max": "不限",
"max": "最大",
"max_tokens": {
"confirm": "設置最大 Token 數",
"confirm_content": "設置單次交互所用的最大 Token 數,會影響返回結果的長度。要根據模型上下文限制來設定,否則會發生錯誤",
@@ -1052,12 +1047,10 @@
"clear": "清除",
"close": "關閉",
"collapse": "折疊",
"completed": "已完成",
"confirm": "確認",
"copied": "已複製",
"copy": "複製",
"copy_failed": "複製失敗",
"current": "当前",
"cut": "剪下",
"default": "預設",
"delete": "刪除",
@@ -2928,14 +2921,15 @@
},
"description": "一款為創作者而生的強大 AI 助手",
"downloading": "正在下載...",
"enterprise": {
"title": "企業版"
},
"feedback": {
"button": "回饋",
"title": "回饋"
},
"label": "關於與回饋",
"license": {
"button": "檢視",
"title": "授權"
},
"releases": {
"button": "檢視",
"title": "更新日誌"
@@ -3043,46 +3037,6 @@
"title": "匯出選單設定",
"yuque": "匯出到語雀"
},
"export_to_phone": {
"confirm": {
"button": "選擇備份檔案"
},
"content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。",
"lan": {
"auto_close_tip": "將於 {{seconds}} 秒後自動關閉...",
"confirm_close_message": "檔案傳輸正在進行中。關閉將會中斷傳輸。您確定要強制關閉嗎?",
"confirm_close_title": "確認關閉",
"connected": "已連線",
"connection_failed": "連線失敗",
"content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。",
"error": {
"init_failed": "初始化失敗",
"no_file": "未選擇檔案",
"no_ip": "無法取得 IP 位址",
"send_failed": "無法傳送檔案"
},
"force_close": "強制關閉",
"generating_qr": "正在生成 QR 碼...",
"noZipSelected": "未選取壓縮檔案",
"scan_qr": "請使用手機掃描QR碼",
"selectZip": "選擇壓縮檔案",
"sendZip": "開始恢復資料",
"status": {
"completed": "轉帳完成",
"connected": "已連線",
"connecting": "連線中...",
"disconnected": "已斷線",
"error": "連線錯誤",
"initializing": "正在初始化連線...",
"preparing": "正在準備傳輸...",
"sending": "傳輸中 {{progress}}%",
"waiting_qr_scan": "請掃描QR碼以連接"
},
"title": "區域網路傳輸",
"transfer_progress": "傳輸進度"
},
"title": "匯出手機"
},
"hour_interval_one": "{{count}} 小時",
"hour_interval_other": "{{count}} 小時",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "無可用提示",
"requiredField": "必填欄位"
},
"protocolInstallWarning": {
"command": "啟動命令",
"message": "此 MCP 透過協議從外部來源安裝,執行來源不明的工具可能會對您的電腦造成危害。",
"run": "執行",
"title": "執行外部 MCP"
},
"provider": "提供者",
"providerPlaceholder": "提供者名稱",
"providerUrl": "提供者網址",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Bearbeiten Sie komplexe Aufgaben mit verschiedenen Werkzeugen",
"error": {
"failed": "Agent hinzufügen fehlgeschlagen",
"invalid_agent": "Ungültiger Agent"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Tägliche Gespräche und kurze Fragen & Antworten",
"title": "Assistent hinzufügen"
},
"option": {
"title": "Typ auswählen"
},
"topic": {
"title": "Neues Thema erstellen"
}
@@ -1052,12 +1047,10 @@
"clear": "Löschen",
"close": "Schließen",
"collapse": "Einklappen",
"completed": "Abgeschlossen",
"confirm": "Bestätigen",
"copied": "Kopiert",
"copy": "Kopieren",
"copy_failed": "Kopieren fehlgeschlagen",
"current": "Aktuell",
"cut": "Ausschneiden",
"default": "Standard",
"delete": "Löschen",
@@ -2928,14 +2921,15 @@
},
"description": "Ein KI-Assistent für Kreative",
"downloading": "Update wird heruntergeladen...",
"enterprise": {
"title": "Unternehmen"
},
"feedback": {
"button": "Feedback",
"title": "Feedback"
},
"label": "Über uns",
"license": {
"button": "Anzeigen",
"title": "Lizenz"
},
"releases": {
"button": "Anzeigen",
"title": "Changelog"
@@ -3043,46 +3037,6 @@
"title": "Export-Menü-Einstellungen",
"yuque": "Nach Yuque exportieren"
},
"export_to_phone": {
"confirm": {
"button": "Sicherungsdatei auswählen"
},
"content": "Exportieren Sie einige Daten, einschließlich Chat-Protokollen und Einstellungen. Bitte beachten Sie, dass der Sicherungsvorgang einige Zeit in Anspruch nehmen kann. Vielen Dank für Ihre Geduld.",
"lan": {
"auto_close_tip": "Automatisches Schließen in {{seconds}} Sekunden...",
"confirm_close_message": "Dateiübertragung läuft. Beim Schließen wird die Übertragung unterbrochen. Möchten Sie wirklich das Schließen erzwingen?",
"confirm_close_title": "Schließen bestätigen",
"connected": "Verbunden",
"connection_failed": "Verbindung fehlgeschlagen",
"content": "Bitte stelle sicher, dass sich dein Computer und dein Telefon im selben Netzwerk befinden, um eine LAN-Übertragung durchzuführen. Öffne die Cherry Studio App, um diesen QR-Code zu scannen.",
"error": {
"init_failed": "Initialisierung fehlgeschlagen",
"no_file": "Keine Datei ausgewählt",
"no_ip": "IP-Adresse kann nicht abgerufen werden",
"send_failed": "Fehler beim Senden der Datei"
},
"force_close": "Erzwungenes Schließen",
"generating_qr": "QR-Code wird generiert...",
"noZipSelected": "Keine komprimierte Datei ausgewählt",
"scan_qr": "Bitte scannen Sie den QR-Code mit Ihrem Telefon.",
"selectZip": "Wählen Sie eine komprimierte Datei",
"sendZip": "Datenwiederherstellung beginnen",
"status": {
"completed": "Übertragung abgeschlossen",
"connected": "Verbunden",
"connecting": "Verbindung wird hergestellt...",
"disconnected": "Getrennt",
"error": "Verbindungsfehler",
"initializing": "Verbindung wird initialisiert...",
"preparing": "Übertragung wird vorbereitet...",
"sending": "Übertrage {{progress}}%",
"waiting_qr_scan": "Bitte QR-Code scannen, um zu verbinden"
},
"title": "LAN-Übertragung",
"transfer_progress": "Übertragungsfortschritt"
},
"title": "Auf Telefon exportieren"
},
"hour_interval_one": "{{count}} Stunde",
"hour_interval_other": "{{count}} Stunden",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "Keine Prompts verfügbar",
"requiredField": "Pflichtfeld"
},
"protocolInstallWarning": {
"command": "Startbefehl",
"message": "Dieses MCP wurde über ein Protokoll aus einer externen Quelle installiert. Das Ausführen unbekannter Tools kann Ihren Computer schädigen.",
"run": "Laufen",
"title": "Externes MCP ausführen?"
},
"provider": "Anbieter",
"providerPlaceholder": "Anbietername",
"providerUrl": "Anbieter-Website",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Χειριστείτε πολύπλοκες εργασίες με διάφορα εργαλεία",
"error": {
"failed": "Αποτυχία προσθήκης πράκτορα",
"invalid_agent": "Μη έγκυρος Agent"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Καθημερινές συνομιλίες και γρήγορες ερωταπαντήσεις",
"title": "Προσθήκη βοηθού"
},
"option": {
"title": "Επιλέξτε Τύπο"
},
"topic": {
"title": "Δημιουργία νέου θέματος"
}
@@ -843,7 +838,7 @@
"label": "Πλήθος ενδιάμεσων",
"tip": "Πλήθος των μηνυμάτων που θα παραμείνουν στα ενδιάμεσα, όσο μεγαλύτερο είναι το αριθμός, τόσο μεγαλύτερο είναι το μήκος του ενδιάμεσου και τόσο περισσότερα tokens χρησιμοποιούνται. Συνομιλία συνήθως συνιστάται μεταξύ 5-10"
},
"max": "άπειρος",
"max": "Όχι ορισμένο",
"max_tokens": {
"confirm": "Ενεργοποίηση περιορισμού μήκους μηνύματος",
"confirm_content": "Μετά την ενεργοποίηση του περιορισμού μήκους μηνύματος, ο μέγιστος αριθμός των tokens που χρησιμοποιούνται κάθε φορά, θα επηρεάζει το μήκος της απάντησης. Πρέπει να το ρυθμίζετε βάσει των περιορισμών του πλαισίου του μοντέλου, διαφορετικά θα σφάλλεται.",
@@ -1052,12 +1047,10 @@
"clear": "Καθαρισμός",
"close": "Κλείσιμο",
"collapse": "Σύμπτυξη",
"completed": "Ολοκληρώθηκε",
"confirm": "Επιβεβαίωση",
"copied": "Αντιγράφηκε",
"copy": "Αντιγραφή",
"copy_failed": "Αποτυχία αντιγραφής",
"current": "Τρέχων",
"cut": "Κοπή",
"default": "Προεπιλογή",
"delete": "Διαγραφή",
@@ -2928,14 +2921,15 @@
},
"description": "Ένα AI ασιστάντα που έχει σχεδιαστεί για δημιουργούς",
"downloading": "Λήψη ενημερώσεων...",
"enterprise": {
"title": "Επιχείρηση"
},
"feedback": {
"button": "Σχόλια και Παρατηρήσεις",
"title": "Αποστολή σχολίων"
},
"label": "Περί μας",
"license": {
"button": "Προβολή",
"title": "Licenses"
},
"releases": {
"button": "Προβολή",
"title": "Ημερολόγιο Ενημερώσεων"
@@ -3043,46 +3037,6 @@
"title": "Εξαγωγή ρυθμίσεων μενού",
"yuque": "Εξαγωγή στο Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Επιλέξτε αρχείο αντιγράφων ασφαλείας"
},
"content": "Εξαγωγή μέρους των δεδομένων, συμπεριλαμβανομένων των ιστορικών συνομιλιών και των ρυθμίσεων. Σημειώστε ότι η διαδικασία δημιουργίας αντιγράφων ασφαλείας ενδέχεται να διαρκέσει κάποιο χρονικό διάστημα, ευχαριστούμε για την υπομονή σας.",
"lan": {
"auto_close_tip": "Αυτόματο κλείσιμο σε {{seconds}} δευτερόλεπτα...",
"confirm_close_message": "Η μεταφορά αρχείων είναι σε εξέλιξη. Το κλείσιμο θα διακόψει τη μεταφορά. Είστε σίγουροι ότι θέλετε να κλείσετε βίαια;",
"confirm_close_title": "Επιβεβαίωση Κλεισίματος",
"connected": "Συνδεδεμένος",
"connection_failed": "Η σύνδεση απέτυχε",
"content": "Βεβαιωθείτε ότι ο υπολογιστής και το κινητό βρίσκονται στο ίδιο δίκτυο για να χρησιμοποιήσετε τη μεταφορά LAN. Ανοίξτε την εφαρμογή Cherry Studio και σαρώστε αυτόν τον κωδικό QR.",
"error": {
"init_failed": "Η αρχικοποίηση απέτυχε",
"no_file": "Κανένα αρχείο δεν επιλέχθηκε",
"no_ip": "Αδυναμία λήψης διεύθυνσης IP",
"send_failed": "Αποτυχία αποστολής αρχείου"
},
"force_close": "Κλείσιμο με βία",
"generating_qr": "Δημιουργία κώδικα QR...",
"noZipSelected": "Δεν επιλέχθηκε συμπιεσμένο αρχείο",
"scan_qr": "Παρακαλώ σαρώστε τον κωδικό QR με το τηλέφωνό σας",
"selectZip": "Επιλέξτε συμπιεσμένο αρχείο",
"sendZip": "Έναρξη ανάκτησης δεδομένων",
"status": {
"completed": "Η μεταφορά ολοκληρώθηκε",
"connected": "Συνδεδεμένος",
"connecting": "Σύνδεση...",
"disconnected": "Αποσυνδέθηκε",
"error": "Σφάλμα σύνδεσης",
"initializing": "Αρχικοποίηση σύνδεσης...",
"preparing": "Προετοιμασία μεταφοράς...",
"sending": "Μεταφορά {{progress}}%",
"waiting_qr_scan": "Παρακαλώ σαρώστε τον κωδικό QR για σύνδεση"
},
"title": "Μεταφορά τοπικού δικτύου",
"transfer_progress": "Πρόοδος μεταφοράς"
},
"title": "Εξαγωγή στο κινητό"
},
"hour_interval_one": "{{count}} ώρα",
"hour_interval_other": "{{count}} ώρες",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "Δεν υπάρχουν διαθέσιμες υποδείξεις",
"requiredField": "Υποχρεωτικό πεδίο"
},
"protocolInstallWarning": {
"command": "Εντολή εκκίνησης",
"message": "Αυτό το MCP εγκαταστάθηκε από εξωτερική πηγή μέσω πρωτοκόλλου. Η εκτέλεση άγνωστων εργαλείων ενδέχεται να βλάψει τον υπολογιστή σας.",
"run": "Τρέξε",
"title": "Εκτέλεση εξωτερικού MCP;"
},
"provider": "Πάροχος",
"providerPlaceholder": "Όνομα παρόχου",
"providerUrl": "URL Παρόχου",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Maneja tareas complejas con varias herramientas",
"error": {
"failed": "Error al añadir agente",
"invalid_agent": "Agent inválido"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Conversaciones diarias y preguntas y respuestas rápidas",
"title": "Agregar asistente"
},
"option": {
"title": "Seleccionar Tipo"
},
"topic": {
"title": "Crear nuevo tema"
}
@@ -1052,12 +1047,10 @@
"clear": "Limpiar",
"close": "Cerrar",
"collapse": "Colapsar",
"completed": "Completado",
"confirm": "Confirmar",
"copied": "Copiado",
"copy": "Copiar",
"copy_failed": "Error al copiar",
"current": "Actual",
"cut": "Cortar",
"default": "Predeterminado",
"delete": "Eliminar",
@@ -2928,14 +2921,15 @@
},
"description": "Una asistente de IA creada para los creadores",
"downloading": "Descargando actualización...",
"enterprise": {
"title": "Empresa"
},
"feedback": {
"button": "Enviar feedback",
"title": "Enviar comentarios"
},
"label": "Acerca de nosotros",
"license": {
"button": "Ver",
"title": "Licencia"
},
"releases": {
"button": "Ver",
"title": "Registro de cambios"
@@ -3043,46 +3037,6 @@
"title": "Exportar configuración del menú",
"yuque": "Exportar a Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Seleccionar archivo de copia de seguridad"
},
"content": "Exportar parte de los datos, incluidos los registros de chat y la configuración. Tenga en cuenta que el proceso de copia de seguridad puede tardar un tiempo; gracias por su paciencia.",
"lan": {
"auto_close_tip": "Cierre automático en {{seconds}} segundos...",
"confirm_close_message": "La transferencia de archivos está en progreso. Cerrar interrumpirá la transferencia. ¿Estás seguro de que quieres forzar el cierre?",
"confirm_close_title": "Confirmar Cierre",
"connected": "Conectado",
"connection_failed": "Conexión fallida",
"content": "Asegúrate de que el ordenador y el móvil estén en la misma red para usar la transferencia por LAN. Abre la aplicación Cherry Studio y escanea este código QR.",
"error": {
"init_failed": "Falló la inicialización",
"no_file": "Ningún archivo seleccionado",
"no_ip": "No se puede obtener la dirección IP",
"send_failed": "Error al enviar el archivo"
},
"force_close": "Cerrar forzosamente",
"generating_qr": "Generando código QR...",
"noZipSelected": "No se ha seleccionado ningún archivo comprimido",
"scan_qr": "Por favor, escanea el código QR con tu teléfono",
"selectZip": "Seleccionar archivo comprimido",
"sendZip": "Comenzar la recuperación de datos",
"status": {
"completed": "Transferencia completada",
"connected": "Conectado",
"connecting": "Conectando...",
"disconnected": "Desconectado",
"error": "Error de conexión",
"initializing": "Inicializando conexión...",
"preparing": "Preparando transferencia...",
"sending": "Transfiriendo {{progress}}%",
"waiting_qr_scan": "Por favor, escanea el código QR para conectarte"
},
"title": "Transferencia de red local",
"transfer_progress": "Progreso de transferencia"
},
"title": "Exportar al teléfono"
},
"hour_interval_one": "{{count}} hora",
"hour_interval_other": "{{count}} horas",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "No hay indicaciones disponibles",
"requiredField": "Campo obligatorio"
},
"protocolInstallWarning": {
"command": "Comando de inicio",
"message": "Este MCP fue instalado desde una fuente externa a través del protocolo. Ejecutar herramientas desconocidas puede dañar tu computadora.",
"run": "Correr",
"title": "¿Ejecutar MCP externo?"
},
"provider": "Proveedor",
"providerPlaceholder": "Nombre del proveedor",
"providerUrl": "URL del proveedor",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Gérez des tâches complexes avec divers outils",
"error": {
"failed": "Échec de l'ajout de l'agent",
"invalid_agent": "Agent invalide"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Conversations quotidiennes et Q&R rapides",
"title": "Ajouter un assistant"
},
"option": {
"title": "Sélectionner le type"
},
"topic": {
"title": "Nouveau sujet"
}
@@ -1052,12 +1047,10 @@
"clear": "Effacer",
"close": "Fermer",
"collapse": "Réduire",
"completed": "Terminé",
"confirm": "Confirmer",
"copied": "Copié",
"copy": "Copier",
"copy_failed": "Échec de la copie",
"current": "Actuel",
"cut": "Couper",
"default": "Défaut",
"delete": "Supprimer",
@@ -2928,14 +2921,15 @@
},
"description": "Un assistant IA conçu pour les créateurs",
"downloading": "Téléchargement de la mise à jour en cours...",
"enterprise": {
"title": "Entreprise"
},
"feedback": {
"button": "Faire un retour",
"title": "Retour d'information"
},
"label": "À propos de nous",
"license": {
"button": "Afficher",
"title": "Licence"
},
"releases": {
"button": "Afficher",
"title": "Journal des mises à jour"
@@ -3043,46 +3037,6 @@
"title": "Exporter les paramètres du menu",
"yuque": "Exporter vers Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Sélectionner le fichier de sauvegarde"
},
"content": "Exporter une partie des données, incluant les historiques de discussion et les paramètres. Veuillez noter que le processus de sauvegarde peut prendre un certain temps ; merci pour votre patience.",
"lan": {
"auto_close_tip": "Fermeture automatique dans {{seconds}} secondes...",
"confirm_close_message": "Le transfert de fichier est en cours. Fermer interrompra le transfert. Êtes-vous sûr de vouloir forcer la fermeture ?",
"confirm_close_title": "Confirmer la fermeture",
"connected": "Connecté",
"connection_failed": "Échec de la connexion",
"content": "Assurez-vous que l'ordinateur et le téléphone sont connectés au même réseau pour utiliser le transfert en réseau local. Ouvrez l'application Cherry Studio et scannez ce code QR.",
"error": {
"init_failed": "Échec de l'initialisation",
"no_file": "Aucun fichier sélectionné",
"no_ip": "Impossible d'obtenir l'adresse IP",
"send_failed": "Échec de l'envoi du fichier"
},
"force_close": "Fermer de force",
"generating_qr": "Génération du code QR...",
"noZipSelected": "Aucun fichier compressé sélectionné",
"scan_qr": "Veuillez scanner le code QR avec votre téléphone",
"selectZip": "Sélectionner le fichier compressé",
"sendZip": "Commencer la restauration des données",
"status": {
"completed": "Transfert terminé",
"connected": "Connecté",
"connecting": "Connexion...",
"disconnected": "Déconnecté",
"error": "Erreur de connexion",
"initializing": "Initialisation de la connexion...",
"preparing": "Préparation du transfert...",
"sending": "Transfert {{progress}} %",
"waiting_qr_scan": "Veuillez scanner le code QR pour vous connecter"
},
"title": "Transmission en réseau local",
"transfer_progress": "Progression du transfert"
},
"title": "Exporter vers le téléphone"
},
"hour_interval_one": "{{count}} heure",
"hour_interval_other": "{{count}} heures",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "Aucune invite disponible",
"requiredField": "Champ obligatoire"
},
"protocolInstallWarning": {
"command": "Commande de démarrage",
"message": "Ce MCP a été installé depuis une source externe via le protocole. L'exécution d'outils inconnus peut endommager votre ordinateur.",
"run": "Courir",
"title": "Exécuter un MCP externe ?"
},
"provider": "Поставщик",
"providerPlaceholder": "Название поставщика",
"providerUrl": "Адрес поставщика",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "さまざまなツールを使って複雑なタスクを処理する",
"error": {
"failed": "エージェントの追加に失敗しました",
"invalid_agent": "無効なエージェント"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "日常会話と簡単なQ&A",
"title": "アシスタントを追加"
},
"option": {
"title": "タイプを選択"
},
"topic": {
"title": "新しいトピック"
}
@@ -843,7 +838,7 @@
"label": "コンテキスト",
"tip": "コンテキストに保持する以前のメッセージの数"
},
"max": "制限なし",
"max": "最大",
"max_tokens": {
"confirm": "最大トークン数",
"confirm_content": "最大トークン数を設定すると、モデルが生成できる最大トークン数が制限されます。これにより、返される結果の長さに影響が出る可能性があります。モデルのコンテキスト制限に基づいて設定する必要があります。そうしないとエラーが発生します",
@@ -1052,12 +1047,10 @@
"clear": "クリア",
"close": "閉じる",
"collapse": "折りたたむ",
"completed": "完了",
"confirm": "確認",
"copied": "コピーされました",
"copy": "コピー",
"copy_failed": "コピーに失敗しました",
"current": "現在",
"cut": "切り取り",
"default": "デフォルト",
"delete": "削除",
@@ -2928,14 +2921,15 @@
},
"description": "クリエイターのための強力なAIアシスタント",
"downloading": "ダウンロード中...",
"enterprise": {
"title": "エンタープライズ"
},
"feedback": {
"button": "フィードバック",
"title": "フィードバック"
},
"label": "について",
"license": {
"button": "ライセンス",
"title": "ライセンス"
},
"releases": {
"button": "リリース",
"title": "リリースノート"
@@ -3043,46 +3037,6 @@
"title": "エクスポートメニュー設定",
"yuque": "語雀にエクスポート"
},
"export_to_phone": {
"confirm": {
"button": "バックアップファイルを選択"
},
"content": "一部のデータ、チャット履歴や設定をエクスポートします。バックアップには時間がかかる場合がありますので、しばらくお待ちください。",
"lan": {
"auto_close_tip": "{{seconds}}秒後に自動的に閉じます...",
"confirm_close_message": "ファイル転送が進行中です。閉じると転送が中断されます。強制終了してもよろしいですか?",
"confirm_close_title": "閉じることを確認",
"connected": "接続済み",
"connection_failed": "接続に失敗しました",
"content": "コンピューターとスマートフォンが同じネットワークに接続されていることを確認し、ローカルエリアネットワーク転送を使用してください。Cherry Studioアプリを開き、このQRコードをスキャンしてください。",
"error": {
"init_failed": "初期化に失敗しました",
"no_file": "ファイルが選択されていません",
"no_ip": "IPアドレスを取得できません",
"send_failed": "ファイルの送信に失敗しました"
},
"force_close": "強制終了",
"generating_qr": "QRコードを生成中...",
"noZipSelected": "圧縮ファイルが選択されていません",
"scan_qr": "携帯電話でQRコードをスキャンしてください",
"selectZip": "圧縮ファイルを選択",
"sendZip": "データの復元を開始します",
"status": {
"completed": "転送完了",
"connected": "接続済み",
"connecting": "接続中...",
"disconnected": "切断されました",
"error": "接続エラー",
"initializing": "接続を初期化中...",
"preparing": "転送準備中...",
"sending": "転送中 {{progress}}%",
"waiting_qr_scan": "QRコードをスキャンして接続してください"
},
"title": "LAN転送",
"transfer_progress": "転送進行"
},
"title": "スマートフォンにエクスポート"
},
"hour_interval_one": "{{count}} 時間",
"hour_interval_other": "{{count}} 時間",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "利用可能なプロンプトはありません",
"requiredField": "必須フィールド"
},
"protocolInstallWarning": {
"command": "起動コマンド",
"message": "このMCPは外部ソースからプロトコル経由でインストールされました。不明なツールを実行すると、コンピューターに危害を及ぼす可能性があります。",
"run": "走る",
"title": "外部のMCPを実行しますか"
},
"provider": "プロバイダー",
"providerPlaceholder": "プロバイダー名",
"providerUrl": "プロバイダーURL",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Lide com tarefas complexas usando várias ferramentas",
"error": {
"failed": "Falha ao adicionar agente",
"invalid_agent": "Agent inválido"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Conversas diárias e perguntas e respostas rápidas",
"title": "Adicionar assistente"
},
"option": {
"title": "Selecionar Tipo"
},
"topic": {
"title": "Novo Tópico"
}
@@ -1052,12 +1047,10 @@
"clear": "Limpar",
"close": "Fechar",
"collapse": "Recolher",
"completed": "Concluído",
"confirm": "Confirmar",
"copied": "Copiado",
"copy": "Copiar",
"copy_failed": "Falha ao copiar",
"current": "Atual",
"cut": "Cortar",
"default": "Padrão",
"delete": "Excluir",
@@ -2928,14 +2921,15 @@
},
"description": "Um assistente de IA criado para criadores",
"downloading": "Baixando atualizações...",
"enterprise": {
"title": "Empresa"
},
"feedback": {
"button": "Feedback",
"title": "Enviar feedback"
},
"label": "Sobre Nós",
"license": {
"button": "Ver",
"title": "Licença"
},
"releases": {
"button": "Ver",
"title": "Registro de alterações"
@@ -3043,46 +3037,6 @@
"title": "Exportar Configurações do Menu",
"yuque": "Exportar para Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Selecionar arquivo de backup"
},
"content": "Exportar parte dos dados, incluindo registros de conversas e configurações. Observe que o processo de backup pode demorar um pouco; agradecemos sua paciência.",
"lan": {
"auto_close_tip": "Fechando automaticamente em {{seconds}} segundos...",
"confirm_close_message": "Transferência de arquivo em andamento. Fechar irá interromper a transferência. Tem certeza de que deseja forçar o fechamento?",
"confirm_close_title": "Confirmar Fechamento",
"connected": "Conectado",
"connection_failed": "Falha na conexão",
"content": "Certifique-se de que o computador e o telefone estejam na mesma rede para usar a transferência via LAN. Abra o aplicativo Cherry Studio e escaneie este código QR.",
"error": {
"init_failed": "Falha na inicialização",
"no_file": "Nenhum arquivo selecionado",
"no_ip": "Incapaz de obter endereço IP",
"send_failed": "Falha ao enviar arquivo"
},
"force_close": "Forçar Fechamento",
"generating_qr": "Gerando código QR...",
"noZipSelected": "Nenhum arquivo de compressão selecionado",
"scan_qr": "Por favor, escaneie o código QR com o seu telefone",
"selectZip": "Selecionar arquivo compactado",
"sendZip": "Iniciar recuperação de dados",
"status": {
"completed": "Transferência concluída",
"connected": "Conectado",
"connecting": "Conectando...",
"disconnected": "Desconectado",
"error": "Erro de conexão",
"initializing": "Inicializando conexão...",
"preparing": "Preparando transferência...",
"sending": "Transferindo {{progress}}%",
"waiting_qr_scan": "Por favor, escaneie o código QR para conectar"
},
"title": "transmissão de rede local",
"transfer_progress": "Progresso da transferência"
},
"title": "Exportar para o telemóvel"
},
"hour_interval_one": "{{count}} hora",
"hour_interval_other": "{{count}} horas",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "Nenhuma dica disponível",
"requiredField": "Campo obrigatório"
},
"protocolInstallWarning": {
"command": "Comando de inicialização",
"message": "Este MCP foi instalado a partir de uma fonte externa via protocolo. Executar ferramentas desconhecidas pode prejudicar seu computador.",
"run": "Correr",
"title": "Executar MCP externo?"
},
"provider": "Fornecedor",
"providerPlaceholder": "Nome do Fornecedor",
"providerUrl": "URL do Fornecedor",

View File

@@ -1,7 +1,6 @@
{
"agent": {
"add": {
"description": "Справляйтесь со сложными задачами с помощью различных инструментов",
"error": {
"failed": "Не удалось добавить агента",
"invalid_agent": "Недействительный агент"
@@ -548,12 +547,8 @@
"chat": {
"add": {
"assistant": {
"description": "Ежедневные разговоры и быстрые вопросы и ответы",
"title": "Добавить ассистента"
},
"option": {
"title": "Выберите тип"
},
"topic": {
"title": "Новый топик"
}
@@ -843,7 +838,7 @@
"label": "Контекст",
"tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте."
},
"max": "без ограничений",
"max": "Максимум",
"max_tokens": {
"confirm": "Максимальное количество токенов",
"confirm_content": "Установить максимальное количество токенов, влияет на длину результата. Нужно учитывать контекст модели, иначе будет ошибка",
@@ -1052,12 +1047,10 @@
"clear": "Очистить",
"close": "Закрыть",
"collapse": "Свернуть",
"completed": "Завершено",
"confirm": "Подтверждение",
"copied": "Скопировано",
"copy": "Копировать",
"copy_failed": "Не удалось скопировать",
"current": "Текущий",
"cut": "Вырезать",
"default": "По умолчанию",
"delete": "Удалить",
@@ -2928,14 +2921,15 @@
},
"description": "Мощный AI-ассистент для созидания",
"downloading": "Загрузка...",
"enterprise": {
"title": "Предприятие"
},
"feedback": {
"button": "Обратная связь",
"title": "Обратная связь"
},
"label": "О программе и обратная связь",
"license": {
"button": "Лицензия",
"title": "Лицензия"
},
"releases": {
"button": "Релизы",
"title": "Заметки о релизах"
@@ -3043,46 +3037,6 @@
"title": "Настройки меню экспорта",
"yuque": "Экспорт в Yuque"
},
"export_to_phone": {
"confirm": {
"button": "Выберите файл резервной копии"
},
"content": "Экспорт части данных, включая чат и настройки. Пожалуйста, обратите внимание, что процесс резервного копирования может занять некоторое время. Благодарим за ваше терпение.",
"lan": {
"auto_close_tip": "Автоматическое закрытие через {{seconds}} секунд...",
"confirm_close_message": "Передача файла в процессе. Закрытие прервет передачу. Вы уверены, что хотите принудительно закрыть?",
"confirm_close_title": "Подтвердить закрытие",
"connected": "Подключено",
"connection_failed": "Соединение не удалось",
"content": "Убедитесь, что компьютер и телефон подключены к одной сети, чтобы использовать локальную передачу. Откройте приложение Cherry Studio и отсканируйте этот QR-код.",
"error": {
"init_failed": "Инициализация не удалась",
"no_file": "Файл не выбран",
"no_ip": "Не удалось получить IP-адрес",
"send_failed": "Не удалось отправить файл"
},
"force_close": "Принудительное закрытие",
"generating_qr": "Генерация QR-кода...",
"noZipSelected": "Архив не выбран",
"scan_qr": "Пожалуйста, отсканируйте QR-код с помощью вашего телефона",
"selectZip": "Выберите архив",
"sendZip": "Начать восстановление данных",
"status": {
"completed": "Перевод завершён",
"connected": "Подключено",
"connecting": "Подключение...",
"disconnected": "Отключено",
"error": "Ошибка подключения",
"initializing": "Инициализация соединения...",
"preparing": "Подготовка передачи...",
"sending": "Передача {{progress}}%",
"waiting_qr_scan": "Пожалуйста, отсканируйте QR-код для подключения"
},
"title": "Передача по локальной сети",
"transfer_progress": "Прогресс передачи"
},
"title": "Экспорт на телефон"
},
"hour_interval_one": "{{count}} час",
"hour_interval_other": "{{count}} часов",
"joplin": {
@@ -3829,12 +3783,6 @@
"noPromptsAvailable": "Нет доступных подсказок",
"requiredField": "Обязательное поле"
},
"protocolInstallWarning": {
"command": "Команда запуска",
"message": "Этот MCP был установлен из внешнего источника через протокол. Запуск неизвестных инструментов может повредить ваш компьютер.",
"run": "Беги",
"title": "Запускать внешний MCP?"
},
"provider": "Провайдер",
"providerPlaceholder": "Имя провайдера",
"providerUrl": "URL провайдера",

View File

@@ -70,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)
@@ -79,7 +79,7 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut('rename_topic', async () => {
useShortcut('shortcut.topic.rename', async () => {
const topic = props.activeTopic
if (!topic) return
@@ -98,7 +98,7 @@ const Chat: FC<Props> = (props) => {
})
useShortcut(
'new_topic',
'shortcut.topic.new',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return

View File

@@ -37,9 +37,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
useShortcut('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()
})

View File

@@ -5,6 +5,7 @@ import { useAssistants } from '@renderer/hooks/useAssistant'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import NavigationService from '@renderer/services/NavigationService'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
@@ -34,10 +35,8 @@ const HomePage: FC = () => {
const location = useLocation()
const state = location.state
const [activeAssistant, _setActiveAssistant] = useState<Assistant>(
state?.assistant || _activeAssistant || assistants[0]
)
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', state?.topic)
const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
const [showAssistants] = usePreference('assistant.tab.show')
const [showTopics] = usePreference('topic.tab.show')
const [topicPosition] = usePreference('topic.position')
@@ -48,20 +47,16 @@ const HomePage: FC = () => {
_activeAssistant = activeAssistant
const setActiveAssistant = useCallback(
// TODO: allow to set it as null.
(newAssistant: Assistant) => {
if (newAssistant.id === activeAssistant?.id) return
if (newAssistant.id === activeAssistant.id) return
startTransition(() => {
_setActiveAssistant(newAssistant)
if (newAssistant.id !== 'fake') {
dispatch(setActiveAgentId(null))
}
// 同步更新 active topic避免不必要的重新渲染
const newTopic = newAssistant.topics[0]
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
})
},
[_setActiveTopic, activeAssistant?.id, dispatch]
[_setActiveTopic, activeAssistant]
)
const setActiveTopic = useCallback(
@@ -85,6 +80,19 @@ const HomePage: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state])
useEffect(() => {
const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
const newAssistant = assistants.find((a) => a.id === assistantId)
if (newAssistant) {
setActiveAssistant(newAssistant)
}
})
return () => {
unsubscribe()
}
}, [assistants, setActiveAssistant])
useEffect(() => {
const canMinimize = topicPosition == 'left' ? !showAssistants : !showAssistants && !showTopics
window.api.window.setMinimumSize(canMinimize ? SECOND_MIN_WINDOW_WIDTH : MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
@@ -94,6 +102,29 @@ const HomePage: FC = () => {
}
}, [showAssistants, showTopics, topicPosition])
useEffect(() => {
if (activeTopicOrSession === 'session') {
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
} else if (activeTopicOrSession === 'topic') {
dispatch(setActiveAgentId(null))
}
}, [activeTopicOrSession, dispatch, setActiveAssistant])
return (
<Container id="home-page">
{isLeftNavbar && (

View File

@@ -48,7 +48,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const { session } = useSession(agentId, sessionId)
const { apiServer } = useSettings()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const newTopicShortcut = useShortcutDisplay('new_topic')
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)

View File

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

View File

@@ -194,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) => {

View File

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

View File

@@ -1,4 +1,4 @@
import { Button } from '@cherrystudio/ui'
import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer'

View File

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

View File

@@ -6,7 +6,7 @@ import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutp
export function GlobTool({ input, output }: { input: GlobToolInputType; output?: GlobToolOutputType }) {
// 如果有输出,计算文件数量
const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
const fileCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
@@ -17,7 +17,7 @@ export function GlobTool({ input, output }: { input: GlobToolInputType; output?:
icon={<FolderSearch className="h-4 w-4" />}
label="Glob"
params={input.pattern}
stats={output ? `${lineCount} of output` : undefined}
stats={output ? `${fileCount} found` : undefined}
/>
}>
<div>{output}</div>

View File

@@ -8,31 +8,20 @@ import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutp
import { AgentToolsType } from './types'
export function ReadTool({ input, output }: { input: ReadToolInputType; output?: ReadToolOutputType }) {
// 移除 system-reminder 标签及其内容的辅助函数
const removeSystemReminderTags = (text: string): string => {
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
}
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
let processedOutput: string
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
processedOutput = output
return output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => removeSystemReminderTags(item.text))
.map((item) => item.text)
.join('')
} else {
// 如果是字符串,直接使用
processedOutput = output
}
// 移除 system-reminder 标签及其内容
return removeSystemReminderTags(processedOutput)
// 如果是字符串,直接返回
return output
}, [output])
// 如果有输出,计算统计信息

View File

@@ -1,16 +0,0 @@
import { AccordionItem } from '@heroui/react'
import { PencilRuler } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { SkillToolInput, SkillToolOutput } from './types'
export function SkillTool({ input, output }: { input: SkillToolInput; output?: SkillToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Skill Tool"
title={<ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input.command} />}>
{output}
</AccordionItem>
)
}

View File

@@ -2,7 +2,11 @@ import { AccordionItem, Card, CardBody, Chip } from '@heroui/react'
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { TodoItem, TodoWriteToolInput as TodoWriteToolInputType } from './types'
import type {
TodoItem,
TodoWriteToolInput as TodoWriteToolInputType,
TodoWriteToolOutput as TodoWriteToolOutputType
} from './types'
import { AgentToolsType } from './types'
const getStatusConfig = (status: TodoItem['status']) => {
@@ -30,7 +34,7 @@ const getStatusConfig = (status: TodoItem['status']) => {
}
}
export function TodoWriteTool({ input }: { input: TodoWriteToolInputType }) {
export function TodoWriteTool({ input, output }: { input: TodoWriteToolInputType; output?: TodoWriteToolOutputType }) {
const doneCount = input.todos.filter((todo) => todo.status === 'completed').length
return (
<AccordionItem
@@ -68,6 +72,7 @@ export function TodoWriteTool({ input }: { input: TodoWriteToolInputType }) {
)
})}
</div>
{output}
</AccordionItem>
)
}

View File

@@ -17,7 +17,6 @@ import { MultiEditTool } from './MultiEditTool'
import { NotebookEditTool } from './NotebookEditTool'
import { ReadTool } from './ReadTool'
import { SearchTool } from './SearchTool'
import { SkillTool } from './SkillTool'
import { TaskTool } from './TaskTool'
import { TodoWriteTool } from './TodoWriteTool'
import type { ToolInput, ToolOutput } from './types'
@@ -26,7 +25,6 @@ import { UnknownToolRenderer } from './UnknownToolRenderer'
import { WebFetchTool } from './WebFetchTool'
import { WebSearchTool } from './WebSearchTool'
import { WriteTool } from './WriteTool'
const logger = loggerService.withContext('MessageAgentTools')
// 创建工具渲染器映射,这样就实现了完全的类型安全
@@ -45,8 +43,7 @@ export const toolRenderers = {
[AgentToolsType.MultiEdit]: MultiEditTool,
[AgentToolsType.BashOutput]: BashOutputTool,
[AgentToolsType.NotebookEdit]: NotebookEditTool,
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool,
[AgentToolsType.Skill]: SkillTool
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool
} as const
// 类型守卫函数

View File

@@ -1,5 +1,4 @@
export enum AgentToolsType {
Skill = 'Skill',
Read = 'Read',
Task = 'Task',
Bash = 'Bash',
@@ -23,15 +22,6 @@ export type TextOutput = {
}
// Read 工具的类型定义
export interface SkillToolInput {
/**
* The skill to use
*/
command: string
}
export type SkillToolOutput = string
export interface ReadToolInput {
/**
* The absolute path to the file to read

View File

@@ -2,7 +2,6 @@ import type { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { MessageAgentTools } from './MessageAgentTools'
import { AgentToolsType } from './MessageAgentTools/types'
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
import { MessageMemorySearchToolTitle } from './MessageMemorySearch'
import { MessageWebSearchToolTitle } from './MessageWebSearch'
@@ -10,12 +9,27 @@ import { MessageWebSearchToolTitle } from './MessageWebSearch'
interface Props {
block: ToolMessageBlock
}
const builtinToolsPrefix = 'builtin_'
const agentMcpToolsPrefix = 'mcp__'
const agentTools = Object.values(AgentToolsType)
const isAgentTool = (toolName: AgentToolsType) => {
if (agentTools.includes(toolName) || toolName.startsWith(agentMcpToolsPrefix)) {
const prefix = 'builtin_'
const agentPrefix = 'mcp__'
const agentTools = [
'Read',
'Task',
'Bash',
'Search',
'Glob',
'TodoWrite',
'WebSearch',
'Grep',
'Write',
'WebFetch',
'Edit',
'MultiEdit',
'BashOutput',
'NotebookEdit',
'ExitPlanMode'
]
const isAgentTool = (toolName: string) => {
if (agentTools.includes(toolName) || toolName.startsWith(agentPrefix)) {
return true
}
return false
@@ -24,8 +38,8 @@ const isAgentTool = (toolName: AgentToolsType) => {
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
let toolName = toolResponse.tool.name
const toolType = toolResponse.tool.type
if (toolName.startsWith(builtinToolsPrefix)) {
toolName = toolName.slice(builtinToolsPrefix.length)
if (toolName.startsWith(prefix)) {
toolName = toolName.slice(prefix.length)
switch (toolName) {
case 'web_search':
case 'web_search_preview':
@@ -37,7 +51,7 @@ const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null =>
default:
return null
}
} else if (isAgentTool(toolName as AgentToolsType)) {
} else if (isAgentTool(toolName)) {
return <MessageAgentTools toolResponse={toolResponse} />
}
return null

View File

@@ -1,5 +1,5 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, Chip, ScrollShadow } from '@heroui/react'
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'
@@ -54,6 +54,7 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
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 (
@@ -146,16 +147,37 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
{t('agent.toolPermission.button.cancel')}
</Button>
<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>
{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={

View File

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

View File

@@ -11,7 +11,7 @@ import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { getErrorMessage } from '@renderer/utils'
import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes'
import type { Assistant, Topic } from '@types'
import type { Assistant } from '@types'
import type { FC } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -38,7 +38,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer()
const { apiServerConfig, apiServerRunning } = useApiServer()
const apiServerEnabled = apiServerConfig.enabled
const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch()
@@ -101,30 +101,6 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
[setAssistantsTabSortType]
)
const handleAgentPress = useCallback(
(agentId: string) => {
setActiveAgentId(agentId)
// TODO: should allow it to be null
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
},
[setActiveAgentId, setActiveAssistant]
)
return (
<Container className="assistants-tab" ref={containerRef}>
{!apiServerConfig.enabled && !apiServerRunning && !iknow[ALERT_KEY] && (
@@ -139,8 +115,8 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
/>
)}
{(agentsLoading || apiServerLoading) && <Spinner />}
{apiServerConfig.enabled && !apiServerLoading && !apiServerRunning && (
{agentsLoading && <Spinner />}
{apiServerConfig.enabled && !apiServerRunning && (
<Alert color="danger" title={t('agent.server.error.not_running')} isClosable className="mb-2" />
)}
{apiServerRunning && agentsError && (
@@ -152,11 +128,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
/>
)}
<UnifiedAddButton
onCreateAssistant={onCreateAssistant}
setActiveAssistant={setActiveAssistant}
setActiveAgentId={setActiveAgentId}
/>
<UnifiedAddButton onCreateAssistant={onCreateAssistant} />
{assistantsTabSortType === 'tags' ? (
<UnifiedTagGroups
@@ -172,7 +144,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={handleAgentPress}
onAgentPress={setActiveAgentId}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
@@ -192,7 +164,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
onAssistantSwitch={setActiveAssistant}
onAssistantDelete={onDeleteAssistant}
onAgentDelete={deleteAgent}
onAgentPress={handleAgentPress}
onAgentPress={setActiveAgentId}
addPreset={addAssistantPreset}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}

View File

@@ -2,12 +2,7 @@ import { Button, DescriptionSwitch, HelpTooltip, RowFlex, Selector, type Selecto
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import EditableNumber from '@renderer/components/EditableNumber'
import Scrollbar from '@renderer/components/Scrollbar'
import {
DEFAULT_CONTEXTCOUNT,
DEFAULT_MAX_TOKENS,
DEFAULT_TEMPERATURE,
MAX_CONTEXT_COUNT
} from '@renderer/config/constant'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import { isOpenAIModel } from '@renderer/config/models'
import { UNKNOWN } from '@renderer/config/translate'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
@@ -219,6 +214,9 @@ const SettingsTab: FC<Props> = (props) => {
setStreamOutput(assistant?.settings?.streamOutput ?? true)
}, [assistant])
const assistantContextCount = assistant?.settings?.contextCount || 20
const maxContextCount = assistantContextCount > 20 ? assistantContextCount : 20
const model = assistant.model || getDefaultModel()
const isOpenAI = isOpenAIModel(model)
@@ -271,44 +269,21 @@ const SettingsTab: FC<Props> = (props) => {
) : (
<SettingDivider />
)}
<Row align="middle" gutter={10} justify="space-between">
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.context_count.label')}
<HelpTooltip title={t('chat.settings.context_count.tip')} />
</SettingRowTitleSmall>
<Col span={8}>
<EditableNumber
min={0}
max={20}
step={1}
value={contextCount}
changeOnBlur
onChange={(value) => {
if (value !== null && value >= 0 && value <= 20) {
setContextCount(value)
onContextCountChange(value)
}
}}
formatter={(value) => (value === MAX_CONTEXT_COUNT ? t('chat.settings.max') : (value ?? ''))}
style={{ width: '100%' }}
/>
</Col>
</Row>
<Row align="middle" gutter={10}>
<Col span={24}>
<Col span={23}>
<Slider
min={0}
max={20}
max={maxContextCount}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={Math.min(contextCount, 20)}
tooltip={{ open: false }}
value={typeof contextCount === 'number' ? contextCount : 0}
step={1}
marks={{
0: '0',
10: '10',
20: '20'
}}
/>
</Col>
</Row>

View File

@@ -85,8 +85,7 @@ export const Container: React.FC<{ isActive?: boolean } & React.HTMLAttributes<H
}) => (
<div
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border border-transparent px-2',
!isActive && 'hover:bg-[var(--color-list-item-hover)]',
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border border-transparent px-2 hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}

View File

@@ -404,8 +404,7 @@ const Container = ({
<div
{...props}
className={cn(
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border-[0.5px] border-transparent px-2',
!isActive && 'hover:bg-[var(--color-list-item-hover)]',
'relative flex h-[37px] w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-row justify-between rounded-[var(--list-item-border-radius)] border-[0.5px] border-transparent px-2 hover:bg-[var(--color-list-item-hover)]',
isActive && 'bg-[var(--color-list-item)] shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
className
)}>

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