Compare commits
13 Commits
refactor/i
...
shortcut-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441fb1de53 | ||
|
|
3e9d9f16d6 | ||
|
|
f3a279d8de | ||
|
|
5790c12011 | ||
|
|
352ecbc506 | ||
|
|
fc4f30feab | ||
|
|
888a183328 | ||
|
|
9a01e092f6 | ||
|
|
5986800c9d | ||
|
|
56d68276e1 | ||
|
|
29c1173365 | ||
|
|
c7ceb3035d | ||
|
|
7bcae6fba2 |
6
.github/workflows/issue-management.yml
vendored
6
.github/workflows/issue-management.yml
vendored
@@ -29,8 +29,10 @@ jobs:
|
||||
days-before-close: 0 # Close immediately after stale
|
||||
stale-issue-label: 'inactive'
|
||||
close-issue-label: 'closed:no-response'
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: true
|
||||
stale-issue-message: |
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
|
||||
It will be closed now due to lack of additional information.
|
||||
|
||||
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
|
||||
@@ -46,6 +48,8 @@ jobs:
|
||||
days-before-stale: ${{ env.daysBeforeStale }}
|
||||
days-before-close: ${{ env.daysBeforeClose }}
|
||||
stale-issue-label: 'inactive'
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: true
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
|
||||
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
|
||||
|
||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
run: yarn typecheck
|
||||
|
||||
- name: i18n Check
|
||||
run: yarn i18n:check
|
||||
run: yarn check:i18n
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
|
||||
35
.vscode/settings.json
vendored
35
.vscode/settings.json
vendored
@@ -27,40 +27,27 @@
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.oxc": "explicit",
|
||||
"source.organizeImports": "never",
|
||||
"source.sort.json.biome": "always"
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"files.associations": {
|
||||
".oxlintrc.json": "jsonc",
|
||||
"*.css": "tailwindcss"
|
||||
"*.css": "tailwindcss",
|
||||
".oxlintrc.json": "jsonc"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
// "i18n-ally.defaultNamespace": "translation",
|
||||
// "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言
|
||||
"i18n-ally.enabledFrameworks": [
|
||||
"react-i18next",
|
||||
"i18next"
|
||||
],
|
||||
"i18n-ally.enabledParsers": [
|
||||
"ts",
|
||||
"js",
|
||||
"json"
|
||||
], // 解析语言
|
||||
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
||||
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
||||
"i18n-ally.fullReloadOnChanged": true,
|
||||
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/renderer/src/i18n/locales"
|
||||
],
|
||||
"i18n-ally.namespace": true, // 开启命名空间
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
||||
// "i18n-ally.namespace": true, // 开启命名空间
|
||||
"i18n-ally.sortKeys": true, // 排序
|
||||
"i18n-ally.sourceLanguage": "zh-cn", // 翻译源语言
|
||||
"i18n-ally.usage.derivedKeyRules": [
|
||||
"{key}_one",
|
||||
"{key}_other"
|
||||
], // 标记单复数形式的键为已翻译
|
||||
"i18n-ally.usage.derivedKeyRules": ["{key}_one", "{key}_other"], // 标记单复数形式的键为已翻译
|
||||
"search.exclude": {
|
||||
".yarn/releases/**": true,
|
||||
"**/dist/**": true
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
},
|
||||
"tailwindCSS.classAttributes": [
|
||||
"className",
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
|
||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
}
|
||||
|
||||
|
||||
// ../src/transport/ProcessTransport.ts
|
||||
-import { spawn } from "child_process";
|
||||
+import { fork } from "child_process";
|
||||
import { createInterface } from "readline";
|
||||
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6473,14 +6473,11 @@ class ProcessTransport {
|
||||
@@ -6487,14 +6487,11 @@ class ProcessTransport {
|
||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
||||
throw new ReferenceError(errorMessage);
|
||||
}
|
||||
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
|
||||
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
|
||||
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
||||
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
|
||||
- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
||||
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`);
|
||||
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
|
||||
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
|
||||
- this.child = spawn(spawnCommand, spawnArgs, {
|
||||
@@ -10,8 +10,9 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
|
||||
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
|
||||
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
|
||||
- **Seek review**: Ask a human developer to review substantial changes before merging.
|
||||
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
|
||||
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
|
||||
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
|
||||
📝 docs:`).
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -19,7 +20,7 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload
|
||||
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
|
||||
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck)
|
||||
- If having i18n sort issues, run `yarn i18n:sync` first to sync template
|
||||
- If having i18n sort issues, run `yarn sync:i18n` first to sync template
|
||||
- If having formatting issues, run `yarn format` first
|
||||
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes
|
||||
- **Single Test**:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"useSortedKeys": {
|
||||
"level": "on",
|
||||
"options": {
|
||||
"sortOrder": "natural"
|
||||
"sortOrder": "lexicographic"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
},
|
||||
"files": { "ignoreUnknown": false },
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**"],
|
||||
"maxSize": 2097152
|
||||
},
|
||||
"formatter": {
|
||||
"attributePosition": "auto",
|
||||
"bracketSameLine": false,
|
||||
|
||||
@@ -107,7 +107,7 @@ By avoiding template strings, you gain better developer experience, more reliabl
|
||||
|
||||
The project includes several scripts to automate i18n-related tasks:
|
||||
|
||||
### `i18n:check` - Validate i18n Structure
|
||||
### `check:i18n` - Validate i18n Structure
|
||||
|
||||
This script checks:
|
||||
|
||||
@@ -116,30 +116,28 @@ This script checks:
|
||||
- Whether keys are properly sorted
|
||||
|
||||
```bash
|
||||
yarn i18n:check
|
||||
yarn check:i18n
|
||||
```
|
||||
|
||||
### `i18n:sync` - Synchronize JSON Structure and Sort Order
|
||||
### `sync:i18n` - Synchronize JSON Structure and Sort Order
|
||||
|
||||
By default, this script uses `en-us.json` as the source of truth to sync structure across all language files, including:
|
||||
This script uses `zh-cn.json` as the source of truth to sync structure across all language files, including:
|
||||
|
||||
1. Adding missing keys, with placeholder `[to be translated]`
|
||||
2. Removing obsolete keys
|
||||
3. Sorting keys automatically
|
||||
|
||||
You can override this behavior by setting the `BASE_LOCALE` environment variable.
|
||||
|
||||
```bash
|
||||
yarn i18n:sync
|
||||
yarn sync:i18n
|
||||
```
|
||||
|
||||
### `i18n:auto` - Automatically Translate Pending Texts
|
||||
### `auto:i18n` - Automatically Translate Pending Texts
|
||||
|
||||
This script automatically translates texts marked as `[to be translated]` using machine translation. Similar to `i18n:sync`, it defaults to using `en-us.json` as the base, but you can override this behavior by setting the `BASE_LOCALE` environment variable.
|
||||
This script fills in texts marked as `[to be translated]` using machine translation.
|
||||
|
||||
Typically, after adding required texts to `en-us.json`, running `i18n:sync && i18n:auto` will automatically complete the translations.
|
||||
Typically, after adding new texts in `zh-cn.json`, run `sync:i18n`, then `auto:i18n` to complete translations.
|
||||
|
||||
Before using this script, you need to configure environment variables, for example:
|
||||
Before using this script, set the required environment variables:
|
||||
|
||||
```bash
|
||||
API_KEY="sk-xxx"
|
||||
@@ -147,23 +145,33 @@ BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1/"
|
||||
MODEL="qwen-plus-latest"
|
||||
```
|
||||
|
||||
You can also add environment variables by directly editing the `.env` file.
|
||||
Alternatively, add these variables directly to your `.env` file.
|
||||
|
||||
```bash
|
||||
yarn i18n:auto
|
||||
yarn auto:i18n
|
||||
```
|
||||
|
||||
### `update:i18n` - Object-level Translation Update
|
||||
|
||||
Updates translations in language files under `src/renderer/src/i18n/translate` at the object level, preserving existing translations and only updating new content.
|
||||
|
||||
**Not recommended** — prefer `auto:i18n` for translation tasks.
|
||||
|
||||
```bash
|
||||
yarn update:i18n
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. During development, first add the required text in `en-us.json`. You can use the quick fix functionality provided by the i18n-ally plugin to easily accomplish this.
|
||||
2. Confirm the text displays correctly in the UI
|
||||
3. Use `yarn i18n:sync` to sync the text to other language files
|
||||
4. Use `yarn i18n:auto` to perform automatic translation
|
||||
5. Grab a coffee and wait for the translation to complete!
|
||||
1. During development, first add the required text in `zh-cn.json`
|
||||
2. Confirm it displays correctly in the Chinese environment
|
||||
3. Run `yarn sync:i18n` to propagate the keys to other language files
|
||||
4. Run `yarn auto:i18n` to perform machine translation
|
||||
5. Grab a coffee and let the magic happen!
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use English as Source Language**: All development starts in English, then translates to other languages.
|
||||
2. **Run Check Script Before Commit**: Use `yarn i18n:check` to catch i18n issues early.
|
||||
1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages.
|
||||
2. **Run Check Script Before Commit**: Use `yarn check:i18n` to catch i18n issues early.
|
||||
3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content.
|
||||
4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error`
|
||||
|
||||
@@ -23,9 +23,9 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反
|
||||
|
||||
## i18n 约定
|
||||
|
||||
### **避免使用flat格式**
|
||||
### **绝对避免使用flat格式**
|
||||
|
||||
避免使用flat格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构:
|
||||
绝对避免使用flat格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构:
|
||||
|
||||
```json
|
||||
// 错误示例 - flat结构
|
||||
@@ -101,7 +101,7 @@ export const getThemeModeLabel = (key: string): string => {
|
||||
|
||||
项目中有一系列脚本来自动化i18n相关任务:
|
||||
|
||||
### `i18n:check` - 检查i18n结构
|
||||
### `check:i18n` - 检查i18n结构
|
||||
|
||||
此脚本会检查:
|
||||
|
||||
@@ -111,28 +111,26 @@ export const getThemeModeLabel = (key: string): string => {
|
||||
- 是否已经有序
|
||||
|
||||
```bash
|
||||
yarn i18n:check
|
||||
yarn check:i18n
|
||||
```
|
||||
|
||||
### `i18n:sync` - 同步json结构与排序
|
||||
### `sync:i18n` - 同步json结构与排序
|
||||
|
||||
此脚本默认以`en-us.json`文件为基准,将结构同步到其他语言文件,包括:
|
||||
此脚本以`zh-cn.json`文件为基准,将结构同步到其他语言文件,包括:
|
||||
|
||||
1. 添加缺失的键。缺少的翻译内容会以`[to be translated]`标记
|
||||
2. 删除多余的键
|
||||
3. 自动排序
|
||||
|
||||
你也可以设置环境变量`BASE_LOCALE`来覆盖这一行为。
|
||||
|
||||
```bash
|
||||
yarn i18n:auto
|
||||
yarn sync:i18n
|
||||
```
|
||||
|
||||
### `i18n:auto` - 自动翻译待翻译文本
|
||||
### `auto:i18n` - 自动翻译待翻译文本
|
||||
|
||||
此脚本自动将标记为待翻译的文本通过机器翻译填充。与 `i18n:sync` 相同,默认以`en-us.json`文件为基准,也可以设置环境变量`BASE_LOCALE`来覆盖这一行为。
|
||||
次脚本自动将标记为待翻译的文本通过机器翻译填充。
|
||||
|
||||
通常,在`en-us.json`中添加所需文案后,执行`i18n:sync && i18n:auto`即可自动完成翻译。
|
||||
通常,在`zh-cn.json`中添加所需文案后,执行`sync:i18n`即可自动完成翻译。
|
||||
|
||||
使用该脚本前,需要配置环境变量,例如:
|
||||
|
||||
@@ -145,19 +143,29 @@ MODEL="qwen-plus-latest"
|
||||
你也可以通过直接编辑`.env`文件来添加环境变量。
|
||||
|
||||
```bash
|
||||
yarn i18n:auto
|
||||
yarn auto:i18n
|
||||
```
|
||||
|
||||
### `update:i18n` - 对象级别翻译更新
|
||||
|
||||
对`src/renderer/src/i18n/translate`中的语言文件进行对象级别的翻译更新,保留已有翻译,只更新新增内容。
|
||||
|
||||
**不建议**使用该脚本,更推荐使用`auto:i18n`进行翻译。
|
||||
|
||||
```bash
|
||||
yarn update:i18n
|
||||
```
|
||||
|
||||
### 工作流
|
||||
|
||||
1. 开发阶段,先在`en-us.json`中添加所需文案。你可以利用 i18n-ally 插件提供的快速修复功能轻松完成这一点。
|
||||
2. 确认文案在 UI 中显示无误后,使用`yarn i18n:sync`将文案同步到其他语言文件
|
||||
3. 使用`yarn i18n:auto`进行自动翻译
|
||||
1. 开发阶段,先在`zh-cn.json`中添加所需文案
|
||||
2. 确认在中文环境下显示无误后,使用`yarn sync:i18n`将文案同步到其他语言文件
|
||||
3. 使用`yarn auto:i18n`进行自动翻译
|
||||
4. 喝杯咖啡,等翻译完成吧!
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **以英文为源语言**:所有开发首先使用英文,再翻译为其他语言
|
||||
2. **提交前运行检查脚本**:使用`yarn i18n:check`检查i18n是否有问题
|
||||
1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言
|
||||
2. **提交前运行检查脚本**:使用`yarn check:i18n`检查i18n是否有问题
|
||||
3. **小步提交翻译**:避免积累大量未翻译文本
|
||||
4. **保持key语义明确**:key应能清晰表达其用途,如`user.profile.avatar.upload.error`
|
||||
|
||||
@@ -67,6 +67,10 @@ asarUnpack:
|
||||
extraResources:
|
||||
- from: "migrations/sqlite-drizzle"
|
||||
to: "migrations/sqlite-drizzle"
|
||||
# copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso
|
||||
- from: "./node_modules/claude-code-plugins/plugins/"
|
||||
to: "claude-code-plugins"
|
||||
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from 'i18next-cli'
|
||||
|
||||
/** @see https://github.com/i18next/i18next-cli */
|
||||
export default defineConfig({
|
||||
locales: ['en-us', 'zh-cn', 'zh-tw', 'de-de', 'el-gr', 'es-es', 'fr-fr', 'ja-jp', 'pt-pt', 'ru-ru'],
|
||||
extract: {
|
||||
input: 'src/renderer/src/**/*.{ts,tsx}',
|
||||
output: 'src/renderer/src/i18n/locales/{{language}}.json',
|
||||
defaultValue: (_1, _2, _3, value) => `[to be translated]${value}`,
|
||||
primaryLanguage: 'en-us',
|
||||
removeUnusedKeys: false
|
||||
},
|
||||
types: {
|
||||
input: ['src/renderer/src/i18n/locales/en-us.json'],
|
||||
output: 'src/renderer/src/i18n/i18next.d.ts',
|
||||
resourcesFile: 'src/renderer/src/i18n/resources.d.ts',
|
||||
enableSelector: true
|
||||
}
|
||||
})
|
||||
18
package.json
18
package.json
@@ -54,11 +54,10 @@
|
||||
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck:ui": "cd packages/ui && npm run type-check",
|
||||
"i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
||||
"i18n:sync": "i18next-cli sync",
|
||||
"i18n:auto": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||
"i18n:status": "i18next-cli status",
|
||||
"i18n:extract": "i18next-cli extract",
|
||||
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
||||
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||
"update:languages": "tsx scripts/update-languages.ts",
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
@@ -70,7 +69,7 @@
|
||||
"test:e2e": "yarn playwright test",
|
||||
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
|
||||
"test:scripts": "vitest scripts",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && biome lint --write && biome format --write && yarn typecheck && yarn i18n:check && yarn format:check",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && biome lint --write && biome format --write && yarn typecheck && yarn check:i18n && yarn format:check",
|
||||
"lint:ox": "oxlint --fix && biome lint --write && biome format --write",
|
||||
"format": "biome format --write && biome lint --write",
|
||||
"format:check": "biome format && biome lint",
|
||||
@@ -82,7 +81,7 @@
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
@@ -90,6 +89,8 @@
|
||||
"express": "^5.1.0",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
@@ -199,6 +200,7 @@
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/he": "^1",
|
||||
"@types/html-to-text": "^9",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
@@ -238,6 +240,7 @@
|
||||
"check-disk-space": "3.4.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"claude-code-plugins": "1.0.1",
|
||||
"cli-progress": "^3.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
@@ -285,7 +288,6 @@
|
||||
"htmlparser2": "^10.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
"i18next-cli": "^1.12.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"isbinaryfile": "5.0.4",
|
||||
|
||||
@@ -96,6 +96,10 @@ export enum IpcChannel {
|
||||
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
||||
AgentMessage_GetHistory = 'agent-message:get-history',
|
||||
|
||||
AgentToolPermission_Request = 'agent-tool-permission:request',
|
||||
AgentToolPermission_Response = 'agent-tool-permission:response',
|
||||
AgentToolPermission_Result = 'agent-tool-permission:result',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
@@ -200,7 +204,9 @@ export enum IpcChannel {
|
||||
|
||||
Export_Word = 'export:word',
|
||||
|
||||
Shortcuts_GetAll = 'shortcuts:getAll',
|
||||
Shortcuts_Update = 'shortcuts:update',
|
||||
Shortcuts_Updated = 'shortcuts:updated',
|
||||
|
||||
// backup
|
||||
Backup_Backup = 'backup:backup',
|
||||
@@ -382,5 +388,14 @@ export enum IpcChannel {
|
||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||
|
||||
// CherryAI
|
||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||
Cherryai_GetSignature = 'cherryai:get-signature',
|
||||
|
||||
// Claude Code Plugins
|
||||
ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available',
|
||||
ClaudeCodePlugin_Install = 'claudeCodePlugin:install',
|
||||
ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall',
|
||||
ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed',
|
||||
ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache',
|
||||
ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content',
|
||||
ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content'
|
||||
}
|
||||
|
||||
@@ -11,12 +11,23 @@
|
||||
|
||||
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
|
||||
import * as PreferenceTypes from '@shared/data/preference/preferenceTypes'
|
||||
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||
|
||||
/* eslint @typescript-eslint/member-ordering: ["error", {
|
||||
"interfaces": { "order": "alphabetically" },
|
||||
"typeLiterals": { "order": "alphabetically" }
|
||||
}] */
|
||||
|
||||
const defaultShortcutPreferences: PreferenceTypes.ShortcutPreferencesValue = Object.fromEntries(
|
||||
shortcutDefinitions.map((definition) => [
|
||||
definition.name,
|
||||
{
|
||||
enabled: definition.defaultEnabled,
|
||||
key: [...definition.defaultKey]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
export interface PreferenceSchemas {
|
||||
default: {
|
||||
// redux/settings/enableDeveloperMode
|
||||
@@ -377,6 +388,8 @@ export interface PreferenceSchemas {
|
||||
'shortcut.chat.search_message': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.toggle_new_context
|
||||
'shortcut.chat.toggle_new_context': Record<string, unknown>
|
||||
// unified shortcut overrides
|
||||
'shortcut.preferences': PreferenceTypes.ShortcutPreferencesValue
|
||||
// redux/shortcuts/shortcuts.selection_assistant_select_text
|
||||
'shortcut.selection.get_text': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.selection_assistant_toggle
|
||||
@@ -645,6 +658,7 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
key: ['CommandOrControl', 'K'],
|
||||
system: false
|
||||
},
|
||||
'shortcut.preferences': defaultShortcutPreferences,
|
||||
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
|
||||
'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true },
|
||||
'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
|
||||
|
||||
import type { PreferenceSchemas } from './preferenceSchemas'
|
||||
|
||||
export type PreferenceDefaultScopeType = PreferenceSchemas['default']
|
||||
@@ -14,6 +16,8 @@ export type PreferenceShortcutType = {
|
||||
system: boolean
|
||||
}
|
||||
|
||||
export type ShortcutPreferencesValue = ShortcutPreferenceMap
|
||||
|
||||
export enum SelectionTriggerMode {
|
||||
Selected = 'selected',
|
||||
Ctrlkey = 'ctrlkey',
|
||||
|
||||
175
packages/shared/shortcuts/definitions.ts
Normal file
175
packages/shared/shortcuts/definitions.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { ShortcutDefinition } from './types'
|
||||
|
||||
export const shortcutDefinitions: ShortcutDefinition[] = [
|
||||
{
|
||||
name: 'show_app',
|
||||
defaultKey: [],
|
||||
defaultEnabled: true,
|
||||
description: 'Show or hide the main window',
|
||||
scope: 'main',
|
||||
editable: true,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'show_mini_window',
|
||||
defaultKey: ['CommandOrControl', 'E'],
|
||||
defaultEnabled: false,
|
||||
description: 'Show or hide the mini window',
|
||||
scope: 'main',
|
||||
editable: true,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'selection_assistant_toggle',
|
||||
defaultKey: [],
|
||||
defaultEnabled: false,
|
||||
description: 'Enable or disable the selection assistant',
|
||||
scope: 'main',
|
||||
editable: true,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'selection_assistant_select_text',
|
||||
defaultKey: [],
|
||||
defaultEnabled: false,
|
||||
description: 'Trigger selection assistant text capture',
|
||||
scope: 'main',
|
||||
editable: true,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'zoom_in',
|
||||
defaultKey: ['CommandOrControl', '='],
|
||||
defaultEnabled: true,
|
||||
description: 'Zoom in',
|
||||
scope: 'main',
|
||||
editable: false,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'zoom_out',
|
||||
defaultKey: ['CommandOrControl', '-'],
|
||||
defaultEnabled: true,
|
||||
description: 'Zoom out',
|
||||
scope: 'main',
|
||||
editable: false,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'zoom_reset',
|
||||
defaultKey: ['CommandOrControl', '0'],
|
||||
defaultEnabled: true,
|
||||
description: 'Reset zoom',
|
||||
scope: 'main',
|
||||
editable: false,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'show_settings',
|
||||
defaultKey: ['CommandOrControl', ','],
|
||||
defaultEnabled: true,
|
||||
description: 'Open settings',
|
||||
scope: 'renderer',
|
||||
editable: false,
|
||||
system: true
|
||||
},
|
||||
{
|
||||
name: 'new_topic',
|
||||
defaultKey: ['CommandOrControl', 'N'],
|
||||
defaultEnabled: true,
|
||||
description: 'Start a new chat topic',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'rename_topic',
|
||||
defaultKey: ['CommandOrControl', 'T'],
|
||||
defaultEnabled: false,
|
||||
description: 'Rename current topic',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'toggle_show_assistants',
|
||||
defaultKey: ['CommandOrControl', '['],
|
||||
defaultEnabled: true,
|
||||
description: 'Toggle assistant sidebar',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'toggle_show_topics',
|
||||
defaultKey: ['CommandOrControl', ']'],
|
||||
defaultEnabled: true,
|
||||
description: 'Toggle topic sidebar',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'copy_last_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'C'],
|
||||
defaultEnabled: false,
|
||||
description: 'Copy the last assistant reply',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'edit_last_user_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'E'],
|
||||
defaultEnabled: false,
|
||||
description: 'Edit the last user message',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'search_message_in_chat',
|
||||
defaultKey: ['CommandOrControl', 'F'],
|
||||
defaultEnabled: true,
|
||||
description: 'Search messages in current chat',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'search_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'F'],
|
||||
defaultEnabled: true,
|
||||
description: 'Search messages globally',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'clear_topic',
|
||||
defaultKey: ['CommandOrControl', 'L'],
|
||||
defaultEnabled: true,
|
||||
description: 'Clear current topic',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'toggle_new_context',
|
||||
defaultKey: ['CommandOrControl', 'K'],
|
||||
defaultEnabled: true,
|
||||
description: 'Toggle new context mode',
|
||||
scope: 'renderer',
|
||||
editable: true,
|
||||
system: false
|
||||
},
|
||||
{
|
||||
name: 'exit_fullscreen',
|
||||
defaultKey: ['Escape'],
|
||||
defaultEnabled: true,
|
||||
description: 'Exit fullscreen mode',
|
||||
scope: 'renderer',
|
||||
editable: false,
|
||||
system: true
|
||||
}
|
||||
]
|
||||
25
packages/shared/shortcuts/types.ts
Normal file
25
packages/shared/shortcuts/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type ShortcutScope = 'main' | 'renderer'
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
name: string
|
||||
defaultKey: string[]
|
||||
defaultEnabled: boolean
|
||||
description: string
|
||||
scope: ShortcutScope
|
||||
editable: boolean
|
||||
system: boolean
|
||||
}
|
||||
|
||||
export interface ShortcutPreferenceEntry {
|
||||
key?: string[]
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type ShortcutPreferenceMap = Record<string, ShortcutPreferenceEntry>
|
||||
|
||||
export type HydratedShortcut = ShortcutDefinition & {
|
||||
key: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type HydratedShortcutMap = Record<string, HydratedShortcut>
|
||||
@@ -48,7 +48,7 @@ Usage Instructions:
|
||||
- pt-pt (Portuguese)
|
||||
|
||||
Run Command:
|
||||
yarn i18n:auto
|
||||
yarn auto:i18n
|
||||
|
||||
Performance Optimization Recommendations:
|
||||
- For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50
|
||||
@@ -257,6 +257,7 @@ const main = async () => {
|
||||
validateConfig()
|
||||
|
||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||
const baseLocale = process.env.TRANSLATION_BASE_LOCALE ?? 'en-us'
|
||||
const baseFileName = `${baseLocale}.json`
|
||||
const baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
|
||||
@@ -271,16 +272,19 @@ const main = async () => {
|
||||
console.log('')
|
||||
|
||||
// Process files using ES6+ array methods
|
||||
const files = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => {
|
||||
const filename = file.replace('.json', '')
|
||||
return file.endsWith('.json') && file !== baseFileName && !SCRIPT_CONFIG.SKIP_LANGUAGES.includes(filename)
|
||||
})
|
||||
.map((filename) => path.join(localesDir, filename))
|
||||
const getFiles = (dir: string) =>
|
||||
fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => {
|
||||
const filename = file.replace('.json', '')
|
||||
return file.endsWith('.json') && file !== baseFileName && !SCRIPT_CONFIG.SKIP_LANGUAGES.includes(filename)
|
||||
})
|
||||
.map((filename) => path.join(dir, filename))
|
||||
const localeFiles = getFiles(localesDir)
|
||||
const translateFiles = getFiles(translateDir)
|
||||
const files = [...localeFiles, ...translateFiles]
|
||||
|
||||
console.info(`📂 Base Locale: ${baseLocale}`)
|
||||
|
||||
console.info('📂 Files to translate:')
|
||||
files.forEach((filePath) => {
|
||||
const filename = path.basename(filePath, '.json')
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path'
|
||||
import { sortedObjectByKeys } from './sort'
|
||||
|
||||
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
const baseLocale = process.env.BASE_LOCALE ?? 'en-us'
|
||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||
const baseFileName = `${baseLocale}.json`
|
||||
const baseFilePath = path.join(translationsDir, baseFileName)
|
||||
|
||||
@@ -12,41 +12,39 @@ type I18NValue = string | { [key: string]: I18NValue }
|
||||
type I18N = { [key: string]: I18NValue }
|
||||
|
||||
/**
|
||||
* Recursively check and synchronize the key-value structure of target object with template object
|
||||
* 1. If target object is missing keys from template object, throw error
|
||||
* 2. If target object has keys that don't exist in template object, throw error
|
||||
* 3. For nested objects, recursively perform synchronization operation
|
||||
* 递归检查并同步目标对象与模板对象的键值结构
|
||||
* 1. 如果目标对象缺少模板对象中的键,抛出错误
|
||||
* 2. 如果目标对象存在模板对象中不存在的键,抛出错误
|
||||
* 3. 对于嵌套对象,递归执行同步操作
|
||||
*
|
||||
* This function ensures all translation files maintain completely consistent key-value structure
|
||||
* with the base template (usually the base translation file).
|
||||
* Any structural differences will cause errors to be thrown for timely detection and fixing
|
||||
* of translation file issues.
|
||||
* 该函数用于确保所有翻译文件与基准模板(通常是中文翻译文件)保持完全一致的键值结构。
|
||||
* 任何结构上的差异都会导致错误被抛出,以便及时发现和修复翻译文件中的问题。
|
||||
*
|
||||
* @param target The target translation object to check
|
||||
* @param template The template object used as base (usually the base translation file)
|
||||
* @throws {Error} Thrown when key-value structure mismatch is found
|
||||
* @param target 需要检查的目标翻译对象
|
||||
* @param template 作为基准的模板对象(通常是中文翻译文件)
|
||||
* @throws {Error} 当发现键值结构不匹配时抛出错误
|
||||
*/
|
||||
function checkRecursively(target: I18N, template: I18N): void {
|
||||
for (const key in template) {
|
||||
if (!(key in target)) {
|
||||
throw new Error(`Missing property ${key}`)
|
||||
throw new Error(`缺少属性 ${key}`)
|
||||
}
|
||||
if (key.includes('.')) {
|
||||
throw new Error(`Should use strict nested structure for key ${key}`)
|
||||
throw new Error(`应该使用严格嵌套结构 ${key}`)
|
||||
}
|
||||
if (typeof template[key] === 'object' && template[key] !== null) {
|
||||
if (typeof target[key] !== 'object' || target[key] === null) {
|
||||
throw new Error(`Property ${key} is not an object`)
|
||||
throw new Error(`属性 ${key} 不是对象`)
|
||||
}
|
||||
// Recursively check child objects
|
||||
// 递归检查子对象
|
||||
checkRecursively(target[key], template[key])
|
||||
}
|
||||
}
|
||||
|
||||
// Remove keys that exist in target but not in template
|
||||
// 删除 target 中存在但 template 中没有的 key
|
||||
for (const targetKey in target) {
|
||||
if (!(targetKey in template)) {
|
||||
throw new Error(`Extra property ${targetKey}`)
|
||||
throw new Error(`多余属性 ${targetKey}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,9 +56,9 @@ function isSortedI18N(obj: I18N): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for duplicate keys in JSON object and collect all duplicate keys
|
||||
* @param obj The object to check
|
||||
* @returns Array of duplicate keys (returns empty array if no duplicates)
|
||||
* 检查 JSON 对象中是否存在重复键,并收集所有重复键
|
||||
* @param obj 要检查的对象
|
||||
* @returns 返回重复键的数组(若无重复则返回空数组)
|
||||
*/
|
||||
function checkDuplicateKeys(obj: I18N): string[] {
|
||||
const keys = new Set<string>()
|
||||
@@ -71,7 +69,7 @@ function checkDuplicateKeys(obj: I18N): string[] {
|
||||
const fullPath = path ? `${path}.${key}` : key
|
||||
|
||||
if (keys.has(fullPath)) {
|
||||
// When duplicate key is found, add to array (avoid duplicate additions)
|
||||
// 发现重复键时,添加到数组中(避免重复添加)
|
||||
if (!duplicateKeys.includes(fullPath)) {
|
||||
duplicateKeys.push(fullPath)
|
||||
}
|
||||
@@ -79,7 +77,7 @@ function checkDuplicateKeys(obj: I18N): string[] {
|
||||
keys.add(fullPath)
|
||||
}
|
||||
|
||||
// Recursively check child objects
|
||||
// 递归检查子对象
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
checkObject(obj[key], fullPath)
|
||||
}
|
||||
@@ -92,7 +90,7 @@ function checkDuplicateKeys(obj: I18N): string[] {
|
||||
|
||||
function checkTranslations() {
|
||||
if (!fs.existsSync(baseFilePath)) {
|
||||
throw new Error(`Base template file ${baseFileName} does not exist, please check path or filename`)
|
||||
throw new Error(`主模板文件 ${baseFileName} 不存在,请检查路径或文件名`)
|
||||
}
|
||||
|
||||
const baseContent = fs.readFileSync(baseFilePath, 'utf-8')
|
||||
@@ -100,23 +98,23 @@ function checkTranslations() {
|
||||
try {
|
||||
baseJson = JSON.parse(baseContent)
|
||||
} catch (error) {
|
||||
throw new Error(`Error parsing ${baseFileName}. ${error}`)
|
||||
throw new Error(`解析 ${baseFileName} 出错。${error}`)
|
||||
}
|
||||
|
||||
// Check if base template has duplicate keys
|
||||
// 检查主模板是否存在重复键
|
||||
const duplicateKeys = checkDuplicateKeys(baseJson)
|
||||
if (duplicateKeys.length > 0) {
|
||||
throw new Error(`Base template file ${baseFileName} has the following duplicate keys:\n${duplicateKeys.join('\n')}`)
|
||||
throw new Error(`主模板文件 ${baseFileName} 存在以下重复键:\n${duplicateKeys.join('\n')}`)
|
||||
}
|
||||
|
||||
// Check if base template is sorted
|
||||
// 检查主模板是否有序
|
||||
if (!isSortedI18N(baseJson)) {
|
||||
throw new Error(`Base template file ${baseFileName} keys are not sorted in dictionary order.`)
|
||||
throw new Error(`主模板文件 ${baseFileName} 的键值未按字典序排序。`)
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(translationsDir).filter((file) => file.endsWith('.json') && file !== baseFileName)
|
||||
|
||||
// Sync keys
|
||||
// 同步键
|
||||
for (const file of files) {
|
||||
const filePath = path.join(translationsDir, file)
|
||||
let targetJson: I18N = {}
|
||||
@@ -124,19 +122,19 @@ function checkTranslations() {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||
targetJson = JSON.parse(fileContent)
|
||||
} catch (error) {
|
||||
throw new Error(`Error parsing ${file}.`)
|
||||
throw new Error(`解析 ${file} 出错。`)
|
||||
}
|
||||
|
||||
// Check if sorted
|
||||
// 检查有序性
|
||||
if (!isSortedI18N(targetJson)) {
|
||||
throw new Error(`Translation file ${file} keys are not sorted.`)
|
||||
throw new Error(`翻译文件 ${file} 的键值未按字典序排序。`)
|
||||
}
|
||||
|
||||
try {
|
||||
checkRecursively(targetJson, baseJson)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error(`Error while checking ${filePath}`)
|
||||
throw new Error(`在检查 ${filePath} 时出错`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,10 +142,10 @@ function checkTranslations() {
|
||||
export function main() {
|
||||
try {
|
||||
checkTranslations()
|
||||
console.log('i18n check passed')
|
||||
console.log('i18n 检查已通过')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw new Error(`Check failed. Try running yarn i18n:sync to fix the issue.`)
|
||||
throw new Error(`检查未通过。尝试运行 yarn sync:i18n 以解决问题。`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
// https://github.com/Gudahtt/prettier-plugin-sort-json/blob/main/src/index.ts
|
||||
/**
|
||||
* Natural sort function for strings, meant to be used as the sort
|
||||
* Lexical sort function for strings, meant to be used as the sort
|
||||
* function for `Array.prototype.sort`.
|
||||
*
|
||||
* @param a - First element to compare.
|
||||
* @param b - Second element to compare.
|
||||
* @returns A number indicating which element should come first.
|
||||
*/
|
||||
function naturalSort(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||
function lexicalSort(a: string, b: string): number {
|
||||
if (a > b) {
|
||||
return 1
|
||||
}
|
||||
if (a < b) {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort object keys in dictionary order (supports nested objects)
|
||||
* @param obj The object to be sorted
|
||||
* @returns A new object with sorted keys
|
||||
* 对对象的键按照字典序进行排序(支持嵌套对象)
|
||||
* @param obj 需要排序的对象
|
||||
* @returns 返回排序后的新对象
|
||||
*/
|
||||
export function sortedObjectByKeys(obj: object): object {
|
||||
const sortedKeys = Object.keys(obj).sort(naturalSort)
|
||||
const sortedKeys = Object.keys(obj).sort(lexicalSort)
|
||||
|
||||
const sortedObj = {}
|
||||
for (const key of sortedKeys) {
|
||||
let value = obj[key]
|
||||
// If the value is an object, sort it recursively
|
||||
// 如果值是对象,递归排序
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
value = sortedObjectByKeys(value)
|
||||
}
|
||||
|
||||
147
scripts/update-i18n.ts
Normal file
147
scripts/update-i18n.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 使用 OpenAI 兼容的模型生成 i18n 文本,并更新到 translate 目录
|
||||
*
|
||||
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
|
||||
*/
|
||||
|
||||
import OpenAI from '@cherrystudio/openai'
|
||||
import cliProgress from 'cli-progress'
|
||||
import fs from 'fs'
|
||||
|
||||
type I18NValue = string | { [key: string]: I18NValue }
|
||||
type I18N = { [key: string]: I18NValue }
|
||||
|
||||
const API_KEY = process.env.API_KEY
|
||||
const BASE_URL = process.env.BASE_URL || 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
const MODEL = process.env.MODEL || 'qwen-plus-latest'
|
||||
|
||||
const INDEX = [
|
||||
// 语言的名称代码用来翻译的模型
|
||||
{ name: 'France', code: 'fr-fr', model: MODEL },
|
||||
{ name: 'Spanish', code: 'es-es', model: MODEL },
|
||||
{ name: 'Portuguese', code: 'pt-pt', model: MODEL },
|
||||
{ name: 'Greek', code: 'el-gr', model: MODEL }
|
||||
]
|
||||
|
||||
const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as I18N
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL
|
||||
})
|
||||
|
||||
// 递归遍历翻译
|
||||
async function translate(baseObj: I18N, targetObj: I18N, targetLang: string, model: string, updateFile) {
|
||||
const toTranslateTexts: { [key: string]: string } = {}
|
||||
for (const key in baseObj) {
|
||||
if (typeof baseObj[key] == 'object') {
|
||||
// 遍历下一层
|
||||
if (!targetObj[key] || typeof targetObj[key] != 'object') targetObj[key] = {}
|
||||
await translate(baseObj[key], targetObj[key], targetLang, model, updateFile)
|
||||
} else if (
|
||||
!targetObj[key] ||
|
||||
typeof targetObj[key] != 'string' ||
|
||||
(typeof targetObj[key] === 'string' && targetObj[key].startsWith('[to be translated]'))
|
||||
) {
|
||||
// 加入到本层待翻译列表
|
||||
toTranslateTexts[key] = baseObj[key]
|
||||
}
|
||||
}
|
||||
if (Object.keys(toTranslateTexts).length > 0) {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `
|
||||
You are a robot specifically designed for translation tasks. As a model that has been extensively fine-tuned on Russian language corpora, you are proficient in using the Russian language.
|
||||
Now, please output the translation based on the input content. The input will include both Chinese and English key values, and you should output the corresponding key values in the Russian language.
|
||||
When translating, ensure that no key value is omitted, and maintain the accuracy and fluency of the translation. Pay attention to the capitalization rules in the output to match the source text, and especially pay attention to whether to capitalize the first letter of each word except for prepositions. For strings containing \`{{value}}\`, ensure that the format is not disrupted.
|
||||
Output in JSON.
|
||||
######################################################
|
||||
INPUT
|
||||
######################################################
|
||||
${JSON.stringify({
|
||||
confirm: '确定要备份数据吗?',
|
||||
select_model: '选择模型',
|
||||
title: '文件',
|
||||
deeply_thought: '已深度思考(用时 {{seconds}} 秒)'
|
||||
})}
|
||||
######################################################
|
||||
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
|
||||
######################################################
|
||||
`
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: JSON.stringify({
|
||||
confirm: 'Подтвердите резервное копирование данных?',
|
||||
select_model: 'Выберите Модель',
|
||||
title: 'Файл',
|
||||
deeply_thought: 'Глубоко продумано (заняло {{seconds}} секунд)'
|
||||
})
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `
|
||||
You are a robot specifically designed for translation tasks. As a model that has been extensively fine-tuned on ${targetLang} language corpora, you are proficient in using the ${targetLang} language.
|
||||
Now, please output the translation based on the input content. The input will include both Chinese and English key values, and you should output the corresponding key values in the ${targetLang} language.
|
||||
When translating, ensure that no key value is omitted, and maintain the accuracy and fluency of the translation. Pay attention to the capitalization rules in the output to match the source text, and especially pay attention to whether to capitalize the first letter of each word except for prepositions. For strings containing \`{{value}}\`, ensure that the format is not disrupted.
|
||||
Output in JSON.
|
||||
######################################################
|
||||
INPUT
|
||||
######################################################
|
||||
${JSON.stringify(toTranslateTexts)}
|
||||
######################################################
|
||||
MAKE SURE TO OUTPUT IN ${targetLang}. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
|
||||
######################################################
|
||||
`
|
||||
}
|
||||
]
|
||||
})
|
||||
// 添加翻译后的键值,并打印错译漏译内容
|
||||
try {
|
||||
const result = JSON.parse(completion.choices[0].message.content!)
|
||||
// console.debug('result', result)
|
||||
for (const e in toTranslateTexts) {
|
||||
if (result[e] && typeof result[e] === 'string') {
|
||||
targetObj[e] = result[e]
|
||||
} else {
|
||||
console.warn(`missing value "${e}" in ${targetLang} translation`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
for (const e in toTranslateTexts) {
|
||||
console.warn(`missing value "${e}" in ${targetLang} translation`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 删除多余的键值
|
||||
for (const e in targetObj) {
|
||||
if (!baseObj[e]) {
|
||||
delete targetObj[e]
|
||||
}
|
||||
}
|
||||
// 更新文件
|
||||
updateFile()
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
;(async () => {
|
||||
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic)
|
||||
bar.start(INDEX.length, 0)
|
||||
for (const { name, code, model } of INDEX) {
|
||||
const obj = fs.existsSync(`src/renderer/src/i18n/translate/${code}.json`)
|
||||
? (JSON.parse(fs.readFileSync(`src/renderer/src/i18n/translate/${code}.json`, 'utf8')) as I18N)
|
||||
: {}
|
||||
await translate(zh, obj, name, model, () => {
|
||||
fs.writeFileSync(`src/renderer/src/i18n/translate/${code}.json`, JSON.stringify(obj, null, 2), 'utf8')
|
||||
})
|
||||
count += 1
|
||||
bar.update(count)
|
||||
}
|
||||
bar.stop()
|
||||
})()
|
||||
@@ -36,8 +36,6 @@ import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefa
|
||||
import { dataApiService } from '@data/DataApiService'
|
||||
import { cacheService } from '@data/CacheService'
|
||||
import { initWebviewHotkeys } from './services/WebviewService'
|
||||
import { i18n } from './utils/language'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
|
||||
const logger = loggerService.withContext('MainEntry')
|
||||
|
||||
@@ -171,22 +169,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
await preferenceService.initialize()
|
||||
|
||||
const userLanguage = preferenceService.get('app.language')
|
||||
if (userLanguage) {
|
||||
i18n.changeLanguage(userLanguage)
|
||||
// Do not care about cleanup because it spans the whole lifecyle of the app
|
||||
preferenceService.subscribeChange('app.language', (newLang) => {
|
||||
if (newLang) {
|
||||
i18n.changeLanguage(newLang)
|
||||
} else {
|
||||
logger.error('New langauge is null, skip.')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.error('No user language preference found, falling back to default language')
|
||||
i18n.changeLanguage(defaultLanguage)
|
||||
}
|
||||
|
||||
// Initialize DataApiService
|
||||
await dataApiService.initialize()
|
||||
|
||||
|
||||
148
src/main/ipc.ts
148
src/main/ipc.ts
@@ -14,11 +14,13 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
||||
import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
|
||||
import type {
|
||||
AgentPersistedMessage,
|
||||
FileMetadata,
|
||||
Notification,
|
||||
OcrProvider,
|
||||
PluginError,
|
||||
Provider,
|
||||
Shortcut,
|
||||
SupportedOcrFile
|
||||
@@ -34,7 +36,6 @@ import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { codeToolsService } from './services/CodeToolsService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@@ -49,12 +50,12 @@ import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import OvmsManager from './services/OvmsManager'
|
||||
import { PluginService } from './services/PluginService'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import {
|
||||
addEndMessage,
|
||||
addStreamMessage,
|
||||
@@ -95,6 +96,18 @@ const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
const ovmsManager = new OvmsManager()
|
||||
const pluginService = PluginService.getInstance()
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function extractPluginError(error: unknown): PluginError | null {
|
||||
if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') {
|
||||
return error as PluginError
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
@@ -568,13 +581,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// shortcuts
|
||||
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
|
||||
configManager.setShortcuts(shortcuts)
|
||||
// Refresh shortcuts registration
|
||||
if (mainWindow) {
|
||||
unregisterAllShortcuts()
|
||||
registerShortcuts(mainWindow)
|
||||
ipcMain.handle(IpcChannel.Shortcuts_Update, async (_, shortcuts: Shortcut[]) => {
|
||||
const existingPreferences = preferenceService.get('shortcut.preferences') ?? {}
|
||||
const nextPreferences: ShortcutPreferenceMap = { ...existingPreferences }
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const name = shortcut.key === 'mini_window' ? 'show_mini_window' : shortcut.key
|
||||
nextPreferences[name] = {
|
||||
key: [...shortcut.shortcut],
|
||||
enabled: shortcut.enabled
|
||||
}
|
||||
}
|
||||
|
||||
await preferenceService.set('shortcut.preferences', nextPreferences)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
|
||||
@@ -894,6 +913,119 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// CherryAI
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
// Claude Code Plugins
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => {
|
||||
try {
|
||||
const data = await pluginService.listAvailable()
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to list available plugins', pluginError)
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to list available plugins', err)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation: 'list-available',
|
||||
reason: err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => {
|
||||
try {
|
||||
const data = await pluginService.install(options)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
logger.error('Failed to install plugin', { options, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => {
|
||||
try {
|
||||
await pluginService.uninstall(options)
|
||||
return { success: true, data: undefined }
|
||||
} catch (error) {
|
||||
logger.error('Failed to uninstall plugin', { options, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => {
|
||||
try {
|
||||
const data = await pluginService.listInstalled(agentId)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to list installed plugins', { agentId, error: err })
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation: 'list-installed',
|
||||
reason: err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => {
|
||||
try {
|
||||
pluginService.invalidateCache()
|
||||
return { success: true, data: undefined }
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to invalidate plugin cache', pluginError)
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to invalidate plugin cache', err)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'TRANSACTION_FAILED',
|
||||
operation: 'invalidate-cache',
|
||||
reason: err.message
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => {
|
||||
try {
|
||||
const data = await pluginService.readContent(sourcePath)
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
logger.error('Failed to read plugin content', { sourcePath, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => {
|
||||
try {
|
||||
await pluginService.writeContent(options.agentId, options.filename, options.type, options.content)
|
||||
return { success: true, data: undefined }
|
||||
} catch (error) {
|
||||
logger.error('Failed to write plugin content', { options, error })
|
||||
return { success: false, error }
|
||||
}
|
||||
})
|
||||
|
||||
// Preference handlers
|
||||
PreferenceService.registerIpcHandler()
|
||||
}
|
||||
|
||||
199
src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts
Normal file
199
src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import type { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { net } from 'electron'
|
||||
import FormData from 'form-data'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
const logger = loggerService.withContext('MineruPreprocessProvider')
|
||||
|
||||
export default class OpenMineruPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider, userId?: string) {
|
||||
super(provider, userId)
|
||||
}
|
||||
|
||||
public async parseFile(
|
||||
sourceId: string,
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||
try {
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
logger.info(`Open MinerU preprocess processing started: ${filePath}`)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 1. Update progress
|
||||
await this.sendPreprocessProgress(sourceId, 50)
|
||||
logger.info(`File ${file.name} is starting processing...`)
|
||||
|
||||
// 2. Upload file and extract
|
||||
const { path: outputPath } = await this.uploadFileAndExtract(file)
|
||||
|
||||
// 3. Check quota
|
||||
const quota = await this.checkQuota()
|
||||
|
||||
// 4. Create processed file info
|
||||
return {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath),
|
||||
quota
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Open MinerU preprocess processing failed for:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuota() {
|
||||
// self-hosted version always has enough quota
|
||||
return Infinity
|
||||
}
|
||||
|
||||
private async validateFile(filePath: string): Promise<void> {
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const doc = await this.readPdf(pdfBuffer)
|
||||
|
||||
// File page count must be less than 600 pages
|
||||
if (doc.numPages >= 600) {
|
||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
||||
}
|
||||
// File size must be less than 200MB
|
||||
if (pdfBuffer.length >= 200 * 1024 * 1024) {
|
||||
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
|
||||
}
|
||||
}
|
||||
|
||||
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||
// Find the main file after extraction
|
||||
let finalPath = ''
|
||||
let finalName = file.origin_name.replace('.pdf', '.md')
|
||||
// Find the corresponding folder by file name
|
||||
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
|
||||
try {
|
||||
const files = fs.readdirSync(outputPath)
|
||||
|
||||
const mdFile = files.find((f) => f.endsWith('.md'))
|
||||
if (mdFile) {
|
||||
const originalMdPath = path.join(outputPath, mdFile)
|
||||
const newMdPath = path.join(outputPath, finalName)
|
||||
|
||||
// Rename file to original file name
|
||||
try {
|
||||
fs.renameSync(originalMdPath, newMdPath)
|
||||
finalPath = newMdPath
|
||||
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
|
||||
} catch (renameError) {
|
||||
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
|
||||
// If rename fails, use the original file
|
||||
finalPath = originalMdPath
|
||||
finalName = mdFile
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to read output directory ${outputPath}:`, error as Error)
|
||||
finalPath = path.join(outputPath, `${file.id}.md`)
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
name: finalName,
|
||||
path: finalPath,
|
||||
ext: '.md',
|
||||
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFileAndExtract(
|
||||
file: FileMetadata,
|
||||
maxRetries: number = 5,
|
||||
intervalMs: number = 5000
|
||||
): Promise<{ path: string }> {
|
||||
let retries = 0
|
||||
|
||||
const endpoint = `${this.provider.apiHost}/file_parse`
|
||||
|
||||
// Get file stream
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
const fileBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('return_md', 'true')
|
||||
formData.append('response_format_zip', 'true')
|
||||
formData.append('files', fileBuffer, {
|
||||
filename: file.origin_name
|
||||
})
|
||||
|
||||
while (retries < maxRetries) {
|
||||
let zipPath: string | undefined
|
||||
|
||||
try {
|
||||
const response = await net.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
token: this.userId ?? '',
|
||||
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
|
||||
...formData.getHeaders()
|
||||
},
|
||||
body: formData.getBuffer()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Check if response header is application/zip
|
||||
if (response.headers.get('content-type') !== 'application/zip') {
|
||||
throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`)
|
||||
}
|
||||
|
||||
const dirPath = this.storageDir
|
||||
|
||||
zipPath = path.join(dirPath, `${file.id}.zip`)
|
||||
const extractPath = path.join(dirPath, `${file.id}`)
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
|
||||
logger.info(`Downloaded ZIP file: ${zipPath}`)
|
||||
|
||||
// Ensure extraction directory exists
|
||||
if (!fs.existsSync(extractPath)) {
|
||||
fs.mkdirSync(extractPath, { recursive: true })
|
||||
}
|
||||
|
||||
// Extract files
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(extractPath, true)
|
||||
logger.info(`Extracted files to: ${extractPath}`)
|
||||
|
||||
return { path: extractPath }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}`
|
||||
)
|
||||
if (retries === maxRetries - 1) {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
// Delete temporary ZIP file
|
||||
if (zipPath && fs.existsSync(zipPath)) {
|
||||
try {
|
||||
fs.unlinkSync(zipPath)
|
||||
logger.info(`Deleted temporary ZIP file: ${zipPath}`)
|
||||
} catch (deleteError) {
|
||||
logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retries++
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||
}
|
||||
|
||||
throw new Error(`Processing timeout for file: ${file.id}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import DefaultPreprocessProvider from './DefaultPreprocessProvider'
|
||||
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
|
||||
import MineruPreprocessProvider from './MineruPreprocessProvider'
|
||||
import MistralPreprocessProvider from './MistralPreprocessProvider'
|
||||
import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider'
|
||||
export default class PreprocessProviderFactory {
|
||||
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
|
||||
switch (provider.id) {
|
||||
@@ -14,6 +15,8 @@ export default class PreprocessProviderFactory {
|
||||
return new MistralPreprocessProvider(provider)
|
||||
case 'mineru':
|
||||
return new MineruPreprocessProvider(provider, userId)
|
||||
case 'open-mineru':
|
||||
return new OpenMineruPreprocessProvider(provider, userId)
|
||||
default:
|
||||
return new DefaultPreprocessProvider(provider)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from '@main/utils/language'
|
||||
import { getI18n } from '@main/utils/language'
|
||||
import type { MenuItemConstructorOptions } from 'electron'
|
||||
import { Menu } from 'electron'
|
||||
|
||||
@@ -26,10 +26,12 @@ class ContextMenu {
|
||||
}
|
||||
|
||||
private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] {
|
||||
const i18n = getI18n()
|
||||
const { common } = i18n.translation
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
id: 'inspect',
|
||||
label: t('common.inspect'),
|
||||
label: common.inspect,
|
||||
click: () => {
|
||||
w.toggleDevTools()
|
||||
},
|
||||
@@ -41,27 +43,29 @@ class ContextMenu {
|
||||
}
|
||||
|
||||
private createEditMenuItems(properties: Electron.ContextMenuParams): MenuItemConstructorOptions[] {
|
||||
const i18n = getI18n()
|
||||
const { common } = i18n.translation
|
||||
const hasText = properties.selectionText.trim().length > 0
|
||||
const can = (type: string) => properties.editFlags[`can${type}`] && hasText
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
id: 'copy',
|
||||
label: t('common.copy'),
|
||||
label: common.copy,
|
||||
role: 'copy',
|
||||
enabled: can('Copy'),
|
||||
visible: properties.isEditable || hasText
|
||||
},
|
||||
{
|
||||
id: 'paste',
|
||||
label: t('common.paste'),
|
||||
label: common.paste,
|
||||
role: 'paste',
|
||||
enabled: properties.editFlags.canPaste,
|
||||
visible: properties.isEditable
|
||||
},
|
||||
{
|
||||
id: 'cut',
|
||||
label: t('common.cut'),
|
||||
label: common.cut,
|
||||
role: 'cut',
|
||||
enabled: can('Cut'),
|
||||
visible: properties.isEditable
|
||||
|
||||
1171
src/main/services/PluginService.ts
Normal file
1171
src/main/services/PluginService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,298 +1,349 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { loggerService } from '@logger'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import type { Shortcut } from '@types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||
import type { HydratedShortcut, ShortcutDefinition, ShortcutPreferenceMap } from '@shared/shortcuts/types'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { globalShortcut } from 'electron'
|
||||
import { BrowserWindow as ElectronBrowserWindow, globalShortcut, ipcMain } from 'electron'
|
||||
|
||||
import { 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
|
||||
type ShortcutHandler = (window: BrowserWindow | undefined) => void
|
||||
|
||||
//indicate if the shortcuts are registered on app boot time
|
||||
let isRegisterOnBoot = true
|
||||
class ShortcutService {
|
||||
private handlers = new Map<string, ShortcutHandler>()
|
||||
private hydratedShortcuts = new Map<string, HydratedShortcut>()
|
||||
private registeredAccelerators = new Map<string, string[]>()
|
||||
private readonly definitionMap = new Map<string, ShortcutDefinition>()
|
||||
private ipcRegistered = false
|
||||
|
||||
// store the focus and blur handlers for each window to unregister them later
|
||||
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
|
||||
constructor() {
|
||||
this.definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
|
||||
|
||||
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()
|
||||
this.setupIpcHandlers()
|
||||
this.registerDefaultHandlers()
|
||||
this.hydrateShortcuts()
|
||||
this.registerPreferenceListeners()
|
||||
}
|
||||
|
||||
public registerHandler(name: string, handler: ShortcutHandler) {
|
||||
if (this.handlers.has(name)) {
|
||||
logger.warn(`Handler for shortcut '${name}' is being overwritten.`)
|
||||
}
|
||||
this.handlers.set(name, handler)
|
||||
}
|
||||
|
||||
public registerMainProcessShortcuts(window?: BrowserWindow) {
|
||||
const targetWindow = this.getTargetWindow(window)
|
||||
|
||||
this.unregisterTrackedAccelerators()
|
||||
|
||||
for (const config of this.hydratedShortcuts.values()) {
|
||||
if (config.scope !== 'main') {
|
||||
continue
|
||||
}
|
||||
case 'mini_window':
|
||||
return () => {
|
||||
windowService.toggleMiniWindow()
|
||||
|
||||
if (!config.enabled || config.key.length === 0) {
|
||||
continue
|
||||
}
|
||||
case 'selection_assistant_toggle':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
|
||||
const handler = this.handlers.get(config.name)
|
||||
if (!handler) {
|
||||
logger.warn(`No handler registered for shortcut '${config.name}'.`)
|
||||
continue
|
||||
}
|
||||
|
||||
const accelerators = this.buildAccelerators(config)
|
||||
if (accelerators.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const accelerator of accelerators) {
|
||||
try {
|
||||
const registered = globalShortcut.register(accelerator, () => {
|
||||
try {
|
||||
handler(this.getTargetWindow(targetWindow))
|
||||
} catch (error) {
|
||||
logger.error(`Error while executing handler for shortcut '${config.name}':`, error as Error)
|
||||
}
|
||||
})
|
||||
|
||||
if (!registered) {
|
||||
logger.warn(`Electron rejected shortcut accelerator '${accelerator}' for '${config.name}'.`)
|
||||
continue
|
||||
}
|
||||
|
||||
this.trackAccelerator(config.name, accelerator)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to register shortcut '${config.name}' with accelerator '${accelerator}':`, error as Error)
|
||||
}
|
||||
}
|
||||
case 'selection_assistant_select_text':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.processSelectTextByShortcut()
|
||||
}
|
||||
|
||||
this.broadcastShortcuts()
|
||||
}
|
||||
|
||||
public unregisterAllShortcuts() {
|
||||
this.unregisterTrackedAccelerators()
|
||||
}
|
||||
|
||||
public getHydratedShortcuts(): Record<string, HydratedShortcut> {
|
||||
return Object.fromEntries(
|
||||
[...this.hydratedShortcuts.entries()].map(([name, config]) => [
|
||||
name,
|
||||
{
|
||||
...config,
|
||||
key: [...config.key]
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
private setupIpcHandlers() {
|
||||
if (this.ipcRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
ipcMain.handle(IpcChannel.Shortcuts_GetAll, () => {
|
||||
return this.getHydratedShortcuts()
|
||||
})
|
||||
|
||||
this.ipcRegistered = true
|
||||
}
|
||||
|
||||
private registerPreferenceListeners() {
|
||||
preferenceService.subscribeChange('shortcut.preferences', (newPreferences) => {
|
||||
this.hydrateAndRegister(newPreferences)
|
||||
})
|
||||
}
|
||||
|
||||
private hydrateAndRegister(preferences?: ShortcutPreferenceMap) {
|
||||
this.hydrateShortcuts(preferences)
|
||||
this.registerMainProcessShortcuts()
|
||||
}
|
||||
|
||||
private hydrateShortcuts(preferences?: ShortcutPreferenceMap) {
|
||||
const preferenceSnapshot = preferences ?? preferenceService.get('shortcut.preferences')
|
||||
|
||||
this.hydratedShortcuts.clear()
|
||||
|
||||
for (const definition of shortcutDefinitions) {
|
||||
const userPreference = preferenceSnapshot?.[definition.name]
|
||||
const key =
|
||||
userPreference?.key && userPreference.key.length > 0 ? [...userPreference.key] : [...definition.defaultKey]
|
||||
const enabled = typeof userPreference?.enabled === 'boolean' ? userPreference.enabled : definition.defaultEnabled
|
||||
|
||||
this.hydratedShortcuts.set(definition.name, {
|
||||
...definition,
|
||||
key,
|
||||
enabled
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastShortcuts() {
|
||||
const payload = this.getHydratedShortcuts()
|
||||
|
||||
for (const window of ElectronBrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
window.webContents.send(IpcChannel.Shortcuts_Updated, payload)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to broadcast shortcut update to renderer window:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unregisterTrackedAccelerators() {
|
||||
for (const accelerators of this.registeredAccelerators.values()) {
|
||||
for (const accelerator of accelerators) {
|
||||
try {
|
||||
globalShortcut.unregister(accelerator)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to unregister accelerator '${accelerator}':`, error as Error)
|
||||
}
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
this.registeredAccelerators.clear()
|
||||
}
|
||||
|
||||
private trackAccelerator(name: string, accelerator: string) {
|
||||
if (!this.registeredAccelerators.has(name)) {
|
||||
this.registeredAccelerators.set(name, [])
|
||||
}
|
||||
this.registeredAccelerators.get(name)!.push(accelerator)
|
||||
}
|
||||
|
||||
private buildAccelerators(config: HydratedShortcut): string[] {
|
||||
if (config.key.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const baseAccelerator = this.normalizeAccelerator(config.key)
|
||||
if (!baseAccelerator) {
|
||||
logger.warn(`Invalid shortcut configuration for '${config.name}', skipping registration.`)
|
||||
return []
|
||||
}
|
||||
|
||||
if (config.name === 'zoom_in' && this.isUsingDefaultKey(config)) {
|
||||
return [baseAccelerator, 'CommandOrControl+numadd']
|
||||
}
|
||||
|
||||
if (config.name === 'zoom_out' && this.isUsingDefaultKey(config)) {
|
||||
return [baseAccelerator, 'CommandOrControl+numsub']
|
||||
}
|
||||
|
||||
if (config.name === 'zoom_reset' && this.isUsingDefaultKey(config)) {
|
||||
return [baseAccelerator, 'CommandOrControl+num0']
|
||||
}
|
||||
|
||||
return [baseAccelerator]
|
||||
}
|
||||
|
||||
private isUsingDefaultKey(config: HydratedShortcut): boolean {
|
||||
const definition = this.definitionMap.get(config.name)
|
||||
if (!definition) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (definition.defaultKey.length !== config.key.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return definition.defaultKey.every((key, index) => key === config.key[index])
|
||||
}
|
||||
|
||||
private normalizeAccelerator(keys: string[]): string | null {
|
||||
const normalizedKeys = keys.map((key) => this.normalizeKeyForElectron(key)).filter((key): key is string => !!key)
|
||||
|
||||
if (normalizedKeys.length !== keys.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizedKeys.join('+')
|
||||
}
|
||||
|
||||
private normalizeKeyForElectron(key: string): string | null {
|
||||
switch (key) {
|
||||
case 'CommandOrControl':
|
||||
case 'Ctrl':
|
||||
case 'Alt':
|
||||
case 'Meta':
|
||||
case 'Shift':
|
||||
return key
|
||||
case 'Command':
|
||||
case 'Cmd':
|
||||
return 'CommandOrControl'
|
||||
case 'Control':
|
||||
return 'Ctrl'
|
||||
case 'ArrowUp':
|
||||
return 'Up'
|
||||
case 'ArrowDown':
|
||||
return 'Down'
|
||||
case 'ArrowLeft':
|
||||
return 'Left'
|
||||
case 'ArrowRight':
|
||||
return 'Right'
|
||||
case 'AltGraph':
|
||||
return 'AltGr'
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Semicolon':
|
||||
return ';'
|
||||
case 'BracketLeft':
|
||||
return '['
|
||||
case 'BracketRight':
|
||||
return ']'
|
||||
case 'Backslash':
|
||||
return '\\'
|
||||
case 'Quote':
|
||||
return "'"
|
||||
case 'Comma':
|
||||
return ','
|
||||
case 'Minus':
|
||||
return '-'
|
||||
case 'Equal':
|
||||
return '='
|
||||
case 'Space':
|
||||
return 'Space'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
private registerDefaultHandlers() {
|
||||
this.registerHandler('zoom_in', (window) => {
|
||||
const target = this.getTargetWindow(window)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
handleZoomFactor([target], 0.1)
|
||||
})
|
||||
|
||||
this.registerHandler('zoom_out', (window) => {
|
||||
const target = this.getTargetWindow(window)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
handleZoomFactor([target], -0.1)
|
||||
})
|
||||
|
||||
this.registerHandler('zoom_reset', (window) => {
|
||||
const target = this.getTargetWindow(window)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
handleZoomFactor([target], 0, true)
|
||||
})
|
||||
|
||||
this.registerHandler('show_app', () => {
|
||||
windowService.toggleMainWindow()
|
||||
})
|
||||
|
||||
this.registerHandler('show_mini_window', () => {
|
||||
if (!preferenceService.get('feature.quick_assistant.enabled')) {
|
||||
return
|
||||
}
|
||||
windowService.toggleMiniWindow()
|
||||
})
|
||||
|
||||
this.registerHandler('selection_assistant_toggle', () => {
|
||||
selectionService?.toggleEnabled()
|
||||
})
|
||||
|
||||
this.registerHandler('selection_assistant_select_text', () => {
|
||||
selectionService?.processSelectTextByShortcut()
|
||||
})
|
||||
}
|
||||
|
||||
private getTargetWindow(window?: BrowserWindow): BrowserWindow | undefined {
|
||||
if (window && !window.isDestroyed()) {
|
||||
return window
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
})()
|
||||
|
||||
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 const shortcutService = new ShortcutService()
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
if (isRegisterOnBoot) {
|
||||
window.once('ready-to-show', () => {
|
||||
if (preferenceService.get('app.tray.on_launch')) {
|
||||
registerOnlyUniversalShortcuts()
|
||||
}
|
||||
})
|
||||
isRegisterOnBoot = false
|
||||
}
|
||||
|
||||
//only for clearer code
|
||||
const registerOnlyUniversalShortcuts = () => {
|
||||
register(true)
|
||||
}
|
||||
|
||||
//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
|
||||
|
||||
const shortcuts = configManager.getShortcuts()
|
||||
if (!shortcuts) return
|
||||
|
||||
shortcuts.forEach((shortcut) => {
|
||||
try {
|
||||
if (shortcut.shortcut.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
//if not enabled, exit early from the process.
|
||||
if (!shortcut.enabled) {
|
||||
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)
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (shortcut.key) {
|
||||
case 'show_app':
|
||||
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to unregister shortcuts')
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
window.on('focus', registerHandler)
|
||||
window.on('blur', unregister)
|
||||
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
|
||||
}
|
||||
|
||||
if (!window.isDestroyed() && window.isFocused()) {
|
||||
register()
|
||||
}
|
||||
shortcutService.registerMainProcessShortcuts(window)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
shortcutService.unregisterAllShortcuts()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { t } from '@main/utils/language'
|
||||
import { getI18n } from '@main/utils/language'
|
||||
import type { MenuItemConstructorOptions } from 'electron'
|
||||
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
|
||||
|
||||
@@ -72,20 +72,23 @@ export class TrayService {
|
||||
}
|
||||
|
||||
private updateContextMenu() {
|
||||
const i18n = getI18n()
|
||||
const { tray: trayLocale, selection: selectionLocale } = i18n.translation
|
||||
|
||||
const quickAssistantEnabled = preferenceService.get('feature.quick_assistant.enabled')
|
||||
const selectionAssistantEnabled = preferenceService.get('feature.selection.enabled')
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: t('tray.show_window'),
|
||||
label: trayLocale.show_window,
|
||||
click: () => windowService.showMainWindow()
|
||||
},
|
||||
quickAssistantEnabled && {
|
||||
label: t('tray.show_mini_window'),
|
||||
label: trayLocale.show_mini_window,
|
||||
click: () => windowService.showMiniWindow()
|
||||
},
|
||||
(isWin || isMac) && {
|
||||
label: t('selection.name') + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||
click: () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
@@ -95,7 +98,7 @@ export class TrayService {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('tray.quit'),
|
||||
label: trayLocale.quit,
|
||||
click: () => this.quit()
|
||||
}
|
||||
].filter(Boolean) as MenuItemConstructorOptions[]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import type { McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { config as apiConfigService } from '@main/apiServer/config'
|
||||
@@ -12,10 +12,23 @@ import { app } from 'electron'
|
||||
|
||||
import type { GetAgentSessionResponse } from '../..'
|
||||
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||
import { promptForToolApproval } from './tool-permissions'
|
||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
||||
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
|
||||
type UserInputMessage = {
|
||||
type: 'user'
|
||||
parent_tool_use_id: string | null
|
||||
session_id: string
|
||||
message: {
|
||||
role: 'user'
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
class ClaudeCodeStream extends EventEmitter implements AgentStream {
|
||||
declare emit: (event: 'data', data: AgentStreamEvent) => boolean
|
||||
@@ -100,6 +113,41 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
const errorChunks: string[] = []
|
||||
|
||||
const sessionAllowedTools = new Set<string>(session.allowed_tools ?? [])
|
||||
const autoAllowTools = new Set<string>([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools])
|
||||
const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name)
|
||||
|
||||
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
||||
logger.info('Handling tool permission check', {
|
||||
toolName,
|
||||
suggestionCount: options.suggestions?.length ?? 0
|
||||
})
|
||||
|
||||
if (shouldAutoApproveTools) {
|
||||
logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName })
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
if (options.signal.aborted) {
|
||||
logger.debug('Permission request signal already aborted; denying tool', { toolName })
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Tool request was cancelled before prompting the user'
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedToolName = normalizeToolName(toolName)
|
||||
if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) {
|
||||
logger.debug('Auto-allowing tool from allowed list', {
|
||||
toolName,
|
||||
normalizedToolName
|
||||
})
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
return promptForToolApproval(toolName, input, options)
|
||||
}
|
||||
|
||||
// Build SDK options from parameters
|
||||
const options: Options = {
|
||||
abortController,
|
||||
@@ -122,7 +170,8 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
includePartialMessages: true,
|
||||
permissionMode: session.configuration?.permission_mode,
|
||||
maxTurns: session.configuration?.max_turns,
|
||||
allowedTools: session.allowed_tools
|
||||
allowedTools: session.allowed_tools,
|
||||
canUseTool
|
||||
}
|
||||
|
||||
if (session.accessible_paths.length > 1) {
|
||||
@@ -161,9 +210,14 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
resume: options.resume
|
||||
})
|
||||
|
||||
const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream(
|
||||
prompt,
|
||||
abortController.signal
|
||||
)
|
||||
|
||||
// Start async processing on the next tick so listeners can subscribe first
|
||||
setImmediate(() => {
|
||||
this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => {
|
||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
||||
logger.error('Unhandled Claude Code stream error', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
@@ -177,17 +231,90 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
return aiStream
|
||||
}
|
||||
|
||||
private async *userMessages(prompt: string) {
|
||||
{
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
message: {
|
||||
role: 'user' as const,
|
||||
content: prompt
|
||||
private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) {
|
||||
const queue: Array<UserInputMessage | null> = []
|
||||
const waiters: Array<(value: UserInputMessage | null) => void> = []
|
||||
let closed = false
|
||||
|
||||
const flushWaiters = (value: UserInputMessage | null) => {
|
||||
const resolve = waiters.shift()
|
||||
if (resolve) {
|
||||
resolve(value)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const enqueue = (value: UserInputMessage | null) => {
|
||||
if (closed) return
|
||||
if (value === null) {
|
||||
closed = true
|
||||
}
|
||||
if (!flushWaiters(value)) {
|
||||
queue.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
enqueue(null)
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
close()
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
close()
|
||||
} else {
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
const iterator = (async function* () {
|
||||
try {
|
||||
while (true) {
|
||||
let value: UserInputMessage | null
|
||||
if (queue.length > 0) {
|
||||
value = queue.shift() ?? null
|
||||
} else if (closed) {
|
||||
break
|
||||
} else {
|
||||
// Wait for next message or close signal
|
||||
value = await new Promise<UserInputMessage | null>((resolve) => {
|
||||
waiters.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
break
|
||||
}
|
||||
|
||||
yield value
|
||||
}
|
||||
} finally {
|
||||
closed = true
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
while (waiters.length > 0) {
|
||||
const resolve = waiters.shift()
|
||||
resolve?.(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
enqueue({
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: initialPrompt
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
stream: iterator,
|
||||
enqueue,
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +322,8 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
* Process SDK query and emit stream events
|
||||
*/
|
||||
private async processSDKQuery(
|
||||
prompt: string,
|
||||
promptStream: AsyncIterable<UserInputMessage>,
|
||||
closePromptStream: () => void,
|
||||
options: Options,
|
||||
stream: ClaudeCodeStream,
|
||||
errorChunks: string[]
|
||||
@@ -203,14 +331,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
const jsonOutput: SDKMessage[] = []
|
||||
let hasCompleted = false
|
||||
const startTime = Date.now()
|
||||
|
||||
const streamState = new ClaudeStreamState()
|
||||
|
||||
try {
|
||||
// Process streaming responses using SDK query
|
||||
for await (const message of query({
|
||||
prompt: this.userMessages(prompt),
|
||||
options
|
||||
})) {
|
||||
for await (const message of query({ prompt: promptStream, options })) {
|
||||
if (hasCompleted) break
|
||||
|
||||
jsonOutput.push(message)
|
||||
@@ -221,10 +345,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
content: JSON.stringify(message.message.content)
|
||||
})
|
||||
} else if (message.type === 'stream_event') {
|
||||
logger.silly('Claude stream event', {
|
||||
message,
|
||||
event: JSON.stringify(message.event)
|
||||
})
|
||||
// logger.silly('Claude stream event', {
|
||||
// message,
|
||||
// event: JSON.stringify(message.event)
|
||||
// })
|
||||
} else {
|
||||
logger.silly('Claude response', {
|
||||
message,
|
||||
@@ -232,7 +356,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
})
|
||||
}
|
||||
|
||||
// Transform SDKMessage to UIMessageChunks
|
||||
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
||||
for (const chunk of chunks) {
|
||||
stream.emit('data', {
|
||||
@@ -242,7 +365,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully completed
|
||||
hasCompleted = true
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
@@ -251,7 +373,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
messageCount: jsonOutput.length
|
||||
})
|
||||
|
||||
// Emit completion event
|
||||
stream.emit('data', {
|
||||
type: 'complete'
|
||||
})
|
||||
@@ -260,8 +381,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
hasCompleted = true
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Check if this is an abort error
|
||||
const errorObj = error as any
|
||||
const isAborted =
|
||||
errorObj?.name === 'AbortError' ||
|
||||
@@ -270,7 +389,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
if (isAborted) {
|
||||
logger.info('SDK query aborted by client disconnect', { duration })
|
||||
// Simply cleanup and return - don't emit error events
|
||||
stream.emit('data', {
|
||||
type: 'cancelled',
|
||||
error: new Error('Request aborted by client')
|
||||
@@ -285,11 +403,13 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj),
|
||||
stderr: errorChunks
|
||||
})
|
||||
// Emit error event
|
||||
|
||||
stream.emit('data', {
|
||||
type: 'error',
|
||||
error: new Error(errorMessage)
|
||||
})
|
||||
} finally {
|
||||
closePromptStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
323
src/main/services/agents/services/claudecode/tool-permissions.ts
Normal file
323
src/main/services/agents/services/claudecode/tool-permissions.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { windowService } from '../../../WindowService'
|
||||
import { builtinTools } from './tools'
|
||||
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = 30_000
|
||||
const MAX_PREVIEW_LENGTH = 2_000
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
|
||||
type ToolPermissionBehavior = 'allow' | 'deny'
|
||||
|
||||
type ToolPermissionResponsePayload = {
|
||||
requestId: string
|
||||
behavior: ToolPermissionBehavior
|
||||
updatedInput?: unknown
|
||||
message?: string
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}
|
||||
|
||||
type PendingPermissionRequest = {
|
||||
fulfill: (update: PermissionResult) => void
|
||||
timeout: NodeJS.Timeout
|
||||
signal?: AbortSignal
|
||||
abortListener?: () => void
|
||||
originalInput: Record<string, unknown>
|
||||
toolName: string
|
||||
}
|
||||
|
||||
type RendererPermissionRequestPayload = {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
description?: string
|
||||
requiresPermissions: boolean
|
||||
input: Record<string, unknown>
|
||||
inputPreview: string
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
suggestions: PermissionUpdate[]
|
||||
}
|
||||
|
||||
type RendererPermissionResultPayload = {
|
||||
requestId: string
|
||||
behavior: ToolPermissionBehavior
|
||||
message?: string
|
||||
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, PendingPermissionRequest>()
|
||||
let ipcHandlersInitialized = false
|
||||
|
||||
const jsonReplacer = (_key: string, value: unknown) => {
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (value instanceof Map) return Object.fromEntries(value.entries())
|
||||
if (value instanceof Set) return Array.from(value.values())
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
if (typeof value === 'function') return undefined
|
||||
if (value === undefined) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
const sanitizeStructuredData = <T>(value: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value, jsonReplacer)) as T
|
||||
} catch (error) {
|
||||
logger.warn('Failed to sanitize structured data for tool permission payload', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const buildInputPreview = (value: unknown): string => {
|
||||
let preview: string
|
||||
|
||||
try {
|
||||
preview = JSON.stringify(value, null, 2)
|
||||
} catch (error) {
|
||||
preview = typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
if (preview.length > MAX_PREVIEW_LENGTH) {
|
||||
preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...`
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
const broadcastToRenderer = (
|
||||
channel: IpcChannel,
|
||||
payload: RendererPermissionRequestPayload | RendererPermissionResultPayload
|
||||
): boolean => {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
if (!mainWindow) {
|
||||
logger.warn('Unable to send agent tool permission payload – main window unavailable', {
|
||||
channel,
|
||||
requestId: 'requestId' in payload ? payload.requestId : undefined
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
mainWindow.webContents.send(channel, payload)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const finalizeRequest = (
|
||||
requestId: string,
|
||||
update: PermissionResult,
|
||||
reason: RendererPermissionResultPayload['reason']
|
||||
) => {
|
||||
const pending = pendingRequests.get(requestId)
|
||||
|
||||
if (!pending) {
|
||||
logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason })
|
||||
return false
|
||||
}
|
||||
|
||||
logger.debug('Finalizing tool permission request', {
|
||||
requestId,
|
||||
toolName: pending.toolName,
|
||||
behavior: update.behavior,
|
||||
reason
|
||||
})
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
if (pending.signal && pending.abortListener) {
|
||||
pending.signal.removeEventListener('abort', pending.abortListener)
|
||||
}
|
||||
|
||||
pending.fulfill(update)
|
||||
|
||||
const resultPayload: RendererPermissionResultPayload = {
|
||||
requestId,
|
||||
behavior: update.behavior,
|
||||
message: update.behavior === 'deny' ? update.message : undefined,
|
||||
reason
|
||||
}
|
||||
|
||||
const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload)
|
||||
|
||||
logger.debug('Sent tool permission result to renderer', {
|
||||
requestId,
|
||||
dispatched
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const ensureIpcHandlersRegistered = () => {
|
||||
if (ipcHandlersInitialized) return
|
||||
|
||||
ipcHandlersInitialized = true
|
||||
|
||||
ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => {
|
||||
logger.debug('main received AgentToolPermission_Response', payload)
|
||||
const { requestId, behavior, updatedInput, message } = payload
|
||||
const pending = pendingRequests.get(requestId)
|
||||
|
||||
if (!pending) {
|
||||
logger.warn('Received renderer tool permission response for unknown request', { requestId })
|
||||
return { success: false, error: 'unknown-request' }
|
||||
}
|
||||
|
||||
logger.debug('Received renderer response for tool permission', {
|
||||
requestId,
|
||||
toolName: pending.toolName,
|
||||
behavior,
|
||||
hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0
|
||||
})
|
||||
|
||||
const maybeUpdatedInput =
|
||||
updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput)
|
||||
? (updatedInput as Record<string, unknown>)
|
||||
: pending.originalInput
|
||||
|
||||
const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions)
|
||||
? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm))
|
||||
: undefined
|
||||
|
||||
const finalUpdate: PermissionResult =
|
||||
behavior === 'allow'
|
||||
? {
|
||||
behavior: 'allow',
|
||||
updatedInput: sanitizeStructuredData(maybeUpdatedInput),
|
||||
updatedPermissions: sanitizedUpdatedPermissions
|
||||
}
|
||||
: {
|
||||
behavior: 'deny',
|
||||
message: message ?? 'User denied permission for this tool'
|
||||
}
|
||||
|
||||
finalizeRequest(requestId, finalUpdate, 'response')
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
export async function promptForToolApproval(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
|
||||
): Promise<PermissionResult> {
|
||||
if (shouldAutoApproveTools) {
|
||||
logger.debug('promptForToolApproval auto-approving tool for test', {
|
||||
toolName
|
||||
})
|
||||
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
ensureIpcHandlersRegistered()
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName })
|
||||
return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' }
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
if (!mainWindow) {
|
||||
logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName })
|
||||
return { behavior: 'deny', message: 'Unable to request approval – renderer not ready' }
|
||||
}
|
||||
|
||||
const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName)
|
||||
const sanitizedInput = sanitizeStructuredData(input)
|
||||
const inputPreview = buildInputPreview(sanitizedInput)
|
||||
const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion))
|
||||
|
||||
const requestId = randomUUID()
|
||||
const createdAt = Date.now()
|
||||
const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS
|
||||
|
||||
logger.info('Requesting user approval for tool usage', {
|
||||
requestId,
|
||||
toolName,
|
||||
description: toolMetadata?.description
|
||||
})
|
||||
|
||||
const requestPayload: RendererPermissionRequestPayload = {
|
||||
requestId,
|
||||
toolName,
|
||||
toolId: toolMetadata?.id ?? toolName,
|
||||
description: toolMetadata?.description,
|
||||
requiresPermissions: toolMetadata?.requirePermissions ?? false,
|
||||
input: sanitizedInput,
|
||||
inputPreview,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
suggestions: sanitizedSuggestions
|
||||
}
|
||||
|
||||
const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' }
|
||||
|
||||
logger.debug('Registering tool permission request', {
|
||||
requestId,
|
||||
toolName,
|
||||
requiresPermissions: requestPayload.requiresPermissions,
|
||||
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
|
||||
suggestionCount: sanitizedSuggestions.length
|
||||
})
|
||||
|
||||
return new Promise<PermissionResult>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.info('User tool permission request timed out', { requestId, toolName })
|
||||
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
|
||||
}, TOOL_APPROVAL_TIMEOUT_MS)
|
||||
|
||||
const pending: PendingPermissionRequest = {
|
||||
fulfill: resolve,
|
||||
timeout,
|
||||
originalInput: sanitizedInput,
|
||||
toolName,
|
||||
signal: options?.signal
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
const abortListener = () => {
|
||||
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
|
||||
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
|
||||
}
|
||||
|
||||
pending.abortListener = abortListener
|
||||
options.signal.addEventListener('abort', abortListener, { once: true })
|
||||
}
|
||||
|
||||
pendingRequests.set(requestId, pending)
|
||||
|
||||
logger.debug('Pending tool permission request count', {
|
||||
count: pendingRequests.size
|
||||
})
|
||||
|
||||
const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload)
|
||||
|
||||
logger.debug('Broadcasted tool permission request to renderer', {
|
||||
requestId,
|
||||
toolName,
|
||||
sent
|
||||
})
|
||||
|
||||
if (!sent) {
|
||||
finalizeRequest(
|
||||
requestId,
|
||||
{
|
||||
behavior: 'deny',
|
||||
message: 'Unable to request approval because the renderer window is unavailable'
|
||||
},
|
||||
'no-window'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
223
src/main/utils/fileOperations.ts
Normal file
223
src/main/utils/fileOperations.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
import { isPathInside } from './file'
|
||||
|
||||
const logger = loggerService.withContext('Utils:FileOperations')
|
||||
|
||||
const MAX_RECURSION_DEPTH = 1000
|
||||
|
||||
/**
|
||||
* Recursively copy a directory and all its contents
|
||||
* @param source - Source directory path (must be absolute)
|
||||
* @param destination - Destination directory path (must be absolute)
|
||||
* @param options - Copy options
|
||||
* @param depth - Current recursion depth (internal use)
|
||||
* @throws If copy operation fails or paths are invalid
|
||||
*/
|
||||
export async function copyDirectoryRecursive(
|
||||
source: string,
|
||||
destination: string,
|
||||
options?: { allowedBasePath?: string },
|
||||
depth = 0
|
||||
): Promise<void> {
|
||||
// Input validation
|
||||
if (!source || !destination) {
|
||||
throw new TypeError('Source and destination paths are required')
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(source) || !path.isAbsolute(destination)) {
|
||||
throw new Error('Source and destination paths must be absolute')
|
||||
}
|
||||
|
||||
// Depth limit to prevent stack overflow
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
|
||||
}
|
||||
|
||||
// Path validation - ensure operations stay within allowed boundaries
|
||||
if (options?.allowedBasePath) {
|
||||
if (!isPathInside(source, options.allowedBasePath)) {
|
||||
throw new Error(`Source path is outside allowed directory: ${source}`)
|
||||
}
|
||||
if (!isPathInside(destination, options.allowedBasePath)) {
|
||||
throw new Error(`Destination path is outside allowed directory: ${destination}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify source exists and is a directory
|
||||
const sourceStats = await fs.promises.lstat(source)
|
||||
if (!sourceStats.isDirectory()) {
|
||||
throw new Error(`Source is not a directory: ${source}`)
|
||||
}
|
||||
|
||||
// Create destination directory
|
||||
await fs.promises.mkdir(destination, { recursive: true })
|
||||
logger.debug('Created destination directory', { destination })
|
||||
|
||||
// Read source directory
|
||||
const entries = await fs.promises.readdir(source, { withFileTypes: true })
|
||||
|
||||
// Copy each entry
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(source, entry.name)
|
||||
const destPath = path.join(destination, entry.name)
|
||||
|
||||
// Use lstat to detect symlinks and prevent following them
|
||||
const entryStats = await fs.promises.lstat(sourcePath)
|
||||
|
||||
if (entryStats.isSymbolicLink()) {
|
||||
logger.warn('Skipping symlink for security', { path: sourcePath })
|
||||
continue
|
||||
}
|
||||
|
||||
if (entryStats.isDirectory()) {
|
||||
// Recursively copy subdirectory
|
||||
await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1)
|
||||
} else if (entryStats.isFile()) {
|
||||
// Copy file with error handling for race conditions
|
||||
try {
|
||||
await fs.promises.copyFile(sourcePath, destPath)
|
||||
// Preserve file permissions
|
||||
await fs.promises.chmod(destPath, entryStats.mode)
|
||||
logger.debug('Copied file', { from: sourcePath, to: destPath })
|
||||
} catch (error) {
|
||||
// Handle race condition where file was deleted during copy
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn('File disappeared during copy', { sourcePath })
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
// Skip special files (pipes, sockets, devices, etc.)
|
||||
logger.debug('Skipping special file', { path: sourcePath })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Directory copied successfully', { from: source, to: destination, depth })
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy directory', { source, destination, depth, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory and all its contents
|
||||
* @param dirPath - Directory path to delete (must be absolute)
|
||||
* @param options - Delete options
|
||||
* @throws If deletion fails or path is invalid
|
||||
*/
|
||||
export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise<void> {
|
||||
// Input validation
|
||||
if (!dirPath) {
|
||||
throw new TypeError('Directory path is required')
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(dirPath)) {
|
||||
throw new Error('Directory path must be absolute')
|
||||
}
|
||||
|
||||
// Path validation - ensure operations stay within allowed boundaries
|
||||
if (options?.allowedBasePath) {
|
||||
if (!isPathInside(dirPath, options.allowedBasePath)) {
|
||||
throw new Error(`Path is outside allowed directory: ${dirPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify path exists before attempting deletion
|
||||
try {
|
||||
const stats = await fs.promises.lstat(dirPath)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${dirPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn('Directory already deleted', { dirPath })
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Node.js 14.14+ has fs.rm with recursive option
|
||||
await fs.promises.rm(dirPath, { recursive: true, force: true })
|
||||
logger.info('Directory deleted successfully', { dirPath })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete directory', { dirPath, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total size of a directory (in bytes)
|
||||
* @param dirPath - Directory path (must be absolute)
|
||||
* @param options - Size calculation options
|
||||
* @param depth - Current recursion depth (internal use)
|
||||
* @returns Total size in bytes
|
||||
* @throws If size calculation fails or path is invalid
|
||||
*/
|
||||
export async function getDirectorySize(
|
||||
dirPath: string,
|
||||
options?: { allowedBasePath?: string },
|
||||
depth = 0
|
||||
): Promise<number> {
|
||||
// Input validation
|
||||
if (!dirPath) {
|
||||
throw new TypeError('Directory path is required')
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(dirPath)) {
|
||||
throw new Error('Directory path must be absolute')
|
||||
}
|
||||
|
||||
// Depth limit to prevent stack overflow
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`)
|
||||
}
|
||||
|
||||
// Path validation - ensure operations stay within allowed boundaries
|
||||
if (options?.allowedBasePath) {
|
||||
if (!isPathInside(dirPath, options.allowedBasePath)) {
|
||||
throw new Error(`Path is outside allowed directory: ${dirPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
let totalSize = 0
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dirPath, entry.name)
|
||||
|
||||
// Use lstat to detect symlinks and prevent following them
|
||||
const entryStats = await fs.promises.lstat(entryPath)
|
||||
|
||||
if (entryStats.isSymbolicLink()) {
|
||||
logger.debug('Skipping symlink in size calculation', { path: entryPath })
|
||||
continue
|
||||
}
|
||||
|
||||
if (entryStats.isDirectory()) {
|
||||
// Recursively get size of subdirectory
|
||||
totalSize += await getDirectorySize(entryPath, options, depth + 1)
|
||||
} else if (entryStats.isFile()) {
|
||||
// Get file size from lstat (already have it)
|
||||
totalSize += entryStats.size
|
||||
} else {
|
||||
// Skip special files
|
||||
logger.debug('Skipping special file in size calculation', { path: entryPath })
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Calculated directory size', { dirPath, size: totalSize, depth })
|
||||
return totalSize
|
||||
} catch (error) {
|
||||
logger.error('Failed to calculate directory size', { dirPath, depth, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,33 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { loggerService } from '@logger'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import type { LanguageVarious } from '@shared/data/preference/preferenceTypes'
|
||||
import { app } from 'electron'
|
||||
import i18n from 'i18next'
|
||||
|
||||
import enUS from '../../renderer/src/i18n/locales/en-us.json'
|
||||
import zhCN from '../../renderer/src/i18n/locales/zh-cn.json'
|
||||
import zhTW from '../../renderer/src/i18n/locales/zh-tw.json'
|
||||
// import deDE from '../../renderer/src/i18n/locales/de-de.json'
|
||||
// import elGR from '../../renderer/src/i18n/locales/el-gr.json'
|
||||
// import esES from '../../renderer/src/i18n/locales/es-es.json'
|
||||
// import frFR from '../../renderer/src/i18n/locales/fr-fr.json'
|
||||
// import JaJP from '../../renderer/src/i18n/locales/ja-jp.json'
|
||||
// import ptPT from '../../renderer/src/i18n/locales/pt-pt.json'
|
||||
// import RuRu from '../../renderer/src/i18n/locales/ru-ru.json'
|
||||
import EnUs from '../../renderer/src/i18n/locales/en-us.json'
|
||||
import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json'
|
||||
import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json'
|
||||
// Machine translation
|
||||
import deDE from '../../renderer/src/i18n/translate/de-de.json'
|
||||
import elGR from '../../renderer/src/i18n/translate/el-gr.json'
|
||||
import esES from '../../renderer/src/i18n/translate/es-es.json'
|
||||
import frFR from '../../renderer/src/i18n/translate/fr-fr.json'
|
||||
import jaJP from '../../renderer/src/i18n/translate/ja-jp.json'
|
||||
import JaJP from '../../renderer/src/i18n/translate/ja-jp.json'
|
||||
import ptPT from '../../renderer/src/i18n/translate/pt-pt.json'
|
||||
import ruRU from '../../renderer/src/i18n/translate/ru-ru.json'
|
||||
import RuRu from '../../renderer/src/i18n/translate/ru-ru.json'
|
||||
|
||||
const logger = loggerService.withContext('main:i18n')
|
||||
|
||||
// const resources = Object.fromEntries([
|
||||
// ['en-US', enUS],
|
||||
// ['zh-CN', zhCN],
|
||||
// ['zh-TW', zhTW],
|
||||
// ['de-DE', deDE],
|
||||
// ['el-GR', elGR],
|
||||
// ['es-ES', esES],
|
||||
// ['fr-FR', frFR],
|
||||
// ['ja-JP', jaJP],
|
||||
// ['pt-PT', ptPT],
|
||||
// ['ru-RU', ruRU]
|
||||
// ] as const)
|
||||
const resources = Object.fromEntries(
|
||||
(
|
||||
[
|
||||
['en-US', enUS],
|
||||
['zh-CN', zhCN],
|
||||
['zh-TW', zhTW],
|
||||
['de-DE', deDE],
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
['ja-JP', jaJP],
|
||||
['pt-PT', ptPT],
|
||||
['ru-RU', ruRU]
|
||||
] as const
|
||||
).map(([key, translation]) => [key, { translation }])
|
||||
export const locales = Object.fromEntries(
|
||||
[
|
||||
['en-US', EnUs],
|
||||
['zh-CN', ZhCn],
|
||||
['zh-TW', ZhTw],
|
||||
['ja-JP', JaJP],
|
||||
['ru-RU', RuRu],
|
||||
['de-DE', deDE],
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
['pt-PT', ptPT]
|
||||
].map(([locale, translation]) => [locale, { translation }])
|
||||
)
|
||||
|
||||
export const getAppLanguage = (): LanguageVarious => {
|
||||
@@ -62,44 +38,10 @@ export const getAppLanguage = (): LanguageVarious => {
|
||||
return language
|
||||
}
|
||||
|
||||
return (Object.keys(resources).includes(appLocale) ? appLocale : defaultLanguage) as LanguageVarious
|
||||
return (Object.keys(locales).includes(appLocale) ? appLocale : defaultLanguage) as LanguageVarious
|
||||
}
|
||||
|
||||
export const getI18n = (): Record<string, any> => {
|
||||
const language = getAppLanguage()
|
||||
return resources[language]
|
||||
return locales[language]
|
||||
}
|
||||
|
||||
let t: (key: string) => string = () => {
|
||||
logger.error('i18n not inialized')
|
||||
return ''
|
||||
}
|
||||
|
||||
let changeLang: (lang: LanguageVarious) => void = () => {
|
||||
logger.error('i18n not inialized')
|
||||
}
|
||||
|
||||
i18n
|
||||
.init({
|
||||
resources,
|
||||
lng: getAppLanguage(),
|
||||
fallbackLng: defaultLanguage,
|
||||
ns: 'translation',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
saveMissing: true,
|
||||
missingKeyHandler: (_1, _2, key) => {
|
||||
logger.error(`Missing key: ${key}`)
|
||||
}
|
||||
})
|
||||
.then((tfn) => {
|
||||
changeLang = (lang: LanguageVarious) => {
|
||||
i18n.changeLanguage(lang)
|
||||
}
|
||||
t = (key: string) => tfn(key)
|
||||
const lng = getAppLanguage()
|
||||
logger.debug('i18n context', { lng, resource: resources[lng] })
|
||||
})
|
||||
|
||||
export { changeLang, i18n, t }
|
||||
|
||||
309
src/main/utils/markdownParser.ts
Normal file
309
src/main/utils/markdownParser.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PluginError, PluginMetadata } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
import * as yaml from 'js-yaml'
|
||||
import * as path from 'path'
|
||||
|
||||
import { getDirectorySize } from './fileOperations'
|
||||
|
||||
const logger = loggerService.withContext('Utils:MarkdownParser')
|
||||
|
||||
/**
|
||||
* Parse plugin metadata from a markdown file with frontmatter
|
||||
* @param filePath Absolute path to the markdown file
|
||||
* @param sourcePath Relative source path from plugins directory
|
||||
* @param category Category name derived from parent folder
|
||||
* @param type Plugin type (agent or command)
|
||||
* @returns PluginMetadata object with parsed frontmatter and file info
|
||||
*/
|
||||
export async function parsePluginMetadata(
|
||||
filePath: string,
|
||||
sourcePath: string,
|
||||
category: string,
|
||||
type: 'agent' | 'command'
|
||||
): Promise<PluginMetadata> {
|
||||
const content = await fs.promises.readFile(filePath, 'utf8')
|
||||
const stats = await fs.promises.stat(filePath)
|
||||
|
||||
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
|
||||
const { data } = matter(content, {
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate content hash for integrity checking
|
||||
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
|
||||
|
||||
// Extract filename
|
||||
const filename = path.basename(filePath)
|
||||
|
||||
// Parse allowed_tools - handle both array and comma-separated string
|
||||
let allowedTools: string[] | undefined
|
||||
if (data['allowed-tools'] || data.allowed_tools) {
|
||||
const toolsData = data['allowed-tools'] || data.allowed_tools
|
||||
if (Array.isArray(toolsData)) {
|
||||
allowedTools = toolsData
|
||||
} else if (typeof toolsData === 'string') {
|
||||
allowedTools = toolsData
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tools - similar handling
|
||||
let tools: string[] | undefined
|
||||
if (data.tools) {
|
||||
if (Array.isArray(data.tools)) {
|
||||
tools = data.tools
|
||||
} else if (typeof data.tools === 'string') {
|
||||
tools = data.tools
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
let tags: string[] | undefined
|
||||
if (data.tags) {
|
||||
if (Array.isArray(data.tags)) {
|
||||
tags = data.tags
|
||||
} else if (typeof data.tags === 'string') {
|
||||
tags = data.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath,
|
||||
filename,
|
||||
name: data.name || filename.replace(/\.md$/, ''),
|
||||
description: data.description,
|
||||
allowed_tools: allowedTools,
|
||||
tools,
|
||||
category,
|
||||
type,
|
||||
tags,
|
||||
version: data.version,
|
||||
author: data.author,
|
||||
size: stats.size,
|
||||
contentHash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all directories containing SKILL.md
|
||||
*
|
||||
* @param dirPath - Directory to search in
|
||||
* @param basePath - Base path for calculating relative source paths
|
||||
* @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops)
|
||||
* @param currentDepth - Current search depth (used internally)
|
||||
* @returns Array of objects with absolute folder path and relative source path
|
||||
*/
|
||||
export async function findAllSkillDirectories(
|
||||
dirPath: string,
|
||||
basePath: string,
|
||||
maxDepth = 10,
|
||||
currentDepth = 0
|
||||
): Promise<Array<{ folderPath: string; sourcePath: string }>> {
|
||||
const results: Array<{ folderPath: string; sourcePath: string }> = []
|
||||
|
||||
// Prevent excessive recursion
|
||||
if (currentDepth > maxDepth) {
|
||||
return results
|
||||
}
|
||||
|
||||
// Check if current directory contains SKILL.md
|
||||
const skillMdPath = path.join(dirPath, 'SKILL.md')
|
||||
|
||||
try {
|
||||
await fs.promises.stat(skillMdPath)
|
||||
// Found SKILL.md in this directory
|
||||
const relativePath = path.relative(basePath, dirPath)
|
||||
results.push({
|
||||
folderPath: dirPath,
|
||||
sourcePath: relativePath
|
||||
})
|
||||
return results
|
||||
} catch {
|
||||
// SKILL.md not in current directory
|
||||
}
|
||||
|
||||
// Only search subdirectories if current directory doesn't have SKILL.md
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const subDirPath = path.join(dirPath, entry.name)
|
||||
const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1)
|
||||
results.push(...subResults)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ignore errors when reading subdirectories (e.g., permission denied)
|
||||
logger.debug('Failed to read subdirectory during skill search', {
|
||||
dirPath,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse metadata from SKILL.md within a skill folder
|
||||
*
|
||||
* @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md)
|
||||
* @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill")
|
||||
* @param category - Category name (typically "skills" for flat structure)
|
||||
* @returns PluginMetadata with folder name as filename (no extension)
|
||||
* @throws PluginError if SKILL.md not found or parsing fails
|
||||
*/
|
||||
export async function parseSkillMetadata(
|
||||
skillFolderPath: string,
|
||||
sourcePath: string,
|
||||
category: string
|
||||
): Promise<PluginMetadata> {
|
||||
// Input validation
|
||||
if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) {
|
||||
throw {
|
||||
type: 'INVALID_METADATA',
|
||||
reason: 'Skill folder path must be absolute',
|
||||
path: skillFolderPath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Look for SKILL.md directly in this folder (no recursion)
|
||||
const skillMdPath = path.join(skillFolderPath, 'SKILL.md')
|
||||
|
||||
// Check if SKILL.md exists
|
||||
try {
|
||||
await fs.promises.stat(skillMdPath)
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.error('SKILL.md not found in skill folder', { skillMdPath })
|
||||
throw {
|
||||
type: 'FILE_NOT_FOUND',
|
||||
path: skillMdPath,
|
||||
message: 'SKILL.md not found in skill folder'
|
||||
} as PluginError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Read SKILL.md content
|
||||
let content: string
|
||||
try {
|
||||
content = await fs.promises.readFile(skillMdPath, 'utf8')
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to read SKILL.md', { skillMdPath, error })
|
||||
throw {
|
||||
type: 'READ_FAILED',
|
||||
path: skillMdPath,
|
||||
reason: error.message || 'Unknown error'
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks
|
||||
let data: any
|
||||
try {
|
||||
const parsed = matter(content, {
|
||||
engines: {
|
||||
yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object
|
||||
}
|
||||
})
|
||||
data = parsed.data
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error })
|
||||
throw {
|
||||
type: 'INVALID_METADATA',
|
||||
reason: `Failed to parse frontmatter: ${error.message}`,
|
||||
path: skillMdPath
|
||||
} as PluginError
|
||||
}
|
||||
|
||||
// Calculate hash of SKILL.md only (not entire folder)
|
||||
// Note: This means changes to other files in the skill won't trigger cache invalidation
|
||||
// This is intentional - only SKILL.md metadata changes should trigger updates
|
||||
const contentHash = crypto.createHash('sha256').update(content).digest('hex')
|
||||
|
||||
// Get folder name as identifier (NO EXTENSION)
|
||||
const folderName = path.basename(skillFolderPath)
|
||||
|
||||
// Get total folder size
|
||||
let folderSize: number
|
||||
try {
|
||||
folderSize = await getDirectorySize(skillFolderPath)
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to calculate skill folder size', { skillFolderPath, error })
|
||||
// Use 0 as fallback instead of failing completely
|
||||
folderSize = 0
|
||||
}
|
||||
|
||||
// Parse tools (skills use 'tools', not 'allowed_tools')
|
||||
let tools: string[] | undefined
|
||||
if (data.tools) {
|
||||
if (Array.isArray(data.tools)) {
|
||||
// Validate all elements are strings
|
||||
tools = data.tools.filter((t) => typeof t === 'string')
|
||||
} else if (typeof data.tools === 'string') {
|
||||
tools = data.tools
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
let tags: string[] | undefined
|
||||
if (data.tags) {
|
||||
if (Array.isArray(data.tags)) {
|
||||
// Validate all elements are strings
|
||||
tags = data.tags.filter((t) => typeof t === 'string')
|
||||
} else if (typeof data.tags === 'string') {
|
||||
tags = data.tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and sanitize name
|
||||
const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName
|
||||
|
||||
// Validate and sanitize description
|
||||
const description =
|
||||
typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined
|
||||
|
||||
// Validate version and author
|
||||
const version = typeof data.version === 'string' ? data.version : undefined
|
||||
const author = typeof data.author === 'string' ? data.author : undefined
|
||||
|
||||
logger.debug('Successfully parsed skill metadata', {
|
||||
skillFolderPath,
|
||||
folderName,
|
||||
size: folderSize
|
||||
})
|
||||
|
||||
return {
|
||||
sourcePath, // e.g., "skills/my-skill"
|
||||
filename: folderName, // e.g., "my-skill" (folder name, NO .md extension)
|
||||
name,
|
||||
description,
|
||||
tools,
|
||||
category, // "skills" for flat structure
|
||||
type: 'skill',
|
||||
tags,
|
||||
version,
|
||||
author,
|
||||
size: folderSize,
|
||||
contentHash // Hash of SKILL.md content only
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import type { SpanContext } from '@opentelemetry/api'
|
||||
@@ -42,6 +43,16 @@ import type { OpenDialogOptions } from 'electron'
|
||||
import { contextBridge, ipcRenderer, shell, webUtils } from 'electron'
|
||||
import type { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import type {
|
||||
InstalledPlugin,
|
||||
InstallPluginOptions,
|
||||
ListAvailablePluginsResult,
|
||||
PluginMetadata,
|
||||
PluginResult,
|
||||
UninstallPluginOptions,
|
||||
WritePluginContentOptions
|
||||
} from '../renderer/src/types/plugin'
|
||||
|
||||
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
|
||||
if (spanContext) {
|
||||
const data = { type: 'trace', context: spanContext }
|
||||
@@ -226,6 +237,7 @@ const api = {
|
||||
},
|
||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
|
||||
shortcuts: {
|
||||
getAll: () => ipcRenderer.invoke(IpcChannel.Shortcuts_GetAll),
|
||||
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
|
||||
},
|
||||
knowledgeBase: {
|
||||
@@ -426,6 +438,15 @@ const api = {
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
agentTools: {
|
||||
respondToPermission: (payload: {
|
||||
requestId: string
|
||||
behavior: 'allow' | 'deny'
|
||||
updatedInput?: Record<string, unknown>
|
||||
message?: string
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
|
||||
// setDisableHardwareAcceleration: (isDisable: boolean) =>
|
||||
// ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
|
||||
@@ -548,6 +569,21 @@ const api = {
|
||||
start: (): Promise<StartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Start),
|
||||
restart: (): Promise<RestartApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Restart),
|
||||
stop: (): Promise<StopApiServerStatusResult> => ipcRenderer.invoke(IpcChannel.ApiServer_Stop)
|
||||
},
|
||||
claudeCodePlugin: {
|
||||
listAvailable: (): Promise<PluginResult<ListAvailablePluginsResult>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable),
|
||||
install: (options: InstallPluginOptions): Promise<PluginResult<PluginMetadata>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options),
|
||||
uninstall: (options: UninstallPluginOptions): Promise<PluginResult<void>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options),
|
||||
listInstalled: (agentId: string): Promise<PluginResult<InstalledPlugin[]>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId),
|
||||
invalidateCache: (): Promise<PluginResult<void>> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache),
|
||||
readContent: (sourcePath: string): Promise<PluginResult<string>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath),
|
||||
writeContent: (options: WritePluginContentOptions): Promise<PluginResult<void>> =>
|
||||
ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ export default class ModernAiProvider {
|
||||
// 提前构建中间件
|
||||
const middlewares = buildAiSdkMiddlewares({
|
||||
...config,
|
||||
provider: this.actualProvider
|
||||
provider: this.actualProvider,
|
||||
assistant: config.assistant
|
||||
})
|
||||
logger.debug('Built middlewares in completions', {
|
||||
middlewareCount: middlewares.length,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
|
||||
import { loggerService } from '@logger'
|
||||
import { type MCPTool, type Message, type Model, type Provider } from '@renderer/types'
|
||||
import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models'
|
||||
import { isSupportEnableThinkingProvider } from '@renderer/config/providers'
|
||||
import type { MCPTool } from '@renderer/types'
|
||||
import { type Assistant, type Message, type Model, type Provider } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import type { LanguageModelMiddleware } from 'ai'
|
||||
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { isOpenRouterGeminiGenerateImageModel } from '../utils/image'
|
||||
import { noThinkMiddleware } from './noThinkMiddleware'
|
||||
import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware'
|
||||
import { qwenThinkingMiddleware } from './qwenThinkingMiddleware'
|
||||
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
|
||||
|
||||
const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
|
||||
@@ -20,6 +25,7 @@ export interface AiSdkMiddlewareConfig {
|
||||
onChunk?: (chunk: Chunk) => void
|
||||
model?: Model
|
||||
provider?: Provider
|
||||
assistant?: Assistant
|
||||
enableReasoning: boolean
|
||||
// 是否开启提示词工具调用
|
||||
isPromptToolUse: boolean
|
||||
@@ -128,7 +134,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo
|
||||
const builder = new AiSdkMiddlewareBuilder()
|
||||
|
||||
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
|
||||
if (config.knowledgeRecognition === 'off') {
|
||||
if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') {
|
||||
builder.add({
|
||||
name: 'force-knowledge-first',
|
||||
middleware: toolChoiceMiddleware('builtin_knowledge_search')
|
||||
@@ -219,6 +225,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config:
|
||||
function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void {
|
||||
if (!config.model || !config.provider) return
|
||||
|
||||
// Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA)
|
||||
// Use /think or /no_think suffix to control thinking mode
|
||||
if (
|
||||
config.provider &&
|
||||
isSupportedThinkingTokenQwenModel(config.model) &&
|
||||
!isSupportEnableThinkingProvider(config.provider)
|
||||
) {
|
||||
const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined
|
||||
builder.add({
|
||||
name: 'qwen-thinking-control',
|
||||
middleware: qwenThinkingMiddleware(enableThinking)
|
||||
})
|
||||
logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`)
|
||||
}
|
||||
|
||||
// 可以根据模型ID或特性添加特定中间件
|
||||
// 例如:图像生成模型、多模态模型等
|
||||
if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) {
|
||||
|
||||
39
src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts
Normal file
39
src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LanguageModelMiddleware } from 'ai'
|
||||
|
||||
/**
|
||||
* Qwen Thinking Middleware
|
||||
* Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama)
|
||||
* Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting
|
||||
* @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined)
|
||||
* @returns LanguageModelMiddleware
|
||||
*/
|
||||
export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware {
|
||||
const suffix = enableThinking ? ' /think' : ' /no_think'
|
||||
|
||||
return {
|
||||
middlewareVersion: 'v2',
|
||||
|
||||
transformParams: async ({ params }) => {
|
||||
const transformedParams = { ...params }
|
||||
// Process messages in prompt
|
||||
if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) {
|
||||
transformedParams.prompt = transformedParams.prompt.map((message) => {
|
||||
// Only process user messages
|
||||
if (message.role === 'user') {
|
||||
// Process content array
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) {
|
||||
part.text += suffix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
return transformedParams
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@cherrystudio/ui'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -20,18 +20,12 @@ const ExpandableText = ({
|
||||
setIsExpanded((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const button = useMemo(() => {
|
||||
return (
|
||||
<Button variant="ghost" onClick={toggleExpand} className="self-end">
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</Button>
|
||||
)
|
||||
}, [isExpanded, t, toggleExpand])
|
||||
|
||||
return (
|
||||
<Container ref={ref} style={style} $expanded={isExpanded}>
|
||||
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
|
||||
{button}
|
||||
<Button variant="ghost" onClick={toggleExpand} className="self-end">
|
||||
{isExpanded ? t('common.collapse') : t('common.expand')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -48,4 +42,4 @@ const TextContainer = styled.div<{ $expanded?: boolean }>`
|
||||
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
|
||||
`
|
||||
|
||||
export default memo(ExpandableText)
|
||||
export default ExpandableText
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-
|
||||
import { Button, Flex, Tooltip } from '@cherrystudio/ui'
|
||||
import { restoreFromLocal } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Modal, Table } from 'antd'
|
||||
import { Modal, Space, Table } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -221,6 +221,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
}
|
||||
}
|
||||
|
||||
const footerContent = (
|
||||
<Space align="center">
|
||||
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
{t('settings.data.local.backup.manager.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
key="delete"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}>
|
||||
<DeleteOutlined />
|
||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.local.backup.manager.title')}
|
||||
@@ -229,24 +249,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
width={800}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
classNames={{ footer: 'flex justify-end gap-1' }}
|
||||
footer={[
|
||||
<Button key="refresh" onClick={fetchBackupFiles} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
{t('settings.data.local.backup.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}>
|
||||
<DeleteOutlined />
|
||||
{t('settings.data.local.backup.manager.delete.selected')} ({selectedRowKeys.length})
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
]}>
|
||||
footer={footerContent}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
|
||||
@@ -12,8 +12,8 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
// Mock ImageToolButton
|
||||
vi.mock('../ImageToolButton', () => ({
|
||||
default: vi.fn(({ tooltip, onPress, icon }) => (
|
||||
<button type="button" onClick={onPress} role="button" aria-label={tooltip}>
|
||||
default: vi.fn(({ tooltip, onClick, icon }) => (
|
||||
<button type="button" onClick={onClick} role="button" aria-label={tooltip}>
|
||||
{icon}
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -4,8 +4,8 @@ exports[`ImageToolButton > should match snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<button
|
||||
aria-label="Test tooltip"
|
||||
class="rounded-full"
|
||||
data-testid="button"
|
||||
radius="full"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Tooltip } from '@cherrystudio/ui'
|
||||
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||
import type { S3Config } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Modal, Space, Table } from 'antd'
|
||||
import { Modal, Table } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -9,6 +9,15 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k })
|
||||
}))
|
||||
|
||||
// mock @cherrystudio/ui Button component
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Button: ({ children, onPress, ...props }: any) => (
|
||||
<button type="button" onClick={onPress} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
describe('ExpandableText', () => {
|
||||
const TEXT = 'This is a long text for testing.'
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const NavbarContainer = styled.div<{ $isFullScreen: boolean }>`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: ${isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)'};
|
||||
min-height: ${({ $isFullScreen }) => (!$isFullScreen && isMac ? 'env(titlebar-area-height)' : 'var(--navbar-height)')};
|
||||
max-height: var(--navbar-height);
|
||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1 + 2px)' : 0};
|
||||
padding-left: ${({ $isFullScreen }) =>
|
||||
|
||||
@@ -11,6 +11,8 @@ export function getPreprocessProviderLogo(providerId: PreprocessProviderId) {
|
||||
return MistralLogo
|
||||
case 'mineru':
|
||||
return MinerULogo
|
||||
case 'open-mineru':
|
||||
return MinerULogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -36,5 +38,11 @@ export const PREPROCESS_PROVIDER_CONFIG: Record<PreprocessProviderId, Preprocess
|
||||
official: 'https://mineru.net/',
|
||||
apiKey: 'https://mineru.net/apiManage'
|
||||
}
|
||||
},
|
||||
'open-mineru': {
|
||||
websites: {
|
||||
official: 'https://github.com/opendatalab/MinerU/',
|
||||
apiKey: 'https://github.com/opendatalab/MinerU/'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
|
||||
anthropicApiHost: 'https://dashscope.aliyuncs.com/apps/anthropic',
|
||||
models: SYSTEM_MODELS.dashscope,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
|
||||
10
src/renderer/src/env.d.ts
vendored
10
src/renderer/src/env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { ToastUtilities } from '@cherrystudio/ui'
|
||||
import type { HookAPI } from 'antd/es/modal/useModal'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
@@ -19,5 +20,14 @@ declare global {
|
||||
store: any
|
||||
navigate: NavigateFunction
|
||||
toast: ToastUtilities
|
||||
agentTools: {
|
||||
respondToPermission: (payload: {
|
||||
requestId: string
|
||||
behavior: 'allow' | 'deny'
|
||||
updatedInput?: Record<string, unknown>
|
||||
message?: string
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}) => Promise<{ success: boolean }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useShortcutConfig } from '@renderer/hooks/useShortcuts'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
@@ -7,9 +7,8 @@ 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
|
||||
)
|
||||
const showSettingsShortcut = useShortcutConfig('show_settings')
|
||||
const showSettingsShortcutEnabled = showSettingsShortcut?.enabled ?? false
|
||||
|
||||
useHotkeys(
|
||||
'meta+, ! ctrl+,',
|
||||
|
||||
49
src/renderer/src/hooks/agents/useCreateDefaultSession.ts
Normal file
49
src/renderer/src/hooks/agents/useCreateDefaultSession.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||
import type { CreateSessionForm } from '@renderer/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Returns a stable callback that creates a default agent session and updates UI state.
|
||||
*/
|
||||
export const useCreateDefaultSession = (agentId: string | null) => {
|
||||
const { agent } = useAgent(agentId)
|
||||
const { createSession } = useSessions(agentId)
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
const [creatingSession, setCreatingSession] = useState(false)
|
||||
|
||||
const createDefaultSession = useCallback(async () => {
|
||||
if (!agentId || !agent || creatingSession) {
|
||||
return null
|
||||
}
|
||||
|
||||
setCreatingSession(true)
|
||||
try {
|
||||
const session = {
|
||||
...agent,
|
||||
id: undefined,
|
||||
name: t('common.unnamed')
|
||||
} satisfies CreateSessionForm
|
||||
|
||||
const created = await createSession(session)
|
||||
|
||||
if (created) {
|
||||
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
|
||||
dispatch(setActiveTopicOrSessionAction('session'))
|
||||
}
|
||||
|
||||
return created
|
||||
} finally {
|
||||
setCreatingSession(false)
|
||||
}
|
||||
}, [agentId, agent, createSession, creatingSession, dispatch, t])
|
||||
|
||||
return {
|
||||
createDefaultSession,
|
||||
creatingSession
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,18 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
import { selectMemoryConfig } from '@renderer/store/memory'
|
||||
import {
|
||||
type ToolPermissionRequestPayload,
|
||||
type ToolPermissionResultPayload,
|
||||
toolPermissionsActions
|
||||
} from '@renderer/store/toolPermissions'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
import { checkDataLimit } from '@renderer/utils'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import useFullScreenNotice from './useFullScreenNotice'
|
||||
@@ -27,6 +33,7 @@ import { useNavbarPosition } from './useNavbar'
|
||||
const logger = loggerService.withContext('useAppInit')
|
||||
|
||||
export function useAppInit() {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const [language] = usePreference('app.language')
|
||||
const [windowStyle] = usePreference('ui.window_style')
|
||||
@@ -148,6 +155,64 @@ export function useAppInit() {
|
||||
}
|
||||
}, [customCss])
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron?.ipcRenderer) return
|
||||
|
||||
const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
|
||||
logger.debug('Renderer received tool permission request', {
|
||||
requestId: payload.requestId,
|
||||
toolName: payload.toolName,
|
||||
expiresAt: payload.expiresAt,
|
||||
suggestionCount: payload.suggestions.length
|
||||
})
|
||||
dispatch(toolPermissionsActions.requestReceived(payload))
|
||||
}
|
||||
|
||||
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
|
||||
logger.debug('Renderer received tool permission result', {
|
||||
requestId: payload.requestId,
|
||||
behavior: payload.behavior,
|
||||
reason: payload.reason
|
||||
})
|
||||
dispatch(toolPermissionsActions.requestResolved(payload))
|
||||
|
||||
if (payload.behavior === 'deny') {
|
||||
const message =
|
||||
payload.reason === 'timeout'
|
||||
? (payload.message ?? t('agent.toolPermission.toast.timeout'))
|
||||
: (payload.message ?? t('agent.toolPermission.toast.denied'))
|
||||
|
||||
if (payload.reason === 'no-window') {
|
||||
logger.debug('Displaying deny toast for tool permission', {
|
||||
requestId: payload.requestId,
|
||||
behavior: payload.behavior,
|
||||
reason: payload.reason
|
||||
})
|
||||
window.toast?.error?.(message)
|
||||
} else if (payload.reason === 'timeout') {
|
||||
logger.debug('Displaying timeout toast for tool permission', {
|
||||
requestId: payload.requestId
|
||||
})
|
||||
window.toast?.warning?.(message)
|
||||
} else {
|
||||
logger.debug('Displaying info toast for tool permission deny', {
|
||||
requestId: payload.requestId,
|
||||
reason: payload.reason
|
||||
})
|
||||
window.toast?.info?.(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener)
|
||||
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
|
||||
|
||||
return () => {
|
||||
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Request, requestListener)
|
||||
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Result, resultListener)
|
||||
}
|
||||
}, [dispatch, t])
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: init data collection
|
||||
}, [enableDataCollection])
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
|
||||
label: t('settings.tool.preprocess.provider'),
|
||||
title: t('settings.tool.preprocess.provider'),
|
||||
options: preprocessProviders
|
||||
.filter((p) => p.apiKey !== '' || p.id === 'mineru')
|
||||
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru'].includes(p.id))
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
}
|
||||
return [preprocessOptions]
|
||||
|
||||
163
src/renderer/src/hooks/usePlugins.ts
Normal file
163
src/renderer/src/hooks/usePlugins.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { InstalledPlugin, PluginError, PluginMetadata } from '@renderer/types/plugin'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Helper to extract error message from PluginError union type
|
||||
*/
|
||||
function getPluginErrorMessage(error: PluginError, defaultMessage: string): string {
|
||||
if ('message' in error && error.message) return error.message
|
||||
if ('reason' in error) return error.reason
|
||||
if ('path' in error) return `Error with file: ${error.path}`
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and cache available plugins from the resources directory
|
||||
* @returns Object containing available agents, commands, skills, loading state, and error
|
||||
*/
|
||||
export function useAvailablePlugins() {
|
||||
const [agents, setAgents] = useState<PluginMetadata[]>([])
|
||||
const [commands, setCommands] = useState<PluginMetadata[]>([])
|
||||
const [skills, setSkills] = useState<PluginMetadata[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvailablePlugins = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.listAvailable()
|
||||
|
||||
if (result.success) {
|
||||
setAgents(result.data.agents)
|
||||
setCommands(result.data.commands)
|
||||
setSkills(result.data.skills)
|
||||
} else {
|
||||
setError(getPluginErrorMessage(result.error, 'Failed to load available plugins'))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAvailablePlugins()
|
||||
}, [])
|
||||
|
||||
return { agents, commands, skills, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch installed plugins for a specific agent
|
||||
* @param agentId - The ID of the agent to fetch plugins for
|
||||
* @returns Object containing installed plugins, loading state, error, and refresh function
|
||||
*/
|
||||
export function useInstalledPlugins(agentId: string | undefined) {
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!agentId) {
|
||||
setPlugins([])
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.listInstalled(agentId)
|
||||
|
||||
if (result.success) {
|
||||
setPlugins(result.data)
|
||||
} else {
|
||||
setError(getPluginErrorMessage(result.error, 'Failed to load installed plugins'))
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
return { plugins, loading, error, refresh }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to provide install and uninstall actions for plugins
|
||||
* @param agentId - The ID of the agent to perform actions for
|
||||
* @param onSuccess - Optional callback to be called on successful operations
|
||||
* @returns Object containing install, uninstall functions and their loading states
|
||||
*/
|
||||
export function usePluginActions(agentId: string, onSuccess?: () => void) {
|
||||
const [installing, setInstalling] = useState<boolean>(false)
|
||||
const [uninstalling, setUninstalling] = useState<boolean>(false)
|
||||
|
||||
const install = useCallback(
|
||||
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
|
||||
setInstalling(true)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.install({
|
||||
agentId,
|
||||
sourcePath,
|
||||
type
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.()
|
||||
return { success: true as const, data: result.data }
|
||||
} else {
|
||||
const errorMessage = getPluginErrorMessage(result.error, 'Failed to install plugin')
|
||||
return { success: false as const, error: errorMessage }
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
return { success: false as const, error: errorMessage }
|
||||
} finally {
|
||||
setInstalling(false)
|
||||
}
|
||||
},
|
||||
[agentId, onSuccess]
|
||||
)
|
||||
|
||||
const uninstall = useCallback(
|
||||
async (filename: string, type: 'agent' | 'command' | 'skill') => {
|
||||
setUninstalling(true)
|
||||
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.uninstall({
|
||||
agentId,
|
||||
filename,
|
||||
type
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.()
|
||||
return { success: true as const }
|
||||
} else {
|
||||
const errorMessage = getPluginErrorMessage(result.error, 'Failed to uninstall plugin')
|
||||
return { success: false as const, error: errorMessage }
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
return { success: false as const, error: errorMessage }
|
||||
} finally {
|
||||
setUninstalling(false)
|
||||
}
|
||||
},
|
||||
[agentId, onSuccess]
|
||||
)
|
||||
|
||||
return { install, uninstall, installing, uninstalling }
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { shortcutRendererStore } from '@renderer/services/ShortcutService'
|
||||
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||
import type { HydratedShortcut, ShortcutPreferenceEntry, ShortcutPreferenceMap } from '@shared/shortcuts/types'
|
||||
import { orderBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
interface UseShortcutOptions {
|
||||
export interface UseShortcutOptions {
|
||||
preventDefault?: boolean
|
||||
enableOnFormTags?: boolean
|
||||
enabled?: boolean
|
||||
@@ -17,77 +20,232 @@ const defaultOptions: UseShortcutOptions = {
|
||||
enabled: true
|
||||
}
|
||||
|
||||
const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
|
||||
|
||||
const toHotkeysFormat = (keys: string[]): string => {
|
||||
return keys
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'commandorcontrol':
|
||||
case 'command':
|
||||
case 'cmd':
|
||||
return 'mod'
|
||||
case 'control':
|
||||
case 'ctrl':
|
||||
return 'ctrl'
|
||||
case 'alt':
|
||||
case 'altgraph':
|
||||
return 'alt'
|
||||
case 'shift':
|
||||
return 'shift'
|
||||
case 'meta':
|
||||
return 'meta'
|
||||
case 'arrowup':
|
||||
return 'up'
|
||||
case 'arrowdown':
|
||||
return 'down'
|
||||
case 'arrowleft':
|
||||
return 'left'
|
||||
case 'arrowright':
|
||||
return 'right'
|
||||
case 'escape':
|
||||
return 'escape'
|
||||
case 'space':
|
||||
return 'space'
|
||||
default:
|
||||
return key.toLowerCase()
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}
|
||||
|
||||
const toDisplayFormat = (keys: string[]): string => {
|
||||
return keys
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'control':
|
||||
case 'ctrl':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'command':
|
||||
case 'cmd':
|
||||
case 'commandorcontrol':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
case 'meta':
|
||||
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
||||
case 'alt':
|
||||
case 'altgraph':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'shift':
|
||||
return isMac ? '⇧' : 'Shift'
|
||||
case 'arrowup':
|
||||
return '↑'
|
||||
case 'arrowdown':
|
||||
return '↓'
|
||||
case 'arrowleft':
|
||||
return '←'
|
||||
case 'arrowright':
|
||||
return '→'
|
||||
case 'slash':
|
||||
return '/'
|
||||
case 'semicolon':
|
||||
return ';'
|
||||
case 'bracketleft':
|
||||
return '['
|
||||
case 'bracketright':
|
||||
return ']'
|
||||
case 'backslash':
|
||||
return '\\'
|
||||
case 'quote':
|
||||
return "'"
|
||||
case 'comma':
|
||||
return ','
|
||||
case 'minus':
|
||||
return '-'
|
||||
case 'equal':
|
||||
return '='
|
||||
case 'escape':
|
||||
return isMac ? '⎋' : 'Esc'
|
||||
default:
|
||||
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
|
||||
}
|
||||
})
|
||||
.join(isMac ? '' : ' + ')
|
||||
}
|
||||
|
||||
const useShortcutMap = () =>
|
||||
useSyncExternalStore(
|
||||
shortcutRendererStore.subscribe,
|
||||
shortcutRendererStore.getSnapshot,
|
||||
shortcutRendererStore.getServerSnapshot
|
||||
)
|
||||
|
||||
export const useShortcut = (
|
||||
shortcutKey: string,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
name: string,
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
options: UseShortcutOptions = defaultOptions
|
||||
) => {
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
const shortcuts = useShortcutMap()
|
||||
const shortcutConfig = shortcuts[name]
|
||||
|
||||
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 shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
|
||||
const hotkey = useMemo(() => {
|
||||
if (
|
||||
!shortcutConfig ||
|
||||
shortcutConfig.scope !== 'renderer' ||
|
||||
!shortcutConfig.enabled ||
|
||||
shortcutConfig.key.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return toHotkeysFormat(shortcutConfig.key)
|
||||
}, [shortcutConfig])
|
||||
|
||||
useHotkeys(
|
||||
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
|
||||
(e) => {
|
||||
hotkey ?? 'none',
|
||||
(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 ?? shortcutConfig?.description,
|
||||
enabled: Boolean(hotkey && shortcutConfig?.enabled)
|
||||
},
|
||||
[
|
||||
callback,
|
||||
hotkey,
|
||||
shortcutConfig,
|
||||
options.preventDefault,
|
||||
options.enableOnFormTags,
|
||||
options.enabled,
|
||||
options.description
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export function useShortcuts() {
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
|
||||
const shortcuts = useShortcutMap()
|
||||
const list = useMemo(() => {
|
||||
return orderBy(
|
||||
Object.values(shortcuts).map((shortcut) => ({
|
||||
...shortcut,
|
||||
key: [...shortcut.key]
|
||||
})),
|
||||
['system', 'name'],
|
||||
['desc', 'asc']
|
||||
)
|
||||
}, [shortcuts])
|
||||
|
||||
return { shortcuts: list }
|
||||
}
|
||||
|
||||
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 function useShortcutConfig(name: string): HydratedShortcut | undefined {
|
||||
const shortcuts = useShortcutMap()
|
||||
return shortcuts[name]
|
||||
}
|
||||
|
||||
export function useShortcutDisplay(name: string) {
|
||||
const shortcut = useShortcutConfig(name)
|
||||
return useMemo(() => {
|
||||
if (!shortcut || !shortcut.enabled || shortcut.key.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return toDisplayFormat(shortcut.key)
|
||||
}, [shortcut])
|
||||
}
|
||||
|
||||
async function writeShortcutPreferences(updater: (current: ShortcutPreferenceMap) => ShortcutPreferenceMap) {
|
||||
const current = await preferenceService.get('shortcut.preferences')
|
||||
const next = updater({ ...current })
|
||||
await preferenceService.set('shortcut.preferences', next)
|
||||
}
|
||||
|
||||
export async function setShortcutBinding(name: string, keys: string[]) {
|
||||
await writeShortcutPreferences((current) => {
|
||||
const entry: ShortcutPreferenceEntry = { ...current[name] }
|
||||
entry.key = [...keys]
|
||||
current[name] = entry
|
||||
return current
|
||||
})
|
||||
}
|
||||
|
||||
export async function setShortcutEnabled(name: string, enabled: boolean) {
|
||||
await writeShortcutPreferences((current) => {
|
||||
const entry: ShortcutPreferenceEntry = { ...current[name] }
|
||||
entry.enabled = enabled
|
||||
current[name] = entry
|
||||
return current
|
||||
})
|
||||
}
|
||||
|
||||
export async function resetShortcut(name: string) {
|
||||
const definition = definitionMap.get(name)
|
||||
if (!definition) {
|
||||
return
|
||||
}
|
||||
|
||||
await writeShortcutPreferences((current) => {
|
||||
current[name] = {
|
||||
key: [...definition.defaultKey],
|
||||
enabled: definition.defaultEnabled
|
||||
}
|
||||
return current
|
||||
})
|
||||
}
|
||||
|
||||
export async function resetAllShortcuts() {
|
||||
await writeShortcutPreferences(() => {
|
||||
return Object.fromEntries(
|
||||
shortcutDefinitions.map((definition) => [
|
||||
definition.name,
|
||||
{
|
||||
key: [...definition.defaultKey],
|
||||
enabled: definition.defaultEnabled
|
||||
}
|
||||
])
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,16 +4,11 @@ import { defaultLanguage } from '@shared/config/constant'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
// Original translation
|
||||
import enUS from './locales/en-us.json'
|
||||
import zhCN from './locales/zh-cn.json'
|
||||
import zhTW from './locales/zh-tw.json'
|
||||
// import deDE from './locales/de-de.json'
|
||||
// import elGR from './locales/el-gr.json'
|
||||
// import esES from './locales/es-es.json'
|
||||
// import frFR from './locales/fr-fr.json'
|
||||
// import jaJP from './locales/ja-jp.json'
|
||||
// import ptPT from './locales/pt-pt.json'
|
||||
// import ruRU from './locales/ru-ru.json'
|
||||
// Machine translation
|
||||
import deDE from './translate/de-de.json'
|
||||
import elGR from './translate/el-gr.json'
|
||||
import esES from './translate/es-es.json'
|
||||
@@ -22,35 +17,21 @@ import jaJP from './translate/ja-jp.json'
|
||||
import ptPT from './translate/pt-pt.json'
|
||||
import ruRU from './translate/ru-ru.json'
|
||||
|
||||
const logger = loggerService.withContext('renderer:i18n')
|
||||
const logger = loggerService.withContext('I18N')
|
||||
|
||||
// const resources = Object.fromEntries([
|
||||
// ['en-US', enUS],
|
||||
// ['zh-CN', zhCN],
|
||||
// ['zh-TW', zhTW],
|
||||
// ['de-DE', deDE],
|
||||
// ['el-GR', elGR],
|
||||
// ['es-ES', esES],
|
||||
// ['fr-FR', frFR],
|
||||
// ['ja-JP', jaJP],
|
||||
// ['pt-PT', ptPT],
|
||||
// ['ru-RU', ruRU]
|
||||
// ])
|
||||
const resources = Object.fromEntries(
|
||||
(
|
||||
[
|
||||
['en-US', enUS],
|
||||
['zh-CN', zhCN],
|
||||
['zh-TW', zhTW],
|
||||
['de-DE', deDE],
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
['ja-JP', jaJP],
|
||||
['pt-PT', ptPT],
|
||||
['ru-RU', ruRU]
|
||||
] as const
|
||||
).map(([key, translation]) => [key, { translation }])
|
||||
[
|
||||
['en-US', enUS],
|
||||
['ja-JP', jaJP],
|
||||
['ru-RU', ruRU],
|
||||
['zh-CN', zhCN],
|
||||
['zh-TW', zhTW],
|
||||
['de-DE', deDE],
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
['pt-PT', ptPT]
|
||||
].map(([locale, translation]) => [locale, { translation }])
|
||||
)
|
||||
|
||||
export const getLanguage = async () => {
|
||||
|
||||
@@ -200,6 +200,7 @@ const shortcutKeyMap = {
|
||||
exit_fullscreen: 'settings.shortcuts.exit_fullscreen',
|
||||
label: 'settings.shortcuts.label',
|
||||
mini_window: 'settings.shortcuts.mini_window',
|
||||
show_mini_window: 'settings.shortcuts.show_mini_window',
|
||||
new_topic: 'settings.shortcuts.new_topic',
|
||||
press_shortcut: 'settings.shortcuts.press_shortcut',
|
||||
reset_defaults: 'settings.shortcuts.reset_defaults',
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
仅 en-us, zh-cn, zh-tw 经过人工确认,其他翻译文件由机器翻译生成
|
||||
Only en-us, zh-cn, zh-tw are manually maintained; other translation files are machine-translated.
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Advanced Settings"
|
||||
},
|
||||
"essential": "Essential Settings",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Available Plugins"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Are you sure you want to uninstall this plugin?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "No plugins found matching your filters. Try adjusting your search or category filters."
|
||||
},
|
||||
"error": {
|
||||
"install": "Failed to install plugin",
|
||||
"load": "Failed to load plugins",
|
||||
"uninstall": "Failed to uninstall plugin"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All Categories"
|
||||
},
|
||||
"install": "Install",
|
||||
"installed": {
|
||||
"empty": "No plugins installed yet. Browse available plugins to get started.",
|
||||
"title": "Installed Plugins"
|
||||
},
|
||||
"installing": "Installing...",
|
||||
"results": "{{count}} plugin(s) found",
|
||||
"search": {
|
||||
"placeholder": "Search plugins..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin installed successfully",
|
||||
"uninstall": "Plugin uninstalled successfully"
|
||||
},
|
||||
"tab": "Plugins",
|
||||
"type": {
|
||||
"agent": "Agent",
|
||||
"agents": "Agents",
|
||||
"all": "All",
|
||||
"command": "Command",
|
||||
"commands": "Commands",
|
||||
"skills": "Skills"
|
||||
},
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling..."
|
||||
},
|
||||
"prompt": "Prompt Settings",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Allow tool request",
|
||||
"denyRequest": "Deny tool request",
|
||||
"hideDetails": "Hide tool details",
|
||||
"runWithOptions": "Run with additional options",
|
||||
"showDetails": "Show tool details"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Cancel",
|
||||
"run": "Run"
|
||||
},
|
||||
"confirmation": "Are you sure you want to run this Claude tool?",
|
||||
"defaultDenyMessage": "User denied permission for this tool.",
|
||||
"defaultDescription": "Executes code or system actions in your environment. Make sure the command looks safe before running it.",
|
||||
"error": {
|
||||
"sendFailed": "Failed to send your decision. Please try again."
|
||||
},
|
||||
"expired": "Expired",
|
||||
"inputPreview": "Tool input preview",
|
||||
"pending": "Pending ({{seconds}}s)",
|
||||
"permissionExpired": "Permission request expired. Waiting for new instructions...",
|
||||
"requiresElevatedPermissions": "This tool requires elevated permissions.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Approving may update multiple session permissions if you chose to always allow this tool.",
|
||||
"permissionUpdateSingle": "Approving may update your session permissions if you chose to always allow this tool."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Tool request was denied.",
|
||||
"timeout": "Tool request timed out before receiving approval."
|
||||
},
|
||||
"waiting": "Waiting for tool permission decision..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Agent Type",
|
||||
"unknown": "Unknown Type"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Paused",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Provider",
|
||||
"provider_disabled": "Model provider is not enabled",
|
||||
"providerId": "Provider ID",
|
||||
"provider_disabled": "Model provider is not enabled",
|
||||
"reason": "Reason",
|
||||
"render": {
|
||||
"description": "Failed to render message content. Please check if the message content format is correct",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Go Back",
|
||||
"goForward": "Go Forward",
|
||||
"minimize": "Minimize MinApp",
|
||||
"openExternal": "Open in Browser",
|
||||
"open_link_external_off": "Current: Open links in default window",
|
||||
"open_link_external_on": "Current: Open links in browser",
|
||||
"openExternal": "Open in Browser",
|
||||
"refresh": "Refresh",
|
||||
"rightclick_copyurl": "Right-click to copy URL"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Controls upscaling randomness"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Actions",
|
||||
"agents": "Agents",
|
||||
"all_categories": "All Categories",
|
||||
"all_types": "All",
|
||||
"category": "Category",
|
||||
"commands": "Commands",
|
||||
"confirm_uninstall": "Are you sure you want to uninstall {{name}}?",
|
||||
"install": "Install",
|
||||
"install_plugins_from_browser": "Browse available plugins to get started",
|
||||
"installing": "Installing...",
|
||||
"name": "Name",
|
||||
"no_description": "No description available",
|
||||
"no_installed_plugins": "No plugins installed yet",
|
||||
"no_results": "No plugins found",
|
||||
"search_placeholder": "Search plugins...",
|
||||
"showing_results": "Showing {{count}} plugin",
|
||||
"showing_results_one": "Showing {{count}} plugin",
|
||||
"showing_results_other": "Showing {{count}} plugins",
|
||||
"showing_results_plural": "Showing {{count}} plugins",
|
||||
"skills": "Skills",
|
||||
"try_different_search": "Try adjusting your search or category filters",
|
||||
"type": "Type",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copy as image"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "Image upload failed",
|
||||
"uploadFile": "Upload file",
|
||||
"uploadHint": "Supports JPG, PNG, GIF and other formats, max 10MB",
|
||||
"uploading": "Uploading image",
|
||||
"uploadSuccess": "Image uploaded successfully",
|
||||
"uploadText": "Click or drag image here to upload",
|
||||
"uploading": "Uploading image",
|
||||
"urlPlaceholder": "Paste image link",
|
||||
"urlRequired": "Please enter image URL"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Checking for updates...",
|
||||
"checkUpdate": {
|
||||
"available": "Update",
|
||||
"label": "Check Update"
|
||||
},
|
||||
"checkingUpdate": "Checking for updates...",
|
||||
"contact": {
|
||||
"button": "Email",
|
||||
"title": "Contact"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "高级设置"
|
||||
},
|
||||
"essential": "基础设置",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "可用插件"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "确定要卸载此插件吗?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "未找到匹配的插件。请尝试调整搜索或类别筛选。"
|
||||
},
|
||||
"error": {
|
||||
"install": "安装插件失败",
|
||||
"load": "加载插件失败",
|
||||
"uninstall": "卸载插件失败"
|
||||
},
|
||||
"filter": {
|
||||
"all": "所有类别"
|
||||
},
|
||||
"install": "安装",
|
||||
"installed": {
|
||||
"empty": "尚未安装任何插件。浏览可用插件以开始使用。",
|
||||
"title": "已安装插件"
|
||||
},
|
||||
"installing": "安装中...",
|
||||
"results": "找到 {{count}} 个插件",
|
||||
"search": {
|
||||
"placeholder": "搜索插件..."
|
||||
},
|
||||
"success": {
|
||||
"install": "插件安装成功",
|
||||
"uninstall": "插件卸载成功"
|
||||
},
|
||||
"tab": "插件",
|
||||
"type": {
|
||||
"agent": "代理",
|
||||
"agents": "代理",
|
||||
"all": "全部",
|
||||
"command": "命令",
|
||||
"commands": "命令",
|
||||
"skills": "技能"
|
||||
},
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中..."
|
||||
},
|
||||
"prompt": "提示词设置",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "允许工具请求",
|
||||
"denyRequest": "拒绝工具请求",
|
||||
"hideDetails": "隐藏工具详情",
|
||||
"runWithOptions": "带选项运行",
|
||||
"showDetails": "显示工具详情"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "取消",
|
||||
"run": "运行"
|
||||
},
|
||||
"confirmation": "确定要运行此 Claude 工具吗?",
|
||||
"defaultDenyMessage": "用户拒绝了该工具的权限。",
|
||||
"defaultDescription": "在您的环境中执行代码或系统操作。运行前请确保命令安全。",
|
||||
"error": {
|
||||
"sendFailed": "发送您的决定失败,请重试。"
|
||||
},
|
||||
"expired": "已过期",
|
||||
"inputPreview": "工具输入预览",
|
||||
"pending": "等待中 ({{seconds}}秒)",
|
||||
"permissionExpired": "权限请求已过期。等待新指令...",
|
||||
"requiresElevatedPermissions": "此工具需要更高权限。",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "如果您选择总是允许此工具,批准可能会更新多个会话权限。",
|
||||
"permissionUpdateSingle": "如果您选择总是允许此工具,批准可能会更新您的会话权限。"
|
||||
},
|
||||
"toast": {
|
||||
"denied": "工具请求已被拒绝。",
|
||||
"timeout": "工具请求在收到批准前超时。"
|
||||
},
|
||||
"waiting": "等待工具权限决定..."
|
||||
},
|
||||
"type": {
|
||||
"label": "智能体类型",
|
||||
"unknown": "未知类型"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "已中断",
|
||||
"prompt": "提示词",
|
||||
"provider": "提供商",
|
||||
"provider_disabled": "模型提供商未启用",
|
||||
"providerId": "提供商 ID",
|
||||
"provider_disabled": "模型提供商未启用",
|
||||
"reason": "原因",
|
||||
"render": {
|
||||
"description": "消息内容渲染失败,请检查消息内容格式是否正确",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "后退",
|
||||
"goForward": "前进",
|
||||
"minimize": "最小化小程序",
|
||||
"openExternal": "在浏览器中打开",
|
||||
"open_link_external_off": "当前:使用默认窗口打开链接",
|
||||
"open_link_external_on": "当前:在浏览器中打开链接",
|
||||
"openExternal": "在浏览器中打开",
|
||||
"refresh": "刷新",
|
||||
"rightclick_copyurl": "右键复制 URL"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "控制放大结果的随机性"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "操作",
|
||||
"agents": "代理",
|
||||
"all_categories": "所有类别",
|
||||
"all_types": "全部",
|
||||
"category": "类别",
|
||||
"commands": "命令",
|
||||
"confirm_uninstall": "确定要卸载 {{name}} 吗?",
|
||||
"install": "安装",
|
||||
"install_plugins_from_browser": "浏览可用插件以开始使用",
|
||||
"installing": "安装中...",
|
||||
"name": "名称",
|
||||
"no_description": "无描述",
|
||||
"no_installed_plugins": "尚未安装任何插件",
|
||||
"no_results": "未找到插件",
|
||||
"search_placeholder": "搜索插件...",
|
||||
"showing_results": "显示 {{count}} 个插件",
|
||||
"showing_results_one": "显示 {{count}} 个插件",
|
||||
"showing_results_other": "显示 {{count}} 个插件",
|
||||
"showing_results_plural": "显示 {{count}} 个插件",
|
||||
"skills": "技能",
|
||||
"try_different_search": "请尝试调整搜索或类别筛选",
|
||||
"type": "类型",
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "复制为图片"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "图片上传失败",
|
||||
"uploadFile": "上传文件",
|
||||
"uploadHint": "支持 JPG、PNG、GIF 等格式,最大 10MB",
|
||||
"uploading": "正在上传图片",
|
||||
"uploadSuccess": "图片上传成功",
|
||||
"uploadText": "点击或拖拽图片到此处上传",
|
||||
"uploading": "正在上传图片",
|
||||
"urlPlaceholder": "粘贴图片链接地址",
|
||||
"urlRequired": "请输入图片链接地址"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "正在检查更新...",
|
||||
"checkUpdate": {
|
||||
"available": "立即更新",
|
||||
"label": "检查更新"
|
||||
},
|
||||
"checkingUpdate": "正在检查更新...",
|
||||
"contact": {
|
||||
"button": "邮件",
|
||||
"title": "邮件联系"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "進階設定"
|
||||
},
|
||||
"essential": "必要設定",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "可用外掛"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "確定要解除安裝此外掛嗎?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "未找到符合的外掛。請嘗試調整搜尋或類別篩選。"
|
||||
},
|
||||
"error": {
|
||||
"install": "安裝外掛失敗",
|
||||
"load": "載入外掛失敗",
|
||||
"uninstall": "解除安裝外掛失敗"
|
||||
},
|
||||
"filter": {
|
||||
"all": "所有類別"
|
||||
},
|
||||
"install": "安裝",
|
||||
"installed": {
|
||||
"empty": "尚未安裝任何外掛。瀏覽可用外掛以開始使用。",
|
||||
"title": "已安裝外掛"
|
||||
},
|
||||
"installing": "安裝中...",
|
||||
"results": "找到 {{count}} 個外掛",
|
||||
"search": {
|
||||
"placeholder": "搜尋外掛..."
|
||||
},
|
||||
"success": {
|
||||
"install": "外掛安裝成功",
|
||||
"uninstall": "外掛解除安裝成功"
|
||||
},
|
||||
"tab": "外掛",
|
||||
"type": {
|
||||
"agent": "代理",
|
||||
"agents": "代理",
|
||||
"all": "全部",
|
||||
"command": "指令",
|
||||
"commands": "指令",
|
||||
"skills": "技能"
|
||||
},
|
||||
"uninstall": "解除安裝",
|
||||
"uninstalling": "解除安裝中..."
|
||||
},
|
||||
"prompt": "提示設定",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "允許工具請求",
|
||||
"denyRequest": "拒絕工具請求",
|
||||
"hideDetails": "隱藏工具詳情",
|
||||
"runWithOptions": "帶選項執行",
|
||||
"showDetails": "顯示工具詳情"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "取消",
|
||||
"run": "執行"
|
||||
},
|
||||
"confirmation": "確定要執行此 Claude 工具嗎?",
|
||||
"defaultDenyMessage": "使用者拒絕了該工具的權限。",
|
||||
"defaultDescription": "在您的環境中執行程式碼或系統操作。執行前請確保指令安全。",
|
||||
"error": {
|
||||
"sendFailed": "傳送您的決定失敗,請重試。"
|
||||
},
|
||||
"expired": "已過期",
|
||||
"inputPreview": "工具輸入預覽",
|
||||
"pending": "等待中 ({{seconds}}秒)",
|
||||
"permissionExpired": "權限請求已過期。等待新指令...",
|
||||
"requiresElevatedPermissions": "此工具需要提升的權限。",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "如果您選擇總是允許此工具,核准可能會更新多個工作階段權限。",
|
||||
"permissionUpdateSingle": "如果您選擇總是允許此工具,核准可能會更新您的工作階段權限。"
|
||||
},
|
||||
"toast": {
|
||||
"denied": "工具請求已被拒絕。",
|
||||
"timeout": "工具請求在收到核准前逾時。"
|
||||
},
|
||||
"waiting": "等待工具權限決定..."
|
||||
},
|
||||
"type": {
|
||||
"label": "代理類型",
|
||||
"unknown": "未知類型"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "回應已暫停",
|
||||
"prompt": "提示詞",
|
||||
"provider": "提供商",
|
||||
"provider_disabled": "模型供應商未啟用",
|
||||
"providerId": "提供者 ID",
|
||||
"provider_disabled": "模型供應商未啟用",
|
||||
"reason": "原因",
|
||||
"render": {
|
||||
"description": "消息內容渲染失敗,請檢查消息內容格式是否正確",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "上一頁",
|
||||
"goForward": "下一頁",
|
||||
"minimize": "最小化小工具",
|
||||
"openExternal": "在瀏覽器中開啟",
|
||||
"open_link_external_off": "当前:使用預設視窗開啟連結",
|
||||
"open_link_external_on": "当前:在瀏覽器中開啟連結",
|
||||
"openExternal": "在瀏覽器中開啟",
|
||||
"refresh": "重新整理",
|
||||
"rightclick_copyurl": "右鍵複製 URL"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "控制放大結果的隨機性"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "操作",
|
||||
"agents": "代理",
|
||||
"all_categories": "所有類別",
|
||||
"all_types": "全部",
|
||||
"category": "類別",
|
||||
"commands": "指令",
|
||||
"confirm_uninstall": "確定要解除安裝 {{name}} 嗎?",
|
||||
"install": "安裝",
|
||||
"install_plugins_from_browser": "瀏覽可用外掛以開始使用",
|
||||
"installing": "安裝中...",
|
||||
"name": "名稱",
|
||||
"no_description": "無描述",
|
||||
"no_installed_plugins": "尚未安裝任何外掛",
|
||||
"no_results": "未找到外掛",
|
||||
"search_placeholder": "搜尋外掛...",
|
||||
"showing_results": "顯示 {{count}} 個外掛",
|
||||
"showing_results_one": "顯示 {{count}} 個外掛",
|
||||
"showing_results_other": "顯示 {{count}} 個外掛",
|
||||
"showing_results_plural": "顯示 {{count}} 個外掛",
|
||||
"skills": "技能",
|
||||
"try_different_search": "請嘗試調整搜尋或類別篩選",
|
||||
"type": "類型",
|
||||
"uninstall": "解除安裝",
|
||||
"uninstalling": "解除安裝中..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "複製為圖片"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "圖片上傳失敗",
|
||||
"uploadFile": "上傳檔案",
|
||||
"uploadHint": "支援 JPG、PNG、GIF 等格式,最大 10MB",
|
||||
"uploading": "正在上傳圖片",
|
||||
"uploadSuccess": "圖片上傳成功",
|
||||
"uploadText": "點擊或拖拽圖片到此處上傳",
|
||||
"uploading": "正在上傳圖片",
|
||||
"urlPlaceholder": "貼上圖片連結地址",
|
||||
"urlRequired": "請輸入圖片連結地址"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "正在檢查更新...",
|
||||
"checkUpdate": {
|
||||
"available": "立即更新",
|
||||
"label": "檢查更新"
|
||||
},
|
||||
"checkingUpdate": "正在檢查更新...",
|
||||
"contact": {
|
||||
"button": "電子郵件",
|
||||
"title": "聯絡方式"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Erweiterte Einstellungen"
|
||||
},
|
||||
"essential": "Grundeinstellungen",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Verfügbare Plugins"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Sind Sie sicher, dass Sie dieses Plugin deinstallieren möchten?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Keine Plugins gefunden, die deinen Filtern entsprechen. Versuche, deine Such- oder Kategoriefilter anzupassen."
|
||||
},
|
||||
"error": {
|
||||
"install": "Fehler beim Installieren des Plugins",
|
||||
"load": "Fehler beim Laden der Plugins",
|
||||
"uninstall": "Fehler beim Deinstallieren des Plugins"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Alle Kategorien"
|
||||
},
|
||||
"install": "Installieren",
|
||||
"installed": {
|
||||
"empty": "Noch keine Plugins installiert. Durchsuche verfügbare Plugins, um loszulegen.",
|
||||
"title": "Installierte Plugins"
|
||||
},
|
||||
"installing": "Wird installiert...",
|
||||
"results": "{{count}} Plugin(s) gefunden",
|
||||
"search": {
|
||||
"placeholder": "Such-Plugins..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin erfolgreich installiert",
|
||||
"uninstall": "Plugin erfolgreich deinstalliert"
|
||||
},
|
||||
"tab": "Plugins",
|
||||
"type": {
|
||||
"agent": "Agent",
|
||||
"agents": "Agenten",
|
||||
"all": "Alle",
|
||||
"command": "Befehl",
|
||||
"commands": "Befehle",
|
||||
"skills": "Fähigkeiten"
|
||||
},
|
||||
"uninstall": "Deinstallieren",
|
||||
"uninstalling": "Deinstallation läuft..."
|
||||
},
|
||||
"prompt": "Prompt-Einstellungen",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Werkzeuganfrage zulassen",
|
||||
"denyRequest": "Werkzeuganfrage ablehnen",
|
||||
"hideDetails": "Werkzeugdetails ausblenden",
|
||||
"runWithOptions": "Mit zusätzlichen Optionen ausführen",
|
||||
"showDetails": "Zeige Werkzeugdetails"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Abbrechen",
|
||||
"run": "Laufen"
|
||||
},
|
||||
"confirmation": "Bist du sicher, dass du dieses Claude-Tool ausführen möchtest?",
|
||||
"defaultDenyMessage": "Der Benutzer hat die Berechtigung für dieses Tool verweigert.",
|
||||
"defaultDescription": "Führt Code oder Systemaktionen in Ihrer Umgebung aus. Vergewissern Sie sich, dass der Befehl sicher aussieht, bevor Sie ihn ausführen.",
|
||||
"error": {
|
||||
"sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"expired": "Abgelaufen",
|
||||
"inputPreview": "Vorschau der Werkzeugeingabe",
|
||||
"pending": "Ausstehend ({{seconds}}s)",
|
||||
"permissionExpired": "Berechtigungsanfrage abgelaufen. Warte auf neue Anweisungen...",
|
||||
"requiresElevatedPermissions": "Dieses Tool erfordert erhöhte Berechtigungen.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Das Genehmigen kann mehrere Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen.",
|
||||
"permissionUpdateSingle": "Das Genehmigen kann Ihre Sitzungsberechtigungen aktualisieren, wenn Sie sich entschieden haben, dieses Tool immer zuzulassen."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Tool-Anfrage wurde abgelehnt.",
|
||||
"timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist."
|
||||
},
|
||||
"waiting": "Warten auf Entscheidung über Tool-Berechtigung..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Agent-Typ",
|
||||
"unknown": "Unbekannter Typ"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Unterbrochen",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Anbieter",
|
||||
"provider_disabled": "Modellanbieter nicht aktiviert",
|
||||
"providerId": "Anbieter-ID",
|
||||
"provider_disabled": "Modellanbieter nicht aktiviert",
|
||||
"reason": "Grund",
|
||||
"render": {
|
||||
"description": "Rendering der Nachricht fehlgeschlagen. Bitte überprüfen Sie das Format des Nachrichteninhalts",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Zurück",
|
||||
"goForward": "Vorwärts",
|
||||
"minimize": "Mini-App minimieren",
|
||||
"openExternal": "In Browser öffnen",
|
||||
"open_link_external_off": "Aktuell: Links im Standardfenster öffnen",
|
||||
"open_link_external_on": "Aktuell: Links im Browser öffnen",
|
||||
"openExternal": "In Browser öffnen",
|
||||
"refresh": "Aktualisieren",
|
||||
"rightclick_copyurl": "Rechtsklick zum Kopieren der URL"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Kontrolle der Zufälligkeit des Upscale-Ergebnisses"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Aktionen",
|
||||
"agents": "Agenten",
|
||||
"all_categories": "Alle Kategorien",
|
||||
"all_types": "Alle",
|
||||
"category": "Kategorie",
|
||||
"commands": "Befehle",
|
||||
"confirm_uninstall": "Sind Sie sicher, dass Sie {{name}} deinstallieren möchten?",
|
||||
"install": "Installieren",
|
||||
"install_plugins_from_browser": "Durchsuche verfügbare Plugins, um loszulegen",
|
||||
"installing": "Installiere…",
|
||||
"name": "Name",
|
||||
"no_description": "Keine Beschreibung verfügbar",
|
||||
"no_installed_plugins": "Noch keine Plugins installiert",
|
||||
"no_results": "Keine Plugins gefunden",
|
||||
"search_placeholder": "Such-Plugins...",
|
||||
"showing_results": "{{count}} Plugin anzeigen",
|
||||
"showing_results_one": "{{count}} Plugin anzeigen",
|
||||
"showing_results_other": "Zeige {{count}} Plugins",
|
||||
"showing_results_plural": "{{count}} Plugins anzeigen",
|
||||
"skills": "Fähigkeiten",
|
||||
"try_different_search": "Versuchen Sie, Ihre Suche oder die Kategoriefilter anzupassen.",
|
||||
"type": "Typ",
|
||||
"uninstall": "Deinstallieren",
|
||||
"uninstalling": "Deinstallation läuft..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Als Bild kopieren"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "Bild-Upload fehlgeschlagen",
|
||||
"uploadFile": "Datei hochladen",
|
||||
"uploadHint": "Unterstützt JPG, PNG, GIF usw., maximal 10 MB",
|
||||
"uploading": "Bild wird hochgeladen",
|
||||
"uploadSuccess": "Bild erfolgreich hochgeladen",
|
||||
"uploadText": "Klicken oder Bild hierher ziehen zum Hochladen",
|
||||
"uploading": "Bild wird hochgeladen",
|
||||
"urlPlaceholder": "Bildlink-Adresse einfügen",
|
||||
"urlRequired": "Bitte Bildlink-Adresse eingeben"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Sucht nach Updates...",
|
||||
"checkUpdate": {
|
||||
"available": "Jetzt aktualisieren",
|
||||
"label": "Auf Updates prüfen"
|
||||
},
|
||||
"checkingUpdate": "Sucht nach Updates...",
|
||||
"contact": {
|
||||
"button": "E-Mail",
|
||||
"title": "E-Mail-Kontakt"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Ρυθμίσεις για προχωρημένους"
|
||||
},
|
||||
"essential": "Βασικές Ρυθμίσεις",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Διαθέσιμα πρόσθετα"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Είστε βέβαιοι ότι θέλετε να απεγκαταστήσετε αυτό το πρόσθετο;"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Δεν βρέθηκε συμβατό πρόσθετο. Δοκιμάστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών."
|
||||
},
|
||||
"error": {
|
||||
"install": "Η εγκατάσταση του πρόσθετου απέτυχε",
|
||||
"load": "Η φόρτωση του πρόσθετου απέτυχε",
|
||||
"uninstall": "Η απεγκατάσταση του πρόσθετου απέτυχε"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Όλες οι κατηγορίες"
|
||||
},
|
||||
"install": "εγκατάσταση",
|
||||
"installed": {
|
||||
"empty": "Δεν έχει εγκατασταθεί κανένα πρόσθετο. Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε.",
|
||||
"title": "Έχει εγκατασταθεί το πρόσθετο"
|
||||
},
|
||||
"installing": "Εγκατάσταση...",
|
||||
"results": "Βρέθηκαν {{count}} πρόσθετα",
|
||||
"search": {
|
||||
"placeholder": "Αναζήτηση πρόσθετου..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Η εγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία",
|
||||
"uninstall": "Η απεγκατάσταση του πρόσθετου ολοκληρώθηκε με επιτυχία"
|
||||
},
|
||||
"tab": "Πρόσθετο",
|
||||
"type": {
|
||||
"agent": "αντιπρόσωπος",
|
||||
"agents": "αντιπρόσωπος",
|
||||
"all": "όλα",
|
||||
"command": "εντολή",
|
||||
"commands": "εντολή",
|
||||
"skills": "δεξιότητα"
|
||||
},
|
||||
"uninstall": "απεγκατάσταση",
|
||||
"uninstalling": "Απεγκατάσταση..."
|
||||
},
|
||||
"prompt": "Ρυθμίσεις Προτροπής",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Επίτρεψη αίτησης εργαλείου",
|
||||
"denyRequest": "Απόρριψη αιτήματος εργαλείου",
|
||||
"hideDetails": "Απόκρυψη λεπτομερειών εργαλείου",
|
||||
"runWithOptions": "Εκτέλεση με επιπλέον επιλογές",
|
||||
"showDetails": "Εμφάνιση λεπτομερειών εργαλείου"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Ακύρωση",
|
||||
"run": "Τρέξε"
|
||||
},
|
||||
"confirmation": "Είσαι σίγουρος ότι θέλεις να εκτελέσεις αυτό το εργαλείο Claude;",
|
||||
"defaultDenyMessage": "Ο χρήστης αρνήθηκε την άδεια για αυτό το εργαλείο.",
|
||||
"defaultDescription": "Εκτελεί κώδικα ή ενέργειες συστήματος στο περιβάλλον σας. Βεβαιωθείτε ότι η εντολή φαίνεται ασφαλής πριν την εκτελέσετε.",
|
||||
"error": {
|
||||
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
|
||||
},
|
||||
"expired": "Ληγμένο",
|
||||
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
|
||||
"pending": "Εκκρεμεί ({{seconds}}δ)",
|
||||
"permissionExpired": "Το αίτημα άδειας έληξε. Αναμονή για νέες οδηγίες...",
|
||||
"requiresElevatedPermissions": "Αυτό το εργαλείο απαιτεί αυξημένα δικαιώματα.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Η έγκριση μπορεί να ενημερώσει πολλές άδειες συνεδρίας αν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο.",
|
||||
"permissionUpdateSingle": "Η έγκριση ενδέχεται να ενημερώσει τα δικαιώματα περιόδου σύνδεσής σας, εάν επιλέξατε να επιτρέπετε πάντα αυτό το εργαλείο."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Το αίτημα για εργαλείο απορρίφθηκε.",
|
||||
"timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση."
|
||||
},
|
||||
"waiting": "Αναμονή για απόφαση άδειας εργαλείου..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Τύπος Πράκτορα",
|
||||
"unknown": "Άγνωστος Τύπος"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Διακόπηκε",
|
||||
"prompt": "συμβουλές",
|
||||
"provider": "πάροχος",
|
||||
"provider_disabled": "Ο παρεχόμενος παροχός του μοντέλου δεν είναι ενεργοποιημένος",
|
||||
"providerId": "Αναγνωριστικό παρόχου",
|
||||
"provider_disabled": "Ο παρεχόμενος παροχός του μοντέλου δεν είναι ενεργοποιημένος",
|
||||
"reason": "αιτία",
|
||||
"render": {
|
||||
"description": "Απέτυχε η ώθηση της εξίσωσης, παρακαλώ ελέγξτε το σωστό μορφάτι της",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Πίσω",
|
||||
"goForward": "Μπροστά",
|
||||
"minimize": "Ελαχιστοποίηση της εφαρμογής",
|
||||
"openExternal": "Άνοιγμα στον περιηγητή",
|
||||
"open_link_external_off": "Τρέχον: Άνοιγμα συνδέσμου χρησιμοποιώντας το προεπιλεγμένο παράθυρο",
|
||||
"open_link_external_on": "Τρέχον: Άνοιγμα συνδέσμου στον περιηγητή",
|
||||
"openExternal": "Άνοιγμα στον περιηγητή",
|
||||
"refresh": "Ανανέωση",
|
||||
"rightclick_copyurl": "Αντιγραφή URL με δεξί κλικ"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Ελέγχει την τυχαιότητα του αποτελέσματος μεγέθυνσης"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Λειτουργία",
|
||||
"agents": "αντιπρόσωπος",
|
||||
"all_categories": "Όλες οι κατηγορίες",
|
||||
"all_types": "ολόκληρο",
|
||||
"category": "Κατηγορία",
|
||||
"commands": "εντολή",
|
||||
"confirm_uninstall": "Είστε σίγουροι ότι θέλετε να απεγκαταστήσετε το {{name}};",
|
||||
"install": "εγκατάσταση",
|
||||
"install_plugins_from_browser": "Περιηγηθείτε στα διαθέσιμα πρόσθετα για να ξεκινήσετε",
|
||||
"installing": "Εγκατάσταση...",
|
||||
"name": "Όνομα",
|
||||
"no_description": "Χωρίς περιγραφή",
|
||||
"no_installed_plugins": "Δεν έχει εγκατασταθεί κανένα πρόσθετο",
|
||||
"no_results": "Δεν βρέθηκε πρόσθετο",
|
||||
"search_placeholder": "Πρόσθετο αναζήτησης...",
|
||||
"showing_results": "Εμφάνιση {{count}} προσθέτων",
|
||||
"showing_results_one": "Εμφάνιση {{count}} προσθέτων",
|
||||
"showing_results_other": "Εμφάνιση {{count}} προσθέτων",
|
||||
"showing_results_plural": "Εμφάνιση {{count}} πρόσθετων",
|
||||
"skills": "δεξιότητα",
|
||||
"try_different_search": "Προσπαθήστε να προσαρμόσετε την αναζήτηση ή το φίλτρο κατηγοριών",
|
||||
"type": "τύπος",
|
||||
"uninstall": "κατάργηση εγκατάστασης",
|
||||
"uninstalling": "Απεγκατάσταση..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Αντιγραφή ως εικόνα"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "Η μεταφόρτωση της εικόνας απέτυχε",
|
||||
"uploadFile": "Ανέβασμα αρχείου",
|
||||
"uploadHint": "Υποστηρίζει μορφές όπως JPG, PNG, GIF, μέγιστο μέγεθος 10MB",
|
||||
"uploading": "Ανεβάζει εικόνα",
|
||||
"uploadSuccess": "Η εικόνα ανέβηκε με επιτυχία",
|
||||
"uploadText": "Κάντε κλικ ή σύρετε την εικόνα εδώ για μεταφόρτωση",
|
||||
"uploading": "Ανεβάζει εικόνα",
|
||||
"urlPlaceholder": "Επικολλήστε τη διεύθυνση συνδέσμου της εικόνας",
|
||||
"urlRequired": "Παρακαλώ εισαγάγετε τη διεύθυνση σύνδεσης της εικόνας"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Ελέγχω ενημερώσεις...",
|
||||
"checkUpdate": {
|
||||
"available": "Άμεση ενημέρωση",
|
||||
"label": "Έλεγχος ενημερώσεων"
|
||||
},
|
||||
"checkingUpdate": "Ελέγχω ενημερώσεις...",
|
||||
"contact": {
|
||||
"button": "Ταχυδρομείο",
|
||||
"title": "Επικοινωνία μέσω ταχυδρομείου"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Configuración avanzada"
|
||||
},
|
||||
"essential": "Configuraciones esenciales",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Complementos disponibles"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "¿Estás seguro de que quieres desinstalar este complemento?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "No se encontró ningún complemento que coincida. Intenta ajustar la búsqueda o los filtros de categoría."
|
||||
},
|
||||
"error": {
|
||||
"install": "Error al instalar el complemento",
|
||||
"load": "Error al cargar el complemento",
|
||||
"uninstall": "Error al desinstalar el complemento"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Todas las categorías"
|
||||
},
|
||||
"install": "instalación",
|
||||
"installed": {
|
||||
"empty": "Aún no se ha instalado ningún complemento. Explora los complementos disponibles para comenzar.",
|
||||
"title": "Complemento instalado"
|
||||
},
|
||||
"installing": "Instalando...",
|
||||
"results": "Encontrados {{count}} complementos",
|
||||
"search": {
|
||||
"placeholder": "Buscar complemento..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Complemento instalado con éxito",
|
||||
"uninstall": "Complemento desinstalado correctamente"
|
||||
},
|
||||
"tab": "complemento",
|
||||
"type": {
|
||||
"agent": "agente",
|
||||
"agents": "Agente",
|
||||
"all": "todo",
|
||||
"command": "comando",
|
||||
"commands": "comando",
|
||||
"skills": "habilidad"
|
||||
},
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"prompt": "Configuración de indicaciones",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Permitir solicitud de herramienta",
|
||||
"denyRequest": "Denegar solicitud de herramienta",
|
||||
"hideDetails": "Ocultar detalles de la herramienta",
|
||||
"runWithOptions": "Ejecutar con opciones adicionales",
|
||||
"showDetails": "Mostrar detalles de la herramienta"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"run": "Correr"
|
||||
},
|
||||
"confirmation": "¿Estás seguro de que quieres ejecutar esta herramienta de Claude?",
|
||||
"defaultDenyMessage": "El usuario denegó el permiso para esta herramienta.",
|
||||
"defaultDescription": "Ejecuta código o acciones del sistema en tu entorno. Asegúrate de que el comando parezca seguro antes de ejecutarlo.",
|
||||
"error": {
|
||||
"sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo."
|
||||
},
|
||||
"expired": "Caducado",
|
||||
"inputPreview": "Vista previa de entrada de herramienta",
|
||||
"pending": "Pendiente ({{seconds}}s)",
|
||||
"permissionExpired": "Solicitud de permiso expirada. Esperando nuevas instrucciones...",
|
||||
"requiresElevatedPermissions": "Esta herramienta requiere permisos elevados.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Aprobar puede actualizar varios permisos de sesión si elegiste permitir siempre esta herramienta.",
|
||||
"permissionUpdateSingle": "Aprobar puede actualizar los permisos de tu sesión si elegiste permitir siempre esta herramienta."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "La solicitud de herramienta fue denegada.",
|
||||
"timeout": "La solicitud de herramienta expiró antes de recibir la aprobación."
|
||||
},
|
||||
"waiting": "Esperando la decisión de permiso de la herramienta..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo de Agente",
|
||||
"unknown": "Tipo desconocido"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Interrumpido",
|
||||
"prompt": "prompt",
|
||||
"provider": "proveedor",
|
||||
"provider_disabled": "El proveedor de modelos no está habilitado",
|
||||
"providerId": "ID del proveedor",
|
||||
"provider_disabled": "El proveedor de modelos no está habilitado",
|
||||
"reason": "causa",
|
||||
"render": {
|
||||
"description": "Error al renderizar la fórmula, por favor, compruebe si el formato de la fórmula es correcto",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Retroceder",
|
||||
"goForward": "Avanzar",
|
||||
"minimize": "Minimizar la aplicación",
|
||||
"openExternal": "Abrir en el navegador",
|
||||
"open_link_external_off": "Actual: Abrir enlaces en ventana predeterminada",
|
||||
"open_link_external_on": "Actual: Abrir enlaces en el navegador",
|
||||
"openExternal": "Abrir en el navegador",
|
||||
"refresh": "Actualizar",
|
||||
"rightclick_copyurl": "Copiar URL con clic derecho"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Controla la aleatoriedad del resultado de la ampliación"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Operación",
|
||||
"agents": "Agente",
|
||||
"all_categories": "Todas las categorías",
|
||||
"all_types": "todo",
|
||||
"category": "Categoría",
|
||||
"commands": "comando",
|
||||
"confirm_uninstall": "¿Estás seguro de que quieres desinstalar {{name}}?",
|
||||
"install": "instalación",
|
||||
"install_plugins_from_browser": "Explora los complementos disponibles para empezar a usar",
|
||||
"installing": "Instalando...",
|
||||
"name": "Nombre",
|
||||
"no_description": "Sin descripción",
|
||||
"no_installed_plugins": "Aún no se ha instalado ningún complemento",
|
||||
"no_results": "No se encontró el complemento",
|
||||
"search_placeholder": "Buscar complemento...",
|
||||
"showing_results": "Mostrar {{count}} complementos",
|
||||
"showing_results_one": "Mostrar {{count}} complementos",
|
||||
"showing_results_other": "Mostrar {{count}} complementos",
|
||||
"showing_results_plural": "Mostrar {{count}} complementos",
|
||||
"skills": "habilidad",
|
||||
"try_different_search": "Por favor, intenta ajustar la búsqueda o los filtros de categoría.",
|
||||
"type": "tipo",
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copiar como imagen"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "La subida de la imagen falló",
|
||||
"uploadFile": "subir archivo",
|
||||
"uploadHint": "Admite formatos como JPG, PNG, GIF, entre otros, con un tamaño máximo de 10MB",
|
||||
"uploading": "Subiendo imágenes",
|
||||
"uploadSuccess": "Imagen subida con éxito",
|
||||
"uploadText": "Haz clic o arrastra la imagen aquí para subirla",
|
||||
"uploading": "Subiendo imágenes",
|
||||
"urlPlaceholder": "pegar el enlace de la imagen",
|
||||
"urlRequired": "Por favor, introduce la dirección del enlace de la imagen"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Verificando actualizaciones...",
|
||||
"checkUpdate": {
|
||||
"available": "Actualizar ahora",
|
||||
"label": "Comprobar actualizaciones"
|
||||
},
|
||||
"checkingUpdate": "Verificando actualizaciones...",
|
||||
"contact": {
|
||||
"button": "Correo electrónico",
|
||||
"title": "Contacto por correo electrónico"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Paramètres avancés"
|
||||
},
|
||||
"essential": "Paramètres essentiels",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Plugins disponibles"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Êtes-vous sûr de vouloir désinstaller ce plugin ?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Aucun plugin correspondant trouvé. Veuillez essayer d’ajuster la recherche ou les filtres de catégorie."
|
||||
},
|
||||
"error": {
|
||||
"install": "Échec de l'installation du plugin",
|
||||
"load": "Échec du chargement du plugin",
|
||||
"uninstall": "Échec de la désinstallation du plugin"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Toutes les catégories"
|
||||
},
|
||||
"install": "Installation",
|
||||
"installed": {
|
||||
"empty": "Aucun plugin n'est encore installé. Parcourez les plugins disponibles pour commencer.",
|
||||
"title": "Extension installée"
|
||||
},
|
||||
"installing": "Installation en cours...",
|
||||
"results": "{{count}} modules complémentaires trouvés",
|
||||
"search": {
|
||||
"placeholder": "Recherche de plug-ins..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Installation du plugin réussie",
|
||||
"uninstall": "Désinstallation du plugin réussie"
|
||||
},
|
||||
"tab": "Module d'extension",
|
||||
"type": {
|
||||
"agent": "mandataire",
|
||||
"agents": "mandataire",
|
||||
"all": "Tout",
|
||||
"command": "commande",
|
||||
"commands": "commande",
|
||||
"skills": "compétence"
|
||||
},
|
||||
"uninstall": "Désinstaller",
|
||||
"uninstalling": "Désinstallation en cours..."
|
||||
},
|
||||
"prompt": "Paramètres de l'invite",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Autoriser la demande d'outil",
|
||||
"denyRequest": "Refuser la demande d'outil",
|
||||
"hideDetails": "Masquer les détails de l'outil",
|
||||
"runWithOptions": "Exécuter avec des options supplémentaires",
|
||||
"showDetails": "Afficher les détails de l'outil"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Annuler",
|
||||
"run": "Courir"
|
||||
},
|
||||
"confirmation": "Êtes-vous sûr de vouloir exécuter cet outil Claude ?",
|
||||
"defaultDenyMessage": "L'utilisateur a refusé l'autorisation pour cet outil.",
|
||||
"defaultDescription": "Exécute du code ou des actions système dans votre environnement. Assurez-vous que la commande semble sûre avant de l’exécuter.",
|
||||
"error": {
|
||||
"sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer."
|
||||
},
|
||||
"expired": "Expiré",
|
||||
"inputPreview": "Aperçu de l'entrée de l'outil",
|
||||
"pending": "En attente ({{seconds}}s)",
|
||||
"permissionExpired": "Demande de permission expirée. En attente de nouvelles instructions...",
|
||||
"requiresElevatedPermissions": "Cet outil nécessite des autorisations élevées.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Approuver peut mettre à jour plusieurs autorisations de session si vous avez choisi de toujours autoriser cet outil.",
|
||||
"permissionUpdateSingle": "Approuver peut mettre à jour vos permissions de session si vous avez choisi de toujours autoriser cet outil."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "La demande d'outil a été refusée.",
|
||||
"timeout": "La demande d'outil a expiré avant d'obtenir l'approbation."
|
||||
},
|
||||
"waiting": "En attente de la décision d'autorisation de l'outil..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Type d'agent",
|
||||
"unknown": "Type inconnu"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Прервано",
|
||||
"prompt": "mot-clé",
|
||||
"provider": "fournisseur",
|
||||
"provider_disabled": "Le fournisseur de modèles n'est pas activé",
|
||||
"providerId": "ID du fournisseur",
|
||||
"provider_disabled": "Le fournisseur de modèles n'est pas activé",
|
||||
"reason": "raison",
|
||||
"render": {
|
||||
"description": "La formule n'a pas été rendue avec succès, veuillez vérifier si le format de la formule est correct",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Reculer",
|
||||
"goForward": "Avancer",
|
||||
"minimize": "Свернуть мини-программу",
|
||||
"openExternal": "Открыть в браузере",
|
||||
"open_link_external_off": "Текущий: открывать ссылки в окне по умолчанию",
|
||||
"open_link_external_on": "Текущий: открывать ссылки в браузере",
|
||||
"openExternal": "Открыть в браузере",
|
||||
"refresh": "Обновить",
|
||||
"rightclick_copyurl": "Скопировать URL через правую кнопку мыши"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Contrôle la randomisation du résultat d'agrandissement"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Opération",
|
||||
"agents": "mandataire",
|
||||
"all_categories": "Toutes les catégories",
|
||||
"all_types": "Tout",
|
||||
"category": "Catégorie",
|
||||
"commands": "commande",
|
||||
"confirm_uninstall": "Êtes-vous sûr de vouloir désinstaller {{name}} ?",
|
||||
"install": "Installation",
|
||||
"install_plugins_from_browser": "Parcourir les plugins disponibles pour commencer",
|
||||
"installing": "Installation en cours...",
|
||||
"name": "Nom",
|
||||
"no_description": "Sans description",
|
||||
"no_installed_plugins": "Aucun plugin n’est encore installé",
|
||||
"no_results": "Aucun plugin trouvé",
|
||||
"search_placeholder": "Rechercher des modules d'extension...",
|
||||
"showing_results": "Afficher {{count}} extensions",
|
||||
"showing_results_one": "Afficher {{count}} modules d’extension",
|
||||
"showing_results_other": "Afficher {{count}} modules d'extension",
|
||||
"showing_results_plural": "Afficher {{count}} modules d'extension",
|
||||
"skills": "compétence",
|
||||
"try_different_search": "Veuillez essayer d’ajuster la recherche ou le filtre de catégorie.",
|
||||
"type": "type",
|
||||
"uninstall": "Désinstaller",
|
||||
"uninstalling": "Désinstallation en cours..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copier en tant qu'image"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "Échec du téléversement de l'image",
|
||||
"uploadFile": "télécharger un fichier",
|
||||
"uploadHint": "prend en charge les formats JPG, PNG, GIF, etc., jusqu'à 10 Mo max.",
|
||||
"uploading": "Téléchargement de l'image en cours",
|
||||
"uploadSuccess": "L'image a été téléchargée avec succès",
|
||||
"uploadText": "Cliquez ou faites glisser l'image ici pour la télécharger",
|
||||
"uploading": "Téléchargement de l'image en cours",
|
||||
"urlPlaceholder": "coller l'URL de l'image",
|
||||
"urlRequired": "Veuillez entrer l'URL de l'image"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Vérification des mises à jour en cours...",
|
||||
"checkUpdate": {
|
||||
"available": "Mettre à jour maintenant",
|
||||
"label": "Vérifier les mises à jour"
|
||||
},
|
||||
"checkingUpdate": "Vérification des mises à jour en cours...",
|
||||
"contact": {
|
||||
"button": "Courriel",
|
||||
"title": "Contactez-nous par courriel"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "高級設定"
|
||||
},
|
||||
"essential": "必須設定",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "利用可能なプラグイン"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "このプラグインをアンインストールしてもよろしいですか?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "一致するプラグインが見つかりませんでした。検索キーワードやカテゴリフィルターを調整してみてください。"
|
||||
},
|
||||
"error": {
|
||||
"install": "プラグインのインストールに失敗しました",
|
||||
"load": "プラグインの読み込みに失敗しました",
|
||||
"uninstall": "プラグインのアンインストールに失敗しました"
|
||||
},
|
||||
"filter": {
|
||||
"all": "すべてのカテゴリー"
|
||||
},
|
||||
"install": "インストール",
|
||||
"installed": {
|
||||
"empty": "まだプラグインがインストールされていません。利用可能なプラグインを見てみましょう。",
|
||||
"title": "インストール済みプラグイン"
|
||||
},
|
||||
"installing": "インストール中...",
|
||||
"results": "{{count}} 個のプラグインが見つかりました",
|
||||
"search": {
|
||||
"placeholder": "検索プラグイン..."
|
||||
},
|
||||
"success": {
|
||||
"install": "プラグインのインストールが成功しました",
|
||||
"uninstall": "プラグインのアンインストールが成功しました"
|
||||
},
|
||||
"tab": "プラグイン",
|
||||
"type": {
|
||||
"agent": "代理",
|
||||
"agents": "代理",
|
||||
"all": "全部",
|
||||
"command": "命令",
|
||||
"commands": "命令",
|
||||
"skills": "技能"
|
||||
},
|
||||
"uninstall": "アンインストール",
|
||||
"uninstalling": "アンインストール中..."
|
||||
},
|
||||
"prompt": "プロンプト設定",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "ツールリクエストを許可",
|
||||
"denyRequest": "ツールリクエストを拒否",
|
||||
"hideDetails": "ツールの詳細を非表示",
|
||||
"runWithOptions": "追加オプションで実行",
|
||||
"showDetails": "ツールの詳細を表示"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "キャンセル",
|
||||
"run": "走る"
|
||||
},
|
||||
"confirmation": "このClaudeツールを実行してもよろしいですか?",
|
||||
"defaultDenyMessage": "ユーザーはこのツールの使用を拒否しました。",
|
||||
"defaultDescription": "環境内でコードまたはシステムアクションを実行します。実行前にコマンドが安全であることを確認してください。",
|
||||
"error": {
|
||||
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"expired": "期限切れ",
|
||||
"inputPreview": "ツール入力プレビュー",
|
||||
"pending": "保留中({{seconds}}秒)",
|
||||
"permissionExpired": "許可リクエストの期限が切れました。新しい指示を待っています...",
|
||||
"requiresElevatedPermissions": "このツールは昇格した権限が必要です。",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "承認すると、このツールを常に許可することを選択した場合、複数のセッション権限が更新されることがあります。",
|
||||
"permissionUpdateSingle": "承認すると、このツールを常に許可することを選択した場合、セッションの権限が更新されることがあります。"
|
||||
},
|
||||
"toast": {
|
||||
"denied": "ツールリクエストは拒否されました。",
|
||||
"timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。"
|
||||
},
|
||||
"waiting": "ツールの許可決定を待っています..."
|
||||
},
|
||||
"type": {
|
||||
"label": "エージェントタイプ",
|
||||
"unknown": "不明なタイプ"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "応答を一時停止しました",
|
||||
"prompt": "プロンプトを表示する",
|
||||
"provider": "プロバイダー",
|
||||
"provider_disabled": "モデルプロバイダーが有効になっていません",
|
||||
"providerId": "プロバイダーID",
|
||||
"provider_disabled": "モデルプロバイダーが有効になっていません",
|
||||
"reason": "原因",
|
||||
"render": {
|
||||
"description": "メッセージの内容のレンダリングに失敗しました。メッセージの内容の形式が正しいか確認してください",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "戻る",
|
||||
"goForward": "進む",
|
||||
"minimize": "ミニアプリを最小化",
|
||||
"openExternal": "ブラウザで開く",
|
||||
"open_link_external_off": "現在:デフォルトのウィンドウで開く",
|
||||
"open_link_external_on": "現在:ブラウザで開く",
|
||||
"openExternal": "ブラウザで開く",
|
||||
"refresh": "更新",
|
||||
"rightclick_copyurl": "右クリックでURLをコピー"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "拡大結果のランダム性を制御します"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "操作",
|
||||
"agents": "代理",
|
||||
"all_categories": "すべてのカテゴリー",
|
||||
"all_types": "全部",
|
||||
"category": "カテゴリー",
|
||||
"commands": "命令",
|
||||
"confirm_uninstall": "{{name}}をアンインストールしてもよろしいですか?",
|
||||
"install": "インストール",
|
||||
"install_plugins_from_browser": "利用可能なプラグインを閲覧して、使用を開始してください",
|
||||
"installing": "インストール中...",
|
||||
"name": "名称",
|
||||
"no_description": "説明なし",
|
||||
"no_installed_plugins": "まだプラグインがインストールされていません",
|
||||
"no_results": "プラグインが見つかりません",
|
||||
"search_placeholder": "検索プラグイン...",
|
||||
"showing_results": "{{count}} 個のプラグインを表示",
|
||||
"showing_results_one": "{{count}} 個のプラグインを表示",
|
||||
"showing_results_other": "{{count}} 個のプラグインを表示",
|
||||
"showing_results_plural": "{{count}} 個のプラグインを表示",
|
||||
"skills": "スキル",
|
||||
"try_different_search": "検索またはカテゴリフィルターを調整してみてください",
|
||||
"type": "タイプ",
|
||||
"uninstall": "アンインストール",
|
||||
"uninstalling": "アンインストール中..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "画像としてコピー"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "画像のアップロードに失敗しました",
|
||||
"uploadFile": "ファイルをアップロード",
|
||||
"uploadHint": "JPG、PNG、GIFおよびその他の形式をサポートし、最大10MB",
|
||||
"uploading": "写真のアップロード",
|
||||
"uploadSuccess": "画像アップロードに正常にアップロードします",
|
||||
"uploadText": "画像をクリックまたはドラッグしてここにアップロードします",
|
||||
"uploading": "写真のアップロード",
|
||||
"urlPlaceholder": "画像リンクアドレスを貼り付けます",
|
||||
"urlRequired": "画像リンクアドレスを入力してください"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "更新を確認中...",
|
||||
"checkUpdate": {
|
||||
"available": "今すぐ更新",
|
||||
"label": "更新を確認"
|
||||
},
|
||||
"checkingUpdate": "更新を確認中...",
|
||||
"contact": {
|
||||
"button": "メール",
|
||||
"title": "連絡先"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Configurações avançadas"
|
||||
},
|
||||
"essential": "Configurações Essenciais",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Plugins disponíveis"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Tem certeza de que deseja desinstalar este plugin?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Nenhum plugin correspondente encontrado. Tente ajustar a pesquisa ou os filtros de categoria."
|
||||
},
|
||||
"error": {
|
||||
"install": "Falha na instalação do plugin",
|
||||
"load": "Falha ao carregar o plugin",
|
||||
"uninstall": "Falha ao desinstalar o plug-in"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Todas as categorias"
|
||||
},
|
||||
"install": "Instalação",
|
||||
"installed": {
|
||||
"empty": "Nenhum plugin foi instalado ainda. Explore os plugins disponíveis para começar.",
|
||||
"title": "Plugin instalado"
|
||||
},
|
||||
"installing": "Instalando...",
|
||||
"results": "Encontrados {{count}} plugins",
|
||||
"search": {
|
||||
"placeholder": "Pesquisar extensão..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin instalado com sucesso",
|
||||
"uninstall": "插件 desinstalado com sucesso"
|
||||
},
|
||||
"tab": "plug-in",
|
||||
"type": {
|
||||
"agent": "agente",
|
||||
"agents": "agente",
|
||||
"all": "tudo",
|
||||
"command": "comando",
|
||||
"commands": "comando",
|
||||
"skills": "habilidade"
|
||||
},
|
||||
"uninstall": "desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"prompt": "Configurações de Prompt",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Permitir solicitação de ferramenta",
|
||||
"denyRequest": "Negar solicitação de ferramenta",
|
||||
"hideDetails": "Ocultar detalhes da ferramenta",
|
||||
"runWithOptions": "Executar com opções adicionais",
|
||||
"showDetails": "Mostrar detalhes da ferramenta"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"run": "Correr"
|
||||
},
|
||||
"confirmation": "Tem certeza de que quer executar esta ferramenta Claude?",
|
||||
"defaultDenyMessage": "Usuário negou permissão para esta ferramenta.",
|
||||
"defaultDescription": "Executa código ou ações do sistema no seu ambiente. Certifique-se de que o comando parece seguro antes de executá-lo.",
|
||||
"error": {
|
||||
"sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente."
|
||||
},
|
||||
"expired": "Expirado",
|
||||
"inputPreview": "Pré-visualização da entrada da ferramenta",
|
||||
"pending": "Pendente ({{seconds}}s)",
|
||||
"permissionExpired": "Solicitação de permissão expirou. Aguardando novas instruções...",
|
||||
"requiresElevatedPermissions": "Esta ferramenta requer permissões elevadas.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Aprovar pode atualizar várias permissões de sessão se você escolheu sempre permitir esta ferramenta.",
|
||||
"permissionUpdateSingle": "Aprovar pode atualizar as permissões da sua sessão se você escolheu sempre permitir esta ferramenta."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Solicitação de ferramenta foi negada.",
|
||||
"timeout": "A solicitação da ferramenta expirou antes de receber aprovação."
|
||||
},
|
||||
"waiting": "Aguardando decisão de permissão da ferramenta..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Tipo de Agente",
|
||||
"unknown": "Tipo Desconhecido"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Interrompido",
|
||||
"prompt": "prompt",
|
||||
"provider": "fornecedor",
|
||||
"provider_disabled": "O provedor de modelos está desativado",
|
||||
"providerId": "ID do fornecedor",
|
||||
"provider_disabled": "O provedor de modelos está desativado",
|
||||
"reason": "causa",
|
||||
"render": {
|
||||
"description": "Falha ao renderizar a fórmula, por favor verifique se o formato da fórmula está correto",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Voltar",
|
||||
"goForward": "Avançar",
|
||||
"minimize": "Minimizar aplicativo",
|
||||
"openExternal": "Abrir no navegador",
|
||||
"open_link_external_off": "Atual: Abrir links em janela padrão",
|
||||
"open_link_external_on": "Atual: Abrir links no navegador",
|
||||
"openExternal": "Abrir no navegador",
|
||||
"refresh": "Atualizar",
|
||||
"rightclick_copyurl": "Copiar URL com botão direito"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Controla a aleatoriedade do resultado de ampliação"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Operação",
|
||||
"agents": "agente",
|
||||
"all_categories": "Todas as categorias",
|
||||
"all_types": "Tudo",
|
||||
"category": "categoria",
|
||||
"commands": "comando",
|
||||
"confirm_uninstall": "Tem certeza de que deseja desinstalar {{name}}?",
|
||||
"install": "Instalação",
|
||||
"install_plugins_from_browser": "Navegue pelos plugins disponíveis para começar a usar",
|
||||
"installing": "Instalando...",
|
||||
"name": "Nome",
|
||||
"no_description": "Sem descrição",
|
||||
"no_installed_plugins": "Nenhum plugin foi instalado ainda",
|
||||
"no_results": "Plugin não encontrado",
|
||||
"search_placeholder": "Pesquisar plugin...",
|
||||
"showing_results": "Exibir {{count}} extensões",
|
||||
"showing_results_one": "Mostrar {{count}} extensões",
|
||||
"showing_results_other": "Exibir {{count}} extensões",
|
||||
"showing_results_plural": "Exibir {{count}} extensões",
|
||||
"skills": "habilidade",
|
||||
"try_different_search": "Por favor, tente ajustar a pesquisa ou os filtros de categoria.",
|
||||
"type": "tipo",
|
||||
"uninstall": "Desinstalar",
|
||||
"uninstalling": "Desinstalando..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Copiar como imagem"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "Falha no carregamento da imagem",
|
||||
"uploadFile": "enviar arquivo",
|
||||
"uploadHint": "Compatível com formatos como JPG, PNG, GIF, etc., tamanho máximo de 10MB",
|
||||
"uploading": "enviando imagem",
|
||||
"uploadSuccess": "Imagem enviada com sucesso",
|
||||
"uploadText": "Clique ou arraste a imagem aqui para enviar",
|
||||
"uploading": "enviando imagem",
|
||||
"urlPlaceholder": "colar o endereço do link da imagem",
|
||||
"urlRequired": "Por favor, insira o endereço do link da imagem"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Verificando atualizações...",
|
||||
"checkUpdate": {
|
||||
"available": "Atualizar agora",
|
||||
"label": "Verificar atualizações"
|
||||
},
|
||||
"checkingUpdate": "Verificando atualizações...",
|
||||
"contact": {
|
||||
"button": "E-mail",
|
||||
"title": "Contato por e-mail"
|
||||
|
||||
@@ -107,6 +107,50 @@
|
||||
"title": "Расширенные настройки"
|
||||
},
|
||||
"essential": "Основные настройки",
|
||||
"plugins": {
|
||||
"available": {
|
||||
"title": "Доступные плагины"
|
||||
},
|
||||
"confirm": {
|
||||
"uninstall": "Вы уверены, что хотите удалить этот плагин?"
|
||||
},
|
||||
"empty": {
|
||||
"available": "Совпадающие плагины не найдены. Попробуйте изменить поиск или фильтр категорий."
|
||||
},
|
||||
"error": {
|
||||
"install": "Ошибка установки плагина",
|
||||
"load": "Ошибка загрузки плагина",
|
||||
"uninstall": "Не удалось удалить плагин"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Все категории"
|
||||
},
|
||||
"install": "установка",
|
||||
"installed": {
|
||||
"empty": "Плагины ещё не установлены. Просмотрите доступные плагины, чтобы начать.",
|
||||
"title": "Установленный плагин"
|
||||
},
|
||||
"installing": "Установка...",
|
||||
"results": "Найдено {{count}} плагинов",
|
||||
"search": {
|
||||
"placeholder": "Поиск плагинов..."
|
||||
},
|
||||
"success": {
|
||||
"install": "Плагин успешно установлен",
|
||||
"uninstall": "Плагин успешно удалён"
|
||||
},
|
||||
"tab": "плагин",
|
||||
"type": {
|
||||
"agent": "агент",
|
||||
"agents": "Прокси",
|
||||
"all": "всё",
|
||||
"command": "команда",
|
||||
"commands": "команда",
|
||||
"skills": "навык"
|
||||
},
|
||||
"uninstall": "Удаление",
|
||||
"uninstalling": "Удаление..."
|
||||
},
|
||||
"prompt": "Настройки подсказки",
|
||||
"tooling": {
|
||||
"mcp": {
|
||||
@@ -191,6 +235,39 @@
|
||||
"toggle": "{{defaultValue}}"
|
||||
}
|
||||
},
|
||||
"toolPermission": {
|
||||
"aria": {
|
||||
"allowRequest": "Разрешить запрос инструмента",
|
||||
"denyRequest": "Отклонить запрос на инструмент",
|
||||
"hideDetails": "Скрыть сведения об инструменте",
|
||||
"runWithOptions": "Запустить с дополнительными параметрами",
|
||||
"showDetails": "Показать сведения об инструменте"
|
||||
},
|
||||
"button": {
|
||||
"cancel": "Отмена",
|
||||
"run": "Беги"
|
||||
},
|
||||
"confirmation": "Вы уверены, что хотите запустить этот инструмент Claude?",
|
||||
"defaultDenyMessage": "Пользователь отказал в разрешении на использование этого инструмента.",
|
||||
"defaultDescription": "Выполняет код или системные действия в вашей среде. Убедитесь, что команда выглядит безопасно, прежде чем запускать её.",
|
||||
"error": {
|
||||
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
|
||||
},
|
||||
"expired": "Истёк",
|
||||
"inputPreview": "Предварительный просмотр ввода инструмента",
|
||||
"pending": "Ожидание ({{seconds}}с)",
|
||||
"permissionExpired": "Срок действия запроса на разрешение истёк. Ожидание новых инструкций...",
|
||||
"requiresElevatedPermissions": "Этому инструменту требуются повышенные разрешения.",
|
||||
"suggestion": {
|
||||
"permissionUpdateMultiple": "Одобрение может обновить разрешения для нескольких сеансов, если вы выбрали всегда разрешать использование этого инструмента.",
|
||||
"permissionUpdateSingle": "Одобрение может обновить разрешения вашей сессии, если вы выбрали всегда разрешать использование этого инструмента."
|
||||
},
|
||||
"toast": {
|
||||
"denied": "Запрос на инструмент был отклонён.",
|
||||
"timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения."
|
||||
},
|
||||
"waiting": "Ожидание решения о разрешении на использование инструмента..."
|
||||
},
|
||||
"type": {
|
||||
"label": "Тип агента",
|
||||
"unknown": "Неизвестный тип"
|
||||
@@ -1129,8 +1206,8 @@
|
||||
"pause_placeholder": "Получение ответа приостановлено",
|
||||
"prompt": "подсказка",
|
||||
"provider": "поставщик",
|
||||
"provider_disabled": "Провайдер моделей не включен",
|
||||
"providerId": "ID поставщика",
|
||||
"provider_disabled": "Провайдер моделей не включен",
|
||||
"reason": "причина",
|
||||
"render": {
|
||||
"description": "Не удалось рендерить содержимое сообщения. Пожалуйста, проверьте, правильно ли формат содержимого сообщения",
|
||||
@@ -1780,9 +1857,9 @@
|
||||
"goBack": "Назад",
|
||||
"goForward": "Вперед",
|
||||
"minimize": "Свернуть встроенное приложение",
|
||||
"openExternal": "Открыть в браузере",
|
||||
"open_link_external_off": "Текущий: Открыть ссылки в окне по умолчанию",
|
||||
"open_link_external_on": "Текущий: Открыть ссылки в браузере",
|
||||
"openExternal": "Открыть в браузере",
|
||||
"refresh": "Обновить",
|
||||
"rightclick_copyurl": "ПКМ → Копировать URL"
|
||||
},
|
||||
@@ -2299,6 +2376,32 @@
|
||||
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"actions": "Операция",
|
||||
"agents": "агент",
|
||||
"all_categories": "Все категории",
|
||||
"all_types": "всё",
|
||||
"category": "категория",
|
||||
"commands": "команда",
|
||||
"confirm_uninstall": "Вы уверены, что хотите удалить {{name}}?",
|
||||
"install": "установка",
|
||||
"install_plugins_from_browser": "Просмотрите доступные плагины, чтобы начать работу",
|
||||
"installing": "Установка...",
|
||||
"name": "название",
|
||||
"no_description": "Без описания",
|
||||
"no_installed_plugins": "Плагины ещё не установлены",
|
||||
"no_results": "Плагин не найден",
|
||||
"search_placeholder": "Поиск плагинов...",
|
||||
"showing_results": "Отображено {{count}} плагинов",
|
||||
"showing_results_one": "Отображено {{count}} плагинов",
|
||||
"showing_results_other": "Отображено {{count}} плагинов",
|
||||
"showing_results_plural": "Отображение {{count}} плагинов",
|
||||
"skills": "навык",
|
||||
"try_different_search": "Пожалуйста, попробуйте изменить поиск или фильтры категорий",
|
||||
"type": "тип",
|
||||
"uninstall": "Удаление",
|
||||
"uninstalling": "Удаление..."
|
||||
},
|
||||
"preview": {
|
||||
"copy": {
|
||||
"image": "Скопировать как изображение"
|
||||
@@ -2570,9 +2673,9 @@
|
||||
"uploadError": "Загрузка изображения не удалась",
|
||||
"uploadFile": "Загрузить файл",
|
||||
"uploadHint": "Поддерживает JPG, PNG, GIF и другие форматы, до 10 МБ",
|
||||
"uploading": "Загрузка изображений",
|
||||
"uploadSuccess": "Загрузка изображения успешно",
|
||||
"uploadText": "Нажмите или перетащите изображение, чтобы загрузить здесь",
|
||||
"uploading": "Загрузка изображений",
|
||||
"urlPlaceholder": "Вставьте адрес ссылки изображения",
|
||||
"urlRequired": "Пожалуйста, введите адрес ссылки изображения"
|
||||
},
|
||||
@@ -2803,11 +2906,11 @@
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"checkingUpdate": "Проверка обновлений...",
|
||||
"checkUpdate": {
|
||||
"available": "Обновить",
|
||||
"label": "Проверить обновления"
|
||||
},
|
||||
"checkingUpdate": "Проверка обновлений...",
|
||||
"contact": {
|
||||
"button": "Электронная почта",
|
||||
"title": "Контакты"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { loggerService } from '@logger'
|
||||
|
||||
import { startAutoSync } from './services/BackupService'
|
||||
import { startNutstoreAutoSync } from './services/NutstoreService'
|
||||
import { initializeShortcutService } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { webTraceService } from './services/WebTraceService'
|
||||
loggerService.initWindowSource('mainWindow')
|
||||
@@ -36,3 +37,4 @@ function initWebTrace() {
|
||||
initAutoSync()
|
||||
initStoreSync()
|
||||
initWebTrace()
|
||||
initializeShortcutService()
|
||||
|
||||
@@ -72,7 +72,7 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
||||
},
|
||||
dashscope: {
|
||||
anthropic: {
|
||||
api_base_url: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
|
||||
api_base_url: 'https://dashscope.aliyuncs.com/apps/anthropic'
|
||||
}
|
||||
},
|
||||
modelscope: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ContentSearch } from '@renderer/components/ContentSearch'
|
||||
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
|
||||
@@ -56,6 +57,8 @@ const Chat: FC<Props> = (props) => {
|
||||
const { chat } = useRuntime()
|
||||
const { activeTopicOrSession, activeAgentId, activeSessionIdMap } = chat
|
||||
const activeSessionId = activeAgentId ? activeSessionIdMap[activeAgentId] : null
|
||||
const sessionAgentId = activeTopicOrSession === 'session' ? activeAgentId : null
|
||||
const { createDefaultSession } = useCreateDefaultSession(sessionAgentId)
|
||||
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
@@ -94,6 +97,21 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut(
|
||||
'new_topic',
|
||||
() => {
|
||||
if (activeTopicOrSession !== 'session' || !activeAgentId) {
|
||||
return
|
||||
}
|
||||
void createDefaultSession()
|
||||
},
|
||||
{
|
||||
enabled: activeTopicOrSession === 'session',
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
}
|
||||
)
|
||||
|
||||
const contentSearchFilter: NodeFilter = {
|
||||
acceptNode(node) {
|
||||
const container = node.parentElement?.closest('.message-content-container')
|
||||
|
||||
@@ -3,10 +3,12 @@ import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelView } from '@renderer/components/QuickPanel'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useSession } from '@renderer/hooks/agents/useSession'
|
||||
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { getModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { pauseTrace } from '@renderer/services/SpanManagerService'
|
||||
@@ -22,7 +24,7 @@ import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/
|
||||
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
|
||||
import TextArea, { type TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { CirclePause } from 'lucide-react'
|
||||
import { CirclePause, MessageSquareDiff } from 'lucide-react'
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -47,6 +49,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
const { session } = useSession(agentId, sessionId)
|
||||
const { agent } = useAgent(agentId)
|
||||
const { apiServer } = useSettings()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
|
||||
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
@@ -88,6 +92,22 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
}, [topicMessages])
|
||||
|
||||
const canAbort = loading && streamingAskIds.length > 0
|
||||
const createSessionDisabled = creatingSession || !apiServer.enabled
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (createSessionDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createDefaultSession()
|
||||
if (created) {
|
||||
focusTextarea()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create agent session via toolbar:', error as Error)
|
||||
}
|
||||
}, [createDefaultSession, createSessionDisabled, focusTextarea])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
//to check if the SendMessage key is pressed
|
||||
@@ -287,8 +307,16 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
}}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
/>
|
||||
<div className="flex justify-end px-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Toolbar>
|
||||
<ToolbarGroup>
|
||||
<Tooltip placement="top" content={t('chat.input.new_topic', { Command: newTopicShortcut })} delay={0}>
|
||||
<ActionIconButton
|
||||
onClick={handleCreateSession}
|
||||
disabled={createSessionDisabled}
|
||||
icon={<MessageSquareDiff size={19} />}></ActionIconButton>
|
||||
</Tooltip>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup>
|
||||
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
|
||||
{canAbort && (
|
||||
<Tooltip placement="top" content={t('chat.input.pause')}>
|
||||
@@ -299,8 +327,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
</InputBarContainer>
|
||||
</Container>
|
||||
</NarrowLayout>
|
||||
@@ -346,6 +374,25 @@ const InputBarContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 8px;
|
||||
height: 40px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const ToolbarGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '6px 15px 0px' // 减小顶部padding
|
||||
|
||||
@@ -27,7 +27,7 @@ const MessageVideo: FC<Props> = ({ block }) => {
|
||||
const renderLocalVideo = () => {
|
||||
if (!block.filePath) {
|
||||
logger.warn('Local video was requested but block.filePath is missing.')
|
||||
return <div>{t('message.message.video.error.local_file_missing')}</div>
|
||||
return <div>{t('message.video.error.local_file_missing')}</div>
|
||||
}
|
||||
|
||||
const videoSrc = `file://${block.metadata?.video.path}`
|
||||
@@ -67,7 +67,7 @@ const MessageVideo: FC<Props> = ({ block }) => {
|
||||
}
|
||||
|
||||
logger.warn(`Unsupported video type: ${block.metadata?.type} or missing necessary data.`)
|
||||
return <div>{t('message.message.video.error.unsupported_type')}</div>
|
||||
return <div>{t('message.video.error.unsupported_type')}</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NormalToolResponse } from '@renderer/types'
|
||||
export * from './types'
|
||||
|
||||
// 导入所有渲染器
|
||||
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
|
||||
import { BashOutputTool } from './BashOutputTool'
|
||||
import { BashTool } from './BashTool'
|
||||
import { EditTool } from './EditTool'
|
||||
@@ -78,12 +79,16 @@ function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?:
|
||||
|
||||
// 统一的组件渲染入口
|
||||
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const { arguments: args, response, tool } = toolResponse
|
||||
const { arguments: args, response, tool, status } = toolResponse
|
||||
logger.info('Rendering agent tool response', {
|
||||
tool: tool,
|
||||
arguments: args,
|
||||
response
|
||||
})
|
||||
|
||||
if (status === 'pending') {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
}
|
||||
|
||||
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { Button, ButtonGroup, Chip, ScrollShadow } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('ToolPermissionRequestCard')
|
||||
|
||||
interface Props {
|
||||
toolResponse: NormalToolResponse
|
||||
}
|
||||
|
||||
export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const request = useAppSelector((state) =>
|
||||
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
|
||||
)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return
|
||||
|
||||
logger.debug('Rendering inline tool permission card', {
|
||||
requestId: request.requestId,
|
||||
toolName: request.toolName,
|
||||
expiresAt: request.expiresAt
|
||||
})
|
||||
|
||||
setNow(Date.now())
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!request) return 0
|
||||
return Math.max(0, request.expiresAt - now)
|
||||
}, [request, now])
|
||||
|
||||
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
|
||||
const isExpired = remainingMs <= 0
|
||||
|
||||
const isSubmittingAllow = request?.status === 'submitting-allow'
|
||||
const isSubmittingDeny = request?.status === 'submitting-deny'
|
||||
const isSubmitting = isSubmittingAllow || isSubmittingDeny
|
||||
const hasSuggestions = (request?.suggestions?.length ?? 0) > 0
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (
|
||||
behavior: 'allow' | 'deny',
|
||||
extra?: {
|
||||
updatedInput?: Record<string, unknown>
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
message?: string
|
||||
}
|
||||
) => {
|
||||
if (!request) return
|
||||
|
||||
logger.debug('Submitting inline tool permission decision', {
|
||||
requestId: request.requestId,
|
||||
toolName: request.toolName,
|
||||
behavior
|
||||
})
|
||||
|
||||
dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior }))
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
requestId: request.requestId,
|
||||
behavior,
|
||||
...(behavior === 'allow'
|
||||
? {
|
||||
updatedInput: extra?.updatedInput ?? request.input,
|
||||
updatedPermissions: extra?.updatedPermissions
|
||||
}
|
||||
: {
|
||||
message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage')
|
||||
})
|
||||
}
|
||||
|
||||
const response = await window.api.agentTools.respondToPermission(payload)
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error('Renderer response rejected by main process')
|
||||
}
|
||||
|
||||
logger.debug('Tool permission decision acknowledged by main process', {
|
||||
requestId: request.requestId,
|
||||
behavior
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send tool permission response', error as Error)
|
||||
window.toast?.error?.(t('agent.toolPermission.error.sendFailed'))
|
||||
dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId }))
|
||||
}
|
||||
},
|
||||
[dispatch, request, t]
|
||||
)
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm">
|
||||
{t('agent.toolPermission.waiting')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
|
||||
<div className="text-default-500 text-xs">
|
||||
{request.description?.trim() || t('agent.toolPermission.defaultDescription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Chip color={isExpired ? 'danger' : 'warning'} size="sm" variant="flat">
|
||||
{isExpired
|
||||
? t('agent.toolPermission.expired')
|
||||
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
|
||||
</Chip>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.denyRequest')}
|
||||
className="h-8"
|
||||
color="danger"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingDeny}
|
||||
onPress={() => handleDecision('deny')}
|
||||
startContent={<CircleX size={16} />}
|
||||
variant="bordered">
|
||||
{t('agent.toolPermission.button.cancel')}
|
||||
</Button>
|
||||
|
||||
{hasSuggestions ? (
|
||||
<ButtonGroup className="h-8">
|
||||
<Button
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.runWithOptions')}
|
||||
className="h-8 rounded-l-none"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isIconOnly
|
||||
variant="solid"></Button>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={t('agent.toolPermission.aria.allowRequest')}
|
||||
className="h-8 px-3"
|
||||
color="success"
|
||||
isDisabled={isSubmitting || isExpired}
|
||||
isLoading={isSubmittingAllow}
|
||||
onPress={() => handleDecision('allow')}
|
||||
startContent={<CirclePlay size={16} />}>
|
||||
{t('agent.toolPermission.button.run')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8"
|
||||
isIconOnly
|
||||
onPress={() => setShowDetails((value) => !value)}
|
||||
variant="light">
|
||||
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
|
||||
<div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm">
|
||||
{t('agent.toolPermission.confirmation')}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-default-200 bg-default-100 p-3">
|
||||
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
|
||||
{t('agent.toolPermission.inputPreview')}
|
||||
</p>
|
||||
<ScrollShadow className="max-h-48 font-mono text-xs" hideScrollBar>
|
||||
<pre className="whitespace-pre-wrap break-all text-left">{request.inputPreview}</pre>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
|
||||
{request.requiresPermissions && (
|
||||
<div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs">
|
||||
{t('agent.toolPermission.requiresElevatedPermissions')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{request.suggestions.length > 0 && (
|
||||
<div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs">
|
||||
{request.suggestions.length === 1
|
||||
? t('agent.toolPermission.suggestion.permissionUpdateSingle')
|
||||
: t('agent.toolPermission.suggestion.permissionUpdateMultiple')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && !isSubmitting && (
|
||||
<div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolPermissionRequestCard
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, Spinner } from '@heroui/react'
|
||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
setActiveTopicOrSessionAction,
|
||||
setSessionWaitingAction
|
||||
} from '@renderer/store/runtime'
|
||||
import type { CreateSessionForm } from '@renderer/types'
|
||||
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||
import { motion } from 'framer-motion'
|
||||
import { memo, useCallback, useEffect } from 'react'
|
||||
@@ -27,11 +26,11 @@ interface SessionsProps {
|
||||
|
||||
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { agent } = useAgent(agentId)
|
||||
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
|
||||
const { sessions, isLoading, error, deleteSession } = useSessions(agentId)
|
||||
const { chat } = useRuntime()
|
||||
const { activeSessionIdMap } = chat
|
||||
const dispatch = useAppDispatch()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
|
||||
const setActiveSessionId = useCallback(
|
||||
(agentId: string, sessionId: string | null) => {
|
||||
@@ -41,19 +40,6 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (!agent) return
|
||||
const session = {
|
||||
...agent,
|
||||
id: undefined,
|
||||
name: t('common.unnamed')
|
||||
} satisfies CreateSessionForm
|
||||
const created = await createSession(session)
|
||||
if (created) {
|
||||
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
|
||||
}
|
||||
}, [agent, agentId, createSession, dispatch, t])
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (id: string) => {
|
||||
if (sessions.length === 1) {
|
||||
@@ -110,7 +96,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
|
||||
return (
|
||||
<div className="sessions-tab flex h-full w-full flex-col p-2">
|
||||
<AddButton onClick={handleCreateSession} className="mb-2">
|
||||
<AddButton onClick={createDefaultSession} className="mb-2" disabled={creatingSession}>
|
||||
{t('agent.session.add.title')}
|
||||
</AddButton>
|
||||
{/* h-9 */}
|
||||
|
||||
@@ -120,7 +120,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
|
||||
return
|
||||
}
|
||||
try {
|
||||
target.findInPage(text, options)
|
||||
target.findInPage(text, options || {})
|
||||
} catch (error) {
|
||||
logger.error('findInPage failed', { error })
|
||||
window.toast?.error(t('common.error'))
|
||||
|
||||
@@ -19,6 +19,15 @@ vi.mock('react-i18next', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// mock @cherrystudio/ui Button component to handle onClick
|
||||
vi.mock('@cherrystudio/ui', () => ({
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}))
|
||||
|
||||
const createWebviewMock = () => {
|
||||
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
|
||||
const findInPageMock = vi.fn()
|
||||
@@ -255,7 +264,7 @@ describe('WebviewSearch', () => {
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
@@ -307,7 +316,7 @@ describe('WebviewSearch', () => {
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', {})
|
||||
})
|
||||
findInPageMock.mockClear()
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const result = await SaveToKnowledgePopup.showForNote(note)
|
||||
|
||||
if (result?.success) {
|
||||
window.toast.success(t('notes.export_success'))
|
||||
window.toast.success(t('notes.export_success', { count: result.savedCount }))
|
||||
}
|
||||
} catch (error) {
|
||||
window.toast.error(t('notes.export_failed'))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AdvancedSettings from './AdvancedSettings'
|
||||
import EssentialSettings from './EssentialSettings'
|
||||
import PluginSettings from './PluginSettings'
|
||||
import PromptSettings from './PromptSettings'
|
||||
import { AgentLabel, LeftMenu, Settings, StyledMenu, StyledModal } from './shared'
|
||||
import ToolingSettings from './ToolingSettings'
|
||||
@@ -20,7 +21,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
|
||||
resolve: () => void
|
||||
}
|
||||
|
||||
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'session-mcps'
|
||||
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tooling' | 'advanced' | 'plugins' | 'session-mcps'
|
||||
|
||||
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
@@ -56,6 +57,10 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
key: 'tooling',
|
||||
label: t('agent.settings.tooling.tab', 'Tooling & permissions')
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
label: t('agent.settings.plugins.tab', 'Plugins')
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('agent.settings.advance.title', 'Advanced Settings')
|
||||
@@ -75,6 +80,9 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!agent) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex w-full flex-1">
|
||||
<LeftMenu>
|
||||
@@ -90,6 +98,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
|
||||
{menu === 'essential' && <EssentialSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'prompt' && <PromptSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'tooling' && <ToolingSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'plugins' && <PluginSettings agentBase={agent} update={updateAgent} />}
|
||||
{menu === 'advanced' && <AdvancedSettings agentBase={agent} update={updateAgent} />}
|
||||
</Settings>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
|
||||
import type { AgentEntity, UpdateAgentForm } from '@renderer/types'
|
||||
import { isAgentType } from '@renderer/types'
|
||||
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -20,13 +20,11 @@ export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update })
|
||||
|
||||
const updateAvatar = useCallback(
|
||||
(avatar: string) => {
|
||||
const parsedConfiguration = AgentConfigurationSchema.parse(agent.configuration ?? {})
|
||||
const payload = {
|
||||
id: agent.id,
|
||||
// hard-encoded default values. better to implement incremental update for configuration
|
||||
configuration: {
|
||||
...agent.configuration,
|
||||
permission_mode: agent.configuration?.permission_mode ?? 'default',
|
||||
max_turns: agent.configuration?.max_turns ?? 100,
|
||||
...parsedConfiguration,
|
||||
avatar
|
||||
}
|
||||
} satisfies UpdateAgentForm
|
||||
|
||||
115
src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx
Normal file
115
src/renderer/src/pages/settings/AgentSettings/PluginSettings.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Card, CardBody, Tab, Tabs } from '@heroui/react'
|
||||
import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
|
||||
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentBaseForm } from '@renderer/types/agent'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { InstalledPluginsList } from './components/InstalledPluginsList'
|
||||
import { PluginBrowser } from './components/PluginBrowser'
|
||||
import { SettingsContainer } from './shared'
|
||||
|
||||
interface PluginSettingsProps {
|
||||
agentBase: GetAgentResponse | GetAgentSessionResponse
|
||||
update: (partial: UpdateAgentBaseForm) => Promise<void>
|
||||
}
|
||||
|
||||
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Fetch available plugins
|
||||
const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins()
|
||||
|
||||
// Fetch installed plugins
|
||||
const { plugins, loading: loadingInstalled, error: errorInstalled, refresh } = useInstalledPlugins(agentBase.id)
|
||||
|
||||
// Plugin actions
|
||||
const { install, uninstall, installing, uninstalling } = usePluginActions(agentBase.id, refresh)
|
||||
|
||||
// Handle install action
|
||||
const handleInstall = useCallback(
|
||||
async (sourcePath: string, type: 'agent' | 'command' | 'skill') => {
|
||||
const result = await install(sourcePath, type)
|
||||
|
||||
if (result.success) {
|
||||
window.toast.success(t('agent.settings.plugins.success.install'))
|
||||
} else {
|
||||
window.toast.error(t('agent.settings.plugins.error.install') + (result.error ? ': ' + result.error : ''))
|
||||
}
|
||||
},
|
||||
[install, t]
|
||||
)
|
||||
|
||||
// Handle uninstall action
|
||||
const handleUninstall = useCallback(
|
||||
async (filename: string, type: 'agent' | 'command' | 'skill') => {
|
||||
const result = await uninstall(filename, type)
|
||||
|
||||
if (result.success) {
|
||||
window.toast.success(t('agent.settings.plugins.success.uninstall'))
|
||||
} else {
|
||||
window.toast.error(t('agent.settings.plugins.error.uninstall') + (result.error ? ': ' + result.error : ''))
|
||||
}
|
||||
},
|
||||
[uninstall, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<Tabs
|
||||
aria-label="Plugin settings tabs"
|
||||
classNames={{
|
||||
base: 'w-full',
|
||||
tabList: 'w-full',
|
||||
panel: 'w-full flex-1 overflow-hidden'
|
||||
}}>
|
||||
<Tab key="available" title={t('agent.settings.plugins.available.title')}>
|
||||
<div className="flex h-full flex-col overflow-y-auto pt-4">
|
||||
{errorAvailable ? (
|
||||
<Card className="bg-danger-50 dark:bg-danger-900/20">
|
||||
<CardBody>
|
||||
<p className="text-danger">
|
||||
{t('agent.settings.plugins.error.load')}: {errorAvailable}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<PluginBrowser
|
||||
agentId={agentBase.id}
|
||||
agents={agents}
|
||||
commands={commands}
|
||||
skills={skills}
|
||||
installedPlugins={plugins}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
loading={loadingAvailable || installing || uninstalling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab key="installed" title={t('agent.settings.plugins.installed.title')}>
|
||||
<div className="flex h-full flex-col overflow-y-auto pt-4">
|
||||
{errorInstalled ? (
|
||||
<Card className="bg-danger-50 dark:bg-danger-900/20">
|
||||
<CardBody>
|
||||
<p className="text-danger">
|
||||
{t('agent.settings.plugins.error.load')}: {errorInstalled}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<InstalledPluginsList
|
||||
plugins={plugins}
|
||||
onUninstall={handleUninstall}
|
||||
loading={loadingInstalled || uninstalling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</SettingsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginSettings
|
||||
@@ -502,13 +502,22 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
|
||||
})}
|
||||
</Chip>
|
||||
<Chip variant="flat" color="default">
|
||||
{t('agent.settings.tooling.review.autoTools')}: {autoCount}
|
||||
{t('agent.settings.tooling.review.autoTools', {
|
||||
defaultValue: `Auto: ${autoCount}`,
|
||||
count: autoCount
|
||||
})}
|
||||
</Chip>
|
||||
<Chip variant="flat" color="success">
|
||||
{t('agent.settings.tooling.review.customTools')}: {customCount}
|
||||
{t('agent.settings.tooling.review.customTools', {
|
||||
defaultValue: `Custom: ${customCount}`,
|
||||
count: customCount
|
||||
})}
|
||||
</Chip>
|
||||
<Chip variant="flat" color="warning">
|
||||
{t('agent.settings.tooling.review.mcp')}: {agentSummary.mcps}
|
||||
{t('agent.settings.tooling.review.mcp', {
|
||||
defaultValue: `MCP: ${agentSummary.mcps}`,
|
||||
count: agentSummary.mcps
|
||||
})}
|
||||
</Chip>
|
||||
</div>
|
||||
<span className="text-foreground-500 text-xs">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Chip } from '@heroui/react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface CategoryFilterProps {
|
||||
categories: string[]
|
||||
selectedCategories: string[]
|
||||
onChange: (categories: string[]) => void
|
||||
}
|
||||
|
||||
export const CategoryFilter: FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isAllSelected = selectedCategories.length === 0
|
||||
|
||||
const handleCategoryClick = (category: string) => {
|
||||
if (selectedCategories.includes(category)) {
|
||||
onChange(selectedCategories.filter((c) => c !== category))
|
||||
} else {
|
||||
onChange([...selectedCategories, category])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAllClick = () => {
|
||||
onChange([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-24 flex-wrap gap-2 overflow-y-auto">
|
||||
<Chip
|
||||
variant={isAllSelected ? 'solid' : 'bordered'}
|
||||
color={isAllSelected ? 'primary' : 'default'}
|
||||
onClick={handleAllClick}
|
||||
className="cursor-pointer">
|
||||
{t('plugins.all_categories')}
|
||||
</Chip>
|
||||
|
||||
{categories.map((category) => {
|
||||
const isSelected = selectedCategories.includes(category)
|
||||
return (
|
||||
<Chip
|
||||
key={category}
|
||||
variant={isSelected ? 'solid' : 'bordered'}
|
||||
color={isSelected ? 'primary' : 'default'}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className="cursor-pointer">
|
||||
{category}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'
|
||||
import type { InstalledPlugin } from '@renderer/types/plugin'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface InstalledPluginsListProps {
|
||||
plugins: InstalledPlugin[]
|
||||
onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, onUninstall, loading }) => {
|
||||
const { t } = useTranslation()
|
||||
const [uninstallingPlugin, setUninstallingPlugin] = useState<string | null>(null)
|
||||
|
||||
const handleUninstall = useCallback(
|
||||
(plugin: InstalledPlugin) => {
|
||||
const confirmed = window.confirm(
|
||||
t('plugins.confirm_uninstall', { name: plugin.metadata.name || plugin.filename })
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setUninstallingPlugin(plugin.filename)
|
||||
onUninstall(plugin.filename, plugin.type)
|
||||
// Reset after a delay to allow the operation to complete
|
||||
setTimeout(() => setUninstallingPlugin(null), 2000)
|
||||
}
|
||||
},
|
||||
[onUninstall, t]
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (plugins.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-default-400">{t('plugins.no_installed_plugins')}</p>
|
||||
<p className="text-default-300 text-small">{t('plugins.install_plugins_from_browser')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table aria-label="Installed plugins table" removeWrapper>
|
||||
<TableHeader>
|
||||
<TableColumn>{t('plugins.name')}</TableColumn>
|
||||
<TableColumn>{t('plugins.type')}</TableColumn>
|
||||
<TableColumn>{t('plugins.category')}</TableColumn>
|
||||
<TableColumn width={100}>{t('plugins.actions')}</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plugins.map((plugin) => (
|
||||
<TableRow key={plugin.filename}>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-small">{plugin.metadata.name}</span>
|
||||
{plugin.metadata.description && (
|
||||
<span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="sm" variant="flat" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
|
||||
{plugin.type}
|
||||
</Chip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="sm" variant="dot">
|
||||
{plugin.metadata.category}
|
||||
</Chip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="light"
|
||||
isIconOnly
|
||||
onPress={() => handleUninstall(plugin)}
|
||||
isLoading={uninstallingPlugin === plugin.filename}
|
||||
isDisabled={loading}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { Input, Pagination, Tab, Tabs } from '@heroui/react'
|
||||
import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin'
|
||||
import { Search } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CategoryFilter } from './CategoryFilter'
|
||||
import { PluginCard } from './PluginCard'
|
||||
import { PluginDetailModal } from './PluginDetailModal'
|
||||
|
||||
export interface PluginBrowserProps {
|
||||
agentId: string
|
||||
agents: PluginMetadata[]
|
||||
commands: PluginMetadata[]
|
||||
skills: PluginMetadata[]
|
||||
installedPlugins: InstalledPlugin[]
|
||||
onInstall: (sourcePath: string, type: 'agent' | 'command' | 'skill') => void
|
||||
onUninstall: (filename: string, type: 'agent' | 'command' | 'skill') => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
type PluginType = 'all' | 'agent' | 'command' | 'skill'
|
||||
|
||||
const ITEMS_PER_PAGE = 12
|
||||
|
||||
export const PluginBrowser: FC<PluginBrowserProps> = ({
|
||||
agentId,
|
||||
agents,
|
||||
commands,
|
||||
skills,
|
||||
installedPlugins,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
loading
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [activeType, setActiveType] = useState<PluginType>('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [actioningPlugin, setActioningPlugin] = useState<string | null>(null)
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginMetadata | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
// Combine all plugins based on active type
|
||||
const allPlugins = useMemo(() => {
|
||||
switch (activeType) {
|
||||
case 'agent':
|
||||
return agents
|
||||
case 'command':
|
||||
return commands
|
||||
case 'skill':
|
||||
return skills
|
||||
case 'all':
|
||||
default:
|
||||
return [...agents, ...commands, ...skills]
|
||||
}
|
||||
}, [agents, commands, skills, activeType])
|
||||
|
||||
// Extract all unique categories
|
||||
const allCategories = useMemo(() => {
|
||||
const categories = new Set<string>()
|
||||
allPlugins.forEach((plugin) => {
|
||||
if (plugin.category) {
|
||||
categories.add(plugin.category)
|
||||
}
|
||||
})
|
||||
return Array.from(categories).sort()
|
||||
}, [allPlugins])
|
||||
|
||||
// Filter plugins based on search query and selected categories
|
||||
const filteredPlugins = useMemo(() => {
|
||||
return allPlugins.filter((plugin) => {
|
||||
// Filter by search query
|
||||
const searchLower = searchQuery.toLowerCase()
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
plugin.name.toLowerCase().includes(searchLower) ||
|
||||
plugin.description?.toLowerCase().includes(searchLower) ||
|
||||
plugin.tags?.some((tag) => tag.toLowerCase().includes(searchLower))
|
||||
|
||||
// Filter by selected categories
|
||||
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(plugin.category)
|
||||
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
}, [allPlugins, searchQuery, selectedCategories])
|
||||
|
||||
// Paginate filtered plugins
|
||||
const paginatedPlugins = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE
|
||||
return filteredPlugins.slice(startIndex, endIndex)
|
||||
}, [filteredPlugins, currentPage])
|
||||
|
||||
const totalPages = Math.ceil(filteredPlugins.length / ITEMS_PER_PAGE)
|
||||
|
||||
// Check if a plugin is installed
|
||||
const isPluginInstalled = (plugin: PluginMetadata): boolean => {
|
||||
return installedPlugins.some(
|
||||
(installed) => installed.filename === plugin.filename && installed.type === plugin.type
|
||||
)
|
||||
}
|
||||
|
||||
// Handle install with loading state
|
||||
const handleInstall = async (plugin: PluginMetadata) => {
|
||||
setActioningPlugin(plugin.sourcePath)
|
||||
await onInstall(plugin.sourcePath, plugin.type)
|
||||
setActioningPlugin(null)
|
||||
}
|
||||
|
||||
// Handle uninstall with loading state
|
||||
const handleUninstall = async (plugin: PluginMetadata) => {
|
||||
setActioningPlugin(plugin.sourcePath)
|
||||
await onUninstall(plugin.filename, plugin.type)
|
||||
setActioningPlugin(null)
|
||||
}
|
||||
|
||||
// Reset to first page when filters change
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleCategoryChange = (categories: string[]) => {
|
||||
setSelectedCategories(categories)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handleTypeChange = (type: string | number) => {
|
||||
setActiveType(type as PluginType)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
const handlePluginClick = (plugin: PluginMetadata) => {
|
||||
setSelectedPlugin(plugin)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false)
|
||||
setSelectedPlugin(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search Input */}
|
||||
<Input
|
||||
placeholder={t('plugins.search_placeholder')}
|
||||
value={searchQuery}
|
||||
onValueChange={handleSearchChange}
|
||||
startContent={<Search className="h-4 w-4 text-default-400" />}
|
||||
isClearable
|
||||
classNames={{
|
||||
input: 'text-small',
|
||||
inputWrapper: 'h-10'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Category Filter */}
|
||||
<CategoryFilter
|
||||
categories={allCategories}
|
||||
selectedCategories={selectedCategories}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
{/* Type Tabs */}
|
||||
<Tabs selectedKey={activeType} onSelectionChange={handleTypeChange} variant="underlined">
|
||||
<Tab key="all" title={t('plugins.all_types')} />
|
||||
<Tab key="agent" title={t('plugins.agents')} />
|
||||
<Tab key="command" title={t('plugins.commands')} />
|
||||
<Tab key="skill" title={t('plugins.skills')} />
|
||||
</Tabs>
|
||||
|
||||
{/* Result Count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-default-500 text-small">{t('plugins.showing_results', { count: filteredPlugins.length })}</p>
|
||||
</div>
|
||||
|
||||
{/* Plugin Grid */}
|
||||
{paginatedPlugins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-default-400">{t('plugins.no_results')}</p>
|
||||
<p className="text-default-300 text-small">{t('plugins.try_different_search')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedPlugins.map((plugin) => {
|
||||
const installed = isPluginInstalled(plugin)
|
||||
const isActioning = actioningPlugin === plugin.sourcePath
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={`${plugin.type}-${plugin.sourcePath}`}
|
||||
plugin={plugin}
|
||||
installed={installed}
|
||||
onInstall={() => handleInstall(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
loading={loading || isActioning}
|
||||
onClick={() => handlePluginClick(plugin)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination total={totalPages} page={currentPage} onChange={setCurrentPage} showControls />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin Detail Modal */}
|
||||
<PluginDetailModal
|
||||
agentId={agentId}
|
||||
plugin={selectedPlugin}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
installed={selectedPlugin ? isPluginInstalled(selectedPlugin) : false}
|
||||
onInstall={() => selectedPlugin && handleInstall(selectedPlugin)}
|
||||
onUninstall={() => selectedPlugin && handleUninstall(selectedPlugin)}
|
||||
loading={selectedPlugin ? actioningPlugin === selectedPlugin.sourcePath : false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Button, Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react'
|
||||
import type { PluginMetadata } from '@renderer/types/plugin'
|
||||
import { Download, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface PluginCardProps {
|
||||
plugin: PluginMetadata
|
||||
installed: boolean
|
||||
onInstall: () => void
|
||||
onUninstall: () => void
|
||||
loading: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Card className="w-full cursor-pointer transition-shadow hover:shadow-md" isPressable onPress={onClick}>
|
||||
<CardHeader className="flex flex-col items-start gap-2 pb-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="font-semibold text-medium">{plugin.name}</h3>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color={plugin.type === 'agent' ? 'primary' : plugin.type === 'skill' ? 'success' : 'secondary'}>
|
||||
{plugin.type}
|
||||
</Chip>
|
||||
</div>
|
||||
<Chip size="sm" variant="dot" color="default">
|
||||
{plugin.category}
|
||||
</Chip>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="py-2">
|
||||
<p className="line-clamp-3 text-default-500 text-small">{plugin.description || t('plugins.no_description')}</p>
|
||||
|
||||
{plugin.tags && plugin.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{plugin.tags.map((tag) => (
|
||||
<Chip key={tag} size="sm" variant="bordered" className="text-tiny">
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
<CardFooter className="pt-2">
|
||||
{installed ? (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUninstall()
|
||||
}}
|
||||
isDisabled={loading}
|
||||
fullWidth>
|
||||
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="sm"
|
||||
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onInstall()
|
||||
}}
|
||||
isDisabled={loading}
|
||||
fullWidth>
|
||||
{loading ? t('plugins.installing') : t('plugins.install')}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Spinner,
|
||||
Textarea
|
||||
} from '@heroui/react'
|
||||
import type { PluginMetadata } from '@renderer/types/plugin'
|
||||
import { Download, Edit, Save, Trash2, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface PluginDetailModalProps {
|
||||
agentId: string
|
||||
plugin: PluginMetadata | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
installed: boolean
|
||||
onInstall: () => void
|
||||
onUninstall: () => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export const PluginDetailModal: FC<PluginDetailModalProps> = ({
|
||||
agentId,
|
||||
plugin,
|
||||
isOpen,
|
||||
onClose,
|
||||
installed,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
loading
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [content, setContent] = useState<string>('')
|
||||
const [contentLoading, setContentLoading] = useState(false)
|
||||
const [contentError, setContentError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedContent, setEditedContent] = useState<string>('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Fetch plugin content when modal opens or plugin changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !plugin) {
|
||||
setContent('')
|
||||
setContentError(null)
|
||||
setIsEditing(false)
|
||||
setEditedContent('')
|
||||
return
|
||||
}
|
||||
|
||||
const fetchContent = async () => {
|
||||
setContentLoading(true)
|
||||
setContentError(null)
|
||||
setIsEditing(false)
|
||||
setEditedContent('')
|
||||
try {
|
||||
let sourcePath = plugin.sourcePath
|
||||
if (plugin.type === 'skill') {
|
||||
sourcePath = sourcePath + '/' + 'SKILL.md'
|
||||
}
|
||||
|
||||
const result = await window.api.claudeCodePlugin.readContent(sourcePath)
|
||||
if (result.success) {
|
||||
setContent(result.data)
|
||||
} else {
|
||||
setContentError(`Failed to load content: ${result.error.type}`)
|
||||
}
|
||||
} catch (error) {
|
||||
setContentError(`Error loading content: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
setContentLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchContent()
|
||||
}, [isOpen, plugin])
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditedContent(content)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false)
|
||||
setEditedContent('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!plugin) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await window.api.claudeCodePlugin.writeContent({
|
||||
agentId,
|
||||
filename: plugin.filename,
|
||||
type: plugin.type,
|
||||
content: editedContent
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
setContent(editedContent)
|
||||
setIsEditing(false)
|
||||
window.toast?.success('Plugin content saved successfully')
|
||||
} else {
|
||||
window.toast?.error(`Failed to save: ${result.error.type}`)
|
||||
}
|
||||
} catch (error) {
|
||||
window.toast?.error(`Error saving: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!plugin) return null
|
||||
|
||||
const modalContent = (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="2xl"
|
||||
scrollBehavior="inside"
|
||||
classNames={{
|
||||
wrapper: 'z-[9999]'
|
||||
}}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-bold text-xl">{plugin.name}</h2>
|
||||
<Chip size="sm" variant="solid" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
|
||||
{plugin.type}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" variant="dot" color="default">
|
||||
{plugin.category}
|
||||
</Chip>
|
||||
{plugin.version && (
|
||||
<Chip size="sm" variant="bordered">
|
||||
v{plugin.version}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{/* Description */}
|
||||
{plugin.description && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 font-semibold text-small">Description</h3>
|
||||
<p className="text-default-600 text-small">{plugin.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author */}
|
||||
{plugin.author && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 font-semibold text-small">Author</h3>
|
||||
<p className="text-default-600 text-small">{plugin.author}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools (for agents) */}
|
||||
{plugin.tools && plugin.tools.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 font-semibold text-small">Tools</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plugin.tools.map((tool) => (
|
||||
<Chip key={tool} size="sm" variant="flat">
|
||||
{tool}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allowed Tools (for commands) */}
|
||||
{plugin.allowed_tools && plugin.allowed_tools.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 font-semibold text-small">Allowed Tools</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plugin.allowed_tools.map((tool) => (
|
||||
<Chip key={tool} size="sm" variant="flat">
|
||||
{tool}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{plugin.tags && plugin.tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 font-semibold text-small">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plugin.tags.map((tag) => (
|
||||
<Chip key={tag} size="sm" variant="bordered">
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 font-semibold text-small">Metadata</h3>
|
||||
<div className="space-y-1 text-small">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">File:</span>
|
||||
<span className="font-mono text-default-600 text-tiny">{plugin.filename}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Size:</span>
|
||||
<span className="text-default-600">{(plugin.size / 1024).toFixed(2)} KB</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Source:</span>
|
||||
<span className="font-mono text-default-600 text-tiny">{plugin.sourcePath}</span>
|
||||
</div>
|
||||
{plugin.installedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-default-500">Installed:</span>
|
||||
<span className="text-default-600">{new Date(plugin.installedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-small">Content</h3>
|
||||
{installed && !contentLoading && !contentError && (
|
||||
<div className="flex gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
startContent={<X className="h-3 w-3" />}
|
||||
onPress={handleCancelEdit}
|
||||
isDisabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
startContent={saving ? <Spinner size="sm" color="current" /> : <Save className="h-3 w-3" />}
|
||||
onPress={handleSave}
|
||||
isDisabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="flat" startContent={<Edit className="h-3 w-3" />} onPress={handleEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{contentLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : contentError ? (
|
||||
<div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div>
|
||||
) : isEditing ? (
|
||||
<Textarea
|
||||
value={editedContent}
|
||||
onValueChange={setEditedContent}
|
||||
minRows={20}
|
||||
classNames={{
|
||||
input: 'font-mono text-tiny'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-default-100 p-3 font-mono text-tiny">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{installed ? (
|
||||
<Button
|
||||
color="danger"
|
||||
variant="flat"
|
||||
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
|
||||
onPress={onUninstall}
|
||||
isDisabled={loading}>
|
||||
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="primary"
|
||||
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
|
||||
onPress={onInstall}
|
||||
isDisabled={loading}>
|
||||
{loading ? t('plugins.installing') : t('plugins.install')}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
return createPortal(modalContent, document.body)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type { CategoryFilterProps } from './CategoryFilter'
|
||||
export { CategoryFilter } from './CategoryFilter'
|
||||
export type { InstalledPluginsListProps } from './InstalledPluginsList'
|
||||
export { InstalledPluginsList } from './InstalledPluginsList'
|
||||
export type { PluginBrowserProps } from './PluginBrowser'
|
||||
export { PluginBrowser } from './PluginBrowser'
|
||||
export type { PluginCardProps } from './PluginCard'
|
||||
export { PluginCard } from './PluginCard'
|
||||
export type { PluginDetailModalProps } from './PluginDetailModal'
|
||||
export { PluginDetailModal } from './PluginDetailModal'
|
||||
@@ -77,7 +77,6 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
|
||||
src={getPreprocessProviderLogo(preprocessProvider.id)}
|
||||
className="h-4 w-4 border-[0.5px] border-[var(--color-border)]"
|
||||
/>
|
||||
|
||||
<ProviderName> {preprocessProvider.name}</ProviderName>
|
||||
{officialWebsite && preprocessProviderConfig?.websites && (
|
||||
<Link target="_blank" href={preprocessProviderConfig.websites.official}>
|
||||
@@ -141,6 +140,41 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 这部分看起来暂时用不上了 */}
|
||||
{/* {hasObjectKey(preprocessProvider, 'options') && preprocessProvider.id === 'system' && (
|
||||
<>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.preprocess.mac_system_ocr_options.mode.title')}</SettingRowTitle>
|
||||
<Segmented
|
||||
options={[
|
||||
{
|
||||
label: t('settings.tool.preprocess.mac_system_ocr_options.mode.accurate'),
|
||||
value: 1
|
||||
},
|
||||
{
|
||||
label: t('settings.tool.preprocess.mac_system_ocr_options.mode.fast'),
|
||||
value: 0
|
||||
}
|
||||
]}
|
||||
value={options.recognitionLevel}
|
||||
onChange={(value) => onUpdateOptions('recognitionLevel', value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.preprocess.mac_system_ocr_options.min_confidence')}</SettingRowTitle>
|
||||
<InputNumber
|
||||
value={options.minConfidence}
|
||||
onChange={(value) => onUpdateOptions('minConfidence', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)} */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
|
||||
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import {
|
||||
resetAllShortcuts,
|
||||
resetShortcut,
|
||||
setShortcutBinding,
|
||||
setShortcutEnabled,
|
||||
useShortcuts
|
||||
} from '@renderer/hooks/useShortcuts'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getShortcutLabel } from '@renderer/i18n/label'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts'
|
||||
import type { Shortcut } from '@renderer/types'
|
||||
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||
import type { HydratedShortcut } from '@shared/shortcuts/types'
|
||||
import type { InputRef } from 'antd'
|
||||
import { Input, Table as AntTable } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
@@ -18,10 +23,11 @@ import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
||||
|
||||
const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
|
||||
|
||||
const ShortcutSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { shortcuts: originalShortcuts } = useShortcuts()
|
||||
const inputRefs = useRef<Record<string, InputRef>>({})
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
@@ -32,44 +38,34 @@ const ShortcutSettings: FC = () => {
|
||||
if (!isWin && !isMac) {
|
||||
//Selection Assistant only available on Windows now
|
||||
const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text']
|
||||
shortcuts = shortcuts.filter((s) => !excludedShortcuts.includes(s.key))
|
||||
shortcuts = shortcuts.filter((shortcut) => !excludedShortcuts.includes(shortcut.name))
|
||||
}
|
||||
|
||||
const handleClear = (record: Shortcut) => {
|
||||
dispatch(
|
||||
updateShortcut({
|
||||
...record,
|
||||
shortcut: []
|
||||
})
|
||||
)
|
||||
const handleClear = (record: HydratedShortcut) => {
|
||||
void setShortcutBinding(record.name, [])
|
||||
}
|
||||
|
||||
const handleAddShortcut = (record: Shortcut) => {
|
||||
setEditingKey(record.key)
|
||||
const handleAddShortcut = (record: HydratedShortcut) => {
|
||||
setEditingKey(record.name)
|
||||
setTimeoutTimer(
|
||||
'handleAddShortcut',
|
||||
() => {
|
||||
inputRefs.current[record.key]?.focus()
|
||||
inputRefs.current[record.name]?.focus()
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const isShortcutModified = (record: Shortcut) => {
|
||||
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
|
||||
return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+')
|
||||
const isShortcutModified = (record: HydratedShortcut) => {
|
||||
const definition = definitionMap.get(record.name)
|
||||
if (!definition) {
|
||||
return false
|
||||
}
|
||||
return definition.defaultKey.join('+') !== record.key.join('+')
|
||||
}
|
||||
|
||||
const handleResetShortcut = (record: Shortcut) => {
|
||||
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
|
||||
if (defaultShortcut) {
|
||||
dispatch(
|
||||
updateShortcut({
|
||||
...record,
|
||||
shortcut: defaultShortcut.shortcut
|
||||
})
|
||||
)
|
||||
}
|
||||
const handleResetShortcut = (record: HydratedShortcut) => {
|
||||
void resetShortcut(record.name)
|
||||
}
|
||||
|
||||
const isValidShortcut = (keys: string[]): boolean => {
|
||||
@@ -86,9 +82,10 @@ const ShortcutSettings: FC = () => {
|
||||
return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey
|
||||
}
|
||||
|
||||
const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => {
|
||||
const isDuplicateShortcut = (newShortcut: string[], currentName: string): boolean => {
|
||||
return shortcuts.some(
|
||||
(s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+')
|
||||
(shortcut) =>
|
||||
shortcut.name !== currentName && shortcut.key.length > 0 && shortcut.key.join('+') === newShortcut.join('+')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -272,8 +269,8 @@ const ShortcutSettings: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => {
|
||||
e.preventDefault()
|
||||
const handleKeyDown = (event: React.KeyboardEvent, record: HydratedShortcut) => {
|
||||
event.preventDefault()
|
||||
|
||||
const keys: string[] = []
|
||||
|
||||
@@ -286,12 +283,12 @@ const ShortcutSettings: FC = () => {
|
||||
// NEW WAY FOR MODIFIER KEYS
|
||||
// for capability across platforms, we transform the modifier keys to the really meaning keys
|
||||
// mainly consider the habit of users on different platforms
|
||||
if (e.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS
|
||||
if (e.altKey) keys.push('Alt')
|
||||
if (e.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux
|
||||
if (e.shiftKey) keys.push('Shift')
|
||||
if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS
|
||||
if (event.altKey) keys.push('Alt')
|
||||
if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux
|
||||
if (event.shiftKey) keys.push('Shift')
|
||||
|
||||
const endKey = usableEndKeys(e)
|
||||
const endKey = usableEndKeys(event)
|
||||
if (endKey) {
|
||||
keys.push(endKey)
|
||||
}
|
||||
@@ -300,11 +297,11 @@ const ShortcutSettings: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isDuplicateShortcut(keys, record.key)) {
|
||||
if (isDuplicateShortcut(keys, record.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(updateShortcut({ ...record, shortcut: keys }))
|
||||
void setShortcutBinding(record.name, keys)
|
||||
setEditingKey(null)
|
||||
}
|
||||
|
||||
@@ -312,26 +309,28 @@ const ShortcutSettings: FC = () => {
|
||||
window.modal.confirm({
|
||||
title: t('settings.shortcuts.reset_defaults_confirm'),
|
||||
centered: true,
|
||||
onOk: () => dispatch(resetShortcuts())
|
||||
onOk: () => resetAllShortcuts()
|
||||
})
|
||||
}
|
||||
|
||||
// 由于启用了showHeader = false,不再需要title字段
|
||||
const columns: ColumnsType<Shortcut> = [
|
||||
type ShortcutRow = HydratedShortcut & { displayName: string; shortcut: string[] }
|
||||
|
||||
const columns: ColumnsType<ShortcutRow> = [
|
||||
{
|
||||
// title: t('settings.shortcuts.action'),
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
dataIndex: 'displayName',
|
||||
key: 'displayName'
|
||||
},
|
||||
{
|
||||
// title: t('settings.shortcuts.label'),
|
||||
dataIndex: 'shortcut',
|
||||
key: 'shortcut',
|
||||
align: 'right',
|
||||
render: (shortcut: string[], record: Shortcut) => {
|
||||
const isEditing = editingKey === record.key
|
||||
const shortcutConfig = shortcuts.find((s) => s.key === record.key)
|
||||
const isEditable = shortcutConfig?.editable !== false
|
||||
render: (_: string[], record: ShortcutRow) => {
|
||||
const isEditing = editingKey === record.name
|
||||
const isEditable = record.editable !== false
|
||||
const shortcutKeys = record.key
|
||||
|
||||
return (
|
||||
<RowFlex className="items-center justify-end gap-2">
|
||||
@@ -340,10 +339,10 @@ const ShortcutSettings: FC = () => {
|
||||
<ShortcutInput
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
inputRefs.current[record.key] = el
|
||||
inputRefs.current[record.name] = el
|
||||
}
|
||||
}}
|
||||
value={formatShortcut(shortcut)}
|
||||
value={formatShortcut(shortcutKeys)}
|
||||
placeholder={t('settings.shortcuts.press_shortcut')}
|
||||
onKeyDown={(e) => handleKeyDown(e, record)}
|
||||
onBlur={(e) => {
|
||||
@@ -355,7 +354,7 @@ const ShortcutSettings: FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<ShortcutText isEditable={isEditable} onClick={() => isEditable && handleAddShortcut(record)}>
|
||||
{shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')}
|
||||
{shortcutKeys.length > 0 ? formatShortcut(shortcutKeys) : t('settings.shortcuts.press_shortcut')}
|
||||
</ShortcutText>
|
||||
)}
|
||||
</RowFlex>
|
||||
@@ -368,7 +367,7 @@ const ShortcutSettings: FC = () => {
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
width: '70px',
|
||||
render: (record: Shortcut) => (
|
||||
render: (record: ShortcutRow) => (
|
||||
<RowFlex className="items-center justify-end gap-2">
|
||||
<Tooltip content={t('settings.shortcuts.reset_to_default')}>
|
||||
<Button size="icon-sm" onClick={() => handleResetShortcut(record)} disabled={!isShortcutModified(record)}>
|
||||
@@ -391,8 +390,14 @@ const ShortcutSettings: FC = () => {
|
||||
key: 'enabled',
|
||||
align: 'right',
|
||||
width: '50px',
|
||||
render: (record: Shortcut) => (
|
||||
<Switch size="sm" isSelected={record.enabled} onValueChange={() => dispatch(toggleShortcut(record.key))} />
|
||||
render: (record: ShortcutRow) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={record.enabled}
|
||||
onValueChange={(value) => {
|
||||
void setShortcutEnabled(record.name, value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -404,7 +409,12 @@ const ShortcutSettings: FC = () => {
|
||||
<SettingDivider style={{ marginBottom: 0 }} />
|
||||
<Table
|
||||
columns={columns as ColumnsType<unknown>}
|
||||
dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))}
|
||||
dataSource={shortcuts.map((shortcut) => ({
|
||||
...shortcut,
|
||||
shortcut: shortcut.key,
|
||||
displayName: getShortcutLabel(shortcut.name)
|
||||
}))}
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
showHeader={false}
|
||||
|
||||
87
src/renderer/src/services/ShortcutService.ts
Normal file
87
src/renderer/src/services/ShortcutService.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||
import type { HydratedShortcutMap } from '@shared/shortcuts/types'
|
||||
|
||||
const logger = loggerService.withContext('RendererShortcutService')
|
||||
|
||||
type ShortcutListener = () => void
|
||||
|
||||
let shortcutsState: HydratedShortcutMap = buildDefaultState()
|
||||
const listeners = new Set<ShortcutListener>()
|
||||
let initialized = false
|
||||
|
||||
function buildDefaultState(): HydratedShortcutMap {
|
||||
return Object.fromEntries(
|
||||
shortcutDefinitions.map((definition) => [
|
||||
definition.name,
|
||||
{
|
||||
...definition,
|
||||
key: [...definition.defaultKey],
|
||||
enabled: definition.defaultEnabled
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
logger.error('Shortcut listener threw an error:', error as Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setShortcuts(next: HydratedShortcutMap) {
|
||||
shortcutsState = Object.fromEntries(
|
||||
Object.entries(next).map(([name, config]) => [
|
||||
name,
|
||||
{
|
||||
...config,
|
||||
key: [...config.key]
|
||||
}
|
||||
])
|
||||
)
|
||||
emitChange()
|
||||
}
|
||||
|
||||
function subscribe(listener: ShortcutListener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function getShortcutsSnapshot(): HydratedShortcutMap {
|
||||
return shortcutsState
|
||||
}
|
||||
|
||||
export function initializeShortcutService() {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
initialized = true
|
||||
|
||||
window.electron.ipcRenderer.on(IpcChannel.Shortcuts_Updated, (_event, payload: HydratedShortcutMap) => {
|
||||
setShortcuts(payload)
|
||||
})
|
||||
|
||||
window.api.shortcuts
|
||||
.getAll()
|
||||
.then((payload: HydratedShortcutMap) => {
|
||||
setShortcuts(payload)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.warn('Failed to load shortcuts from main process, using defaults.', error as Error)
|
||||
setShortcuts(buildDefaultState())
|
||||
})
|
||||
}
|
||||
|
||||
export const shortcutRendererStore = {
|
||||
subscribe,
|
||||
getSnapshot: getShortcutsSnapshot,
|
||||
getServerSnapshot: getShortcutsSnapshot
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import selectionStore from './selectionStore'
|
||||
import settings from './settings'
|
||||
import shortcuts from './shortcuts'
|
||||
import tabs from './tabs'
|
||||
import toolPermissions from './toolPermissions'
|
||||
import translate from './translate'
|
||||
import websearch from './websearch'
|
||||
|
||||
@@ -62,15 +63,16 @@ const rootReducer = combineReducers({
|
||||
inputTools: inputToolsReducer,
|
||||
translate,
|
||||
ocr,
|
||||
note
|
||||
note,
|
||||
toolPermissions
|
||||
})
|
||||
|
||||
const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 167,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
version: 168,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
|
||||
migrate
|
||||
},
|
||||
rootReducer
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService'
|
||||
import { defaultPreprocessProviders } from '@renderer/store/preprocess'
|
||||
import type {
|
||||
Assistant,
|
||||
BuiltinOcrProvider,
|
||||
@@ -205,6 +206,18 @@ function addShortcuts(state: RootState, ids: string[], afterId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// add preprocess provider
|
||||
function addPreprocessProviders(state: RootState, id: string) {
|
||||
if (state.preprocess && state.preprocess.providers) {
|
||||
if (!state.preprocess.providers.find((p) => p.id === id)) {
|
||||
const provider = defaultPreprocessProviders.find((p) => p.id === id)
|
||||
if (provider) {
|
||||
state.preprocess.providers.push({ ...provider })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const migrateConfig = {
|
||||
'2': (state: RootState) => {
|
||||
try {
|
||||
@@ -2719,6 +2732,11 @@ const migrateConfig = {
|
||||
preset.settings.toolUseMode = DEFAULT_ASSISTANT_SETTINGS.toolUseMode
|
||||
}
|
||||
})
|
||||
// 更新阿里云百炼的 Anthropic API 地址
|
||||
const dashscopeProvider = state.llm.providers.find((provider) => provider.id === 'dashscope')
|
||||
if (dashscopeProvider) {
|
||||
dashscopeProvider.anthropicApiHost = 'https://dashscope.aliyuncs.com/apps/anthropic'
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 166 error', error as Error)
|
||||
@@ -2733,6 +2751,15 @@ const migrateConfig = {
|
||||
logger.error('migrate 167 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'168': (state: RootState) => {
|
||||
try {
|
||||
addPreprocessProviders(state, 'open-mineru')
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 168 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,19 @@ const initialState: PreprocessState = {
|
||||
model: 'mistral-ocr-latest',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.mistral.ai'
|
||||
},
|
||||
{
|
||||
id: 'open-mineru',
|
||||
name: 'Open MinerU',
|
||||
apiKey: '',
|
||||
apiHost: ''
|
||||
}
|
||||
],
|
||||
defaultProvider: 'mineru'
|
||||
}
|
||||
|
||||
export const defaultPreprocessProviders = initialState.providers
|
||||
|
||||
const preprocessSlice = createSlice({
|
||||
name: 'preprocess',
|
||||
initialState,
|
||||
|
||||
101
src/renderer/src/store/toolPermissions.ts
Normal file
101
src/renderer/src/store/toolPermissions.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
export type ToolPermissionRequestPayload = {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
description?: string
|
||||
requiresPermissions: boolean
|
||||
input: Record<string, unknown>
|
||||
inputPreview: string
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
suggestions: PermissionUpdate[]
|
||||
}
|
||||
|
||||
export type ToolPermissionResultPayload = {
|
||||
requestId: string
|
||||
behavior: 'allow' | 'deny'
|
||||
message?: string
|
||||
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
|
||||
}
|
||||
|
||||
export type ToolPermissionStatus = 'pending' | 'submitting-allow' | 'submitting-deny'
|
||||
|
||||
export type ToolPermissionEntry = ToolPermissionRequestPayload & {
|
||||
status: ToolPermissionStatus
|
||||
}
|
||||
|
||||
export interface ToolPermissionsState {
|
||||
requests: Record<string, ToolPermissionEntry>
|
||||
}
|
||||
|
||||
const initialState: ToolPermissionsState = {
|
||||
requests: {}
|
||||
}
|
||||
|
||||
const toolPermissionsSlice = createSlice({
|
||||
name: 'toolPermissions',
|
||||
initialState,
|
||||
reducers: {
|
||||
requestReceived: (state, action: PayloadAction<ToolPermissionRequestPayload>) => {
|
||||
const payload = action.payload
|
||||
state.requests[payload.requestId] = {
|
||||
...payload,
|
||||
status: 'pending'
|
||||
}
|
||||
},
|
||||
submissionSent: (state, action: PayloadAction<{ requestId: string; behavior: 'allow' | 'deny' }>) => {
|
||||
const { requestId, behavior } = action.payload
|
||||
const entry = state.requests[requestId]
|
||||
if (!entry) return
|
||||
|
||||
entry.status = behavior === 'allow' ? 'submitting-allow' : 'submitting-deny'
|
||||
},
|
||||
submissionFailed: (state, action: PayloadAction<{ requestId: string }>) => {
|
||||
const entry = state.requests[action.payload.requestId]
|
||||
if (!entry) return
|
||||
entry.status = 'pending'
|
||||
},
|
||||
requestResolved: (state, action: PayloadAction<ToolPermissionResultPayload>) => {
|
||||
const { requestId } = action.payload
|
||||
delete state.requests[requestId]
|
||||
},
|
||||
clearAll: (state) => {
|
||||
state.requests = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const toolPermissionsActions = toolPermissionsSlice.actions
|
||||
|
||||
export const selectActiveToolPermission = (state: ToolPermissionsState): ToolPermissionEntry | null => {
|
||||
const activeEntries = Object.values(state.requests).filter(
|
||||
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
|
||||
)
|
||||
|
||||
if (activeEntries.length === 0) return null
|
||||
|
||||
activeEntries.sort((a, b) => a.createdAt - b.createdAt)
|
||||
return activeEntries[0]
|
||||
}
|
||||
|
||||
export const selectPendingPermissionByToolName = (
|
||||
state: ToolPermissionsState,
|
||||
toolName: string
|
||||
): ToolPermissionEntry | undefined => {
|
||||
const activeEntries = Object.values(state.requests)
|
||||
.filter((entry) => entry.toolName === toolName)
|
||||
.filter(
|
||||
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
|
||||
)
|
||||
|
||||
if (activeEntries.length === 0) return undefined
|
||||
|
||||
activeEntries.sort((a, b) => a.createdAt - b.createdAt)
|
||||
return activeEntries[0]
|
||||
}
|
||||
|
||||
export default toolPermissionsSlice.reducer
|
||||
@@ -8,6 +8,7 @@ import type { ModelMessage, TextStreamPart } from 'ai'
|
||||
import * as z from 'zod'
|
||||
|
||||
import type { Message, MessageBlock } from './newMessage'
|
||||
import { PluginMetadataSchema } from './plugin'
|
||||
|
||||
// ------------------ Core enums and helper types ------------------
|
||||
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
|
||||
@@ -57,7 +58,30 @@ export const AgentConfigurationSchema = z
|
||||
|
||||
// https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors
|
||||
permission_mode: PermissionModeSchema.optional().default('default'), // Permission mode, default to 'default'
|
||||
max_turns: z.number().optional().default(100) // Maximum number of interaction turns, default to 100
|
||||
max_turns: z.number().optional().default(100), // Maximum number of interaction turns, default to 100
|
||||
|
||||
// Plugin metadata
|
||||
installed_plugins: z
|
||||
.array(
|
||||
z.object({
|
||||
sourcePath: z.string(), // Full source path for re-install/updates
|
||||
filename: z.string(), // Destination filename (unique)
|
||||
type: z.enum(['agent', 'command', 'skill']),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
category: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
version: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
contentHash: z.string(), // Detect file modifications
|
||||
installedAt: z.number(), // Track installation time
|
||||
updatedAt: z.number().optional() // Track updates
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([])
|
||||
})
|
||||
.loose()
|
||||
|
||||
@@ -265,7 +289,16 @@ export interface UpdateSessionRequest extends Partial<AgentBase> {}
|
||||
export const GetAgentSessionResponseSchema = AgentSessionEntitySchema.extend({
|
||||
tools: z.array(ToolSchema).optional(), // All tools available to the session (including built-in and custom)
|
||||
messages: z.array(AgentSessionMessageEntitySchema).optional(), // Messages in the session
|
||||
slash_commands: z.array(SlashCommandSchema).optional() // Array of slash commands to trigger the agent
|
||||
slash_commands: z.array(SlashCommandSchema).optional(), // Array of slash commands to trigger the agent
|
||||
plugins: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
type: z.enum(['agent', 'command', 'skill']),
|
||||
metadata: PluginMetadataSchema
|
||||
})
|
||||
)
|
||||
.optional() // Installed plugins from workdir
|
||||
})
|
||||
|
||||
export const CreateAgentSessionResponseSchema = GetAgentSessionResponseSchema
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from './knowledge'
|
||||
export * from './mcp'
|
||||
export * from './notification'
|
||||
export * from './ocr'
|
||||
export * from './plugin'
|
||||
export * from './provider'
|
||||
|
||||
export type Assistant = {
|
||||
|
||||
@@ -107,7 +107,8 @@ export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
export const PreprocessProviderIds = {
|
||||
doc2x: 'doc2x',
|
||||
mistral: 'mistral',
|
||||
mineru: 'mineru'
|
||||
mineru: 'mineru',
|
||||
'open-mineru': 'open-mineru'
|
||||
} as const
|
||||
|
||||
export type PreprocessProviderId = keyof typeof PreprocessProviderIds
|
||||
|
||||
98
src/renderer/src/types/plugin.ts
Normal file
98
src/renderer/src/types/plugin.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
// Plugin Type
|
||||
export type PluginType = 'agent' | 'command' | 'skill'
|
||||
|
||||
// Plugin Metadata Type
|
||||
export const PluginMetadataSchema = z.object({
|
||||
// Identification
|
||||
sourcePath: z.string(), // e.g., "agents/ai-specialists/ai-ethics-advisor.md" or "skills/my-skill"
|
||||
filename: z.string(), // IMPORTANT: Semantics vary by type:
|
||||
// - For agents/commands: includes .md extension (e.g., "my-agent.md")
|
||||
// - For skills: folder name only, no extension (e.g., "my-skill")
|
||||
name: z.string(), // Display name from frontmatter or filename
|
||||
|
||||
// Content
|
||||
description: z.string().optional(),
|
||||
allowed_tools: z.array(z.string()).optional(), // from frontmatter (for commands)
|
||||
tools: z.array(z.string()).optional(), // from frontmatter (for agents and skills)
|
||||
|
||||
// Organization
|
||||
category: z.string(), // derived from parent folder name
|
||||
type: z.enum(['agent', 'command', 'skill']), // UPDATED: now includes 'skill'
|
||||
tags: z.array(z.string()).optional(),
|
||||
|
||||
// Versioning (for future updates)
|
||||
version: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
|
||||
// Metadata
|
||||
size: z.number(), // file size in bytes
|
||||
contentHash: z.string(), // SHA-256 hash for change detection
|
||||
installedAt: z.number().optional(), // Unix timestamp (for installed plugins)
|
||||
updatedAt: z.number().optional() // Unix timestamp (for installed plugins)
|
||||
})
|
||||
|
||||
export type PluginMetadata = z.infer<typeof PluginMetadataSchema>
|
||||
|
||||
export const InstalledPluginSchema = z.object({
|
||||
filename: z.string(),
|
||||
type: z.enum(['agent', 'command', 'skill']),
|
||||
metadata: PluginMetadataSchema
|
||||
})
|
||||
|
||||
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>
|
||||
|
||||
// Error handling types
|
||||
export type PluginError =
|
||||
| { type: 'PATH_TRAVERSAL'; message: string; path: string }
|
||||
| { type: 'FILE_NOT_FOUND'; path: string }
|
||||
| { type: 'PERMISSION_DENIED'; path: string }
|
||||
| { type: 'INVALID_METADATA'; reason: string; path: string }
|
||||
| { type: 'FILE_TOO_LARGE'; size: number; max: number }
|
||||
| { type: 'DUPLICATE_FILENAME'; filename: string }
|
||||
| { type: 'INVALID_WORKDIR'; workdir: string; agentId: string; message?: string }
|
||||
| { type: 'INVALID_FILE_TYPE'; extension: string }
|
||||
| { type: 'WORKDIR_NOT_FOUND'; workdir: string }
|
||||
| { type: 'DISK_SPACE_ERROR'; required: number; available: number }
|
||||
| { type: 'TRANSACTION_FAILED'; operation: string; reason: string }
|
||||
| { type: 'READ_FAILED'; path: string; reason: string }
|
||||
| { type: 'WRITE_FAILED'; path: string; reason: string }
|
||||
| { type: 'PLUGIN_NOT_INSTALLED'; filename: string; agentId: string }
|
||||
|
||||
export type PluginResult<T> = { success: true; data: T } | { success: false; error: PluginError }
|
||||
|
||||
export interface InstallPluginOptions {
|
||||
agentId: string
|
||||
sourcePath: string
|
||||
type: 'agent' | 'command' | 'skill'
|
||||
}
|
||||
|
||||
export interface UninstallPluginOptions {
|
||||
agentId: string
|
||||
filename: string
|
||||
type: 'agent' | 'command' | 'skill'
|
||||
}
|
||||
|
||||
export interface WritePluginContentOptions {
|
||||
agentId: string
|
||||
filename: string
|
||||
type: 'agent' | 'command' | 'skill'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ListAvailablePluginsResult {
|
||||
agents: PluginMetadata[]
|
||||
commands: PluginMetadata[]
|
||||
skills: PluginMetadata[] // NEW: skills plugin type
|
||||
total: number
|
||||
}
|
||||
|
||||
// IPC Channel Constants
|
||||
export const CLAUDE_CODE_PLUGIN_IPC_CHANNELS = {
|
||||
LIST_AVAILABLE: 'claudeCodePlugin:list-available',
|
||||
INSTALL: 'claudeCodePlugin:install',
|
||||
UNINSTALL: 'claudeCodePlugin:uninstall',
|
||||
LIST_INSTALLED: 'claudeCodePlugin:list-installed',
|
||||
INVALIDATE_CACHE: 'claudeCodePlugin:invalidate-cache'
|
||||
} as const
|
||||
Reference in New Issue
Block a user