diff --git a/.github/ISSUE_TEMPLATE/0_bug_report.yml b/.github/ISSUE_TEMPLATE/0_bug_report.yml index c50cdef530..9baf846a6d 100644 --- a/.github/ISSUE_TEMPLATE/0_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/0_bug_report.yml @@ -1,4 +1,4 @@ -name: 🐛 Bug Report (English) +name: 🐛 Bug Report description: Create a report to help us improve title: '[Bug]: ' labels: ['BUG'] diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml index 0822742704..d980cb8367 100644 --- a/.github/ISSUE_TEMPLATE/1_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -1,4 +1,4 @@ -name: 💡 Feature Request (English) +name: 💡 Feature Request description: Suggest an idea for this project title: '[Feature]: ' labels: ['feature'] diff --git a/.github/ISSUE_TEMPLATE/3_others.yml b/.github/ISSUE_TEMPLATE/3_others.yml index 4d8a383080..2784c66d59 100644 --- a/.github/ISSUE_TEMPLATE/3_others.yml +++ b/.github/ISSUE_TEMPLATE/3_others.yml @@ -1,4 +1,4 @@ -name: 🤔 Other Questions (English) +name: 🤔 Other Questions description: Submit questions that don't fit into bug reports or feature requests title: '[Other]: ' body: diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index a6c1e3791a..1584ab48db 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -1,4 +1,4 @@ -name: Auto I18N +name: Auto I18N Weekly env: TRANSLATION_API_KEY: ${{ secrets.TRANSLATE_API_KEY }} @@ -7,14 +7,15 @@ env: TRANSLATION_BASE_LOCALE: ${{ vars.AUTO_I18N_BASE_LOCALE || 'en-us'}} on: - pull_request: - types: [opened, synchronize, reopened] + schedule: + # Runs at 00:00 UTC every Sunday. + # This corresponds to 08:00 AM UTC+8 (Beijing time) every Sunday. + - cron: "0 0 * * 0" workflow_dispatch: jobs: auto-i18n: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == 'CherryHQ/cherry-studio' name: Auto I18N permissions: contents: write @@ -24,45 +25,69 @@ jobs: - name: 🐈‍⬛ Checkout uses: actions/checkout@v5 with: - ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 - name: 📦 Setting Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 - package-manager-cache: false + node-version: 22 - - name: 📦 Install dependencies in isolated directory + - name: 📦 Install corepack + run: corepack enable && corepack prepare yarn@4.9.1 --activate + + - name: 📂 Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: 💾 Cache yarn dependencies + uses: actions/cache@v4 + with: + path: | + ${{ steps.yarn-cache-dir-path.outputs.dir }} + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies run: | - # 在临时目录安装依赖 - mkdir -p /tmp/translation-deps - cd /tmp/translation-deps - echo '{"dependencies": {"@cherrystudio/openai": "^6.5.0", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json - npm install --no-package-lock - - # 设置 NODE_PATH 让项目能找到这些依赖 - echo "NODE_PATH=/tmp/translation-deps/node_modules" >> $GITHUB_ENV + yarn install - name: 🏃‍♀️ Translate - run: npx tsx scripts/sync-i18n.ts && npx tsx scripts/auto-translate-i18n.ts + run: yarn sync:i18n && yarn auto:i18n - name: 🔍 Format - run: cd /tmp/translation-deps && npx biome format --config-path /home/runner/work/cherry-studio/cherry-studio/biome.jsonc --write /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/ + run: yarn format - - name: 🔄 Commit changes + - name: 🔍 Check for changes + id: git_status run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add . + # Check if there are any uncommitted changes git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改 - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "fix(i18n): Auto update translations for PR #${{ github.event.pull_request.number }}" - fi + git diff --exit-code --quiet || echo "::set-output name=has_changes::true" + git status --porcelain - - name: 🚀 Push changes - uses: ad-m/github-push-action@master + - name: 📅 Set current date for PR title + id: set_date + run: echo "CURRENT_DATE=$(date +'%b %d, %Y')" >> $GITHUB_ENV # e.g., "Jun 06, 2024" + + - name: 🚀 Create Pull Request if changes exist + if: steps.git_status.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions + commit-message: "feat(bot): Weekly automated script run" + title: "🤖 Weekly Automated Update: ${{ env.CURRENT_DATE }}" + body: | + This PR includes changes generated by the weekly auto i18n. + Review the changes before merging. + + --- + _Generated by the automated weekly workflow_ + branch: "auto-i18n-weekly-${{ github.run_id }}" # Unique branch name + base: "main" # Or 'develop', set your base branch + delete-branch: true # Delete the branch after merging or closing the PR + + - name: 📢 Notify if no changes + if: steps.git_status.outputs.has_changes != 'true' + run: echo "Bot script ran, but no changes were detected. No PR created." diff --git a/.github/workflows/github-issue-tracker.yml b/.github/workflows/github-issue-tracker.yml index 7cc1ad4762..32bd393145 100644 --- a/.github/workflows/github-issue-tracker.yml +++ b/.github/workflows/github-issue-tracker.yml @@ -5,7 +5,7 @@ on: types: [opened] schedule: # Run every day at 8:30 Beijing Time (00:30 UTC) - - cron: '30 0 * * *' + - cron: "30 0 * * *" workflow_dispatch: jobs: @@ -54,9 +54,9 @@ jobs: - name: Setup Node.js if: steps.check_time.outputs.should_delay == 'false' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 22 - name: Process issue with Claude if: steps.check_time.outputs.should_delay == 'false' @@ -121,9 +121,9 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 22 - name: Process pending issues with Claude uses: anthropics/claude-code-action@main diff --git a/.github/workflows/issue-management.yml b/.github/workflows/issue-management.yml index f6041f2336..c9ff497386 100644 --- a/.github/workflows/issue-management.yml +++ b/.github/workflows/issue-management.yml @@ -21,7 +21,7 @@ jobs: contents: none steps: - name: Close needs-more-info issues - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: 'needs-more-info' @@ -42,7 +42,7 @@ jobs: days-before-pr-close: -1 - name: Close inactive issues - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: ${{ env.daysBeforeStale }} diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 9e1608b13e..523a670064 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -3,7 +3,7 @@ name: Nightly Build on: workflow_dispatch: schedule: - - cron: '0 17 * * *' # 1:00 BJ Time + - cron: "0 17 * * *" # 1:00 BJ Time permissions: contents: write @@ -56,9 +56,9 @@ jobs: ref: main - name: Install Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: macos-latest dependencies fix if: matrix.os == 'macos-latest' @@ -66,7 +66,7 @@ jobs: brew install python-setuptools - name: Install corepack - run: corepack enable && corepack prepare yarn@4.6.0 --activate + run: corepack enable && corepack prepare yarn@4.9.1 --activate - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -208,7 +208,7 @@ jobs: echo "总计: $(find renamed-artifacts -type f | wc -l) 个文件" - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: cherry-studio-nightly-${{ steps.date.outputs.date }}-${{ matrix.os }} path: renamed-artifacts/* diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 9108d71fc1..aa273cc56e 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -24,12 +24,12 @@ jobs: uses: actions/checkout@v5 - name: Install Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: Install corepack - run: corepack enable && corepack prepare yarn@4.6.0 --activate + run: corepack enable && corepack prepare yarn@4.9.1 --activate - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c54504de07..8bbb46ee67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag (e.g. v1.0.0)' + description: "Release tag (e.g. v1.0.0)" required: true - default: 'v1.0.0' + default: "v1.0.0" push: tags: - v*.*.* @@ -47,9 +47,9 @@ jobs: npm version "$VERSION" --no-git-tag-version --allow-same-version - name: Install Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: macos-latest dependencies fix if: matrix.os == 'macos-latest' @@ -57,7 +57,7 @@ jobs: brew install python-setuptools - name: Install corepack - run: corepack enable && corepack prepare yarn@4.6.0 --activate + run: corepack enable && corepack prepare yarn@4.9.1 --activate - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -127,5 +127,5 @@ jobs: allowUpdates: true makeLatest: false tag: ${{ steps.get-tag.outputs.tag }} - artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/beta*.yml,dist/*.blockmap' + artifacts: "dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/beta*.yml,dist/*.blockmap" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.oxlintrc.json b/.oxlintrc.json index 5bd988159a..7d18f83c7c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,6 @@ "eslint.config.mjs" ], "overrides": [ - // set different env { "env": { "node": true @@ -36,8 +35,7 @@ "files": [ "src/renderer/**/*.{ts,tsx}", "packages/aiCore/**", - "packages/extension-table-plus/**", - "resources/js/**" + "packages/extension-table-plus/**" ] }, { @@ -53,76 +51,24 @@ "node": true }, "files": ["src/preload/**"] + }, + { + "files": ["packages/ai-sdk-provider/**"], + "globals": { + "fetch": "readonly" + } } ], - // We don't use the React plugin here because its behavior differs slightly from that of ESLint's React plugin. "plugins": ["unicorn", "typescript", "oxc", "import"], "rules": { - "constructor-super": "error", - "for-direction": "error", - "getter-return": "error", "no-array-constructor": "off", - // "import/no-cycle": "error", // tons of error, bro - "no-async-promise-executor": "error", "no-caller": "warn", - "no-case-declarations": "error", - "no-class-assign": "error", - "no-compare-neg-zero": "error", - "no-cond-assign": "error", - "no-const-assign": "error", - "no-constant-binary-expression": "error", - "no-constant-condition": "error", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-dupe-args": "error", - "no-dupe-class-members": "error", - "no-dupe-else-if": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-pattern": "error", - "no-empty-static-block": "error", "no-eval": "warn", - "no-ex-assign": "error", - "no-extra-boolean-cast": "error", "no-fallthrough": "warn", - "no-func-assign": "error", - "no-global-assign": "error", - "no-import-assign": "error", - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-loss-of-precision": "error", - "no-misleading-character-class": "error", - "no-new-native-nonconstructor": "error", - "no-nonoctal-decimal-escape": "error", - "no-obj-calls": "error", - "no-octal": "error", - "no-prototype-builtins": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-self-assign": "error", - "no-setter-return": "error", - "no-shadow-restricted-names": "error", - "no-sparse-arrays": "error", - "no-this-before-super": "error", "no-unassigned-vars": "warn", - "no-undef": "error", - "no-unexpected-multiline": "error", - "no-unreachable": "error", - "no-unsafe-finally": "error", - "no-unsafe-negation": "error", - "no-unsafe-optional-chaining": "error", - "no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()` - "no-unused-labels": "error", - "no-unused-private-class-members": "error", + "no-unused-expressions": "off", "no-unused-vars": ["warn", { "caughtErrors": "none" }], - "no-useless-backreference": "error", - "no-useless-catch": "error", - "no-useless-escape": "error", "no-useless-rename": "warn", - "no-with": "error", "oxc/bad-array-method-on-arguments": "warn", "oxc/bad-char-at-comparison": "warn", "oxc/bad-comparison-sequence": "warn", @@ -134,19 +80,17 @@ "oxc/erasing-op": "warn", "oxc/missing-throw": "warn", "oxc/number-arg-out-of-range": "warn", - "oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future + "oxc/only-used-in-recursion": "off", "oxc/uninvoked-array-callback": "warn", - "require-yield": "error", "typescript/await-thenable": "warn", - // "typescript/ban-ts-comment": "error", + "typescript/consistent-type-imports": "error", "typescript/no-array-constructor": "error", - // "typescript/consistent-type-imports": "error", "typescript/no-array-delete": "warn", "typescript/no-base-to-string": "warn", "typescript/no-duplicate-enum-values": "error", "typescript/no-duplicate-type-constituents": "warn", "typescript/no-empty-object-type": "off", - "typescript/no-explicit-any": "off", // not safe but too many errors + "typescript/no-explicit-any": "off", "typescript/no-extra-non-null-assertion": "error", "typescript/no-floating-promises": "warn", "typescript/no-for-in-array": "warn", @@ -155,7 +99,7 @@ "typescript/no-misused-new": "error", "typescript/no-misused-spread": "warn", "typescript/no-namespace": "error", - "typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on. + "typescript/no-non-null-asserted-optional-chain": "off", "typescript/no-redundant-type-constituents": "warn", "typescript/no-require-imports": "off", "typescript/no-this-alias": "error", @@ -173,20 +117,18 @@ "typescript/triple-slash-reference": "error", "typescript/unbound-method": "warn", "unicorn/no-await-in-promise-methods": "warn", - "unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-empty-file": "off", "unicorn/no-invalid-fetch-options": "warn", "unicorn/no-invalid-remove-event-listener": "warn", - "unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-new-array": "off", "unicorn/no-single-promise-in-promise-methods": "warn", - "unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-thenable": "off", "unicorn/no-unnecessary-await": "warn", "unicorn/no-useless-fallback-in-spread": "warn", "unicorn/no-useless-length-check": "warn", - "unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-useless-spread": "off", "unicorn/prefer-set-size": "warn", - "unicorn/prefer-string-starts-ends-with": "warn", - "use-isnan": "error", - "valid-typeof": "error" + "unicorn/prefer-string-starts-ends-with": "warn" }, "settings": { "jsdoc": { diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch similarity index 81% rename from .yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch rename to .yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch index ba4cd59d4c..75c418e591 100644 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.js b/dist/index.js -index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 100644 +index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { @@ -12,7 +12,7 @@ index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4 // src/google-generative-ai-options.ts diff --git a/dist/index.mjs b/dist/index.mjs -index a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 100644 +index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { diff --git a/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch b/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch similarity index 100% rename from .yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch rename to .yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch diff --git a/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch b/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch similarity index 85% rename from .yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch rename to .yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch index a7985ddfcd..22b5cf6ea8 100644 --- a/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch +++ b/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.js b/dist/index.js -index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644 +index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa96b52ac0d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)( @@ -18,30 +18,29 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31 tool_calls: import_v42.z.array( import_v42.z.object({ index: import_v42.z.number(), -@@ -785,6 +787,14 @@ var OpenAIChatLanguageModel = class { +@@ -785,6 +787,13 @@ var OpenAIChatLanguageModel = class { if (text != null && text.length > 0) { content.push({ type: "text", text }); } -+ const reasoning = -+ choice.message.reasoning_content; ++ const reasoning = choice.message.reasoning_content; + if (reasoning != null && reasoning.length > 0) { + content.push({ + type: 'reasoning', -+ text: reasoning, ++ text: reasoning + }); + } for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) { content.push({ type: "tool-call", -@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class { +@@ -866,6 +875,7 @@ var OpenAIChatLanguageModel = class { }; - let isFirstChunk = true; + let metadataExtracted = false; let isActiveText = false; + let isActiveReasoning = false; const providerMetadata = { openai: {} }; return { stream: response.pipeThrough( -@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class { +@@ -923,6 +933,21 @@ var OpenAIChatLanguageModel = class { return; } const delta = choice.delta; @@ -54,7 +53,6 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31 + }); + isActiveReasoning = true; + } -+ + controller.enqueue({ + type: 'reasoning-delta', + id: 'reasoning-0', @@ -64,7 +62,7 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31 if (delta.content != null) { if (!isActiveText) { controller.enqueue({ type: "text-start", id: "0" }); -@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class { +@@ -1035,6 +1060,9 @@ var OpenAIChatLanguageModel = class { } }, flush(controller) { diff --git a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch similarity index 92% rename from .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch rename to .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch index 057443aa43..896b2d4cbf 100644 --- a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch +++ b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch @@ -1,5 +1,5 @@ diff --git a/sdk.mjs b/sdk.mjs -index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755 +index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755 --- a/sdk.mjs +++ b/sdk.mjs @@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { @@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8 import { createInterface } from "readline"; // ../src/utils/fsOperations.ts -@@ -6487,14 +6487,11 @@ class ProcessTransport { +@@ -6505,14 +6505,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); } diff --git a/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch b/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch deleted file mode 100644 index e1258fcb35..0000000000 --- a/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch +++ /dev/null @@ -1,71 +0,0 @@ -diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs -index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644 ---- a/dist/utils/tiktoken.cjs -+++ b/dist/utils/tiktoken.cjs -@@ -1,25 +1,14 @@ - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.encodingForModel = exports.getEncoding = void 0; --const lite_1 = require("js-tiktoken/lite"); - const async_caller_js_1 = require("./async_caller.cjs"); - const cache = {}; - const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({}); - async function getEncoding(encoding) { -- if (!(encoding in cache)) { -- cache[encoding] = caller -- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`) -- .then((res) => res.json()) -- .then((data) => new lite_1.Tiktoken(data)) -- .catch((e) => { -- delete cache[encoding]; -- throw e; -- }); -- } -- return await cache[encoding]; -+ throw new Error("TikToken Not implemented"); - } - exports.getEncoding = getEncoding; - async function encodingForModel(model) { -- return getEncoding((0, lite_1.getEncodingNameForModel)(model)); -+ throw new Error("TikToken Not implemented"); - } - exports.encodingForModel = encodingForModel; -diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js -index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644 ---- a/dist/utils/tiktoken.js -+++ b/dist/utils/tiktoken.js -@@ -1,20 +1,9 @@ --import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite"; - import { AsyncCaller } from "./async_caller.js"; - const cache = {}; - const caller = /* #__PURE__ */ new AsyncCaller({}); - export async function getEncoding(encoding) { -- if (!(encoding in cache)) { -- cache[encoding] = caller -- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`) -- .then((res) => res.json()) -- .then((data) => new Tiktoken(data)) -- .catch((e) => { -- delete cache[encoding]; -- throw e; -- }); -- } -- return await cache[encoding]; -+ throw new Error("TikToken Not implemented"); - } - export async function encodingForModel(model) { -- return getEncoding(getEncodingNameForModel(model)); -+ throw new Error("TikToken Not implemented"); - } -diff --git a/package.json b/package.json -index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644 ---- a/package.json -+++ b/package.json -@@ -37,7 +37,6 @@ - "ansi-styles": "^5.0.0", - "camelcase": "6", - "decamelize": "1.2.0", -- "js-tiktoken": "^1.0.12", - "langsmith": ">=0.2.8 <0.4.0", - "mustache": "^4.2.0", - "p-queue": "^6.6.2", diff --git a/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch b/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch new file mode 100644 index 0000000000..6c8ad0b426 --- /dev/null +++ b/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch @@ -0,0 +1,68 @@ +diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs +index c5b41f121d2e3d24c3a4969e31fa1acffdcad3b9..ec724489dcae79ee6c61acf2d4d84bd19daef036 100644 +--- a/dist/utils/tiktoken.cjs ++++ b/dist/utils/tiktoken.cjs +@@ -1,6 +1,5 @@ + const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs'); + const require_utils_async_caller = require('./async_caller.cjs'); +-const js_tiktoken_lite = require_rolldown_runtime.__toESM(require("js-tiktoken/lite")); + + //#region src/utils/tiktoken.ts + var tiktoken_exports = {}; +@@ -11,14 +10,10 @@ require_rolldown_runtime.__export(tiktoken_exports, { + const cache = {}; + const caller = /* @__PURE__ */ new require_utils_async_caller.AsyncCaller({}); + async function getEncoding(encoding) { +- if (!(encoding in cache)) cache[encoding] = caller.fetch(`https://tiktoken.pages.dev/js/${encoding}.json`).then((res) => res.json()).then((data) => new js_tiktoken_lite.Tiktoken(data)).catch((e) => { +- delete cache[encoding]; +- throw e; +- }); +- return await cache[encoding]; ++ throw new Error("TikToken Not implemented"); + } + async function encodingForModel(model) { +- return getEncoding((0, js_tiktoken_lite.getEncodingNameForModel)(model)); ++ throw new Error("TikToken Not implemented"); + } + + //#endregion +diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js +index 641acca03cb92f04a6fa5c9c31f1880ce635572e..707389970ad957aa0ff20ef37fa8dd2875be737c 100644 +--- a/dist/utils/tiktoken.js ++++ b/dist/utils/tiktoken.js +@@ -1,6 +1,5 @@ + import { __export } from "../_virtual/rolldown_runtime.js"; + import { AsyncCaller } from "./async_caller.js"; +-import { Tiktoken, getEncodingNameForModel } from "js-tiktoken/lite"; + + //#region src/utils/tiktoken.ts + var tiktoken_exports = {}; +@@ -11,14 +10,10 @@ __export(tiktoken_exports, { + const cache = {}; + const caller = /* @__PURE__ */ new AsyncCaller({}); + async function getEncoding(encoding) { +- if (!(encoding in cache)) cache[encoding] = caller.fetch(`https://tiktoken.pages.dev/js/${encoding}.json`).then((res) => res.json()).then((data) => new Tiktoken(data)).catch((e) => { +- delete cache[encoding]; +- throw e; +- }); +- return await cache[encoding]; ++ throw new Error("TikToken Not implemented"); + } + async function encodingForModel(model) { +- return getEncoding(getEncodingNameForModel(model)); ++ throw new Error("TikToken Not implemented"); + } + + //#endregion +diff --git a/package.json b/package.json +index a24f8fc61de58526051999260f2ebee5f136354b..e885359e8966e7730c51772533ce37e01edb3046 100644 +--- a/package.json ++++ b/package.json +@@ -20,7 +20,6 @@ + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", +- "js-tiktoken": "^1.0.12", + "langsmith": "^0.3.64", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", diff --git a/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch b/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch deleted file mode 100644 index 3f37fb3ed8..0000000000 --- a/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/dist/embeddings.js b/dist/embeddings.js -index 1f8154be3e9c22442a915eb4b85fa6d2a21b0d0c..dc13ef4a30e6c282824a5357bcee9bd0ae222aab 100644 ---- a/dist/embeddings.js -+++ b/dist/embeddings.js -@@ -214,10 +214,12 @@ export class OpenAIEmbeddings extends Embeddings { - * @returns Promise that resolves to an embedding for the document. - */ - async embedQuery(text) { -+ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com') -+ const input = this.stripNewLines ? text.replace(/\n/g, ' ') : text - const params = { - model: this.model, -- input: this.stripNewLines ? text.replace(/\n/g, " ") : text, -- }; -+ input: isBaiduCloud ? [input] : input -+ } - if (this.dimensions) { - params.dimensions = this.dimensions; - } diff --git a/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch b/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch new file mode 100644 index 0000000000..bb4fa01264 --- /dev/null +++ b/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch @@ -0,0 +1,17 @@ +diff --git a/dist/embeddings.js b/dist/embeddings.js +index 6f4b928d3e4717309382e1b5c2e31ab5bc6c5af0..bc79429c88a6d27d4997a2740c4d8ae0707f5991 100644 +--- a/dist/embeddings.js ++++ b/dist/embeddings.js +@@ -94,9 +94,11 @@ var OpenAIEmbeddings = class extends Embeddings { + * @returns Promise that resolves to an embedding for the document. + */ + async embedQuery(text) { ++ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com'); ++ const input = this.stripNewLines ? text.replace(/\n/g, " ") : text + const params = { + model: this.model, +- input: this.stripNewLines ? text.replace(/\n/g, " ") : text ++ input: isBaiduCloud ? [input] : input + }; + if (this.dimensions) params.dimensions = this.dimensions; + if (this.encodingFormat) params.encoding_format = this.encodingFormat; diff --git a/CLAUDE.md b/CLAUDE.md index 2716815ba2..372bff256c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,12 +7,10 @@ This file provides guidance to AI coding assistants when working with code in th - **Keep it clear**: Write code that is easy to read, maintain, and explain. - **Match the house style**: Reuse existing patterns, naming, and conventions. - **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed. -- **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. - **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:`). +- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`). ## Development Commands @@ -41,7 +39,6 @@ This file provides guidance to AI coding assistants when working with code in th - **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc. - **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces. - **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state. -- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements. ### Logging ```typescript diff --git a/README.md b/README.md index c3d3f915a1..1223f73ed0 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Cherry Studio is a desktop client that supports multiple LLM providers, availabl 1. **Diverse LLM Provider Support**: - ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more -- 🔗 AI Web Service Integration: Claude, Perplexity, Poe, and others +- 🔗 AI Web Service Integration: Claude, Perplexity, [Poe](https://poe.com/), and others - 💻 Local Model Support with Ollama, LM Studio 2. **AI Assistants & Conversations**: @@ -238,10 +238,6 @@ The Enterprise Edition addresses core challenges in team collaboration by centra ## ✨ Online Demo -> 🚧 **Public Beta Notice** -> -> The Enterprise Edition is currently in its early public beta stage, and we are actively iterating and optimizing its features. We are aware that it may not be perfectly stable yet. If you encounter any issues or have valuable suggestions during your trial, we would be very grateful if you could contact us via email to provide feedback. - **🔗 [Cherry Studio Enterprise](https://www.cherry-ai.com/enterprise)** ## Version Comparison @@ -249,7 +245,7 @@ The Enterprise Edition addresses core challenges in team collaboration by centra | Feature | Community Edition | Enterprise Edition | | :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | | **Open Source** | ✅ Yes | ⭕️ Partially released to customers | -| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee | +| **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee | | **Admin Backend** | — | ● Centralized **Model** Access
● **Employee** Management
● Shared **Knowledge Base**
● **Access** Control
● **Data** Backup | | **Server** | — | ✅ Dedicated Private Deployment | @@ -262,8 +258,12 @@ We believe the Enterprise Edition will become your team's AI productivity engine # 🔗 Related Projects +- [new-api](https://github.com/QuantumNous/new-api): The next-generation LLM gateway and AI asset management system supports multiple languages. + - [one-api](https://github.com/songquanpeng/one-api): LLM API management and distribution system supporting mainstream models like OpenAI, Azure, and Anthropic. Features a unified API interface, suitable for key management and secondary distribution. +- [Poe](https://poe.com/): Poe gives you access to the best AI, all in one place. Explore GPT-5, Claude Opus 4.1, DeepSeek-R1, Veo 3, ElevenLabs, and millions of others. + - [ublacklist](https://github.com/iorate/ublacklist): Blocks specific sites from appearing in Google search results # 🚀 Contributors diff --git a/biome.jsonc b/biome.jsonc index 94c2e3bae6..9509135fc4 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -23,7 +23,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**"], + "includes": ["**", "!**/.claude/**"], "maxSize": 2097152 }, "formatter": { diff --git a/components.json b/components.json deleted file mode 100644 index c5aceeb3ce..0000000000 --- a/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "aliases": { - "components": "@renderer/ui/third-party", - "hooks": "@renderer/hooks", - "lib": "@renderer/lib", - "ui": "@renderer/ui", - "utils": "@renderer/utils" - }, - "iconLibrary": "lucide", - "rsc": false, - "style": "new-york", - "tailwind": { - "baseColor": "zinc", - "config": "", - "css": "src/renderer/src/assets/styles/tailwind.css", - "cssVariables": true, - "prefix": "" - }, - "tsx": true -} diff --git a/docs/dev.md b/docs/dev.md index 0fdff640ec..fe67742768 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -18,13 +18,13 @@ yarn ### Setup Node.js -Download and install [Node.js v20.x.x](https://nodejs.org/en/download) +Download and install [Node.js v22.x.x](https://nodejs.org/en/download) ### Setup Yarn ```bash corepack enable -corepack prepare yarn@4.6.0 --activate +corepack prepare yarn@4.9.1 --activate ``` ### Install Dependencies diff --git a/docs/testplan-en.md b/docs/testplan-en.md index 0f7cd41473..fad894f22b 100644 --- a/docs/testplan-en.md +++ b/docs/testplan-en.md @@ -11,6 +11,8 @@ The Test Plan is divided into the RC channel and the Beta channel, with the foll Users can enable the "Test Plan" and select the version channel in the software's `Settings` > `About`. Please note that the versions in the "Test Plan" cannot guarantee data consistency, so be sure to back up your data before using them. +After enabling the RC channel or Beta channel, if a stable version is released, users will still be upgraded to the stable version. + Users are welcome to submit issues or provide feedback through other channels for any bugs encountered during testing. Your feedback is very important to us. ## Developer Guide diff --git a/docs/testplan-zh.md b/docs/testplan-zh.md index ed4913d4a4..77d25981de 100644 --- a/docs/testplan-zh.md +++ b/docs/testplan-zh.md @@ -11,6 +11,8 @@ 用户可以在软件的`设置`-`关于`中,开启“测试计划”并选择版本通道。请注意“测试计划”的版本无法保证数据的一致性,请使用前一定要备份数据。 +用户选择RC版通道或Beta版通道后,若发布了正式版,仍旧会升级到正式版。 + 用户在测试过程中发现的BUG,欢迎提交issue或通过其他渠道反馈。用户的反馈对我们非常重要。 ## 开发者指南 diff --git a/electron-builder.yml b/electron-builder.yml index 8e31ee7d9c..2918179152 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,6 +21,8 @@ files: - "**/*" - "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}" - "!electron.vite.config.{js,ts,mjs,cjs}}" + - "!.*" + - "!components.json" - "!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md,biome.jsonc}" - "!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}" - "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}" @@ -133,60 +135,42 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-beta.2 + What's New in v1.7.0-beta.6 New Features: - - Session Settings: Manage session-specific settings and model configurations independently - - Notes Full-Text Search: Search across all notes with match highlighting - - Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only) - - Intel OV OCR: Hardware-accelerated OCR using Intel NPU - - Auto-start API Server: Automatically starts when agents exist + - Enhanced Input Bar: Completely redesigned input bar with improved responsiveness and functionality + - Better File Handling: Improved drag-and-drop and paste support for images and documents + - Smart Tool Suggestions: Enhanced quick panel with better item selection and keyboard shortcuts Improvements: - - Agent model selection now requires explicit user choice - - Added Mistral AI provider support - - Added NewAPI generic provider support - - Improved navbar layout consistency across different modes - - Enhanced chat component responsiveness - - Better code block display on small screens - - Updated OVMS to 2025.3 official release - - Added Greek language support + - Smoother Input Experience: Better auto-resizing and text handling in chat input + - Enhanced AI Performance: Improved connection stability and response speed + - More Reliable File Uploads: Better support for various file types and upload scenarios + - Cleaner Interface: Optimized UI elements for better visual consistency Bug Fixes: - - Fixed GitHub Copilot gpt-5-codex streaming issues - - Fixed assistant creation failures - - Fixed translate auto-copy functionality - - Fixed miniapps external link opening - - Fixed message layout and overflow issues - - Fixed API key parsing to preserve spaces - - Fixed agent display in different navbar layouts + - Fixed image selection issue when adding custom AI providers + - Fixed file upload problems with certain API configurations + - Fixed input bar responsiveness issues + - Fixed quick panel not working properly in some situations - v1.7.0-beta.2 新特性 + v1.7.0-beta.6 新特性 新功能: - - 会话设置:独立管理会话特定的设置和模型配置 - - 笔记全文搜索:跨所有笔记搜索并高亮匹配内容 - - 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区) - - Intel OV OCR:使用 Intel NPU 的硬件加速 OCR - - 自动启动 API 服务器:当存在 Agent 时自动启动 + - 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大 + - 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档 + - 智能工具建议:增强的快速面板,更好的项目选择和键盘快捷键 改进: - - Agent 模型选择现在需要用户显式选择 - - 添加 Mistral AI 提供商支持 - - 添加 NewAPI 通用提供商支持 - - 改进不同模式下的导航栏布局一致性 - - 增强聊天组件响应式设计 - - 优化小屏幕代码块显示 - - 更新 OVMS 至 2025.3 正式版 - - 添加希腊语支持 + - 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳 + - 增强 AI 性能:改进连接稳定性和响应速度 + - 更可靠的文件上传:更好地支持各种文件类型和上传场景 + - 更简洁的界面:优化 UI 元素,视觉一致性更好 问题修复: - - 修复 GitHub Copilot gpt-5-codex 流式传输问题 - - 修复助手创建失败 - - 修复翻译自动复制功能 - - 修复小程序外部链接打开 - - 修复消息布局和溢出问题 - - 修复 API 密钥解析以保留空格 - - 修复不同导航栏布局中的 Agent 显示 + - 修复添加自定义 AI 提供商时的图片选择问题 + - 修复某些 API 配置下的文件上传问题 + - 修复输入栏响应性问题 + - 修复快速面板在某些情况下无法正常工作的问题 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b4914539c7..172d48ca9a 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -95,7 +95,8 @@ export default defineConfig({ '@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'), '@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'), '@cherrystudio/ai-core': resolve('packages/aiCore/src'), - '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src') + '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'), + '@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src') } }, optimizeDeps: { diff --git a/package.json b/package.json index 1d1213a078..fccb139449 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-beta.2", + "version": "1.7.0-beta.3", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -78,10 +78,11 @@ "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.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch", + "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.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", + "@paymoapp/electron-shutdown-handler": "^1.1.2", "@strongtz/win32-arm64-msvc": "^0.4.7", "express": "^5.1.0", "font-list": "^2.0.0", @@ -92,8 +93,10 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", + "qrcode.react": "^4.2.0", "selection-hook": "^1.0.12", "sharp": "^0.34.3", + "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", @@ -103,17 +106,19 @@ "@agentic/exa": "^7.3.3", "@agentic/searxng": "^7.3.3", "@agentic/tavily": "^7.3.3", - "@ai-sdk/amazon-bedrock": "^3.0.42", - "@ai-sdk/google-vertex": "^3.0.48", - "@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch", - "@ai-sdk/mistral": "^2.0.19", - "@ai-sdk/perplexity": "^2.0.13", + "@ai-sdk/amazon-bedrock": "^3.0.53", + "@ai-sdk/cerebras": "^1.0.31", + "@ai-sdk/gateway": "^2.0.9", + "@ai-sdk/google-vertex": "^3.0.62", + "@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch", + "@ai-sdk/mistral": "^2.0.23", + "@ai-sdk/perplexity": "^2.0.17", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@anthropic-ai/sdk": "^0.41.0", "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch", - "@aws-sdk/client-bedrock": "^3.840.0", - "@aws-sdk/client-bedrock-runtime": "^3.840.0", - "@aws-sdk/client-s3": "^3.840.0", + "@aws-sdk/client-bedrock": "^3.910.0", + "@aws-sdk/client-bedrock-runtime": "^3.910.0", + "@aws-sdk/client-s3": "^3.910.0", "@biomejs/biome": "2.2.4", "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18", "@cherrystudio/embedjs": "^0.1.31", @@ -144,9 +149,10 @@ "@eslint/js": "^9.22.0", "@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", "@hello-pangea/dnd": "^18.0.1", - "@heroui/react": "^2.8.3", "@kangfenmao/keyv-storage": "^0.1.0", - "@langchain/community": "^0.3.50", + "@langchain/community": "^1.0.0", + "@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", + "@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@mistralai/mistralai": "^1.7.5", "@modelcontextprotocol/sdk": "^1.17.5", "@mozilla/readability": "^0.6.0", @@ -227,7 +233,7 @@ "@viz-js/lang-dot": "^1.0.5", "@viz-js/viz": "^3.14.0", "@xyflow/react": "^12.4.4", - "ai": "^5.0.76", + "ai": "^5.0.90", "antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch", "archiver": "^7.0.1", "async-mutex": "^0.5.0", @@ -237,7 +243,7 @@ "check-disk-space": "3.4.0", "cheerio": "^1.1.2", "chokidar": "^4.0.3", - "claude-code-plugins": "1.0.1", + "claude-code-plugins": "1.0.3", "cli-progress": "^3.12.0", "clsx": "^2.1.1", "code-inspector-plugin": "^0.20.14", @@ -344,6 +350,7 @@ "striptags": "^3.2.0", "styled-components": "^6.1.11", "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", @@ -369,12 +376,11 @@ "zod": "^4.1.5" }, "resolutions": { + "@smithy/types": "4.7.1", "@codemirror/language": "6.11.3", "@codemirror/lint": "6.8.5", "@codemirror/view": "6.38.1", - "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch", - "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", - "@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", + "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch", "atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch", @@ -390,7 +396,6 @@ "undici": "6.21.2", "vite": "npm:rolldown-vite@7.1.5", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", - "@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch", "@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch", "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", @@ -398,7 +403,13 @@ "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-win32-x64": "0.34.3", - "openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0" + "openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0", + "@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", + "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", + "@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", + "@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/packages/ai-sdk-provider/README.md b/packages/ai-sdk-provider/README.md new file mode 100644 index 0000000000..ecd9df2923 --- /dev/null +++ b/packages/ai-sdk-provider/README.md @@ -0,0 +1,39 @@ +# @cherrystudio/ai-sdk-provider + +CherryIN provider bundle for the [Vercel AI SDK](https://ai-sdk.dev/). +It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Anthropic and Gemini model ids to their CherryIN upstream equivalents. + +## Installation + +```bash +npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai +# or +yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai +``` + +> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed. + +## Usage + +```ts +import { createCherryIn, cherryIn } from '@cherrystudio/ai-sdk-provider' + +const cherryInProvider = createCherryIn({ + apiKey: process.env.CHERRYIN_API_KEY, + // optional overrides: + // baseURL: 'https://open.cherryin.net/v1', + // anthropicBaseURL: 'https://open.cherryin.net/anthropic', + // geminiBaseURL: 'https://open.cherryin.net/gemini/v1beta', +}) + +// Chat models will auto-route based on the model id prefix: +const openaiModel = cherryInProvider.chat('gpt-4o-mini') +const anthropicModel = cherryInProvider.chat('claude-3-5-sonnet-latest') +const geminiModel = cherryInProvider.chat('gemini-2.0-pro-exp') + +const { text } = await openaiModel.invoke('Hello CherryIN!') +``` + +The provider also exposes `completion`, `responses`, `embedding`, `image`, `transcription`, and `speech` helpers aligned with the upstream APIs. + +See [AI SDK docs](https://ai-sdk.dev/providers/community-providers/custom-providers) for configuring custom providers. diff --git a/packages/ai-sdk-provider/package.json b/packages/ai-sdk-provider/package.json new file mode 100644 index 0000000000..fd0aac2643 --- /dev/null +++ b/packages/ai-sdk-provider/package.json @@ -0,0 +1,64 @@ +{ + "name": "@cherrystudio/ai-sdk-provider", + "version": "0.1.0", + "description": "Cherry Studio AI SDK provider bundle with CherryIN routing.", + "keywords": [ + "ai-sdk", + "provider", + "cherryin", + "vercel-ai-sdk", + "cherry-studio" + ], + "author": "Cherry Studio", + "license": "MIT", + "homepage": "https://github.com/CherryHQ/cherry-studio", + "repository": { + "type": "git", + "url": "git+https://github.com/CherryHQ/cherry-studio.git", + "directory": "packages/ai-sdk-provider" + }, + "bugs": { + "url": "https://github.com/CherryHQ/cherry-studio/issues" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "dev": "tsc -w", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "peerDependencies": { + "@ai-sdk/anthropic": "^2.0.29", + "@ai-sdk/google": "^2.0.23", + "@ai-sdk/openai": "^2.0.64", + "ai": "^5.0.26" + }, + "dependencies": { + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.12" + }, + "devDependencies": { + "tsdown": "^0.13.3", + "typescript": "^5.8.2", + "vitest": "^3.2.4" + }, + "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + } +} diff --git a/packages/ai-sdk-provider/src/cherryin-provider.ts b/packages/ai-sdk-provider/src/cherryin-provider.ts new file mode 100644 index 0000000000..478380a411 --- /dev/null +++ b/packages/ai-sdk-provider/src/cherryin-provider.ts @@ -0,0 +1,319 @@ +import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal' +import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal' +import type { OpenAIProviderSettings } from '@ai-sdk/openai' +import { + OpenAIChatLanguageModel, + OpenAICompletionLanguageModel, + OpenAIEmbeddingModel, + OpenAIImageModel, + OpenAIResponsesLanguageModel, + OpenAISpeechModel, + OpenAITranscriptionModel +} from '@ai-sdk/openai/internal' +import { + type EmbeddingModelV2, + type ImageModelV2, + type LanguageModelV2, + type ProviderV2, + type SpeechModelV2, + type TranscriptionModelV2 +} from '@ai-sdk/provider' +import type { FetchFunction } from '@ai-sdk/provider-utils' +import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils' + +export const CHERRYIN_PROVIDER_NAME = 'cherryin' as const +export const DEFAULT_CHERRYIN_BASE_URL = 'https://open.cherryin.net/v1' +export const DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL = 'https://open.cherryin.net/v1' +export const DEFAULT_CHERRYIN_GEMINI_BASE_URL = 'https://open.cherryin.net/v1beta/models' + +const ANTHROPIC_PREFIX = /^anthropic\//i +const GEMINI_PREFIX = /^google\//i +// const GEMINI_EXCLUDED_SUFFIXES = ['-nothink', '-search'] + +type HeaderValue = string | undefined + +type HeadersInput = Record | (() => Record) + +export interface CherryInProviderSettings { + /** + * CherryIN API key. + * + * If omitted, the provider will read the `CHERRYIN_API_KEY` environment variable. + */ + apiKey?: string + /** + * Optional custom fetch implementation. + */ + fetch?: FetchFunction + /** + * Base URL for OpenAI-compatible CherryIN endpoints. + * + * Defaults to `https://open.cherryin.net/v1`. + */ + baseURL?: string + /** + * Base URL for Anthropic-compatible endpoints. + * + * Defaults to `https://open.cherryin.net/anthropic`. + */ + anthropicBaseURL?: string + /** + * Base URL for Gemini-compatible endpoints. + * + * Defaults to `https://open.cherryin.net/gemini/v1beta`. + */ + geminiBaseURL?: string + /** + * Optional static headers applied to every request. + */ + headers?: HeadersInput +} + +export interface CherryInProvider extends ProviderV2 { + (modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + languageModel(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + chat(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + responses(modelId: string): LanguageModelV2 + completion(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + embedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2 + textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2 + textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2 + image(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2 + imageModel(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2 + transcription(modelId: string): TranscriptionModelV2 + transcriptionModel(modelId: string): TranscriptionModelV2 + speech(modelId: string): SpeechModelV2 + speechModel(modelId: string): SpeechModelV2 +} + +const resolveApiKey = (options: CherryInProviderSettings): string => + loadApiKey({ + apiKey: options.apiKey, + environmentVariableName: 'CHERRYIN_API_KEY', + description: 'CherryIN' + }) + +const isAnthropicModel = (modelId: string) => ANTHROPIC_PREFIX.test(modelId) +const isGeminiModel = (modelId: string) => GEMINI_PREFIX.test(modelId) + +const createCustomFetch = (originalFetch?: any) => { + return async (url: string, options: any) => { + if (options?.body) { + try { + const body = JSON.parse(options.body) + if (body.tools && Array.isArray(body.tools) && body.tools.length === 0 && body.tool_choice) { + delete body.tool_choice + options.body = JSON.stringify(body) + } + } catch (error) { + // ignore error + } + } + + return originalFetch ? originalFetch(url, options) : fetch(url, options) + } +} +class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel { + constructor(modelId: string, settings: any) { + super(modelId, { + ...settings, + fetch: createCustomFetch(settings.fetch) + }) + } +} + +const resolveConfiguredHeaders = (headers?: HeadersInput): Record => { + if (typeof headers === 'function') { + return { ...headers() } + } + return headers ? { ...headers } : {} +} + +const toBearerToken = (authorization?: string) => (authorization ? authorization.replace(/^Bearer\s+/i, '') : undefined) + +const createJsonHeadersGetter = (options: CherryInProviderSettings): (() => Record) => { + return () => ({ + Authorization: `Bearer ${resolveApiKey(options)}`, + 'Content-Type': 'application/json', + ...resolveConfiguredHeaders(options.headers) + }) +} + +const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record) => { + return () => ({ + Authorization: `Bearer ${resolveApiKey(options)}`, + ...resolveConfiguredHeaders(options.headers) + }) +} + +export const createCherryIn = (options: CherryInProviderSettings = {}): CherryInProvider => { + const { + baseURL = DEFAULT_CHERRYIN_BASE_URL, + anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL, + geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL, + fetch + } = options + + const getJsonHeaders = createJsonHeadersGetter(options) + const getAuthHeaders = createAuthHeadersGetter(options) + + const url = ({ path }: { path: string; modelId: string }) => `${withoutTrailingSlash(baseURL)}${path}` + + const createAnthropicModel = (modelId: string) => + new AnthropicMessagesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.anthropic`, + baseURL: anthropicBaseURL, + headers: () => { + const headers = getJsonHeaders() + const apiKey = toBearerToken(headers.Authorization) + return { + ...headers, + 'x-api-key': apiKey + } + }, + fetch, + supportedUrls: () => ({ + 'image/*': [/^https?:\/\/.*$/] + }) + }) + + const createGeminiModel = (modelId: string) => + new GoogleGenerativeAILanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.google`, + baseURL: geminiBaseURL, + headers: () => { + const headers = getJsonHeaders() + const apiKey = toBearerToken(headers.Authorization) + return { + ...headers, + 'x-goog-api-key': apiKey + } + }, + fetch, + generateId: () => `${CHERRYIN_PROVIDER_NAME}-${Date.now()}`, + supportedUrls: () => ({}) + }) + + const createOpenAIChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new CherryInOpenAIChatLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.openai-chat`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => { + if (isAnthropicModel(modelId)) { + return createAnthropicModel(modelId) + } + if (isGeminiModel(modelId)) { + return createGeminiModel(modelId) + } + return new OpenAIResponsesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.openai`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + } + + const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new OpenAICompletionLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.completion`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createEmbeddingModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new OpenAIEmbeddingModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.embeddings`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createResponsesModel = (modelId: string) => + new OpenAIResponsesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.responses`, + url, + headers: () => ({ + ...getJsonHeaders() + }), + fetch + }) + + const createImageModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new OpenAIImageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.image`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createTranscriptionModel = (modelId: string) => + new OpenAITranscriptionModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.transcription`, + url, + headers: () => ({ + ...getAuthHeaders() + }), + fetch + }) + + const createSpeechModel = (modelId: string) => + new OpenAISpeechModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.speech`, + url, + headers: () => ({ + ...getJsonHeaders() + }), + fetch + }) + + const provider: CherryInProvider = function (modelId: string, settings?: OpenAIProviderSettings) { + if (new.target) { + throw new Error('CherryIN provider function cannot be called with the new keyword.') + } + + return createChatModel(modelId, settings) + } + + provider.languageModel = createChatModel + provider.chat = createOpenAIChatModel + + provider.responses = createResponsesModel + provider.completion = createCompletionModel + + provider.embedding = createEmbeddingModel + provider.textEmbedding = createEmbeddingModel + provider.textEmbeddingModel = createEmbeddingModel + + provider.image = createImageModel + provider.imageModel = createImageModel + + provider.transcription = createTranscriptionModel + provider.transcriptionModel = createTranscriptionModel + + provider.speech = createSpeechModel + provider.speechModel = createSpeechModel + + return provider +} + +export const cherryIn = createCherryIn() diff --git a/packages/ai-sdk-provider/src/index.ts b/packages/ai-sdk-provider/src/index.ts new file mode 100644 index 0000000000..d397dd5af5 --- /dev/null +++ b/packages/ai-sdk-provider/src/index.ts @@ -0,0 +1 @@ +export * from './cherryin-provider' diff --git a/packages/ai-sdk-provider/tsconfig.json b/packages/ai-sdk-provider/tsconfig.json new file mode 100644 index 0000000000..26ee731bb7 --- /dev/null +++ b/packages/ai-sdk-provider/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "noEmitOnError": false, + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} diff --git a/packages/ai-sdk-provider/tsdown.config.ts b/packages/ai-sdk-provider/tsdown.config.ts new file mode 100644 index 0000000000..0e07d34cac --- /dev/null +++ b/packages/ai-sdk-provider/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + index: 'src/index.ts' + }, + outDir: 'dist', + format: ['esm', 'cjs'], + clean: true, + dts: true, + tsconfig: 'tsconfig.json' +}) diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 8310b4164c..bb673392a2 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -36,14 +36,16 @@ "ai": "^5.0.26" }, "dependencies": { - "@ai-sdk/anthropic": "^2.0.32", - "@ai-sdk/azure": "^2.0.53", - "@ai-sdk/deepseek": "^1.0.23", - "@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch", - "@ai-sdk/openai-compatible": "^1.0.22", + "@ai-sdk/anthropic": "^2.0.43", + "@ai-sdk/azure": "^2.0.66", + "@ai-sdk/deepseek": "^1.0.27", + "@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch", + "@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/openai-compatible": "^1.0.26", "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^3.0.12", - "@ai-sdk/xai": "^2.0.26", + "@ai-sdk/provider-utils": "^3.0.16", + "@ai-sdk/xai": "^2.0.31", + "@cherrystudio/ai-sdk-provider": "workspace:*", "zod": "^4.1.5" }, "devDependencies": { diff --git a/packages/aiCore/src/core/middleware/manager.ts b/packages/aiCore/src/core/middleware/manager.ts index bcb044b3a9..f285b8ecd1 100644 --- a/packages/aiCore/src/core/middleware/manager.ts +++ b/packages/aiCore/src/core/middleware/manager.ts @@ -2,7 +2,7 @@ * 中间件管理器 * 专注于 AI SDK 中间件的管理,与插件系统分离 */ -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' /** * 创建中间件列表 diff --git a/packages/aiCore/src/core/middleware/types.ts b/packages/aiCore/src/core/middleware/types.ts index 50b5210b53..f500b0a91d 100644 --- a/packages/aiCore/src/core/middleware/types.ts +++ b/packages/aiCore/src/core/middleware/types.ts @@ -1,7 +1,7 @@ /** * 中间件系统类型定义 */ -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' /** * 具名中间件接口 diff --git a/packages/aiCore/src/core/middleware/wrapper.ts b/packages/aiCore/src/core/middleware/wrapper.ts index 625eddbab3..059c82380f 100644 --- a/packages/aiCore/src/core/middleware/wrapper.ts +++ b/packages/aiCore/src/core/middleware/wrapper.ts @@ -2,7 +2,7 @@ * 模型包装工具函数 * 用于将中间件应用到LanguageModel上 */ -import { LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' import { wrapLanguageModel } from 'ai' /** diff --git a/packages/aiCore/src/core/models/ModelResolver.ts b/packages/aiCore/src/core/models/ModelResolver.ts index 20bf3d76d1..ed9fa910e0 100644 --- a/packages/aiCore/src/core/models/ModelResolver.ts +++ b/packages/aiCore/src/core/models/ModelResolver.ts @@ -5,7 +5,7 @@ * 集成了来自 ModelCreator 的特殊处理逻辑 */ -import { EmbeddingModelV2, ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { EmbeddingModelV2, ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' import { wrapModelWithMiddlewares } from '../middleware/wrapper' import { DEFAULT_SEPARATOR, globalRegistryManagement } from '../providers/RegistryManagement' diff --git a/packages/aiCore/src/core/models/types.ts b/packages/aiCore/src/core/models/types.ts index 57cb72366e..560d5bbeae 100644 --- a/packages/aiCore/src/core/models/types.ts +++ b/packages/aiCore/src/core/models/types.ts @@ -1,7 +1,7 @@ /** * Creation 模块类型定义 */ -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' import type { ProviderId, ProviderSettingsMap } from '../providers/types' diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index ffeb15185c..ecd53e6330 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -1,4 +1,4 @@ -import { ExtractProviderOptions, ProviderOptionsMap, TypedProviderOptions } from './types' +import type { ExtractProviderOptions, ProviderOptionsMap, TypedProviderOptions } from './types' /** * 创建特定供应商的选项 diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index a2cc7d9aff..274fdcee5c 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -10,7 +10,7 @@ import type { AiRequestContext } from '../../types' import { StreamEventManager } from './StreamEventManager' import { type TagConfig, TagExtractor } from './tagExtraction' import { ToolExecutor } from './ToolExecutor' -import { PromptToolUseConfig, ToolUseResult } from './type' +import type { PromptToolUseConfig, ToolUseResult } from './type' /** * 工具使用标签配置 diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts index 33ed6189ed..4937b25601 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts @@ -1,6 +1,6 @@ -import { ToolSet } from 'ai' +import type { ToolSet } from 'ai' -import { AiRequestContext } from '../..' +import type { AiRequestContext } from '../..' /** * 解析结果类型 diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index fd68da27d2..a50356130d 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -1,10 +1,12 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' -import { InferToolInput, InferToolOutput, type Tool } from 'ai' +import type { InferToolInput, InferToolOutput } from 'ai' +import { type Tool } from 'ai' -import { ProviderOptionsMap } from '../../../options/types' -import { OpenRouterSearchConfig } from './openrouter' +import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' +import type { ProviderOptionsMap } from '../../../options/types' +import type { OpenRouterSearchConfig } from './openrouter' /** * 从 AI SDK 的工具函数中提取参数类型,以确保类型安全。 @@ -94,3 +96,56 @@ export type WebSearchToolInputSchema = { google: InferToolInput 'openai-chat': InferToolInput } + +export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => { + switch (providerId) { + case 'openai': { + if (config.openai) { + if (!params.tools) params.tools = {} + params.tools.web_search = openai.tools.webSearch(config.openai) + } + break + } + case 'openai-chat': { + if (config['openai-chat']) { + if (!params.tools) params.tools = {} + params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) + } + break + } + + case 'anthropic': { + if (config.anthropic) { + if (!params.tools) params.tools = {} + params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) + } + break + } + + case 'google': { + // case 'google-vertex': + if (!params.tools) params.tools = {} + params.tools.web_search = google.tools.googleSearch(config.google || {}) + break + } + + case 'xai': { + if (config.xai) { + const searchOptions = createXaiOptions({ + searchParameters: { ...config.xai, mode: 'on' } + }) + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) + } + break + } + + case 'openrouter': { + if (config.openrouter) { + const searchOptions = createOpenRouterOptions(config.openrouter) + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) + } + break + } + } + return params +} diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts index abd3ce3e2c..23ea952323 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -2,14 +2,11 @@ * Web Search Plugin * 提供统一的网络搜索能力,支持多个 AI Provider */ -import { anthropic } from '@ai-sdk/anthropic' -import { google } from '@ai-sdk/google' -import { openai } from '@ai-sdk/openai' -import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import { definePlugin } from '../../' import type { AiRequestContext } from '../../types' -import { DEFAULT_WEB_SEARCH_CONFIG, WebSearchPluginConfig } from './helper' +import type { WebSearchPluginConfig } from './helper' +import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper' /** * 网络搜索插件 @@ -23,56 +20,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR transformParams: async (params: any, context: AiRequestContext) => { const { providerId } = context - switch (providerId) { - case 'openai': { - if (config.openai) { - if (!params.tools) params.tools = {} - params.tools.web_search = openai.tools.webSearch(config.openai) - } - break - } - case 'openai-chat': { - if (config['openai-chat']) { - if (!params.tools) params.tools = {} - params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) - } - break - } + switchWebSearchTool(providerId, config, params) - case 'anthropic': { - if (config.anthropic) { - if (!params.tools) params.tools = {} - params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) - } - break - } - - case 'google': { - // case 'google-vertex': - if (!params.tools) params.tools = {} - params.tools.web_search = google.tools.googleSearch(config.google || {}) - break - } - - case 'xai': { - if (config.xai) { - const searchOptions = createXaiOptions({ - searchParameters: { ...config.xai, mode: 'on' } - }) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) - } - break - } - - case 'openrouter': { - if (config.openrouter) { - const searchOptions = createOpenRouterOptions(config.openrouter) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) - } - break - } + if (providerId === 'cherryin' || providerId === 'cherryin-chat') { + // cherryin.gemini + const _providerId = params.model.provider.split('.')[1] + switchWebSearchTool(_providerId, config, params) } - return params } }) diff --git a/packages/aiCore/src/core/plugins/manager.ts b/packages/aiCore/src/core/plugins/manager.ts index 4c927ed1de..40f5836c44 100644 --- a/packages/aiCore/src/core/plugins/manager.ts +++ b/packages/aiCore/src/core/plugins/manager.ts @@ -1,4 +1,4 @@ -import { AiPlugin, AiRequestContext } from './types' +import type { AiPlugin, AiRequestContext } from './types' /** * 插件管理器 diff --git a/packages/aiCore/src/core/providers/HubProvider.ts b/packages/aiCore/src/core/providers/HubProvider.ts index 0283d634b0..e87274be98 100644 --- a/packages/aiCore/src/core/providers/HubProvider.ts +++ b/packages/aiCore/src/core/providers/HubProvider.ts @@ -5,7 +5,7 @@ * 例如: aihubmix:anthropic:claude-3.5-sonnet */ -import { ProviderV2 } from '@ai-sdk/provider' +import type { ProviderV2 } from '@ai-sdk/provider' import { customProvider } from 'ai' import { globalRegistryManagement } from './RegistryManagement' diff --git a/packages/aiCore/src/core/providers/RegistryManagement.ts b/packages/aiCore/src/core/providers/RegistryManagement.ts index a8aefd44b2..67d10f3f2f 100644 --- a/packages/aiCore/src/core/providers/RegistryManagement.ts +++ b/packages/aiCore/src/core/providers/RegistryManagement.ts @@ -4,7 +4,7 @@ * 基于 AI SDK 原生的 createProviderRegistry */ -import { EmbeddingModelV2, ImageModelV2, LanguageModelV2, ProviderV2 } from '@ai-sdk/provider' +import type { EmbeddingModelV2, ImageModelV2, LanguageModelV2, ProviderV2 } from '@ai-sdk/provider' import { createProviderRegistry, type ProviderRegistryProvider } from 'ai' type PROVIDERS = Record diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts index 0d507d5cc6..778b1b705a 100644 --- a/packages/aiCore/src/core/providers/schemas.ts +++ b/packages/aiCore/src/core/providers/schemas.ts @@ -10,10 +10,12 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createHuggingFace } from '@ai-sdk/huggingface' import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai' import { createOpenAICompatible } from '@ai-sdk/openai-compatible' -import { LanguageModelV2 } from '@ai-sdk/provider' +import type { LanguageModelV2 } from '@ai-sdk/provider' import { createXai } from '@ai-sdk/xai' +import { type CherryInProviderSettings, createCherryIn } from '@cherrystudio/ai-sdk-provider' import { createOpenRouter } from '@openrouter/ai-sdk-provider' -import { customProvider, Provider } from 'ai' +import type { Provider } from 'ai' +import { customProvider } from 'ai' import * as z from 'zod' /** @@ -30,6 +32,8 @@ export const baseProviderIds = [ 'azure-responses', 'deepseek', 'openrouter', + 'cherryin', + 'cherryin-chat', 'huggingface' ] as const @@ -135,6 +139,26 @@ export const baseProviders = [ creator: createOpenRouter, supportsImageGeneration: true }, + { + id: 'cherryin', + name: 'CherryIN', + creator: createCherryIn, + supportsImageGeneration: true + }, + { + id: 'cherryin-chat', + name: 'CherryIN Chat', + creator: (options: CherryInProviderSettings) => { + const provider = createCherryIn(options) + return customProvider({ + fallbackProvider: { + ...provider, + languageModel: (modelId: string) => provider.chat(modelId) + } + }) + }, + supportsImageGeneration: true + }, { id: 'huggingface', name: 'HuggingFace', diff --git a/packages/aiCore/src/core/providers/types.ts b/packages/aiCore/src/core/providers/types.ts index f862f43a75..6f1ec2c405 100644 --- a/packages/aiCore/src/core/providers/types.ts +++ b/packages/aiCore/src/core/providers/types.ts @@ -4,7 +4,7 @@ import { type DeepSeekProviderSettings } from '@ai-sdk/deepseek' import { type GoogleGenerativeAIProviderSettings } from '@ai-sdk/google' import { type OpenAIProviderSettings } from '@ai-sdk/openai' import { type OpenAICompatibleProviderSettings } from '@ai-sdk/openai-compatible' -import { +import type { EmbeddingModelV2 as EmbeddingModel, ImageModelV2 as ImageModel, LanguageModelV2 as LanguageModel, diff --git a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts index bde5779fd9..217319aacc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts @@ -1,4 +1,4 @@ -import { ImageModelV2 } from '@ai-sdk/provider' +import type { ImageModelV2 } from '@ai-sdk/provider' import { experimental_generateImage as aiGenerateImage, NoImageGeneratedError } from 'ai' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/aiCore/src/core/runtime/executor.ts b/packages/aiCore/src/core/runtime/executor.ts index ab764bacd6..85c1fb64de 100644 --- a/packages/aiCore/src/core/runtime/executor.ts +++ b/packages/aiCore/src/core/runtime/executor.ts @@ -2,12 +2,12 @@ * 运行时执行器 * 专注于插件化的AI调用处理 */ -import { ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModel } from 'ai' import { experimental_generateImage as _generateImage, generateObject as _generateObject, generateText as _generateText, - LanguageModel, streamObject as _streamObject, streamText as _streamText } from 'ai' diff --git a/packages/aiCore/src/core/runtime/index.ts b/packages/aiCore/src/core/runtime/index.ts index 37aa4fec34..3dae7d0c2a 100644 --- a/packages/aiCore/src/core/runtime/index.ts +++ b/packages/aiCore/src/core/runtime/index.ts @@ -11,7 +11,7 @@ export type { RuntimeConfig } from './types' // === 便捷工厂函数 === -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' import { type AiPlugin } from '../plugins' import { type ProviderId, type ProviderSettingsMap } from '../providers/types' diff --git a/packages/aiCore/src/core/runtime/pluginEngine.ts b/packages/aiCore/src/core/runtime/pluginEngine.ts index d0100d2bcb..a2fc08b927 100644 --- a/packages/aiCore/src/core/runtime/pluginEngine.ts +++ b/packages/aiCore/src/core/runtime/pluginEngine.ts @@ -1,6 +1,13 @@ /* eslint-disable @eslint-react/naming-convention/context-name */ -import { ImageModelV2 } from '@ai-sdk/provider' -import { experimental_generateImage, generateObject, generateText, LanguageModel, streamObject, streamText } from 'ai' +import type { ImageModelV2 } from '@ai-sdk/provider' +import type { + experimental_generateImage, + generateObject, + generateText, + LanguageModel, + streamObject, + streamText +} from 'ai' import { type AiPlugin, createContext, PluginManager } from '../plugins' import { type ProviderId } from '../providers/types' diff --git a/packages/aiCore/src/core/runtime/types.ts b/packages/aiCore/src/core/runtime/types.ts index fbdcf46333..b95e00be4f 100644 --- a/packages/aiCore/src/core/runtime/types.ts +++ b/packages/aiCore/src/core/runtime/types.ts @@ -1,8 +1,8 @@ /** * Runtime 层类型定义 */ -import { ImageModelV2 } from '@ai-sdk/provider' -import { experimental_generateImage, generateObject, generateText, streamObject, streamText } from 'ai' +import type { ImageModelV2 } from '@ai-sdk/provider' +import type { experimental_generateImage, generateObject, generateText, streamObject, streamText } from 'ai' import { type ModelConfig } from '../models/types' import { type AiPlugin } from '../plugins' diff --git a/packages/extension-table-plus/src/kit/index.ts b/packages/extension-table-plus/src/kit/index.ts index 00221c5bfe..eb0fefb595 100755 --- a/packages/extension-table-plus/src/kit/index.ts +++ b/packages/extension-table-plus/src/kit/index.ts @@ -1,4 +1,5 @@ -import { Extension, Node } from '@tiptap/core' +import type { Node } from '@tiptap/core' +import { Extension } from '@tiptap/core' import type { TableCellOptions } from '../cell/index.js' import { TableCell } from '../cell/index.js' diff --git a/packages/mcp-trace/trace-core/core/spanConvert.ts b/packages/mcp-trace/trace-core/core/spanConvert.ts index a226f5d108..1a5eafff06 100644 --- a/packages/mcp-trace/trace-core/core/spanConvert.ts +++ b/packages/mcp-trace/trace-core/core/spanConvert.ts @@ -1,7 +1,7 @@ import { SpanKind, SpanStatusCode } from '@opentelemetry/api' -import { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' -import { SpanEntity } from '../types/config' +import type { SpanEntity } from '../types/config' /** * convert ReadableSpan to SpanEntity diff --git a/packages/mcp-trace/trace-core/core/traceCache.ts b/packages/mcp-trace/trace-core/core/traceCache.ts index cc5ba795ff..6b181fa3c8 100644 --- a/packages/mcp-trace/trace-core/core/traceCache.ts +++ b/packages/mcp-trace/trace-core/core/traceCache.ts @@ -1,4 +1,4 @@ -import { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' export interface TraceCache { createSpan: (span: ReadableSpan) => void diff --git a/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts b/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts index 48d769daf8..0bc97b82e1 100644 --- a/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts +++ b/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts @@ -1,5 +1,6 @@ -import { ExportResult, ExportResultCode } from '@opentelemetry/core' -import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { ExportResult } from '@opentelemetry/core' +import { ExportResultCode } from '@opentelemetry/core' +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' export type SaveFunction = (spans: ReadableSpan[]) => Promise diff --git a/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts b/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts index b20a61de06..3d3e1a73a3 100644 --- a/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts +++ b/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts @@ -1,7 +1,9 @@ -import { Context, trace } from '@opentelemetry/api' -import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { Context } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' +import type { BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' -import { TraceCache } from '../core/traceCache' +import type { TraceCache } from '../core/traceCache' export class CacheBatchSpanProcessor extends BatchSpanProcessor { private cache: TraceCache diff --git a/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts b/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts index 41015b2082..c09ec352d6 100644 --- a/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts +++ b/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts @@ -1,6 +1,7 @@ -import { Context } from '@opentelemetry/api' -import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' -import { EventEmitter } from 'stream' +import type { Context } from '@opentelemetry/api' +import type { BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { EventEmitter } from 'stream' import { convertSpanToSpanEntity } from '../core/spanConvert' diff --git a/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts b/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts index 8a7281d955..ba88e322d2 100644 --- a/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts +++ b/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts @@ -1,5 +1,7 @@ -import { Context, trace } from '@opentelemetry/api' -import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { Context } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' +import type { BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' export type SpanFunction = (span: ReadableSpan) => void diff --git a/packages/mcp-trace/trace-core/types/config.ts b/packages/mcp-trace/trace-core/types/config.ts index 37f705eb8d..aa86353eae 100644 --- a/packages/mcp-trace/trace-core/types/config.ts +++ b/packages/mcp-trace/trace-core/types/config.ts @@ -1,5 +1,5 @@ -import { Link } from '@opentelemetry/api' -import { TimedEvent } from '@opentelemetry/sdk-trace-base' +import type { Link } from '@opentelemetry/api' +import type { TimedEvent } from '@opentelemetry/sdk-trace-base' export type AttributeValue = | string diff --git a/packages/mcp-trace/trace-node/nodeTracer.ts b/packages/mcp-trace/trace-node/nodeTracer.ts index aee9525010..79fa4c4393 100644 --- a/packages/mcp-trace/trace-node/nodeTracer.ts +++ b/packages/mcp-trace/trace-node/nodeTracer.ts @@ -1,11 +1,14 @@ -import { trace, Tracer } from '@opentelemetry/api' +import type { Tracer } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' -import { BatchSpanProcessor, ConsoleSpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base' import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' -import { defaultConfig, TraceConfig } from '../trace-core/types/config' +import type { TraceConfig } from '../trace-core/types/config' +import { defaultConfig } from '../trace-core/types/config' export class NodeTracer { private static provider: NodeTracerProvider diff --git a/packages/mcp-trace/trace-web/TopicContextManager.ts b/packages/mcp-trace/trace-web/TopicContextManager.ts index a2688fc02f..2962ae868e 100644 --- a/packages/mcp-trace/trace-web/TopicContextManager.ts +++ b/packages/mcp-trace/trace-web/TopicContextManager.ts @@ -1,4 +1,5 @@ -import { Context, ContextManager, ROOT_CONTEXT } from '@opentelemetry/api' +import type { Context, ContextManager } from '@opentelemetry/api' +import { ROOT_CONTEXT } from '@opentelemetry/api' export class TopicContextManager implements ContextManager { private topicContextStack: Map diff --git a/packages/mcp-trace/trace-web/traceContextPromise.ts b/packages/mcp-trace/trace-web/traceContextPromise.ts index ee99722b71..d529c3ca58 100644 --- a/packages/mcp-trace/trace-web/traceContextPromise.ts +++ b/packages/mcp-trace/trace-web/traceContextPromise.ts @@ -1,4 +1,5 @@ -import { Context, context } from '@opentelemetry/api' +import type { Context } from '@opentelemetry/api' +import { context } from '@opentelemetry/api' const originalPromise = globalThis.Promise diff --git a/packages/mcp-trace/trace-web/webTracer.ts b/packages/mcp-trace/trace-web/webTracer.ts index 0b8af5813a..fdf221f3f1 100644 --- a/packages/mcp-trace/trace-web/webTracer.ts +++ b/packages/mcp-trace/trace-web/webTracer.ts @@ -1,9 +1,11 @@ import { W3CTraceContextPropagator } from '@opentelemetry/core' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' -import { BatchSpanProcessor, ConsoleSpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base' import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' -import { defaultConfig, TraceConfig } from '../trace-core/types/config' +import type { TraceConfig } from '../trace-core/types/config' +import { defaultConfig } from '../trace-core/types/config' import { TopicContextManager } from './TopicContextManager' export const contextManager = new TopicContextManager() diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a8421354f4..81e9f02929 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -189,6 +189,7 @@ export enum IpcChannel { Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', File_IsTextFile = 'file:isTextFile', + File_ListDirectory = 'file:listDirectory', File_GetDirectoryStructure = 'file:getDirectoryStructure', File_CheckFileName = 'file:checkFileName', File_ValidateNotesDirectory = 'file:validateNotesDirectory', @@ -322,6 +323,7 @@ export enum IpcChannel { ApiServer_Stop = 'api-server:stop', ApiServer_Restart = 'api-server:restart', ApiServer_GetStatus = 'api-server:get-status', + ApiServer_Ready = 'api-server:ready', // NOTE: This api is not be used. ApiServer_GetConfig = 'api-server:get-config', @@ -363,5 +365,12 @@ export enum IpcChannel { ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed', ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache', ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', - ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content' + ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', + + // WebSocket + WebSocket_Start = 'webSocket:start', + WebSocket_Stop = 'webSocket:stop', + WebSocket_Status = 'webSocket:status', + WebSocket_SendFile = 'webSocket:send-file', + WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' } diff --git a/packages/shared/anthropic/index.ts b/packages/shared/anthropic/index.ts index 777cbd13e8..b9e9cb8846 100644 --- a/packages/shared/anthropic/index.ts +++ b/packages/shared/anthropic/index.ts @@ -9,9 +9,9 @@ */ import Anthropic from '@anthropic-ai/sdk' -import { TextBlockParam } from '@anthropic-ai/sdk/resources' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' -import { Provider } from '@types' +import type { Provider } from '@types' import type { ModelMessage } from 'ai' const logger = loggerService.withContext('anthropic-sdk') diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 3b38592005..9d9240223a 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -470,3 +470,6 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ }) } ] + +// resources/scripts should be maintained manually +export const HOME_CHERRY_DIR = '.cherrystudio' diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 8012ed9022..5c42f1d2b2 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -1,4 +1,4 @@ -import { ProcessingStatus } from '@types' +import type { ProcessingStatus } from '@types' export type LoaderReturn = { entriesAdded: number @@ -31,3 +31,16 @@ export type WebviewKeyEvent = { shift: boolean alt: boolean } + +export interface WebSocketStatusResponse { + isRunning: boolean + port?: number + ip?: string + clientConnected: boolean +} + +export interface WebSocketCandidatesResponse { + host: string + interface: string + priority: number +} diff --git a/resources/database/drizzle/0002_wealthy_naoko.sql b/resources/database/drizzle/0002_wealthy_naoko.sql new file mode 100644 index 0000000000..c369ccf61f --- /dev/null +++ b/resources/database/drizzle/0002_wealthy_naoko.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions` ADD `slash_commands` text; \ No newline at end of file diff --git a/resources/database/drizzle/meta/0002_snapshot.json b/resources/database/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000000..ef5eefcb65 --- /dev/null +++ b/resources/database/drizzle/meta/0002_snapshot.json @@ -0,0 +1,346 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8", + "prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87", + "tables": { + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_messages": { + "name": "session_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migrations": { + "name": "migrations", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slash_commands": { + "name": "slash_commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/resources/database/drizzle/meta/_journal.json b/resources/database/drizzle/meta/_journal.json index 8648e01703..ac026637aa 100644 --- a/resources/database/drizzle/meta/_journal.json +++ b/resources/database/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1758187378775, "tag": "0001_woozy_captain_flint", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1762526423527, + "tag": "0002_wealthy_naoko", + "breakpoints": true } ] } diff --git a/resources/js/bridge.js b/resources/js/bridge.js deleted file mode 100644 index f6c0021a63..0000000000 --- a/resources/js/bridge.js +++ /dev/null @@ -1,36 +0,0 @@ -;(() => { - let messageId = 0 - const pendingCalls = new Map() - - function api(method, ...args) { - const id = messageId++ - return new Promise((resolve, reject) => { - pendingCalls.set(id, { resolve, reject }) - window.parent.postMessage({ id, type: 'api-call', method, args }, '*') - }) - } - - window.addEventListener('message', (event) => { - if (event.data.type === 'api-response') { - const { id, result, error } = event.data - const pendingCall = pendingCalls.get(id) - if (pendingCall) { - if (error) { - pendingCall.reject(new Error(error)) - } else { - pendingCall.resolve(result) - } - pendingCalls.delete(id) - } - } - }) - - window.api = new Proxy( - {}, - { - get: (target, prop) => { - return (...args) => api(prop, ...args) - } - } - ) -})() diff --git a/resources/js/utils.js b/resources/js/utils.js deleted file mode 100644 index 36981ac44f..0000000000 --- a/resources/js/utils.js +++ /dev/null @@ -1,5 +0,0 @@ -export function getQueryParam(paramName) { - const url = new URL(window.location.href) - const params = new URLSearchParams(url.search) - return params.get(paramName) -} diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 1467a4cde4..33ee18d732 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -7,7 +7,7 @@ const { downloadWithRedirects } = require('./download') // Base URL for downloading bun binaries const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download' -const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version +const DEFAULT_BUN_VERSION = '1.3.1' // Default fallback version // Mapping of platform+arch to binary package name const BUN_PACKAGES = { diff --git a/resources/scripts/install-uv.js b/resources/scripts/install-uv.js index 3dc8b3e477..c3d34efc33 100644 --- a/resources/scripts/install-uv.js +++ b/resources/scripts/install-uv.js @@ -7,28 +7,29 @@ const { downloadWithRedirects } = require('./download') // Base URL for downloading uv binaries const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download' -const DEFAULT_UV_VERSION = '0.7.13' +const DEFAULT_UV_VERSION = '0.9.5' // Mapping of platform+arch to binary package name const UV_PACKAGES = { - 'darwin-arm64': 'uv-aarch64-apple-darwin.zip', - 'darwin-x64': 'uv-x86_64-apple-darwin.zip', + 'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', + 'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', 'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip', 'win32-ia32': 'uv-i686-pc-windows-msvc.zip', 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', - 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip', - 'linux-ia32': 'uv-i686-unknown-linux-gnu.zip', - 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip', - 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip', - 'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip', - 'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip', - 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip', + 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz', + 'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz', + 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz', + 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz', + 'linux-riscv64': 'uv-riscv64gc-unknown-linux-gnu.tar.gz', + 'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz', + 'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz', + 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz', // MUSL variants - 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip', - 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip', - 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip', - 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip', - 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip' + 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz', + 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz', + 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz', + 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz', + 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz' } /** @@ -56,6 +57,7 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is const downloadUrl = `${UV_RELEASE_BASE_URL}/${version}/${packageName}` const tempdir = os.tmpdir() const tempFilename = path.join(tempdir, packageName) + const isTarGz = packageName.endsWith('.tar.gz') try { console.log(`Downloading uv ${version} for ${platformKey}...`) @@ -65,34 +67,58 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is console.log(`Extracting ${packageName} to ${binDir}...`) - const zip = new StreamZip.async({ file: tempFilename }) + if (isTarGz) { + // Use tar command to extract tar.gz files (macOS and Linux) + const tempExtractDir = path.join(tempdir, `uv-extract-${Date.now()}`) + fs.mkdirSync(tempExtractDir, { recursive: true }) - // Get all entries in the zip file - const entries = await zip.entries() + execSync(`tar -xzf "${tempFilename}" -C "${tempExtractDir}"`, { stdio: 'inherit' }) - // Extract files directly to binDir, flattening the directory structure - for (const entry of Object.values(entries)) { - if (!entry.isDirectory) { - // Get just the filename without path - const filename = path.basename(entry.name) - const outputPath = path.join(binDir, filename) - - console.log(`Extracting ${entry.name} -> ${filename}`) - await zip.extract(entry.name, outputPath) - // Make executable files executable on Unix-like systems - if (platform !== 'win32') { - try { + // Find all files in the extracted directory and move them to binDir + const findAndMoveFiles = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + findAndMoveFiles(fullPath) + } else { + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) + fs.copyFileSync(fullPath, outputPath) + console.log(`Extracted ${entry.name} -> ${outputPath}`) + // Make executable on Unix-like systems fs.chmodSync(outputPath, 0o755) - } catch (chmodError) { - console.error(`Warning: Failed to set executable permissions on ${filename}`) - return 102 } } - console.log(`Extracted ${entry.name} -> ${outputPath}`) } + + findAndMoveFiles(tempExtractDir) + + // Clean up temporary extraction directory + fs.rmSync(tempExtractDir, { recursive: true }) + } else { + // Use StreamZip for zip files (Windows) + const zip = new StreamZip.async({ file: tempFilename }) + + // Get all entries in the zip file + const entries = await zip.entries() + + // Extract files directly to binDir, flattening the directory structure + for (const entry of Object.values(entries)) { + if (!entry.isDirectory) { + // Get just the filename without path + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) + + console.log(`Extracting ${entry.name} -> ${filename}`) + await zip.extract(entry.name, outputPath) + console.log(`Extracted ${entry.name} -> ${outputPath}`) + } + } + + await zip.close() } - await zip.close() fs.unlinkSync(tempFilename) console.log(`Successfully installed uv ${version} for ${platform}-${arch}`) return 0 diff --git a/resources/scripts/ipService.js b/resources/scripts/ipService.js deleted file mode 100644 index 8e997659a7..0000000000 --- a/resources/scripts/ipService.js +++ /dev/null @@ -1,88 +0,0 @@ -const https = require('https') -const { loggerService } = require('@logger') - -const logger = loggerService.withContext('IpService') - -/** - * 获取用户的IP地址所在国家 - * @returns {Promise} 返回国家代码,默认为'CN' - */ -async function getIpCountry() { - return new Promise((resolve) => { - // 添加超时控制 - const timeout = setTimeout(() => { - logger.info('IP Address Check Timeout, default to China Mirror') - resolve('CN') - }, 5000) - - const options = { - hostname: 'ipinfo.io', - path: '/json', - method: 'GET', - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.9' - } - } - - const req = https.request(options, (res) => { - clearTimeout(timeout) - let data = '' - - res.on('data', (chunk) => { - data += chunk - }) - - res.on('end', () => { - try { - const parsed = JSON.parse(data) - const country = parsed.country || 'CN' - logger.info(`Detected user IP address country: ${country}`) - resolve(country) - } catch (error) { - logger.error('Failed to parse IP address information:', error.message) - resolve('CN') - } - }) - }) - - req.on('error', (error) => { - clearTimeout(timeout) - logger.error('Failed to get IP address information:', error.message) - resolve('CN') - }) - - req.end() - }) -} - -/** - * 检查用户是否在中国 - * @returns {Promise} 如果用户在中国返回true,否则返回false - */ -async function isUserInChina() { - const country = await getIpCountry() - return country.toLowerCase() === 'cn' -} - -/** - * 根据用户位置获取适合的npm镜像URL - * @returns {Promise} 返回npm镜像URL - */ -async function getNpmRegistryUrl() { - const inChina = await isUserInChina() - if (inChina) { - logger.info('User in China, using Taobao npm mirror') - return 'https://registry.npmmirror.com' - } else { - logger.info('User not in China, using default npm mirror') - return 'https://registry.npmjs.org' - } -} - -module.exports = { - getIpCountry, - isUserInChina, - getNpmRegistryUrl -} diff --git a/scripts/auto-translate-i18n.ts b/scripts/auto-translate-i18n.ts index 71650f6618..7a1bea6f35 100644 --- a/scripts/auto-translate-i18n.ts +++ b/scripts/auto-translate-i18n.ts @@ -18,8 +18,10 @@ import { sortedObjectByKeys } from './sort' // ========== SCRIPT CONFIGURATION AREA - MODIFY SETTINGS HERE ========== const SCRIPT_CONFIG = { // 🔧 Concurrency Control Configuration - MAX_CONCURRENT_TRANSLATIONS: 5, // Max concurrent requests (Make sure the concurrency level does not exceed your provider's limits.) - TRANSLATION_DELAY_MS: 100, // Delay between requests to avoid rate limiting (Recommended: 100-500ms, Range: 0-5000ms) + MAX_CONCURRENT_TRANSLATIONS: process.env.TRANSLATION_MAX_CONCURRENT_REQUESTS + ? parseInt(process.env.TRANSLATION_MAX_CONCURRENT_REQUESTS) + : 5, // Max concurrent requests (Make sure the concurrency level does not exceed your provider's limits.) + TRANSLATION_DELAY_MS: process.env.TRANSLATION_DELAY_MS ? parseInt(process.env.TRANSLATION_DELAY_MS) : 500, // Delay between requests to avoid rate limiting (Recommended: 100-500ms, Range: 0-5000ms) // 🔑 API Configuration API_KEY: process.env.TRANSLATION_API_KEY || '', // API key from environment variable diff --git a/src/main/apiServer/config.ts b/src/main/apiServer/config.ts index c962726d24..60b1986be9 100644 --- a/src/main/apiServer/config.ts +++ b/src/main/apiServer/config.ts @@ -1,4 +1,4 @@ -import { ApiServerConfig } from '@types' +import type { ApiServerConfig } from '@types' import { v4 as uuidv4 } from 'uuid' import { loggerService } from '../services/LoggerService' diff --git a/src/main/apiServer/middleware/auth.ts b/src/main/apiServer/middleware/auth.ts index 93548c4a50..bf44e4eb37 100644 --- a/src/main/apiServer/middleware/auth.ts +++ b/src/main/apiServer/middleware/auth.ts @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { NextFunction, Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { config } from '../config' diff --git a/src/main/apiServer/middleware/error.ts b/src/main/apiServer/middleware/error.ts index 401a8cad84..03c2d5617e 100644 --- a/src/main/apiServer/middleware/error.ts +++ b/src/main/apiServer/middleware/error.ts @@ -1,4 +1,4 @@ -import { NextFunction, Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { loggerService } from '../../services/LoggerService' diff --git a/src/main/apiServer/middleware/openapi.ts b/src/main/apiServer/middleware/openapi.ts index da3cfe0c4c..ff01005bd9 100644 --- a/src/main/apiServer/middleware/openapi.ts +++ b/src/main/apiServer/middleware/openapi.ts @@ -1,4 +1,4 @@ -import { Express } from 'express' +import type { Express } from 'express' import swaggerJSDoc from 'swagger-jsdoc' import swaggerUi from 'swagger-ui-express' @@ -171,7 +171,7 @@ const swaggerOptions: swaggerJSDoc.Options = { } ] }, - apis: ['./src/main/apiServer/routes/*.ts', './src/main/apiServer/app.ts'] + apis: ['./src/main/apiServer/routes/**/*.ts', './src/main/apiServer/app.ts'] } export function setupOpenAPIDocumentation(app: Express) { diff --git a/src/main/apiServer/routes/agents/handlers/agents.ts b/src/main/apiServer/routes/agents/handlers/agents.ts index d6f31a555d..53e5f9433e 100644 --- a/src/main/apiServer/routes/agents/handlers/agents.ts +++ b/src/main/apiServer/routes/agents/handlers/agents.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { AgentModelValidationError, agentService, sessionService } from '@main/services/agents' -import { ListAgentsResponse, type ReplaceAgentRequest, type UpdateAgentRequest } from '@types' -import { Request, Response } from 'express' +import type { ListAgentsResponse } from '@types' +import { type ReplaceAgentRequest, type UpdateAgentRequest } from '@types' +import type { Request, Response } from 'express' import type { ValidationRequest } from '../validators/zodValidator' diff --git a/src/main/apiServer/routes/agents/handlers/messages.ts b/src/main/apiServer/routes/agents/handlers/messages.ts index e18fadc0e0..1b547abba8 100644 --- a/src/main/apiServer/routes/agents/handlers/messages.ts +++ b/src/main/apiServer/routes/agents/handlers/messages.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts' import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController' import { agentService, sessionMessageService, sessionService } from '@main/services/agents' -import { Request, Response } from 'express' +import type { Request, Response } from 'express' const logger = loggerService.withContext('ApiServerMessagesHandlers') diff --git a/src/main/apiServer/routes/agents/handlers/sessions.ts b/src/main/apiServer/routes/agents/handlers/sessions.ts index 72875dab8a..efd5d82573 100644 --- a/src/main/apiServer/routes/agents/handlers/sessions.ts +++ b/src/main/apiServer/routes/agents/handlers/sessions.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { AgentModelValidationError, sessionMessageService, sessionService } from '@main/services/agents' -import { ListAgentSessionsResponse, type ReplaceSessionRequest, UpdateSessionResponse } from '@types' -import { Request, Response } from 'express' +import type { ListAgentSessionsResponse, UpdateSessionResponse } from '@types' +import { type ReplaceSessionRequest } from '@types' +import type { Request, Response } from 'express' import type { ValidationRequest } from '../validators/zodValidator' diff --git a/src/main/apiServer/routes/agents/middleware/common.ts b/src/main/apiServer/routes/agents/middleware/common.ts index d45f197e4a..7ca5db8baf 100644 --- a/src/main/apiServer/routes/agents/middleware/common.ts +++ b/src/main/apiServer/routes/agents/middleware/common.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express' +import type { Request, Response } from 'express' import { agentService } from '../../../../services/agents' import { loggerService } from '../../../../services/LoggerService' diff --git a/src/main/apiServer/routes/agents/validators/zodValidator.ts b/src/main/apiServer/routes/agents/validators/zodValidator.ts index 1a0e83786a..971c1445ed 100644 --- a/src/main/apiServer/routes/agents/validators/zodValidator.ts +++ b/src/main/apiServer/routes/agents/validators/zodValidator.ts @@ -1,5 +1,6 @@ -import { NextFunction, Request, Response } from 'express' -import { ZodError, ZodType } from 'zod' +import type { NextFunction, Request, Response } from 'express' +import type { ZodType } from 'zod' +import { ZodError } from 'zod' export interface ValidationRequest extends Request { validatedBody?: any diff --git a/src/main/apiServer/routes/chat.ts b/src/main/apiServer/routes/chat.ts index baf29aae08..3dd58b9654 100644 --- a/src/main/apiServer/routes/chat.ts +++ b/src/main/apiServer/routes/chat.ts @@ -1,5 +1,6 @@ -import { ChatCompletionCreateParams } from '@cherrystudio/openai/resources' -import express, { Request, Response } from 'express' +import type { ChatCompletionCreateParams } from '@cherrystudio/openai/resources' +import type { Request, Response } from 'express' +import express from 'express' import { loggerService } from '../../services/LoggerService' import { diff --git a/src/main/apiServer/routes/mcp.ts b/src/main/apiServer/routes/mcp.ts index 6474194712..90626af158 100644 --- a/src/main/apiServer/routes/mcp.ts +++ b/src/main/apiServer/routes/mcp.ts @@ -1,4 +1,5 @@ -import express, { Request, Response } from 'express' +import type { Request, Response } from 'express' +import express from 'express' import { loggerService } from '../../services/LoggerService' import { mcpApiService } from '../services/mcp' diff --git a/src/main/apiServer/routes/messages.ts b/src/main/apiServer/routes/messages.ts index 3b7c338199..02ce0544e8 100644 --- a/src/main/apiServer/routes/messages.ts +++ b/src/main/apiServer/routes/messages.ts @@ -1,7 +1,8 @@ -import { MessageCreateParams } from '@anthropic-ai/sdk/resources' +import type { MessageCreateParams } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' -import { Provider } from '@types' -import express, { Request, Response } from 'express' +import type { Provider } from '@types' +import type { Request, Response } from 'express' +import express from 'express' import { messagesService } from '../services/messages' import { getProviderById, validateModelId } from '../utils' diff --git a/src/main/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts index d926a23523..8481e1ea59 100644 --- a/src/main/apiServer/routes/models.ts +++ b/src/main/apiServer/routes/models.ts @@ -1,5 +1,7 @@ -import { ApiModelsFilterSchema, ApiModelsResponse } from '@types' -import express, { Request, Response } from 'express' +import type { ApiModelsResponse } from '@types' +import { ApiModelsFilterSchema } from '@types' +import type { Request, Response } from 'express' +import express from 'express' import { loggerService } from '../../services/LoggerService' import { modelsService } from '../services/models' diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts index 3cb81f4124..9b15e56da0 100644 --- a/src/main/apiServer/server.ts +++ b/src/main/apiServer/server.ts @@ -1,8 +1,10 @@ import { createServer } from 'node:http' import { loggerService } from '@logger' +import { IpcChannel } from '@shared/IpcChannel' import { agentService } from '../services/agents' +import { windowService } from '../services/WindowService' import { app } from './app' import { config } from './config' @@ -43,6 +45,13 @@ export class ApiServer { return new Promise((resolve, reject) => { this.server!.listen(port, host, () => { logger.info('API server started', { host, port }) + + // Notify renderer that API server is ready + const mainWindow = windowService.getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IpcChannel.ApiServer_Ready) + } + resolve() }) diff --git a/src/main/apiServer/services/chat-completion.ts b/src/main/apiServer/services/chat-completion.ts index 63eed3ed8b..a7c6160e81 100644 --- a/src/main/apiServer/services/chat-completion.ts +++ b/src/main/apiServer/services/chat-completion.ts @@ -1,9 +1,10 @@ import OpenAI from '@cherrystudio/openai' -import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources' -import { Provider } from '@types' +import type { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources' +import type { Provider } from '@types' import { loggerService } from '../../services/LoggerService' -import { ModelValidationError, validateModelId } from '../utils' +import type { ModelValidationError } from '../utils' +import { validateModelId } from '../utils' const logger = loggerService.withContext('ChatCompletionService') diff --git a/src/main/apiServer/services/mcp.ts b/src/main/apiServer/services/mcp.ts index c03a90f930..d75fadee6c 100644 --- a/src/main/apiServer/services/mcp.ts +++ b/src/main/apiServer/services/mcp.ts @@ -1,16 +1,12 @@ import mcpService from '@main/services/MCPService' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp' -import { - isJSONRPCRequest, - JSONRPCMessage, - JSONRPCMessageSchema, - MessageExtraInfo -} from '@modelcontextprotocol/sdk/types' -import { MCPServer } from '@types' +import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types' +import { isJSONRPCRequest, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types' +import type { MCPServer } from '@types' import { randomUUID } from 'crypto' import { EventEmitter } from 'events' -import { Request, Response } from 'express' -import { IncomingMessage, ServerResponse } from 'http' +import type { Request, Response } from 'express' +import type { IncomingMessage, ServerResponse } from 'http' import { loggerService } from '../../services/LoggerService' import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp' diff --git a/src/main/apiServer/services/messages.ts b/src/main/apiServer/services/messages.ts index edce9a9528..8b46deaa8f 100644 --- a/src/main/apiServer/services/messages.ts +++ b/src/main/apiServer/services/messages.ts @@ -1,10 +1,10 @@ -import Anthropic from '@anthropic-ai/sdk' -import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources' +import type Anthropic from '@anthropic-ai/sdk' +import type { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' import anthropicService from '@main/services/AnthropicService' import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic' -import { Provider } from '@types' -import { Response } from 'express' +import type { Provider } from '@types' +import type { Response } from 'express' const logger = loggerService.withContext('MessagesService') const EXCLUDED_FORWARD_HEADERS: ReadonlySet = new Set([ diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index 660686ef45..a32d6d37dc 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -1,6 +1,6 @@ import { isEmpty } from 'lodash' -import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' +import type { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' import { loggerService } from '../../services/LoggerService' import { getAvailableProviders, diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index 7fb0c3511f..f9f751c559 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,7 +1,7 @@ import { CacheService } from '@main/services/CacheService' import { loggerService } from '@main/services/LoggerService' import { reduxService } from '@main/services/ReduxService' -import { ApiModel, Model, Provider } from '@types' +import type { ApiModel, Model, Provider } from '@types' const logger = loggerService.withContext('ApiServerUtils') diff --git a/src/main/apiServer/utils/mcp.ts b/src/main/apiServer/utils/mcp.ts index 40a9006528..f110df5847 100644 --- a/src/main/apiServer/utils/mcp.ts +++ b/src/main/apiServer/utils/mcp.ts @@ -1,8 +1,9 @@ import { CacheService } from '@main/services/CacheService' import mcpService from '@main/services/MCPService' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js' -import { MCPServer } from '@types' +import type { ListToolsResult } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import type { MCPServer } from '@types' import { loggerService } from '../../services/LoggerService' import { reduxService } from '../../services/ReduxService' diff --git a/src/main/index.ts b/src/main/index.ts index d9554e1652..025268b651 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -21,6 +21,7 @@ import { appMenuService } from './services/AppMenuService' import { configManager } from './services/ConfigManager' import mcpService from './services/MCPService' import { nodeTraceService } from './services/NodeTraceService' +import powerMonitorService from './services/PowerMonitorService' import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, @@ -30,6 +31,7 @@ import { import selectionService, { initSelectionService } from './services/SelectionService' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' +import { versionService } from './services/VersionService' import { windowService } from './services/WindowService' import { initWebviewHotkeys } from './services/WebviewService' @@ -110,6 +112,10 @@ if (!app.requestSingleInstanceLock()) { // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { + // Record current version for tracking + // A preparation for v2 data refactoring + versionService.recordCurrentVersion() + initWebviewHotkeys() // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') @@ -127,6 +133,7 @@ if (!app.requestSingleInstanceLock()) { appMenuService?.setupApplicationMenu() nodeTraceService.init() + powerMonitorService.init() app.on('activate', function () { const mainWindow = windowService.getMainWindow() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cf0892f935..fd75d8c7ea 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -8,11 +8,12 @@ import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' -import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' -import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' +import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' +import type { UpgradeChannel } from '@shared/config/constant' +import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import type { PluginError } from '@types' -import { +import type { AgentPersistedMessage, FileMetadata, Notification, @@ -23,10 +24,12 @@ import { ThemeMode } from '@types' import checkDiskSpace from 'check-disk-space' -import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' +import type { ProxyConfig } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron' import fontList from 'font-list' import { agentMessageRepository } from './services/agents/database' +import { PluginService } from './services/agents/plugins/PluginService' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' @@ -47,7 +50,7 @@ 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 powerMonitorService from './services/PowerMonitorService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' @@ -70,6 +73,7 @@ import { import storeSyncService from './services/StoreSyncService' import { themeService } from './services/ThemeService' import VertexAIService from './services/VertexAIService' +import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -112,8 +116,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater() const notificationService = new NotificationService() - // Initialize Python service with main window - pythonService.setMainWindow(mainWindow) + // Register shutdown handlers + powerMonitorService.registerShutdownHandler(() => { + appUpdater.setAutoUpdate(false) + }) + + powerMonitorService.registerShutdownHandler(() => { + const mw = windowService.getMainWindow() + if (mw && !mw.isDestroyed()) { + mw.webContents.send(IpcChannel.App_SaveData) + } + }) const checkMainWindow = () => { if (!mainWindow || mainWindow.isDestroyed()) { @@ -538,6 +551,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager)) ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager)) ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager)) ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) @@ -1017,4 +1031,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return { success: false, error } } }) + + // WebSocket + ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) + ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) + ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) + ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) + ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) } diff --git a/src/main/knowledge/embedjs/embeddings/Embeddings.ts b/src/main/knowledge/embedjs/embeddings/Embeddings.ts index 17bb8ff470..3f5b6ced15 100644 --- a/src/main/knowledge/embedjs/embeddings/Embeddings.ts +++ b/src/main/knowledge/embedjs/embeddings/Embeddings.ts @@ -1,6 +1,6 @@ import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import { TraceMethod } from '@mcp-trace/trace-core' -import { ApiClient } from '@types' +import type { ApiClient } from '@types' import EmbeddingsFactory from './EmbeddingsFactory' diff --git a/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts b/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts index f1a2559af7..8a780d5618 100644 --- a/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts +++ b/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts @@ -1,7 +1,8 @@ import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama' import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai' -import { ApiClient } from '@types' +import type { ApiClient } from '@types' +import { net } from 'electron' import { VoyageEmbeddings } from './VoyageEmbeddings' @@ -43,7 +44,7 @@ export default class EmbeddingsFactory { apiKey, dimensions, batchSize, - configuration: { baseURL } + configuration: { baseURL, fetch: net.fetch as typeof fetch } }) } } diff --git a/src/main/knowledge/embedjs/loader/index.ts b/src/main/knowledge/embedjs/loader/index.ts index 4b38418194..9f1efd268c 100644 --- a/src/main/knowledge/embedjs/loader/index.ts +++ b/src/main/knowledge/embedjs/loader/index.ts @@ -1,10 +1,11 @@ -import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs' +import type { RAGApplication } from '@cherrystudio/embedjs' +import { JsonLoader, LocalPathLoader, TextLoader } from '@cherrystudio/embedjs' import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces' import { WebLoader } from '@cherrystudio/embedjs-loader-web' import { loggerService } from '@logger' import { readTextFileWithAutoEncoding } from '@main/utils/file' -import { LoaderReturn } from '@shared/config/types' -import { FileMetadata, KnowledgeBaseParams } from '@types' +import type { LoaderReturn } from '@shared/config/types' +import type { FileMetadata, KnowledgeBaseParams } from '@types' import { DraftsExportLoader } from './draftsExportLoader' import { EpubLoader } from './epubLoader' diff --git a/src/main/knowledge/embedjs/loader/odLoader.ts b/src/main/knowledge/embedjs/loader/odLoader.ts index 03825bf4db..7e325966af 100644 --- a/src/main/knowledge/embedjs/loader/odLoader.ts +++ b/src/main/knowledge/embedjs/loader/odLoader.ts @@ -3,7 +3,8 @@ import { cleanString } from '@cherrystudio/embedjs-utils' import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters' import { loggerService } from '@logger' import md5 from 'md5' -import { OfficeParserConfig, parseOfficeAsync } from 'officeparser' +import type { OfficeParserConfig } from 'officeparser' +import { parseOfficeAsync } from 'officeparser' const logger = loggerService.withContext('OdLoader') diff --git a/src/main/knowledge/preprocess/BasePreprocessProvider.ts b/src/main/knowledge/preprocess/BasePreprocessProvider.ts index daf9901498..ed4a5fb8d4 100644 --- a/src/main/knowledge/preprocess/BasePreprocessProvider.ts +++ b/src/main/knowledge/preprocess/BasePreprocessProvider.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { windowService } from '@main/services/WindowService' import { getFileExt, getTempDir } from '@main/utils/file' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import { PDFDocument } from 'pdf-lib' const logger = loggerService.withContext('BasePreprocessProvider') diff --git a/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts b/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts index 3899a3d25a..64169d935e 100644 --- a/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts @@ -1,4 +1,4 @@ -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import BasePreprocessProvider from './BasePreprocessProvider' diff --git a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts index 6708e8f938..4e2fb09609 100644 --- a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' import { net } from 'electron' diff --git a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts index 1976f64c05..7a5362a116 100644 --- a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' import { net } from 'electron' @@ -275,15 +275,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { try { const fileBuffer = await fs.promises.readFile(filePath) + // https://mineru.net/apiManage/docs const response = await net.fetch(uploadUrl, { method: 'PUT', - body: fileBuffer, - headers: { - 'Content-Type': 'application/pdf' - } - // headers: { - // 'Content-Length': fileBuffer.length.toString() - // } + body: fileBuffer }) if (!response.ok) { diff --git a/src/main/knowledge/preprocess/MistralPreprocessProvider.ts b/src/main/knowledge/preprocess/MistralPreprocessProvider.ts index d5ad3d4e14..a8f7b350ee 100644 --- a/src/main/knowledge/preprocess/MistralPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/MistralPreprocessProvider.ts @@ -4,11 +4,12 @@ import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' import { MistralClientManager } from '@main/services/MistralClientManager' import { MistralService } from '@main/services/remotefile/MistralService' -import { Mistral } from '@mistralai/mistralai' -import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk' -import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk' -import { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse' -import { FileMetadata, FileTypes, PreprocessProvider, Provider } from '@types' +import type { Mistral } from '@mistralai/mistralai' +import type { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk' +import type { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk' +import type { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse' +import type { FileMetadata, PreprocessProvider, Provider } from '@types' +import { FileTypes } from '@types' import path from 'path' import BasePreprocessProvider from './BasePreprocessProvider' diff --git a/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts b/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts index 4225b87003..9a3bca65a1 100644 --- a/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' import { net } from 'electron' import FormData from 'form-data' diff --git a/src/main/knowledge/preprocess/PreprocessProvider.ts b/src/main/knowledge/preprocess/PreprocessProvider.ts index 44a34f64ae..f0b3d8f12e 100644 --- a/src/main/knowledge/preprocess/PreprocessProvider.ts +++ b/src/main/knowledge/preprocess/PreprocessProvider.ts @@ -1,6 +1,6 @@ -import { FileMetadata, PreprocessProvider as Provider } from '@types' +import type { FileMetadata, PreprocessProvider as Provider } from '@types' -import BasePreprocessProvider from './BasePreprocessProvider' +import type BasePreprocessProvider from './BasePreprocessProvider' import PreprocessProviderFactory from './PreprocessProviderFactory' export default class PreprocessProvider { diff --git a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts index e824601749..94d4e70d5a 100644 --- a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts +++ b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts @@ -1,6 +1,6 @@ -import { PreprocessProvider } from '@types' +import type { PreprocessProvider } from '@types' -import BasePreprocessProvider from './BasePreprocessProvider' +import type BasePreprocessProvider from './BasePreprocessProvider' import DefaultPreprocessProvider from './DefaultPreprocessProvider' import Doc2xPreprocessProvider from './Doc2xPreprocessProvider' import MineruPreprocessProvider from './MineruPreprocessProvider' diff --git a/src/main/knowledge/reranker/BaseReranker.ts b/src/main/knowledge/reranker/BaseReranker.ts index 1e321e2d86..ddf5a089c4 100644 --- a/src/main/knowledge/reranker/BaseReranker.ts +++ b/src/main/knowledge/reranker/BaseReranker.ts @@ -1,7 +1,7 @@ import { DEFAULT_DOCUMENT_COUNT, DEFAULT_RELEVANT_SCORE } from '@main/utils/knowledge' -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import type { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' -import { MultiModalDocument, RerankStrategy } from './strategies/RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './strategies/RerankStrategy' import { StrategyFactory } from './strategies/StrategyFactory' export default abstract class BaseReranker { diff --git a/src/main/knowledge/reranker/GeneralReranker.ts b/src/main/knowledge/reranker/GeneralReranker.ts index e3ac5e8c21..96beffbb4c 100644 --- a/src/main/knowledge/reranker/GeneralReranker.ts +++ b/src/main/knowledge/reranker/GeneralReranker.ts @@ -1,4 +1,4 @@ -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import type { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' import { net } from 'electron' import BaseReranker from './BaseReranker' diff --git a/src/main/knowledge/reranker/Reranker.ts b/src/main/knowledge/reranker/Reranker.ts index 59de4b0470..26cb61f6b9 100644 --- a/src/main/knowledge/reranker/Reranker.ts +++ b/src/main/knowledge/reranker/Reranker.ts @@ -1,4 +1,4 @@ -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import type { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' import GeneralReranker from './GeneralReranker' diff --git a/src/main/knowledge/reranker/strategies/BailianStrategy.ts b/src/main/knowledge/reranker/strategies/BailianStrategy.ts index e5932b9f40..8953bfe859 100644 --- a/src/main/knowledge/reranker/strategies/BailianStrategy.ts +++ b/src/main/knowledge/reranker/strategies/BailianStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class BailianStrategy implements RerankStrategy { buildUrl(): string { return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' diff --git a/src/main/knowledge/reranker/strategies/DefaultStrategy.ts b/src/main/knowledge/reranker/strategies/DefaultStrategy.ts index 59ee3fb47b..9bd8e69f73 100644 --- a/src/main/knowledge/reranker/strategies/DefaultStrategy.ts +++ b/src/main/knowledge/reranker/strategies/DefaultStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class DefaultStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/knowledge/reranker/strategies/JinaStrategy.ts b/src/main/knowledge/reranker/strategies/JinaStrategy.ts index 200190f544..b348f2f9bd 100644 --- a/src/main/knowledge/reranker/strategies/JinaStrategy.ts +++ b/src/main/knowledge/reranker/strategies/JinaStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class JinaStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/knowledge/reranker/strategies/StrategyFactory.ts b/src/main/knowledge/reranker/strategies/StrategyFactory.ts index 9e04547e31..e0e782f722 100644 --- a/src/main/knowledge/reranker/strategies/StrategyFactory.ts +++ b/src/main/knowledge/reranker/strategies/StrategyFactory.ts @@ -1,7 +1,7 @@ import { BailianStrategy } from './BailianStrategy' import { DefaultStrategy } from './DefaultStrategy' import { JinaStrategy } from './JinaStrategy' -import { RerankStrategy } from './RerankStrategy' +import type { RerankStrategy } from './RerankStrategy' import { TEIStrategy } from './TeiStrategy' import { isTEIProvider, RERANKER_PROVIDERS } from './types' import { VoyageAIStrategy } from './VoyageStrategy' diff --git a/src/main/knowledge/reranker/strategies/TeiStrategy.ts b/src/main/knowledge/reranker/strategies/TeiStrategy.ts index 58f24661ba..df92f1ea5b 100644 --- a/src/main/knowledge/reranker/strategies/TeiStrategy.ts +++ b/src/main/knowledge/reranker/strategies/TeiStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class TEIStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/knowledge/reranker/strategies/VoyageStrategy.ts b/src/main/knowledge/reranker/strategies/VoyageStrategy.ts index e81319f024..2bd13f85e3 100644 --- a/src/main/knowledge/reranker/strategies/VoyageStrategy.ts +++ b/src/main/knowledge/reranker/strategies/VoyageStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class VoyageAIStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/mcpServers/brave-search.ts b/src/main/mcpServers/brave-search.ts index d11a4f2580..7ba6bb65fa 100644 --- a/src/main/mcpServers/brave-search.ts +++ b/src/main/mcpServers/brave-search.ts @@ -2,7 +2,8 @@ // port https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search/index.ts import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { net } from 'electron' const WEB_SEARCH_TOOL: Tool = { diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 74266d7d35..2323701e49 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types' +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import type { BuiltinMCPServerName } from '@types' +import { BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' import DiDiMcpServer from './didi-mcp' diff --git a/src/main/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts index 90c1c329d5..485e0279e7 100644 --- a/src/main/mcpServers/sequentialthinking.ts +++ b/src/main/mcpServers/sequentialthinking.ts @@ -3,7 +3,8 @@ import { loggerService } from '@logger' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' // Fixed chalk import for ESM import chalk from 'chalk' diff --git a/src/main/services/ApiServerService.ts b/src/main/services/ApiServerService.ts index 76a5eca617..40452b6d19 100644 --- a/src/main/services/ApiServerService.ts +++ b/src/main/services/ApiServerService.ts @@ -1,5 +1,5 @@ import { IpcChannel } from '@shared/IpcChannel' -import { +import type { ApiServerConfig, GetApiServerStatusResult, RestartApiServerStatusResult, diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 7492516507..b3a9de01d0 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -2,20 +2,38 @@ import { isMac } from '@main/constant' import { windowService } from '@main/services/WindowService' import { locales } from '@main/utils/locales' import { IpcChannel } from '@shared/IpcChannel' -import { app, Menu, MenuItemConstructorOptions, shell } from 'electron' +import type { MenuItemConstructorOptions } from 'electron' +import { app, Menu, shell } from 'electron' import { configManager } from './ConfigManager' export class AppMenuService { + private languageChangeCallback?: (newLanguage: string) => void + + constructor() { + // Subscribe to language change events + this.languageChangeCallback = () => { + this.setupApplicationMenu() + } + configManager.subscribe('language', this.languageChangeCallback) + } + + public destroy(): void { + // Clean up subscription to prevent memory leaks + if (this.languageChangeCallback) { + configManager.unsubscribe('language', this.languageChangeCallback) + } + } + public setupApplicationMenu(): void { const locale = locales[configManager.getLanguage()] - const { common } = locale.translation + const { appMenu } = locale.translation const template: MenuItemConstructorOptions[] = [ { label: app.name, submenu: [ { - label: common.about + ' ' + app.name, + label: appMenu.about + ' ' + app.name, click: () => { // Emit event to navigate to About page const mainWindow = windowService.getMainWindow() @@ -26,50 +44,78 @@ export class AppMenuService { } }, { type: 'separator' }, - { role: 'services' }, + { role: 'services', label: appMenu.services }, { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, + { role: 'hide', label: `${appMenu.hide} ${app.name}` }, + { role: 'hideOthers', label: appMenu.hideOthers }, + { role: 'unhide', label: appMenu.unhide }, { type: 'separator' }, - { role: 'quit' } + { role: 'quit', label: `${appMenu.quit} ${app.name}` } ] }, { - role: 'fileMenu' + label: appMenu.file, + submenu: [{ role: 'close', label: appMenu.close }] }, { - role: 'editMenu' + label: appMenu.edit, + submenu: [ + { role: 'undo', label: appMenu.undo }, + { role: 'redo', label: appMenu.redo }, + { type: 'separator' }, + { role: 'cut', label: appMenu.cut }, + { role: 'copy', label: appMenu.copy }, + { role: 'paste', label: appMenu.paste }, + { role: 'delete', label: appMenu.delete }, + { role: 'selectAll', label: appMenu.selectAll } + ] }, { - role: 'viewMenu' + label: appMenu.view, + submenu: [ + { role: 'reload', label: appMenu.reload }, + { role: 'forceReload', label: appMenu.forceReload }, + { role: 'toggleDevTools', label: appMenu.toggleDevTools }, + { type: 'separator' }, + { role: 'resetZoom', label: appMenu.resetZoom }, + { role: 'zoomIn', label: appMenu.zoomIn }, + { role: 'zoomOut', label: appMenu.zoomOut }, + { type: 'separator' }, + { role: 'togglefullscreen', label: appMenu.toggleFullscreen } + ] }, { - role: 'windowMenu' + label: appMenu.window, + submenu: [ + { role: 'minimize', label: appMenu.minimize }, + { role: 'zoom', label: appMenu.zoom }, + { type: 'separator' }, + { role: 'front', label: appMenu.front } + ] }, { - role: 'help', + label: appMenu.help, submenu: [ { - label: 'Website', + label: appMenu.website, click: () => { shell.openExternal('https://cherry-ai.com') } }, { - label: 'Documentation', + label: appMenu.documentation, click: () => { shell.openExternal('https://cherry-ai.com/docs') } }, { - label: 'Feedback', + label: appMenu.feedback, click: () => { shell.openExternal('https://github.com/CherryHQ/cherry-studio/issues/new/choose') } }, { - label: 'Releases', + label: appMenu.releases, click: () => { shell.openExternal('https://github.com/CherryHQ/cherry-studio/releases') } diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index ec0f1b97e0..168084bd32 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -4,9 +4,11 @@ import { getIpCountry } from '@main/utils/ipService' import { generateUserAgent } from '@main/utils/systemInfo' import { FeedUrl, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { CancellationToken, UpdateInfo } from 'builder-util-runtime' +import type { UpdateInfo } from 'builder-util-runtime' +import { CancellationToken } from 'builder-util-runtime' import { app, net } from 'electron' -import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' +import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' +import { autoUpdater } from 'electron-updater' import path from 'path' import semver from 'semver' diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index c6d3ee1841..f331254fdf 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,14 +1,14 @@ import { loggerService } from '@logger' import { IpcChannel } from '@shared/IpcChannel' -import { WebDavConfig } from '@types' -import { S3Config } from '@types' +import type { WebDavConfig } from '@types' +import type { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' import * as fs from 'fs-extra' import StreamZip from 'node-stream-zip' import * as path from 'path' -import { CreateDirectoryOptions, FileStat } from 'webdav' +import type { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' import S3Storage from './S3Storage' diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index d6eea8a9e6..82c9c64f87 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -7,13 +7,13 @@ import { isMac, isWin } from '@main/constant' import { removeEnvProxy } from '@main/utils' import { isUserInChina } from '@main/utils/ipService' import { getBinaryName } from '@main/utils/process' +import type { TerminalConfig, TerminalConfigWithCommand } from '@shared/config/constant' import { codeTools, + HOME_CHERRY_DIR, MACOS_TERMINALS, MACOS_TERMINALS_WITH_COMMANDS, terminalApps, - TerminalConfig, - TerminalConfigWithCommand, WINDOWS_TERMINALS, WINDOWS_TERMINALS_WITH_COMMANDS } from '@shared/config/constant' @@ -67,7 +67,7 @@ class CodeToolsService { } public async getBunPath() { - const dir = path.join(os.homedir(), '.cherrystudio', 'bin') + const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const bunName = await getBinaryName('bun') const bunPath = path.join(dir, bunName) return bunPath @@ -363,7 +363,7 @@ class CodeToolsService { private async isPackageInstalled(cliTool: string): Promise { const executableName = await this.getCliExecutableName(cliTool) - const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) // Ensure bin directory exists @@ -390,7 +390,7 @@ class CodeToolsService { logger.info(`${cliTool} is installed, getting current version`) try { const executableName = await this.getCliExecutableName(cliTool) - const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) const { stdout } = await execAsync(`"${executablePath}" --version`, { @@ -501,7 +501,7 @@ class CodeToolsService { try { const packageName = await this.getPackageName(cliTool) const bunPath = await this.getBunPath() - const bunInstallPath = path.join(os.homedir(), '.cherrystudio') + const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR) const registryUrl = await this.getNpmRegistryUrl() const installEnvPrefix = isWin @@ -551,7 +551,7 @@ class CodeToolsService { const packageName = await this.getPackageName(cliTool) const bunPath = await this.getBunPath() const executableName = await this.getCliExecutableName(cliTool) - const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) logger.debug(`Package name: ${packageName}`) @@ -653,7 +653,7 @@ class CodeToolsService { baseCommand = `${baseCommand} ${configParams}` } - const bunInstallPath = path.join(os.homedir(), '.cherrystudio') + const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR) if (isInstalled) { // If already installed, run executable directly (with optional update message) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 3cab0bf91d..61e285ac1b 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -1,5 +1,7 @@ -import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant' -import { LanguageVarious, Shortcut, ThemeMode } from '@types' +import type { UpgradeChannel } from '@shared/config/constant' +import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant' +import type { LanguageVarious, Shortcut } from '@types' +import { ThemeMode } from '@types' import { app } from 'electron' import Store from 'electron-store' import { v4 as uuidv4 } from 'uuid' diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 411d6e075d..e735a2501f 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -1,4 +1,5 @@ -import { Menu, MenuItemConstructorOptions } from 'electron' +import type { MenuItemConstructorOptions } from 'electron' +import { Menu } from 'electron' import { locales } from '../utils/locales' import { configManager } from './ConfigManager' diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2244b97cb0..3165fcf27e 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -10,19 +10,14 @@ import { scanDir } from '@main/utils/file' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' -import { FileMetadata, NotesTreeNode } from '@types' +import type { FileMetadata, NotesTreeNode } from '@types' import chardet from 'chardet' -import chokidar, { FSWatcher } from 'chokidar' +import type { FSWatcher } from 'chokidar' +import chokidar from 'chokidar' import * as crypto from 'crypto' -import { - dialog, - net, - OpenDialogOptions, - OpenDialogReturnValue, - SaveDialogOptions, - SaveDialogReturnValue, - shell -} from 'electron' +import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' +import { app } from 'electron' +import { dialog, net, shell } from 'electron' import * as fs from 'fs' import { writeFileSync } from 'fs' import { readFile } from 'fs/promises' @@ -36,6 +31,73 @@ import WordExtractor from 'word-extractor' const logger = loggerService.withContext('FileStorage') +// Get ripgrep binary path +const getRipgrepBinaryPath = (): string | null => { + try { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux' + let ripgrepBinaryPath = path.join( + __dirname, + '../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', + `${arch}-${platform}`, + process.platform === 'win32' ? 'rg.exe' : 'rg' + ) + + if (app.isPackaged) { + ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1') + } + + if (fs.existsSync(ripgrepBinaryPath)) { + return ripgrepBinaryPath + } + return null + } catch (error) { + logger.error('Failed to locate ripgrep binary:', error as Error) + return null + } +} + +/** + * Execute ripgrep with captured output + */ +function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve, reject) => { + const ripgrepBinaryPath = getRipgrepBinaryPath() + + if (!ripgrepBinaryPath) { + reject(new Error('Ripgrep binary not available')) + return + } + + const { spawn } = require('child_process') + const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], { + stdio: ['pipe', 'pipe', 'pipe'] + }) + + let output = '' + let errorOutput = '' + + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + }) + + child.stderr.on('data', (data: Buffer) => { + errorOutput += data.toString() + }) + + child.on('close', (code: number) => { + resolve({ + exitCode: code || 0, + output: output || errorOutput + }) + }) + + child.on('error', (error: Error) => { + reject(error) + }) + }) +} + interface FileWatcherConfig { watchExtensions?: string[] ignoredPatterns?: (string | RegExp)[] @@ -60,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required = { eventChannel: 'file-change' } +interface DirectoryListOptions { + recursive?: boolean + maxDepth?: number + includeHidden?: boolean + includeFiles?: boolean + includeDirectories?: boolean + maxEntries?: number + searchPattern?: string +} + +const DEFAULT_DIRECTORY_LIST_OPTIONS: Required = { + recursive: true, + maxDepth: 3, + includeHidden: false, + includeFiles: true, + includeDirectories: true, + maxEntries: 10, + searchPattern: '.' +} + class FileStorage { private storageDir = getFilesDir() private notesDir = getNotesDir() @@ -754,6 +836,284 @@ class FileStorage { } } + public listDirectory = async ( + _: Electron.IpcMainInvokeEvent, + dirPath: string, + options?: DirectoryListOptions + ): Promise => { + const mergedOptions: Required = { + ...DEFAULT_DIRECTORY_LIST_OPTIONS, + ...options + } + + const resolvedPath = path.resolve(dirPath) + + const stat = await fs.promises.stat(resolvedPath).catch((error) => { + logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error) + throw error + }) + + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${resolvedPath}`) + } + + // Use ripgrep for file listing with relevance-based sorting + if (!getRipgrepBinaryPath()) { + throw new Error('Ripgrep binary not available') + } + + return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions) + } + + /** + * Search directories by name pattern + */ + private async searchDirectories( + resolvedPath: string, + options: Required, + currentDepth: number = 0 + ): Promise { + if (!options.includeDirectories) return [] + if (!options.recursive && currentDepth > 0) return [] + if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return [] + + const directories: string[] = [] + const excludedDirs = new Set([ + 'node_modules', + '.git', + '.idea', + '.vscode', + 'dist', + 'build', + '.next', + '.nuxt', + 'coverage', + '.cache' + ]) + + try { + const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true }) + const searchPatternLower = options.searchPattern.toLowerCase() + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + // Skip hidden directories unless explicitly included + if (!options.includeHidden && entry.name.startsWith('.')) continue + + // Skip excluded directories + if (excludedDirs.has(entry.name)) continue + + const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/') + + // Check if directory name matches search pattern + if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) { + directories.push(fullPath) + } + + // Recursively search subdirectories + if (options.recursive && currentDepth < options.maxDepth) { + const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1) + directories.push(...subDirs) + } + } + } catch (error) { + logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error) + } + + return directories + } + + /** + * Search files by filename pattern + */ + private async searchByFilename(resolvedPath: string, options: Required): Promise { + const files: string[] = [] + const directories: string[] = [] + + // Search for files using ripgrep + if (options.includeFiles) { + const args: string[] = ['--files'] + + // Handle hidden files + if (!options.includeHidden) { + args.push('--glob', '!.*') + } + + // Use --iglob to let ripgrep filter filenames (case-insensitive) + if (options.searchPattern && options.searchPattern !== '.') { + args.push('--iglob', `*${options.searchPattern}*`) + } + + // Exclude common hidden directories and large directories + args.push('-g', '!**/node_modules/**') + args.push('-g', '!**/.git/**') + args.push('-g', '!**/.idea/**') + args.push('-g', '!**/.vscode/**') + args.push('-g', '!**/.DS_Store') + args.push('-g', '!**/dist/**') + args.push('-g', '!**/build/**') + args.push('-g', '!**/.next/**') + args.push('-g', '!**/.nuxt/**') + args.push('-g', '!**/coverage/**') + args.push('-g', '!**/.cache/**') + + // Handle max depth + if (!options.recursive) { + args.push('--max-depth', '1') + } else if (options.maxDepth > 0) { + args.push('--max-depth', options.maxDepth.toString()) + } + + // Add the directory path + args.push(resolvedPath) + + const { exitCode, output } = await executeRipgrep(args) + + // Exit code 0 means files found, 1 means no files found (still success), 2+ means error + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + // Parse ripgrep output (no need to filter by filename - ripgrep already did it) + files.push( + ...output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + ) + } + + // Search for directories + if (options.includeDirectories) { + directories.push(...(await this.searchDirectories(resolvedPath, options))) + } + + // Combine and sort: directories first (alphabetically), then files (alphabetically) + const sortedDirectories = directories.sort((a, b) => { + const aName = path.basename(a) + const bName = path.basename(b) + return aName.localeCompare(bName) + }) + + const sortedFiles = files.sort((a, b) => { + const aName = path.basename(a) + const bName = path.basename(b) + return aName.localeCompare(bName) + }) + + return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries) + } + + /** + * Search files by content pattern + */ + private async searchByContent(resolvedPath: string, options: Required): Promise { + const args: string[] = ['-l'] + + // Handle hidden files + if (!options.includeHidden) { + args.push('--glob', '!.*') + } + + // Exclude common hidden directories and large directories + args.push('-g', '!**/node_modules/**') + args.push('-g', '!**/.git/**') + args.push('-g', '!**/.idea/**') + args.push('-g', '!**/.vscode/**') + args.push('-g', '!**/.DS_Store') + args.push('-g', '!**/dist/**') + args.push('-g', '!**/build/**') + args.push('-g', '!**/.next/**') + args.push('-g', '!**/.nuxt/**') + args.push('-g', '!**/coverage/**') + args.push('-g', '!**/.cache/**') + + // Handle max depth + if (!options.recursive) { + args.push('--max-depth', '1') + } else if (options.maxDepth > 0) { + args.push('--max-depth', options.maxDepth.toString()) + } + + // Handle max count + if (options.maxEntries > 0) { + args.push('--max-count', options.maxEntries.toString()) + } + + // Add search pattern (search in content) + args.push(options.searchPattern) + + // Add the directory path + args.push(resolvedPath) + + const { exitCode, output } = await executeRipgrep(args) + + // Exit code 0 means files found, 1 means no files found (still success), 2+ means error + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + // Parse ripgrep output (already sorted by relevance) + const results = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + .slice(0, options.maxEntries) + + return results + } + + private async listDirectoryWithRipgrep( + resolvedPath: string, + options: Required + ): Promise { + const maxEntries = options.maxEntries + + // Step 1: Search by filename first + logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath }) + const filenameResults = await this.searchByFilename(resolvedPath, options) + + logger.debug('Found matches by filename', { count: filenameResults.length }) + + // If we have enough filename matches, return them + if (filenameResults.length >= maxEntries) { + return filenameResults.slice(0, maxEntries) + } + + // Step 2: If filename matches are less than maxEntries, search by content to fill up + logger.debug('Filename matches insufficient, searching by content to fill up', { + filenameCount: filenameResults.length, + needed: maxEntries - filenameResults.length + }) + + // Adjust maxEntries for content search to get enough results + const contentOptions = { + ...options, + maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates + } + + const contentResults = await this.searchByContent(resolvedPath, contentOptions) + + logger.debug('Found matches by content', { count: contentResults.length }) + + // Combine results: filename matches first, then content matches (deduplicated) + const combined = [...filenameResults] + const filenameSet = new Set(filenameResults) + + for (const filePath of contentResults) { + if (!filenameSet.has(filePath)) { + combined.push(filePath) + if (combined.length >= maxEntries) { + break + } + } + } + + logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length }) + return combined.slice(0, maxEntries) + } + public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { try { if (!dirPath || typeof dirPath !== 'string') { diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 4e8707b2b7..f139b3e2c1 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -16,7 +16,8 @@ import * as fs from 'node:fs' import path from 'node:path' -import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs' +import type { RAGApplication } from '@cherrystudio/embedjs' +import { RAGApplicationBuilder } from '@cherrystudio/embedjs' import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { WebLoader } from '@cherrystudio/embedjs-loader-web' @@ -34,7 +35,7 @@ import { TraceMethod } from '@mcp-trace/trace-core' import { MB } from '@shared/config/constant' import type { LoaderReturn } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' -import { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types' +import type { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types' import { v4 as uuidv4 } from 'uuid' const logger = loggerService.withContext('MainKnowledgeService') diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index f1bfbaa841..3831d0af1e 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -10,7 +10,8 @@ import { getBinaryName, getBinaryPath } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core' import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' +import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport, @@ -29,7 +30,8 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' import { nanoid } from '@reduxjs/toolkit' -import { MCPProgressEvent } from '@shared/config/types' +import { HOME_CHERRY_DIR } from '@shared/config/constant' +import type { MCPProgressEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { defaultAppHeaders } from '@shared/utils' import { @@ -714,7 +716,7 @@ class McpService { } public async getInstallInfo() { - const dir = path.join(os.homedir(), '.cherrystudio', 'bin') + const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const uvName = await getBinaryName('uv') const bunName = await getBinaryName('bun') const uvPath = path.join(dir, uvName) diff --git a/src/main/services/MistralClientManager.ts b/src/main/services/MistralClientManager.ts index c2efe35d1d..8f5a90f76e 100644 --- a/src/main/services/MistralClientManager.ts +++ b/src/main/services/MistralClientManager.ts @@ -1,5 +1,5 @@ import { Mistral } from '@mistralai/mistralai' -import { Provider } from '@types' +import type { Provider } from '@types' export class MistralClientManager { private static instance: MistralClientManager diff --git a/src/main/services/NodeTraceService.ts b/src/main/services/NodeTraceService.ts index d2e4db20c9..70fd92a28b 100644 --- a/src/main/services/NodeTraceService.ts +++ b/src/main/services/NodeTraceService.ts @@ -2,7 +2,8 @@ import { loggerService } from '@logger' import { isDev } from '@main/constant' import { CacheBatchSpanProcessor, FunctionSpanExporter } from '@mcp-trace/trace-core' import { NodeTracer as MCPNodeTracer } from '@mcp-trace/trace-node/nodeTracer' -import { context, SpanContext, trace } from '@opentelemetry/api' +import type { SpanContext } from '@opentelemetry/api' +import { context, trace } from '@opentelemetry/api' import { BrowserWindow, ipcMain } from 'electron' import * as path from 'path' diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts index fa9261aa7e..5a599b409d 100644 --- a/src/main/services/NotificationService.ts +++ b/src/main/services/NotificationService.ts @@ -1,4 +1,4 @@ -import { Notification } from '@types' +import type { Notification } from '@types' import { Notification as ElectronNotification } from 'electron' import { windowService } from './WindowService' diff --git a/src/main/services/OvmsManager.ts b/src/main/services/OvmsManager.ts index f319200ac3..3a32d74ecf 100644 --- a/src/main/services/OvmsManager.ts +++ b/src/main/services/OvmsManager.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { promisify } from 'node:util' import { loggerService } from '@logger' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import * as fs from 'fs-extra' import * as path from 'path' @@ -145,7 +146,7 @@ class OvmsManager { */ public async runOvms(): Promise<{ success: boolean; message?: string }> { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') const runBatPath = path.join(ovmsDir, 'run.bat') @@ -195,7 +196,7 @@ class OvmsManager { */ public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> { const homeDir = homedir() - const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe') + const ovmsPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'ovms.exe') try { // Check if OVMS executable exists @@ -273,7 +274,7 @@ class OvmsManager { } const homeDir = homedir() - const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json') + const configPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'models', 'config.json') try { if (!(await fs.pathExists(configPath))) { logger.warn(`Config file does not exist: ${configPath}`) @@ -304,7 +305,7 @@ class OvmsManager { private async applyModelPath(modelDirPath: string): Promise { const homeDir = homedir() - const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch') + const patchDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'patch') if (!(await fs.pathExists(patchDir))) { return true } @@ -355,7 +356,7 @@ class OvmsManager { logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`) const homeDir = homedir() - const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovdndDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const pathModel = path.join(ovdndDir, 'models', modelId) try { @@ -468,7 +469,7 @@ class OvmsManager { */ public async checkModelExists(modelId: string): Promise { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') try { @@ -495,7 +496,7 @@ class OvmsManager { */ public async updateModelConfig(modelName: string, modelId: string): Promise { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') try { @@ -548,7 +549,7 @@ class OvmsManager { */ public async getModels(): Promise { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') try { diff --git a/src/main/services/PluginService.ts b/src/main/services/PluginService.ts deleted file mode 100644 index 8ff1820208..0000000000 --- a/src/main/services/PluginService.ts +++ /dev/null @@ -1,1171 +0,0 @@ -import { loggerService } from '@logger' -import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' -import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' -import type { - AgentEntity, - InstalledPlugin, - InstallPluginOptions, - ListAvailablePluginsResult, - PluginError, - PluginMetadata, - PluginType, - UninstallPluginOptions -} from '@types' -import * as crypto from 'crypto' -import { app } from 'electron' -import * as fs from 'fs' -import * as path from 'path' - -import { AgentService } from './agents/services/AgentService' - -const logger = loggerService.withContext('PluginService') - -interface PluginServiceConfig { - maxFileSize: number // bytes - cacheTimeout: number // milliseconds -} - -/** - * PluginService manages agent and command plugins from resources directory. - * - * Features: - * - Singleton pattern for consistent state management - * - Caching of available plugins for performance - * - Security validation (path traversal, file size, extensions) - * - Transactional install/uninstall operations - * - Integration with AgentService for metadata persistence - */ -export class PluginService { - private static instance: PluginService | null = null - - private availablePluginsCache: ListAvailablePluginsResult | null = null - private cacheTimestamp = 0 - private config: PluginServiceConfig - - private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] - - private constructor(config?: Partial) { - this.config = { - maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default - cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default - } - - logger.info('PluginService initialized', { - maxFileSize: this.config.maxFileSize, - cacheTimeout: this.config.cacheTimeout - }) - } - - /** - * Get singleton instance - */ - static getInstance(config?: Partial): PluginService { - if (!PluginService.instance) { - PluginService.instance = new PluginService(config) - } - return PluginService.instance - } - - /** - * List all available plugins from resources directory (with caching) - */ - async listAvailable(): Promise { - const now = Date.now() - - // Return cached data if still valid - if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { - logger.debug('Returning cached plugin list', { - cacheAge: now - this.cacheTimestamp - }) - return this.availablePluginsCache - } - - logger.info('Scanning available plugins') - - // Scan all plugin types - const [agents, commands, skills] = await Promise.all([ - this.scanPluginDirectory('agent'), - this.scanPluginDirectory('command'), - this.scanSkillDirectory() - ]) - - const result: ListAvailablePluginsResult = { - agents, - commands, - skills, // NEW: include skills - total: agents.length + commands.length + skills.length - } - - // Update cache - this.availablePluginsCache = result - this.cacheTimestamp = now - - logger.info('Available plugins scanned', { - agentsCount: agents.length, - commandsCount: commands.length, - skillsCount: skills.length, - total: result.total - }) - - return result - } - - /** - * Install plugin with validation and transactional safety - */ - async install(options: InstallPluginOptions): Promise { - logger.info('Installing plugin', options) - - // Validate source path - this.validateSourcePath(options.sourcePath) - - // Get agent and validate - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - await this.validateWorkdir(workdir, options.agentId) - - // Get absolute source path - const basePath = this.getPluginsBasePath() - const sourceAbsolutePath = path.join(basePath, options.sourcePath) - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // Validate skill folder exists and is a directory - try { - const stats = await fs.promises.stat(sourceAbsolutePath) - if (!stats.isDirectory()) { - throw { - type: 'INVALID_METADATA', - reason: 'Skill source is not a directory', - path: options.sourcePath - } as PluginError - } - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourceAbsolutePath - } as PluginError - } - - // Parse metadata from SKILL.md - const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') - - // Sanitize folder name (different rules than file names) - const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) - - // Ensure .claude/skills directory exists - await this.ensureClaudeDirectory(workdir, 'skill') - - // Construct destination path (folder, not file) - const destPath = path.join(workdir, '.claude', 'skills', sanitizedFolderName) - - // Update metadata with sanitized folder name - metadata.filename = sanitizedFolderName - - // Execute skill-specific install - await this.installSkill(agent, sourceAbsolutePath, destPath, metadata) - - logger.info('Skill installed successfully', { - agentId: options.agentId, - sourcePath: options.sourcePath, - folderName: sanitizedFolderName - }) - - return { - ...metadata, - installedAt: Date.now() - } - } - - // EXISTING LOGIC for agents/commands (unchanged) - // Files go through existing validation and sanitization - await this.validatePluginFile(sourceAbsolutePath) - - // Parse metadata - const category = path.basename(path.dirname(options.sourcePath)) - const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, options.type) - - // Sanitize filename - const sanitizedFilename = this.sanitizeFilename(metadata.filename) - - // Ensure .claude directory exists - await this.ensureClaudeDirectory(workdir, options.type) - - // Get destination path - const destDir = path.join(workdir, '.claude', options.type === 'agent' ? 'agents' : 'commands') - const destPath = path.join(destDir, sanitizedFilename) - - // Check for duplicate and auto-uninstall if exists - const existingPlugins = agent.configuration?.installed_plugins || [] - const existingPlugin = existingPlugins.find((p) => p.filename === sanitizedFilename && p.type === options.type) - - if (existingPlugin) { - logger.info('Plugin already installed, auto-uninstalling old version', { - filename: sanitizedFilename - }) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - // Re-fetch agent after uninstall - const updatedAgent = await AgentService.getInstance().getAgent(options.agentId) - if (!updatedAgent) { - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: 'Agent not found after uninstall' - } as PluginError - } - - await this.installTransaction(updatedAgent, sourceAbsolutePath, destPath, metadata) - } else { - await this.installTransaction(agent, sourceAbsolutePath, destPath, metadata) - } - - logger.info('Plugin installed successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - - return { - ...metadata, - filename: sanitizedFilename, - installedAt: Date.now() - } - } - - /** - * Uninstall plugin with cleanup - */ - async uninstall(options: UninstallPluginOptions): Promise { - logger.info('Uninstalling plugin', options) - - // Get agent - const agent = await AgentService.getInstance().getAgent(options.agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId: options.agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - // BRANCH: Handle skills differently than files - if (options.type === 'skill') { - // For skills, filename is the folder name (no extension) - // Use sanitizeFolderName to ensure consistency - const sanitizedFolderName = this.sanitizeFolderName(options.filename) - await this.uninstallSkill(agent, sanitizedFolderName) - - logger.info('Skill uninstalled successfully', { - agentId: options.agentId, - folderName: sanitizedFolderName - }) - - return - } - - // EXISTING LOGIC for agents/commands (unchanged) - // For files, filename includes .md extension - const sanitizedFilename = this.sanitizeFilename(options.filename) - await this.uninstallTransaction(agent, sanitizedFilename, options.type) - - logger.info('Plugin uninstalled successfully', { - agentId: options.agentId, - filename: sanitizedFilename, - type: options.type - }) - } - - /** - * List installed plugins for an agent (from database + filesystem validation) - */ - async listInstalled(agentId: string): Promise { - logger.debug('Listing installed plugins', { agentId }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const installedPlugins = agent.configuration?.installed_plugins || [] - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - logger.warn('Agent has no accessible paths', { agentId }) - return [] - } - - // Validate each plugin still exists on filesystem - const validatedPlugins: InstalledPlugin[] = [] - - for (const plugin of installedPlugins) { - // Get plugin path based on type - let pluginPath: string - if (plugin.type === 'skill') { - pluginPath = path.join(workdir, '.claude', 'skills', plugin.filename) - } else { - pluginPath = path.join(workdir, '.claude', plugin.type === 'agent' ? 'agents' : 'commands', plugin.filename) - } - - try { - const stats = await fs.promises.stat(pluginPath) - - // For files (agents/commands), verify file hash if stored - if (plugin.type !== 'skill' && plugin.contentHash) { - const currentHash = await this.calculateFileHash(pluginPath) - if (currentHash !== plugin.contentHash) { - logger.warn('Plugin file hash mismatch', { - filename: plugin.filename, - expected: plugin.contentHash, - actual: currentHash - }) - } - } - - // For skills, stats.size is folder size (handled differently) - // For files, stats.size is file size - validatedPlugins.push({ - filename: plugin.filename, - type: plugin.type, - metadata: { - sourcePath: plugin.sourcePath, - filename: plugin.filename, - name: plugin.name, - description: plugin.description, - allowed_tools: plugin.allowed_tools, - tools: plugin.tools, - category: plugin.category || '', - type: plugin.type, - tags: plugin.tags, - version: plugin.version, - author: plugin.author, - size: stats.size, - contentHash: plugin.contentHash, - installedAt: plugin.installedAt, - updatedAt: plugin.updatedAt - } - }) - } catch (error) { - logger.warn('Plugin not found on filesystem', { - filename: plugin.filename, - path: pluginPath, - error: error instanceof Error ? error.message : String(error) - }) - } - } - - logger.debug('Listed installed plugins', { - agentId, - count: validatedPlugins.length - }) - - return validatedPlugins - } - - /** - * Invalidate plugin cache (for development/testing) - */ - invalidateCache(): void { - this.availablePluginsCache = null - this.cacheTimestamp = 0 - logger.info('Plugin cache invalidated') - } - - /** - * Read plugin content from source (resources directory) - */ - async readContent(sourcePath: string): Promise { - logger.info('Reading plugin content', { sourcePath }) - - // Validate source path - this.validateSourcePath(sourcePath) - - // Get absolute path - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, sourcePath) - - // Validate file exists and is accessible - try { - await fs.promises.access(absolutePath, fs.constants.R_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: sourcePath - } as PluginError - } - - // Read content - try { - const content = await fs.promises.readFile(absolutePath, 'utf8') - logger.debug('Plugin content read successfully', { - sourcePath, - size: content.length - }) - return content - } catch (error) { - throw { - type: 'READ_FAILED', - path: sourcePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Write plugin content to installed plugin (in agent's .claude directory) - * Note: Only works for file-based plugins (agents/commands), not skills - */ - async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { - logger.info('Writing plugin content', { agentId, filename, type }) - - // Get agent - const agent = await AgentService.getInstance().getAgent(agentId) - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent not found' - } as PluginError - } - - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - // Check if plugin is installed - const installedPlugins = agent.configuration?.installed_plugins || [] - const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) - - if (!installedPlugin) { - throw { - type: 'PLUGIN_NOT_INSTALLED', - filename, - agentId - } as PluginError - } - - // Get file path - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Verify file exists - try { - await fs.promises.access(filePath, fs.constants.W_OK) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Write content - try { - await fs.promises.writeFile(filePath, content, 'utf8') - logger.debug('Plugin content written successfully', { - filePath, - size: content.length - }) - - // Update content hash in database - const newContentHash = crypto.createHash('sha256').update(content).digest('hex') - const updatedPlugins = installedPlugins.map((p) => { - if (p.filename === filename && p.type === type) { - return { - ...p, - contentHash: newContentHash, - updatedAt: Date.now() - } - } - return p - }) - - await AgentService.getInstance().updateAgent(agentId, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.info('Plugin content updated successfully', { - agentId, - filename, - type, - newContentHash - }) - } catch (error) { - throw { - type: 'WRITE_FAILED', - path: filePath, - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - // ============================================================================ - // Private Helper Methods - // ============================================================================ - - /** - * Get absolute path to plugins directory (handles packaged vs dev) - */ - private getPluginsBasePath(): string { - // Use the utility function which handles both dev and production correctly - if (app.isPackaged) { - return path.join(process.resourcesPath, 'claude-code-plugins') - } - return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') - } - - /** - * Scan plugin directory and return metadata for all plugins - */ - private async scanPluginDirectory(type: 'agent' | 'command'): Promise { - const basePath = this.getPluginsBasePath() - const typeDir = path.join(basePath, type === 'agent' ? 'agents' : 'commands') - - try { - await fs.promises.access(typeDir, fs.constants.R_OK) - } catch (error) { - logger.warn(`Plugin directory not accessible: ${typeDir}`, { - error: error instanceof Error ? error.message : String(error) - }) - return [] - } - - const plugins: PluginMetadata[] = [] - const categories = await fs.promises.readdir(typeDir, { withFileTypes: true }) - - for (const categoryEntry of categories) { - if (!categoryEntry.isDirectory()) { - continue - } - - const category = categoryEntry.name - const categoryPath = path.join(typeDir, category) - const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) - - for (const file of files) { - if (!file.isFile()) { - continue - } - - const ext = path.extname(file.name).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - continue - } - - try { - const filePath = path.join(categoryPath, file.name) - const sourcePath = path.join(type === 'agent' ? 'agents' : 'commands', category, file.name) - - const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) - plugins.push(metadata) - } catch (error) { - logger.warn(`Failed to parse plugin: ${file.name}`, { - category, - error: error instanceof Error ? error.message : String(error) - }) - } - } - } - - return plugins - } - - /** - * Scan skills directory for skill folders (recursively) - */ - private async scanSkillDirectory(): Promise { - const basePath = this.getPluginsBasePath() - const skillsPath = path.join(basePath, 'skills') - - const skills: PluginMetadata[] = [] - - try { - // Check if skills directory exists - try { - await fs.promises.access(skillsPath) - } catch { - logger.warn('Skills directory not found', { skillsPath }) - return [] - } - - // Recursively find all directories containing SKILL.md - const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) - - logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) - - // Parse metadata for each skill directory - for (const { folderPath, sourcePath } of skillDirectories) { - try { - const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') - skills.push(metadata) - } catch (error) { - logger.warn(`Failed to parse skill folder: ${sourcePath}`, { - folderPath, - error: error instanceof Error ? error.message : String(error) - }) - // Continue with other skills - } - } - } catch (error) { - logger.error('Failed to scan skill directory', { skillsPath, error }) - // Return empty array on error - } - - return skills - } - - /** - * Validate source path to prevent path traversal attacks - */ - private validateSourcePath(sourcePath: string): void { - // Remove any path traversal attempts - const normalized = path.normalize(sourcePath) - - // Ensure no parent directory access - if (normalized.includes('..')) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path traversal detected', - path: sourcePath - } as PluginError - } - - // Ensure path is within plugins directory - const basePath = this.getPluginsBasePath() - const absolutePath = path.join(basePath, normalized) - const resolvedPath = path.resolve(absolutePath) - - if (!resolvedPath.startsWith(path.resolve(basePath))) { - throw { - type: 'PATH_TRAVERSAL', - message: 'Path outside plugins directory', - path: sourcePath - } as PluginError - } - } - - /** - * Validate workdir against agent's accessible paths - */ - private async validateWorkdir(workdir: string, agentId: string): Promise { - // Get agent from database - const agent = await AgentService.getInstance().getAgent(agentId) - - if (!agent) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Agent not found' - } as PluginError - } - - // Verify workdir is in agent's accessible_paths - if (!agent.accessible_paths?.includes(workdir)) { - throw { - type: 'INVALID_WORKDIR', - workdir, - agentId, - message: 'Workdir not in agent accessible paths' - } as PluginError - } - - // Verify workdir exists and is accessible - try { - await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) - } catch (error) { - throw { - type: 'WORKDIR_NOT_FOUND', - workdir, - message: 'Workdir does not exist or is not accessible' - } as PluginError - } - } - - /** - * Sanitize filename to remove unsafe characters (for agents/commands) - */ - private sanitizeFilename(filename: string): string { - // Remove path separators - let sanitized = filename.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore, dot) - sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') - - // Ensure .md extension - if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { - sanitized += '.md' - } - - return sanitized - } - - /** - * Sanitize folder name for skills (different rules than file names) - * NO dots allowed to avoid confusion with file extensions - */ - private sanitizeFolderName(folderName: string): string { - // Remove path separators - let sanitized = folderName.replace(/[/\\]/g, '_') - // Remove null bytes using String method to avoid control-regex lint error - sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') - // Limit to safe characters (alphanumeric, dash, underscore) - // NOTE: No dots allowed to avoid confusion with file extensions - sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') - - // Validate no extension was provided - if (folderName.includes('.')) { - logger.warn('Skill folder name contained dots, sanitized', { - original: folderName, - sanitized - }) - } - - return sanitized - } - - /** - * Validate plugin file (size, extension, frontmatter) - */ - private async validatePluginFile(filePath: string): Promise { - // Check file exists - let stats: fs.Stats - try { - stats = await fs.promises.stat(filePath) - } catch (error) { - throw { - type: 'FILE_NOT_FOUND', - path: filePath - } as PluginError - } - - // Check file size - if (stats.size > this.config.maxFileSize) { - throw { - type: 'FILE_TOO_LARGE', - size: stats.size, - max: this.config.maxFileSize - } as PluginError - } - - // Check file extension - const ext = path.extname(filePath).toLowerCase() - if (!this.ALLOWED_EXTENSIONS.includes(ext)) { - throw { - type: 'INVALID_FILE_TYPE', - extension: ext - } as PluginError - } - - // Validate frontmatter can be parsed safely - // This is handled by parsePluginMetadata which uses FAILSAFE_SCHEMA - try { - const category = path.basename(path.dirname(filePath)) - const sourcePath = path.relative(this.getPluginsBasePath(), filePath) - const type = sourcePath.startsWith('agents') ? 'agent' : 'command' - - await parsePluginMetadata(filePath, sourcePath, category, type) - } catch (error) { - throw { - type: 'INVALID_METADATA', - reason: 'Failed to parse frontmatter', - path: filePath - } as PluginError - } - } - - /** - * Calculate SHA-256 hash of file - */ - private async calculateFileHash(filePath: string): Promise { - const content = await fs.promises.readFile(filePath, 'utf8') - return crypto.createHash('sha256').update(content).digest('hex') - } - - /** - * Ensure .claude subdirectory exists for the given plugin type - */ - private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { - const claudeDir = path.join(workdir, '.claude') - - let subDir: string - if (type === 'agent') { - subDir = 'agents' - } else if (type === 'command') { - subDir = 'commands' - } else if (type === 'skill') { - subDir = 'skills' - } else { - throw new Error(`Unknown plugin type: ${type}`) - } - - const typeDir = path.join(claudeDir, subDir) - - try { - await fs.promises.mkdir(typeDir, { recursive: true }) - logger.debug('Ensured directory exists', { typeDir }) - } catch (error) { - logger.error('Failed to create directory', { - typeDir, - error: error instanceof Error ? error.message : String(error) - }) - throw { - type: 'PERMISSION_DENIED', - path: typeDir - } as PluginError - } - } - - /** - * Transactional install operation - * Steps: - * 1. Copy to temp location - * 2. Update database - * 3. Move to final location (atomic) - * Rollback on error - */ - private async installTransaction( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const tempPath = `${destPath}.tmp` - let fileCopied = false - - try { - // Step 1: Copy file to temporary location - await fs.promises.copyFile(sourceAbsolutePath, tempPath) - fileCopied = true - logger.debug('File copied to temp location', { tempPath }) - - // Step 2: Update agent configuration in database - const existingPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = [ - ...existingPlugins, - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, - type: metadata.type, - name: metadata.name, - description: metadata.description, - allowed_tools: metadata.allowed_tools, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 3: Move temp file to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logger.debug('File moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp file if it exists - if (fileCopied) { - try { - await fs.promises.unlink(tempPath) - logger.debug('Rolled back temp file', { tempPath }) - } catch (unlinkError) { - logger.error('Failed to rollback temp file', { - tempPath, - error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Transactional uninstall operation - * Steps: - * 1. Update database - * 2. Delete file - * Rollback database on error - */ - private async uninstallTransaction(agent: AgentEntity, filename: string, type: 'agent' | 'command'): Promise { - const workdir = agent.accessible_paths?.[0] - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const filePath = path.join(workdir, '.claude', type === 'agent' ? 'agents' : 'commands', filename) - - // Step 1: Update database first (easier to rollback file operations) - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === filename && p.type === type)) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logger.debug('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete file - try { - await fs.promises.unlink(filePath) - logger.debug('Plugin file deleted', { filePath }) - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - if (nodeError.code !== 'ENOENT') { - throw error // File should exist, re-throw if not ENOENT - } - logger.warn('Plugin file already deleted', { filePath }) - } - } catch (error) { - // Rollback: restore database if file deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logger.debug('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logger.error('Failed to rollback database', { - agentId: agent.id, - error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError) - }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Install a skill (copy entire folder) - */ - private async installSkill( - agent: AgentEntity, - sourceAbsolutePath: string, - destPath: string, - metadata: PluginMetadata - ): Promise { - const logContext = logger.withContext('installSkill') - - // Step 1: If destination exists, remove it first (overwrite behavior) - try { - await fs.promises.access(destPath) - // Exists - remove it - await deleteDirectoryRecursive(destPath) - logContext.info('Removed existing skill folder', { destPath }) - } catch { - // Doesn't exist - nothing to remove - } - - // Step 2: Copy folder to temporary location - const tempPath = `${destPath}.tmp` - let folderCopied = false - - try { - // Copy to temp location - await copyDirectoryRecursive(sourceAbsolutePath, tempPath) - folderCopied = true - logContext.info('Skill folder copied to temp location', { tempPath }) - - // Step 3: Update agent configuration in database - const updatedPlugins = [ - ...(agent.configuration?.installed_plugins || []).filter( - (p) => !(p.filename === metadata.filename && p.type === 'skill') - ), - { - sourcePath: metadata.sourcePath, - filename: metadata.filename, // Folder name, no extension - type: metadata.type, - name: metadata.name, - description: metadata.description, - tools: metadata.tools, - category: metadata.category, - tags: metadata.tags, - version: metadata.version, - author: metadata.author, - contentHash: metadata.contentHash, - installedAt: Date.now() - } - ] - - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 4: Move temp folder to final location (atomic on same filesystem) - await fs.promises.rename(tempPath, destPath) - logContext.info('Skill folder moved to final location', { destPath }) - } catch (error) { - // Rollback: delete temp folder if it exists - if (folderCopied) { - try { - await deleteDirectoryRecursive(tempPath) - logContext.info('Rolled back temp folder', { tempPath }) - } catch (unlinkError) { - logContext.error('Failed to rollback temp folder', { tempPath, error: unlinkError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'install-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } - - /** - * Uninstall a skill (remove entire folder) - */ - private async uninstallSkill(agent: AgentEntity, folderName: string): Promise { - const logContext = logger.withContext('uninstallSkill') - const workdir = agent.accessible_paths?.[0] - - if (!workdir) { - throw { - type: 'INVALID_WORKDIR', - agentId: agent.id, - workdir: '', - message: 'Agent has no accessible paths' - } as PluginError - } - - const skillPath = path.join(workdir, '.claude', 'skills', folderName) - - // Step 1: Update database first - const originalPlugins = agent.configuration?.installed_plugins || [] - const updatedPlugins = originalPlugins.filter((p) => !(p.filename === folderName && p.type === 'skill')) - - let dbUpdated = false - - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: updatedPlugins - } - }) - dbUpdated = true - logContext.info('Agent configuration updated', { agentId: agent.id }) - - // Step 2: Delete folder - try { - await deleteDirectoryRecursive(skillPath) - logContext.info('Skill folder deleted', { skillPath }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error // Folder should exist, re-throw if not ENOENT - } - logContext.warn('Skill folder already deleted', { skillPath }) - } - } catch (error) { - // Rollback: restore database if folder deletion failed - if (dbUpdated) { - try { - await AgentService.getInstance().updateAgent(agent.id, { - configuration: { - permission_mode: 'default', - max_turns: 100, - ...agent.configuration, - installed_plugins: originalPlugins - } - }) - logContext.info('Rolled back database update', { agentId: agent.id }) - } catch (rollbackError) { - logContext.error('Failed to rollback database', { agentId: agent.id, error: rollbackError }) - } - } - - throw { - type: 'TRANSACTION_FAILED', - operation: 'uninstall-skill', - reason: error instanceof Error ? error.message : String(error) - } as PluginError - } - } -} - -export const pluginService = PluginService.getInstance() diff --git a/src/main/services/PowerMonitorService.ts b/src/main/services/PowerMonitorService.ts new file mode 100644 index 0000000000..aab3906c9e --- /dev/null +++ b/src/main/services/PowerMonitorService.ts @@ -0,0 +1,112 @@ +import { loggerService } from '@logger' +import { isLinux, isMac, isWin } from '@main/constant' +import ElectronShutdownHandler from '@paymoapp/electron-shutdown-handler' +import { BrowserWindow } from 'electron' +import { powerMonitor } from 'electron' + +const logger = loggerService.withContext('PowerMonitorService') + +type ShutdownHandler = () => void | Promise + +export class PowerMonitorService { + private static instance: PowerMonitorService + private initialized = false + private shutdownHandlers: ShutdownHandler[] = [] + + private constructor() { + // Private constructor to prevent direct instantiation + } + + public static getInstance(): PowerMonitorService { + if (!PowerMonitorService.instance) { + PowerMonitorService.instance = new PowerMonitorService() + } + return PowerMonitorService.instance + } + + /** + * Register a shutdown handler to be called when system shutdown is detected + * @param handler - The handler function to be called on shutdown + */ + public registerShutdownHandler(handler: ShutdownHandler): void { + this.shutdownHandlers.push(handler) + logger.info('Shutdown handler registered', { totalHandlers: this.shutdownHandlers.length }) + } + + /** + * Initialize power monitor to listen for shutdown events + */ + public init(): void { + if (this.initialized) { + logger.warn('PowerMonitorService already initialized') + return + } + + if (isWin) { + this.initWindowsShutdownHandler() + } else if (isMac || isLinux) { + this.initElectronPowerMonitor() + } + + this.initialized = true + logger.info('PowerMonitorService initialized', { platform: process.platform }) + } + + /** + * Execute all registered shutdown handlers + */ + private async executeShutdownHandlers(): Promise { + logger.info('Executing shutdown handlers', { count: this.shutdownHandlers.length }) + for (const handler of this.shutdownHandlers) { + try { + await handler() + } catch (error) { + logger.error('Error executing shutdown handler', error as Error) + } + } + } + + /** + * Initialize shutdown handler for Windows using @paymoapp/electron-shutdown-handler + */ + private initWindowsShutdownHandler(): void { + try { + const zeroMemoryWindow = new BrowserWindow({ show: false }) + // Set the window handle for the shutdown handler + ElectronShutdownHandler.setWindowHandle(zeroMemoryWindow.getNativeWindowHandle()) + + // Listen for shutdown event + ElectronShutdownHandler.on('shutdown', async () => { + logger.info('System shutdown event detected (Windows)') + // Execute all registered shutdown handlers + await this.executeShutdownHandlers() + // Release the shutdown block to allow the system to shut down + ElectronShutdownHandler.releaseShutdown() + }) + + logger.info('Windows shutdown handler registered') + } catch (error) { + logger.error('Failed to initialize Windows shutdown handler', error as Error) + } + } + + /** + * Initialize power monitor for macOS and Linux using Electron's powerMonitor + */ + private initElectronPowerMonitor(): void { + try { + powerMonitor.on('shutdown', async () => { + logger.info('System shutdown event detected', { platform: process.platform }) + // Execute all registered shutdown handlers + await this.executeShutdownHandlers() + }) + + logger.info('Electron powerMonitor shutdown listener registered') + } catch (error) { + logger.error('Failed to initialize Electron powerMonitor', error as Error) + } + } +} + +// Default export as singleton instance +export default PowerMonitorService.getInstance() diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 6fcf20da1c..a4a2211a20 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import axios from 'axios' -import { app, ProxyConfig, session } from 'electron' +import type { ProxyConfig } from 'electron' +import { app, session } from 'electron' import { socksDispatcher } from 'fetch-socks' import http from 'http' import https from 'https' diff --git a/src/main/services/PythonService.ts b/src/main/services/PythonService.ts index 13b4546e56..e9f59fa3be 100644 --- a/src/main/services/PythonService.ts +++ b/src/main/services/PythonService.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' + +import { windowService } from './WindowService' interface PythonExecutionRequest { id: string @@ -20,7 +22,6 @@ interface PythonExecutionResponse { */ export class PythonService { private static instance: PythonService | null = null - private mainWindow: BrowserWindow | null = null private pendingRequests = new Map void; reject: (error: Error) => void }>() private constructor() { @@ -50,10 +51,6 @@ export class PythonService { }) } - public setMainWindow(mainWindow: BrowserWindow) { - this.mainWindow = mainWindow - } - /** * Execute Python code by sending request to renderer PyodideService */ @@ -62,8 +59,8 @@ export class PythonService { context: Record = {}, timeout: number = 60000 ): Promise { - if (!this.mainWindow) { - throw new Error('Main window not set in PythonService') + if (!windowService.getMainWindow()) { + throw new Error('Main window not found') } return new Promise((resolve, reject) => { @@ -94,7 +91,7 @@ export class PythonService { // Send request to renderer const request: PythonExecutionRequest = { id: requestId, script, context, timeout } - this.mainWindow?.webContents.send('python-execution-request', request) + windowService.getMainWindow()?.webContents.send('python-execution-request', request) }) } } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 97216e6a65..583dbbd95c 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { handleZoomFactor } from '@main/utils/zoom' -import { Shortcut } from '@types' -import { BrowserWindow, globalShortcut } from 'electron' +import type { Shortcut } from '@types' +import type { BrowserWindow } from 'electron' +import { globalShortcut } from 'electron' import { configManager } from './ConfigManager' import selectionService from './SelectionService' diff --git a/src/main/services/SpanCacheService.ts b/src/main/services/SpanCacheService.ts index 98ff36d298..47a89d4327 100644 --- a/src/main/services/SpanCacheService.ts +++ b/src/main/services/SpanCacheService.ts @@ -1,7 +1,9 @@ import { loggerService } from '@logger' -import { Attributes, convertSpanToSpanEntity, SpanEntity, TokenUsage, TraceCache } from '@mcp-trace/trace-core' +import type { Attributes, SpanEntity, TokenUsage, TraceCache } from '@mcp-trace/trace-core' +import { convertSpanToSpanEntity } from '@mcp-trace/trace-core' import { SpanStatusCode } from '@opentelemetry/api' -import { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import fs from 'fs/promises' import * as os from 'os' import * as path from 'path' @@ -17,7 +19,7 @@ class SpanCacheService implements TraceCache { pri constructor() { - this.fileDir = path.join(os.homedir(), '.cherrystudio', 'trace') + this.fileDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'trace') } createSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => { diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index 205d7fdee9..41c64e06ca 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -1,6 +1,7 @@ import { isLinux, isMac, isWin } from '@main/constant' import { locales } from '@main/utils/locales' -import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron' +import type { MenuItemConstructorOptions } from 'electron' +import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron' import icon from '../../../build/tray_icon.png?asset' import iconDark from '../../../build/tray_icon_dark.png?asset' diff --git a/src/main/services/VersionService.ts b/src/main/services/VersionService.ts new file mode 100644 index 0000000000..a853b99074 --- /dev/null +++ b/src/main/services/VersionService.ts @@ -0,0 +1,285 @@ +import { loggerService } from '@logger' +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +const logger = loggerService.withContext('VersionService') + +type OS = 'win' | 'mac' | 'linux' | 'unknown' +type Environment = 'prod' | 'dev' +type Packaged = 'packaged' | 'unpackaged' +type Mode = 'install' | 'portable' + +/** + * Version record stored in version.log + */ +interface VersionRecord { + version: string + os: OS + environment: Environment + packaged: Packaged + mode: Mode + timestamp: string +} + +/** + * Service for tracking application version history + * Stores version information in userData/version.log for data migration and diagnostics + */ +class VersionService { + private readonly VERSION_LOG_FILE = 'version.log' + private versionLogPath: string | null = null + + constructor() { + // Lazy initialization of path since app.getPath may not be available during construction + } + + /** + * Gets the full path to version.log file + * @returns {string} Full path to version log file + */ + private getVersionLogPath(): string { + if (!this.versionLogPath) { + this.versionLogPath = path.join(app.getPath('userData'), this.VERSION_LOG_FILE) + } + return this.versionLogPath + } + + /** + * Gets current operating system identifier + * @returns {OS} OS identifier + */ + private getCurrentOS(): OS { + switch (process.platform) { + case 'win32': + return 'win' + case 'darwin': + return 'mac' + case 'linux': + return 'linux' + default: + return 'unknown' + } + } + + /** + * Gets current environment (production or development) + * @returns {Environment} Environment identifier + */ + private getCurrentEnvironment(): Environment { + return import.meta.env.MODE === 'production' ? 'prod' : 'dev' + } + + /** + * Gets packaging status + * @returns {Packaged} Packaging status + */ + private getPackagedStatus(): Packaged { + return app.isPackaged ? 'packaged' : 'unpackaged' + } + + /** + * Gets installation mode (install or portable) + * @returns {Mode} Installation mode + */ + private getInstallMode(): Mode { + return process.env.PORTABLE_EXECUTABLE_DIR !== undefined ? 'portable' : 'install' + } + + /** + * Generates version log line for current application state + * @returns {string} Pipe-separated version record line + */ + private generateCurrentVersionLine(): string { + const version = app.getVersion() + const os = this.getCurrentOS() + const environment = this.getCurrentEnvironment() + const packaged = this.getPackagedStatus() + const mode = this.getInstallMode() + const timestamp = new Date().toISOString() + + return `${version}|${os}|${environment}|${packaged}|${mode}|${timestamp}` + } + + /** + * Parses a version log line into a VersionRecord object + * @param {string} line - Pipe-separated version record line + * @returns {VersionRecord | null} Parsed version record or null if invalid + */ + private parseVersionLine(line: string): VersionRecord | null { + try { + const parts = line.trim().split('|') + if (parts.length !== 6) { + return null + } + + const [version, os, environment, packaged, mode, timestamp] = parts + + // Validate data + if ( + !version || + !['win', 'mac', 'linux', 'unknown'].includes(os) || + !['prod', 'dev'].includes(environment) || + !['packaged', 'unpackaged'].includes(packaged) || + !['install', 'portable'].includes(mode) || + !timestamp + ) { + return null + } + + return { + version, + os: os as OS, + environment: environment as Environment, + packaged: packaged as Packaged, + mode: mode as Mode, + timestamp + } + } catch (error) { + logger.warn(`Failed to parse version line: ${line}`, error as Error) + return null + } + } + + /** + * Reads the last 1KB from version.log and returns all lines + * Uses reverse reading from file end to avoid reading the entire file + * @returns {string[]} Array of version lines from the last 1KB + */ + private readLastVersionLines(): string[] { + const logPath = this.getVersionLogPath() + + try { + if (!fs.existsSync(logPath)) { + return [] + } + + const stats = fs.statSync(logPath) + const fileSize = stats.size + + if (fileSize === 0) { + return [] + } + + // Read from the end of the file, 1KB is enough to find previous version + // Typical line: "1.7.0-beta.3|win|prod|packaged|install|2025-01-15T08:30:00.000Z\n" (~70 bytes) + // 1KB can store ~14 lines, which is more than enough + const bufferSize = Math.min(1024, fileSize) + const buffer = Buffer.alloc(bufferSize) + + const fd = fs.openSync(logPath, 'r') + try { + const startPosition = Math.max(0, fileSize - bufferSize) + fs.readSync(fd, buffer, 0, bufferSize, startPosition) + + const content = buffer.toString('utf-8') + const lines = content + .trim() + .split('\n') + .filter((line) => line.trim()) + + return lines + } finally { + fs.closeSync(fd) + } + } catch (error) { + logger.error('Failed to read version log:', error as Error) + return [] + } + } + + /** + * Appends a version record line to version.log + * @param {string} line - Version record line to append + */ + private appendVersionLine(line: string): void { + const logPath = this.getVersionLogPath() + + try { + fs.appendFileSync(logPath, line + '\n', 'utf-8') + logger.debug(`Version recorded: ${line}`) + } catch (error) { + logger.error('Failed to append version log:', error as Error) + } + } + + /** + * Records the current version on application startup + * Only adds a new record if the version has changed since the last run + */ + recordCurrentVersion(): void { + try { + const currentLine = this.generateCurrentVersionLine() + const lines = this.readLastVersionLines() + + // Add new record if this is the first run or version has changed + if (lines.length === 0) { + logger.info('First run detected, creating version log') + this.appendVersionLine(currentLine) + return + } + + const lastLine = lines[lines.length - 1] + const lastRecord = this.parseVersionLine(lastLine) + const currentVersion = app.getVersion() + + // Check if any meaningful field has changed (version, os, environment, packaged, mode) + const currentOS = this.getCurrentOS() + const currentEnvironment = this.getCurrentEnvironment() + const currentPackaged = this.getPackagedStatus() + const currentMode = this.getInstallMode() + + const hasMeaningfulChange = + !lastRecord || + lastRecord.version !== currentVersion || + lastRecord.os !== currentOS || + lastRecord.environment !== currentEnvironment || + lastRecord.packaged !== currentPackaged || + lastRecord.mode !== currentMode + + if (hasMeaningfulChange) { + logger.info(`Version information changed, recording new entry`) + this.appendVersionLine(currentLine) + } else { + logger.debug(`Version information not changed, skip recording`) + } + } catch (error) { + logger.error('Failed to record current version:', error as Error) + } + } + + /** + * Gets the previous version record (last record with different version than current) + * Reads from the last 1KB of version.log to find the most recent different version + * Useful for detecting version upgrades and running migrations + * @returns {VersionRecord | null} Previous version record or null if not available + */ + getPreviousVersion(): VersionRecord | null { + try { + const lines = this.readLastVersionLines() + if (lines.length === 0) { + return null + } + + const currentVersion = app.getVersion() + + // Read from the end backwards to find the first different version + for (let i = lines.length - 1; i >= 0; i--) { + const record = this.parseVersionLine(lines[i]) + if (record && record.version !== currentVersion) { + return record + } + } + + return null + } catch (error) { + logger.error('Failed to get previous version:', error as Error) + return null + } + } +} + +/** + * Singleton instance of VersionService + */ +export const versionService = new VersionService() diff --git a/src/main/services/WebDav.ts b/src/main/services/WebDav.ts index 11a2d7ebfb..150227f98c 100644 --- a/src/main/services/WebDav.ts +++ b/src/main/services/WebDav.ts @@ -1,16 +1,16 @@ import { loggerService } from '@logger' -import { WebDavConfig } from '@types' +import type { WebDavConfig } from '@types' import https from 'https' import path from 'path' -import Stream from 'stream' -import { +import type Stream from 'stream' +import type { BufferLike, - createClient, CreateDirectoryOptions, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav' +import { createClient } from 'webdav' const logger = loggerService.withContext('WebDav') diff --git a/src/main/services/WebSocketService.ts b/src/main/services/WebSocketService.ts new file mode 100644 index 0000000000..e52919e96a --- /dev/null +++ b/src/main/services/WebSocketService.ts @@ -0,0 +1,359 @@ +import { loggerService } from '@logger' +import type { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types' +import * as fs from 'fs' +import { networkInterfaces } from 'os' +import * as path from 'path' +import type { Socket } from 'socket.io' +import { Server } from 'socket.io' + +import { windowService } from './WindowService' + +const logger = loggerService.withContext('WebSocketService') + +class WebSocketService { + private io: Server | null = null + private isStarted = false + private port = 7017 + private connectedClients = new Set() + + private getLocalIpAddress(): string | undefined { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ interface: string; address: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + interface: name, + address: iface.address, + priority + }) + } + } + } + + if (candidates.length === 0) { + logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1') + return '127.0.0.1' + } + + // 按优先级排序,选择优先级最高的 + candidates.sort((a, b) => a.priority - b.priority) + const best = candidates[0] + + logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`) + return best.address + } + + public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => { + if (this.isStarted && this.io) { + return { success: true, port: this.port } + } + + try { + this.io = new Server(this.port, { + cors: { + origin: '*', + methods: ['GET', 'POST'] + }, + transports: ['websocket', 'polling'], + allowEIO3: true, + pingTimeout: 60000, + pingInterval: 25000 + }) + + this.io.on('connection', (socket: Socket) => { + this.connectedClients.add(socket.id) + + const mainWindow = windowService.getMainWindow() + if (!mainWindow) { + logger.error('Main window is null, cannot send connection event') + } else { + mainWindow.webContents.send('websocket-client-connected', { + connected: true, + clientId: socket.id + }) + logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`) + } + + socket.on('message', (data) => { + logger.info('Received message from mobile:', data) + mainWindow?.webContents.send('websocket-message-received', data) + socket.emit('message_received', { success: true }) + }) + + socket.on('disconnect', () => { + logger.info(`Client disconnected: ${socket.id}`) + this.connectedClients.delete(socket.id) + + if (this.connectedClients.size === 0) { + mainWindow?.webContents.send('websocket-client-connected', { + connected: false, + clientId: socket.id + }) + } + }) + }) + + // Engine 层面的事件监听 + this.io.engine.on('connection_error', (err) => { + logger.error('Engine connection error:', err) + }) + + this.io.engine.on('connection', (rawSocket) => { + const remoteAddr = rawSocket.request.connection.remoteAddress + logger.info(`[Engine] Raw connection from: ${remoteAddr}`) + logger.info(`[Engine] Transport: ${rawSocket.transport.name}`) + + rawSocket.on('packet', (packet: { type: string; data?: any }) => { + logger.info( + `[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`, + packet.data ? { data: packet.data } : {} + ) + }) + + rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => { + logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`) + }) + + rawSocket.on('close', (reason: string) => { + logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`) + }) + + rawSocket.on('error', (error: Error) => { + logger.error(`[Engine] Connection error from ${remoteAddr}:`, error) + }) + }) + + // Socket.IO 握手失败监听 + this.io.on('connection_error', (err) => { + logger.error('[Socket.IO] Connection error during handshake:', err) + }) + + this.isStarted = true + logger.info(`WebSocket server started on port ${this.port}`) + + return { success: true, port: this.port } + } catch (error) { + logger.error('Failed to start WebSocket server:', error as Error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + public stop = async (): Promise<{ success: boolean }> => { + if (!this.isStarted || !this.io) { + return { success: true } + } + + try { + await new Promise((resolve) => { + this.io!.close(() => { + resolve() + }) + }) + + this.io = null + this.isStarted = false + this.connectedClients.clear() + logger.info('WebSocket server stopped') + + return { success: true } + } catch (error) { + logger.error('Failed to stop WebSocket server:', error as Error) + return { success: false } + } + } + + public getStatus = async (): Promise => { + return { + isRunning: this.isStarted, + port: this.isStarted ? this.port : undefined, + ip: this.isStarted ? this.getLocalIpAddress() : undefined, + clientConnected: this.connectedClients.size > 0 + } + } + + public getAllCandidates = async (): Promise => { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ host: string; interface: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + host: iface.address, + interface: name, + priority + }) + + logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`) + } + } + } + + // 按优先级排序返回 + candidates.sort((a, b) => a.priority - b.priority) + logger.info( + `Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}` + ) + return candidates + } + + public sendFile = async ( + _: Electron.IpcMainInvokeEvent, + filePath: string + ): Promise<{ success: boolean; error?: string }> => { + if (!this.isStarted || !this.io) { + const errorMsg = 'WebSocket server is not running.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + if (this.connectedClients.size === 0) { + const errorMsg = 'No client connected.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + const mainWindow = windowService.getMainWindow() + + return new Promise((resolve, reject) => { + const stats = fs.statSync(filePath) + const totalSize = stats.size + const filename = path.basename(filePath) + const stream = fs.createReadStream(filePath) + let bytesSent = 0 + const startTime = Date.now() + + logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`) + + // 向客户端发送文件开始的信号,包含文件名和总大小 + this.io!.emit('zip-file-start', { filename, totalSize }) + + stream.on('data', (chunk) => { + bytesSent += chunk.length + const progress = (bytesSent / totalSize) * 100 + + // 向客户端发送文件块 + this.io!.emit('zip-file-chunk', chunk) + + // 向渲染进程发送进度更新 + mainWindow?.webContents.send('file-send-progress', { progress }) + + // 每10%记录一次进度 + if (Math.floor(progress) % 10 === 0) { + const elapsed = (Date.now() - startTime) / 1000 + const speed = elapsed > 0 ? bytesSent / elapsed : 0 + logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`) + } + }) + + stream.on('end', () => { + const totalTime = (Date.now() - startTime) / 1000 + const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0 + logger.info( + `File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)` + ) + + // 确保发送100%的进度 + mainWindow?.webContents.send('file-send-progress', { progress: 100 }) + // 向客户端发送文件结束的信号 + this.io!.emit('zip-file-end') + resolve({ success: true }) + }) + + stream.on('error', (error) => { + logger.error(`File transfer failed: ${filename}`, error) + reject({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + }) + }) + } + + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } +} + +export default new WebSocketService() diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index 4b3ac70d45..1be0e2f486 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -1,4 +1,4 @@ -import { UpdateInfo } from 'builder-util-runtime' +import type { UpdateInfo } from 'builder-util-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock dependencies diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 86d4aef52c..1c9b438e4a 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -1,8 +1,10 @@ import { type Client, createClient } from '@libsql/client' import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' -import { ModelValidationError, validateModelId } from '@main/apiServer/utils' -import { AgentType, MCPTool, objectKeys, SlashCommand, Tool } from '@types' +import type { ModelValidationError } from '@main/apiServer/utils' +import { validateModelId } from '@main/apiServer/utils' +import type { AgentType, MCPTool, SlashCommand, Tool } from '@types' +import { objectKeys } from '@types' import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' @@ -10,7 +12,8 @@ import path from 'path' import { MigrationService } from './database/MigrationService' import * as schema from './database/schema' import { dbPath } from './drizzle.config' -import { AgentModelField, AgentModelValidationError } from './errors' +import type { AgentModelField } from './errors' +import { AgentModelValidationError } from './errors' import { builtinSlashCommands } from './services/claudecode/commands' import { builtinTools } from './services/claudecode/tools' @@ -33,7 +36,14 @@ export abstract class BaseService { protected static db: LibSQLDatabase | null = null protected static isInitialized = false protected static initializationPromise: Promise | null = null - protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools'] + protected jsonFields: string[] = [ + 'tools', + 'mcps', + 'configuration', + 'accessible_paths', + 'allowed_tools', + 'slash_commands' + ] /** * Initialize database with retry logic and proper error handling diff --git a/src/main/services/agents/database/MigrationService.ts b/src/main/services/agents/database/MigrationService.ts index fce09bc68b..834e39dd80 100644 --- a/src/main/services/agents/database/MigrationService.ts +++ b/src/main/services/agents/database/MigrationService.ts @@ -5,7 +5,7 @@ import { type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' -import * as schema from './schema' +import type * as schema from './schema' import { migrations, type NewMigration } from './schema/migrations.schema' const logger = loggerService.withContext('MigrationService') diff --git a/src/main/services/agents/database/schema/sessions.schema.ts b/src/main/services/agents/database/schema/sessions.schema.ts index 21ac2fe2c6..4b16a9ec41 100644 --- a/src/main/services/agents/database/schema/sessions.schema.ts +++ b/src/main/services/agents/database/schema/sessions.schema.ts @@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', { mcps: text('mcps'), // JSON array of MCP tool IDs allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist) + slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init configuration: text('configuration'), // JSON, extensible settings diff --git a/src/main/services/agents/errors.ts b/src/main/services/agents/errors.ts index b0df2341d7..95f1202e86 100644 --- a/src/main/services/agents/errors.ts +++ b/src/main/services/agents/errors.ts @@ -1,5 +1,5 @@ -import { ModelValidationError } from '@main/apiServer/utils' -import { AgentType } from '@types' +import type { ModelValidationError } from '@main/apiServer/utils' +import type { AgentType } from '@types' export type AgentModelField = 'model' | 'plan_model' | 'small_model' diff --git a/src/main/services/agents/interfaces/AgentStreamInterface.ts b/src/main/services/agents/interfaces/AgentStreamInterface.ts index 1b9c6f136d..7b52515256 100644 --- a/src/main/services/agents/interfaces/AgentStreamInterface.ts +++ b/src/main/services/agents/interfaces/AgentStreamInterface.ts @@ -1,9 +1,9 @@ // Agent-agnostic streaming interface // This interface should be implemented by all agent services -import { EventEmitter } from 'node:events' +import type { EventEmitter } from 'node:events' -import { GetAgentSessionResponse } from '@types' +import type { GetAgentSessionResponse } from '@types' import type { TextStreamPart } from 'ai' // Generic agent stream event that works with any agent type diff --git a/src/main/services/agents/plugins/PluginCacheStore.ts b/src/main/services/agents/plugins/PluginCacheStore.ts new file mode 100644 index 0000000000..77b427f4e8 --- /dev/null +++ b/src/main/services/agents/plugins/PluginCacheStore.ts @@ -0,0 +1,426 @@ +import { loggerService } from '@logger' +import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types' +import { CachedPluginsDataSchema } from '@types' +import * as fs from 'fs' +import * as path from 'path' + +const logger = loggerService.withContext('PluginCacheStore') + +interface PluginCacheStoreDeps { + allowedExtensions: string[] + getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills' + getClaudeBasePath: (workdir: string) => string + getClaudePluginDirectory: (workdir: string, type: PluginType) => string + getPluginsBasePath: () => string +} + +export class PluginCacheStore { + constructor(private readonly deps: PluginCacheStoreDeps) {} + + async listAvailableFilePlugins(type: 'agent' | 'command'): Promise { + const basePath = this.deps.getPluginsBasePath() + const directory = path.join(basePath, this.deps.getPluginDirectoryName(type)) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch (error) { + logger.warn(`Plugin directory not accessible: ${directory}`, { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + + const plugins: PluginMetadata[] = [] + const categories = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const categoryEntry of categories) { + if (!categoryEntry.isDirectory()) { + continue + } + + const category = categoryEntry.name + const categoryPath = path.join(directory, category) + const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(categoryPath, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) + plugins.push(metadata) + } catch (error) { + logger.warn(`Failed to parse plugin: ${file.name}`, { + category, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + return plugins + } + + async listAvailableSkills(): Promise { + const basePath = this.deps.getPluginsBasePath() + const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill')) + const skills: PluginMetadata[] = [] + + try { + await fs.promises.access(skillsPath) + } catch { + logger.warn('Skills directory not found', { skillsPath }) + return [] + } + + try { + const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) + logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + skills.push(metadata) + } catch (error) { + logger.warn(`Failed to parse skill folder: ${sourcePath}`, { + folderPath, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } catch (error) { + logger.error('Failed to scan skill directory', { + skillsPath, + error: error instanceof Error ? error.message : String(error) + }) + } + + return skills + } + + async readSourceContent(sourcePath: string): Promise { + const absolutePath = this.resolveSourcePath(sourcePath) + + try { + await fs.promises.access(absolutePath, fs.constants.R_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourcePath + } as PluginError + } + + try { + return await fs.promises.readFile(absolutePath, 'utf-8') + } catch (error) { + throw { + type: 'READ_FAILED', + path: sourcePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + resolveSourcePath(sourcePath: string): string { + const normalized = path.normalize(sourcePath) + + if (normalized.includes('..')) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path traversal detected', + path: sourcePath + } as PluginError + } + + const basePath = this.deps.getPluginsBasePath() + const absolutePath = path.join(basePath, normalized) + const resolvedPath = path.resolve(absolutePath) + + if (!resolvedPath.startsWith(path.resolve(basePath))) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path outside plugins directory', + path: sourcePath + } as PluginError + } + + return resolvedPath + } + + async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(sourceAbsolutePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourceAbsolutePath + } as PluginError + } + + if (!stats.isDirectory()) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill source is not a directory', + path: sourcePath + } as PluginError + } + } + + async validatePluginFile(filePath: string, maxFileSize: number): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + if (stats.size > maxFileSize) { + throw { + type: 'FILE_TOO_LARGE', + size: stats.size, + max: maxFileSize + } as PluginError + } + + const ext = path.extname(filePath).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + throw { + type: 'INVALID_FILE_TYPE', + extension: ext + } as PluginError + } + + try { + const basePath = this.deps.getPluginsBasePath() + const relativeSourcePath = path.relative(basePath, filePath) + const segments = relativeSourcePath.split(path.sep) + const rootDir = segments[0] + const agentDir = this.deps.getPluginDirectoryName('agent') + const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command' + const category = path.basename(path.dirname(filePath)) + + await parsePluginMetadata(filePath, relativeSourcePath, category, type) + } catch (error) { + throw { + type: 'INVALID_METADATA', + reason: 'Failed to parse frontmatter', + path: filePath + } as PluginError + } + } + + async listInstalled(workdir: string): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + const cacheData = await this.readCacheFile(claudePath) + + if (cacheData) { + logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir }) + return cacheData.plugins + } + + logger.info('Cache read failed, rebuilding from filesystem', { workdir }) + return await this.rebuild(workdir) + } + + async upsert(workdir: string, plugin: InstalledPlugin): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const updatedPlugin: InstalledPlugin = { + ...plugin, + metadata: { + ...plugin.metadata, + installedAt: plugin.metadata.installedAt ?? Date.now() + } + } + + const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type) + if (index >= 0) { + plugins[index] = updatedPlugin + } else { + plugins.push(updatedPlugin) + } + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async remove(workdir: string, filename: string, type: PluginType): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type)) + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins: filtered + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async rebuild(workdir: string): Promise { + logger.info('Rebuilding plugin cache from filesystem', { workdir }) + + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(claudePath, fs.constants.R_OK) + } catch { + logger.warn('.claude directory not found, returning empty plugin list', { claudePath }) + return [] + } + + const plugins: InstalledPlugin[] = [] + + await Promise.all([ + this.collectFilePlugins(workdir, 'agent', plugins), + this.collectFilePlugins(workdir, 'command', plugins), + this.collectSkillPlugins(workdir, plugins) + ]) + + try { + const cacheData: CachedPluginsData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + await this.writeCacheFile(claudePath, cacheData) + logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir }) + } catch (error) { + logger.error('Failed to write cache file after rebuild', { + error: error instanceof Error ? error.message : String(error) + }) + } + + return plugins + } + + private async collectFilePlugins( + workdir: string, + type: Exclude, + plugins: InstalledPlugin[] + ): Promise { + const directory = this.deps.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch { + logger.debug(`${type} directory not found or not accessible`, { directory }) + return + } + + const files = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(directory, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type) + plugins.push({ filename: file.name, type, metadata }) + } catch (error) { + logger.warn(`Failed to parse ${type} plugin: ${file.name}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise { + const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill') + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(skillsPath, fs.constants.R_OK) + } catch { + logger.debug('Skills directory not found or not accessible', { skillsPath }) + return + } + + const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + plugins.push({ filename: metadata.filename, type: 'skill', metadata }) + } catch (error) { + logger.warn(`Failed to parse skill plugin: ${sourcePath}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async readCacheFile(claudePath: string): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + try { + const content = await fs.promises.readFile(cachePath, 'utf-8') + const data = JSON.parse(content) + return CachedPluginsDataSchema.parse(data) + } catch (err) { + logger.warn(`Failed to read cache file at ${cachePath}`, { + error: err instanceof Error ? err.message : String(err) + }) + return null + } + } + + private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + const tempPath = `${cachePath}.tmp` + + const content = JSON.stringify(data, null, 2) + await fs.promises.writeFile(tempPath, content, 'utf-8') + await fs.promises.rename(tempPath, cachePath) + } +} diff --git a/src/main/services/agents/plugins/PluginInstaller.ts b/src/main/services/agents/plugins/PluginInstaller.ts new file mode 100644 index 0000000000..75acfc211f --- /dev/null +++ b/src/main/services/agents/plugins/PluginInstaller.ts @@ -0,0 +1,149 @@ +import { loggerService } from '@logger' +import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' +import type { PluginError } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' + +const logger = loggerService.withContext('PluginInstaller') + +export class PluginInstaller { + async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const tempPath = `${destPath}.tmp` + let fileCopied = false + + try { + await fs.promises.copyFile(sourceAbsolutePath, tempPath) + fileCopied = true + logger.debug('File copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logger.debug('File moved to final location', { agentId, destPath }) + } catch (error) { + if (fileCopied) { + await this.safeUnlink(tempPath, 'temp file') + } + throw this.toPluginError('install', error) + } + } + + async uninstallFilePlugin( + agentId: string, + filename: string, + type: 'agent' | 'command', + filePath: string + ): Promise { + try { + await fs.promises.unlink(filePath) + logger.debug('Plugin file deleted', { agentId, filename, type, filePath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall', error) + } + logger.warn('Plugin file already deleted', { agentId, filename, type, filePath }) + } + } + + async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.W_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + try { + await fs.promises.writeFile(filePath, content, 'utf8') + logger.debug('Plugin content written successfully', { + agentId, + filePath, + size: Buffer.byteLength(content, 'utf8') + }) + } catch (error) { + throw { + type: 'WRITE_FAILED', + path: filePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + + return crypto.createHash('sha256').update(content).digest('hex') + } + + async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const logContext = logger.withContext('installSkill') + let folderCopied = false + const tempPath = `${destPath}.tmp` + + try { + try { + await fs.promises.access(destPath) + await deleteDirectoryRecursive(destPath) + logContext.info('Removed existing skill folder', { agentId, destPath }) + } catch { + // No existing folder + } + + await copyDirectoryRecursive(sourceAbsolutePath, tempPath) + folderCopied = true + logContext.info('Skill folder copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logContext.info('Skill folder moved to final location', { agentId, destPath }) + } catch (error) { + if (folderCopied) { + await this.safeRemoveDirectory(tempPath, 'temp folder') + } + throw this.toPluginError('install-skill', error) + } + } + + async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise { + const logContext = logger.withContext('uninstallSkill') + + try { + await deleteDirectoryRecursive(skillPath) + logContext.info('Skill folder deleted', { agentId, folderName, skillPath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall-skill', error) + } + logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath }) + } + } + + private toPluginError(operation: string, error: unknown): PluginError { + return { + type: 'TRANSACTION_FAILED', + operation, + reason: error instanceof Error ? error.message : String(error) + } + } + + private async safeUnlink(targetPath: string, label: string): Promise { + try { + await fs.promises.unlink(targetPath) + logger.debug(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } + + private async safeRemoveDirectory(targetPath: string, label: string): Promise { + try { + await deleteDirectoryRecursive(targetPath) + logger.info(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } +} diff --git a/src/main/services/agents/plugins/PluginService.ts b/src/main/services/agents/plugins/PluginService.ts new file mode 100644 index 0000000000..3076522a26 --- /dev/null +++ b/src/main/services/agents/plugins/PluginService.ts @@ -0,0 +1,614 @@ +import { loggerService } from '@logger' +import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { + GetAgentResponse, + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginError, + PluginMetadata, + PluginType, + UninstallPluginOptions +} from '@types' +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { AgentService } from '../services/AgentService' +import { PluginCacheStore } from './PluginCacheStore' +import { PluginInstaller } from './PluginInstaller' + +const logger = loggerService.withContext('PluginService') + +interface PluginServiceConfig { + maxFileSize: number // bytes + cacheTimeout: number // milliseconds +} + +/** + * PluginService manages agent and command plugins from resources directory. + * + * Features: + * - Singleton pattern for consistent state management + * - Caching of available plugins for performance + * - Security validation (path traversal, file size, extensions) + * - Transactional install/uninstall operations + * - Integration with AgentService for metadata persistence + */ +export class PluginService { + private static instance: PluginService | null = null + + private availablePluginsCache: ListAvailablePluginsResult | null = null + private cacheTimestamp = 0 + private config: PluginServiceConfig + private readonly cacheStore: PluginCacheStore + private readonly installer: PluginInstaller + private readonly agentService: AgentService + + private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] + + private constructor(config?: Partial) { + this.config = { + maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default + cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default + } + this.agentService = AgentService.getInstance() + this.cacheStore = new PluginCacheStore({ + allowedExtensions: this.ALLOWED_EXTENSIONS, + getPluginDirectoryName: this.getPluginDirectoryName.bind(this), + getClaudeBasePath: this.getClaudeBasePath.bind(this), + getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this), + getPluginsBasePath: this.getPluginsBasePath.bind(this) + }) + this.installer = new PluginInstaller() + + logger.info('PluginService initialized', { + maxFileSize: this.config.maxFileSize, + cacheTimeout: this.config.cacheTimeout + }) + } + + /** + * Get singleton instance + */ + static getInstance(config?: Partial): PluginService { + if (!PluginService.instance) { + PluginService.instance = new PluginService(config) + } + return PluginService.instance + } + + /** + * List all available plugins from resources directory (with caching) + */ + async listAvailable(): Promise { + const now = Date.now() + + // Return cached data if still valid + if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { + logger.debug('Returning cached plugin list', { + cacheAge: now - this.cacheTimestamp + }) + return this.availablePluginsCache + } + + logger.info('Scanning available plugins') + + // Scan all plugin types + const [agents, commands, skills] = await Promise.all([ + this.cacheStore.listAvailableFilePlugins('agent'), + this.cacheStore.listAvailableFilePlugins('command'), + this.cacheStore.listAvailableSkills() + ]) + + const result: ListAvailablePluginsResult = { + agents, + commands, + skills, // NEW: include skills + total: agents.length + commands.length + skills.length + } + + // Update cache + this.availablePluginsCache = result + this.cacheTimestamp = now + + logger.info('Available plugins scanned', { + agentsCount: agents.length, + commandsCount: commands.length, + skillsCount: skills.length, + total: result.total + }) + + return result + } + + /** + * Install plugin with validation and transactional safety + */ + async install(options: InstallPluginOptions): Promise { + logger.info('Installing plugin', options) + + const context = await this.prepareInstallContext(options) + + if (options.type === 'skill') { + return await this.installSkillPlugin(options, context) + } + + return await this.installFilePlugin(options, context) + } + + private async prepareInstallContext(options: InstallPluginOptions): Promise<{ + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + }> { + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath) + + return { agent, workdir, sourceAbsolutePath } + } + + private async installSkillPlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath) + + const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') + const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) + + await this.ensureClaudeDirectory(workdir, 'skill') + const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + metadata.filename = sanitizedFolderName + + await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFolderName, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: 'skill' + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFolderName, + type: 'skill', + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Skill installed successfully', { + agentId: options.agentId, + sourcePath: options.sourcePath, + folderName: sanitizedFolderName + }) + + return metadataWithInstall + } + + private async installFilePlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + if (options.type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: options.type + } as PluginError + } + + const filePluginType: 'agent' | 'command' = options.type + + await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize) + + const category = path.basename(path.dirname(options.sourcePath)) + const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType) + + const sanitizedFilename = this.sanitizeFilename(metadata.filename) + metadata.filename = sanitizedFilename + + await this.ensureClaudeDirectory(workdir, filePluginType) + const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename) + + await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFilename, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: filePluginType + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFilename, + type: filePluginType, + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Plugin installed successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: filePluginType + }) + + return metadataWithInstall + } + + /** + * Uninstall plugin with cleanup + */ + async uninstall(options: UninstallPluginOptions): Promise { + logger.info('Uninstalling plugin', options) + + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + if (options.type === 'skill') { + const sanitizedFolderName = this.sanitizeFolderName(options.filename) + const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath) + await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill') + this.removeAgentPlugin(agent, sanitizedFolderName, 'skill') + + logger.info('Skill uninstalled successfully', { + agentId: options.agentId, + folderName: sanitizedFolderName + }) + + return + } + + const sanitizedFilename = this.sanitizeFilename(options.filename) + const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename) + + await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath) + await this.cacheStore.remove(workdir, sanitizedFilename, options.type) + this.removeAgentPlugin(agent, sanitizedFilename, options.type) + + logger.info('Plugin uninstalled successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: options.type + }) + } + + /** + * List installed plugins for an agent (from database + filesystem validation) + */ + async listInstalled(agentId: string): Promise { + logger.debug('Listing installed plugins', { agentId }) + + const agent = await this.getAgentOrThrow(agentId) + + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + logger.warn('Agent has no accessible paths', { agentId }) + return [] + } + + const plugins = await this.listInstalledFromCache(workdir) + + logger.debug('Listed installed plugins from cache', { + agentId, + count: plugins.length + }) + + return plugins + } + + /** + * Invalidate plugin cache (for development/testing) + */ + invalidateCache(): void { + this.availablePluginsCache = null + this.cacheTimestamp = 0 + logger.info('Plugin cache invalidated') + } + + // ============================================================================ + // Cache File Management (for installed plugins) + // ============================================================================ + + /** + * Read cache file from .claude/plugins.json + * Returns null if cache doesn't exist or is invalid + */ + + /** + * List installed plugins from cache file + * Falls back to filesystem scan if cache is missing or corrupt + */ + async listInstalledFromCache(workdir: string): Promise { + logger.debug('Listing installed plugins from cache', { workdir }) + return await this.cacheStore.listInstalled(workdir) + } + + /** + * Read plugin content from source (resources directory) + */ + async readContent(sourcePath: string): Promise { + logger.info('Reading plugin content', { sourcePath }) + const content = await this.cacheStore.readSourceContent(sourcePath) + logger.debug('Plugin content read successfully', { + sourcePath, + size: content.length + }) + return content + } + + /** + * Write plugin content to installed plugin (in agent's .claude directory) + * Note: Only works for file-based plugins (agents/commands), not skills + */ + async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { + logger.info('Writing plugin content', { agentId, filename, type }) + + const agent = await this.getAgentOrThrow(agentId) + const workdir = this.getWorkdirOrThrow(agent, agentId) + + await this.validateWorkdir(agent, workdir) + + // Check if plugin is installed + let installedPlugins = agent.installed_plugins ?? [] + if (installedPlugins.length === 0) { + installedPlugins = await this.cacheStore.listInstalled(workdir) + agent.installed_plugins = installedPlugins + } + const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) + + if (!installedPlugin) { + throw { + type: 'PLUGIN_NOT_INSTALLED', + filename, + agentId + } as PluginError + } + + if (type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: type + } as PluginError + } + + const filePluginType = type as 'agent' | 'command' + const filePath = this.getClaudePluginPath(workdir, filePluginType, filename) + const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content) + + const updatedMetadata: PluginMetadata = { + ...installedPlugin.metadata, + contentHash: newContentHash, + size: Buffer.byteLength(content, 'utf8'), + updatedAt: Date.now(), + filename, + type: filePluginType + } + const updatedPlugin: InstalledPlugin = { + filename, + type: filePluginType, + metadata: updatedMetadata + } + + await this.cacheStore.upsert(workdir, updatedPlugin) + this.upsertAgentPlugin(agent, updatedPlugin) + + logger.info('Plugin content updated successfully', { + agentId, + filename, + type: filePluginType, + newContentHash + }) + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Resolve plugin type to directory name under .claude + */ + private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' { + if (type === 'agent') { + return 'agents' + } + if (type === 'command') { + return 'commands' + } + return 'skills' + } + + /** + * Get the base .claude directory for a workdir + */ + private getClaudeBasePath(workdir: string): string { + return path.join(workdir, '.claude') + } + + /** + * Get the directory for a specific plugin type inside .claude + */ + private getClaudePluginDirectory(workdir: string, type: PluginType): string { + return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type)) + } + + /** + * Get the absolute path for a plugin file/folder inside .claude + */ + private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string { + return path.join(this.getClaudePluginDirectory(workdir, type), filename) + } + + /** + * Get absolute path to plugins directory (handles packaged vs dev) + */ + private getPluginsBasePath(): string { + // Use the utility function which handles both dev and production correctly + if (app.isPackaged) { + return path.join(process.resourcesPath, 'claude-code-plugins') + } + return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') + } + + /** + * Validate source path to prevent path traversal attacks + */ + private async getAgentOrThrow(agentId: string): Promise { + const agent = await this.agentService.getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + return agent + } + + private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string { + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + return workdir + } + + /** + * Validate workdir against agent's accessible paths + */ + private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise { + // Verify workdir is in agent's accessible_paths + if (!agent.accessible_paths?.includes(workdir)) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId: agent.id, + message: 'Workdir not in agent accessible paths' + } as PluginError + } + + // Verify workdir exists and is accessible + try { + await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) + } catch (error) { + throw { + type: 'WORKDIR_NOT_FOUND', + workdir, + message: 'Workdir does not exist or is not accessible' + } as PluginError + } + } + + private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void { + const existing = agent.installed_plugins ?? [] + const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type)) + agent.installed_plugins = [...filtered, plugin] + } + + private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void { + if (!agent.installed_plugins) { + agent.installed_plugins = [] + return + } + agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type)) + } + + /** + * Sanitize filename to remove unsafe characters (for agents/commands) + */ + private sanitizeFilename(filename: string): string { + // Remove path separators + let sanitized = filename.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore, dot) + sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Ensure .md extension + if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { + sanitized += '.md' + } + + return sanitized + } + + /** + * Sanitize folder name for skills (different rules than file names) + * NO dots allowed to avoid confusion with file extensions + */ + private sanitizeFolderName(folderName: string): string { + // Remove path separators + let sanitized = folderName.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore) + // NOTE: No dots allowed to avoid confusion with file extensions + sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') + + // Validate no extension was provided + if (folderName.includes('.')) { + logger.warn('Skill folder name contained dots, sanitized', { + original: folderName, + sanitized + }) + } + + return sanitized + } + + /** + * Ensure .claude subdirectory exists for the given plugin type + */ + private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { + const typeDir = this.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.mkdir(typeDir, { recursive: true }) + logger.debug('Ensured directory exists', { typeDir }) + } catch (error) { + logger.error('Failed to create directory', { + typeDir, + error: error instanceof Error ? error.message : String(error) + }) + throw { + type: 'PERMISSION_DENIED', + path: typeDir + } as PluginError + } + } +} + +export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index 53af37f670..07ed89a0f3 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -1,8 +1,9 @@ import path from 'node:path' +import { loggerService } from '@logger' +import { pluginService } from '@main/services/agents/plugins/PluginService' import { getDataPath } from '@main/utils' -import { - AgentBaseSchema, +import type { AgentEntity, CreateAgentRequest, CreateAgentResponse, @@ -11,11 +12,14 @@ import { UpdateAgentRequest, UpdateAgentResponse } from '@types' +import { AgentBaseSchema } from '@types' import { asc, count, desc, eq } from 'drizzle-orm' import { BaseService } from '../BaseService' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' -import { AgentModelField } from '../errors' +import type { AgentModelField } from '../errors' + +const logger = loggerService.withContext('AgentService') export class AgentService extends BaseService { private static instance: AgentService | null = null @@ -92,6 +96,24 @@ export class AgentService extends BaseService { const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse agent.tools = await this.listMcpTools(agent.type, agent.mcps) + + // Load installed_plugins from cache file instead of database + const workdir = agent.accessible_paths?.[0] + if (workdir) { + try { + agent.installed_plugins = await pluginService.listInstalledFromCache(workdir) + } catch (error) { + // Log error but don't fail the request + logger.warn(`Failed to load installed plugins for agent ${id}`, { + workdir, + error: error instanceof Error ? error.message : String(error) + }) + agent.installed_plugins = [] + } + } else { + agent.installed_plugins = [] + } + return agent } diff --git a/src/main/services/agents/services/SessionMessageService.ts b/src/main/services/agents/services/SessionMessageService.ts index f7d44e1612..46435fa371 100644 --- a/src/main/services/agents/services/SessionMessageService.ts +++ b/src/main/services/agents/services/SessionMessageService.ts @@ -5,12 +5,12 @@ import type { GetAgentSessionResponse, ListOptions } from '@types' -import { TextStreamPart } from 'ai' +import type { TextStreamPart } from 'ai' import { and, desc, eq, not } from 'drizzle-orm' import { BaseService } from '../BaseService' import { sessionMessagesTable } from '../database/schema' -import { AgentStreamEvent } from '../interfaces/AgentStreamInterface' +import type { AgentStreamEvent } from '../interfaces/AgentStreamInterface' import ClaudeCodeService from './claudecode' const logger = loggerService.withContext('SessionMessageService') diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index 5fcb60600d..c9ecf72c32 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -1,3 +1,5 @@ +import { loggerService } from '@logger' +import type { SlashCommand, UpdateSessionResponse } from '@types' import { AgentBaseSchema, type AgentEntity, @@ -5,14 +7,17 @@ import { type CreateSessionRequest, type GetAgentSessionResponse, type ListOptions, - type UpdateSessionRequest, - UpdateSessionResponse + type UpdateSessionRequest } from '@types' import { and, count, desc, eq, type SQL } from 'drizzle-orm' import { BaseService } from '../BaseService' import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema' -import { AgentModelField } from '../errors' +import type { AgentModelField } from '../errors' +import { pluginService } from '../plugins/PluginService' +import { builtinSlashCommands } from './claudecode/commands' + +const logger = loggerService.withContext('SessionService') export class SessionService extends BaseService { private static instance: SessionService | null = null @@ -29,6 +34,52 @@ export class SessionService extends BaseService { await BaseService.initialize() } + /** + * Override BaseService.listSlashCommands to merge builtin and plugin commands + */ + async listSlashCommands(agentType: string, agentId?: string): Promise { + const commands: SlashCommand[] = [] + + // Add builtin slash commands + if (agentType === 'claude-code') { + commands.push(...builtinSlashCommands) + } + + // Add local command plugins from .claude/commands/ + if (agentId) { + try { + const installedPlugins = await pluginService.listInstalled(agentId) + + // Filter for command type plugins + const commandPlugins = installedPlugins.filter((p) => p.type === 'command') + + // Convert plugin metadata to SlashCommand format + for (const plugin of commandPlugins) { + const commandName = plugin.metadata.filename.replace(/\.md$/i, '') + commands.push({ + command: `/${commandName}`, + description: plugin.metadata.description + }) + } + + logger.info('Listed slash commands', { + agentType, + agentId, + builtinCount: builtinSlashCommands.length, + localCount: commandPlugins.length, + totalCount: commands.length + }) + } catch (error) { + logger.warn('Failed to list local command plugins', { + agentId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + return commands + } + async createSession( agentId: string, req: Partial = {} @@ -78,6 +129,7 @@ export class SessionService extends BaseService { plan_model: serializedData.plan_model || null, small_model: serializedData.small_model || null, mcps: serializedData.mcps || null, + allowed_tools: serializedData.allowed_tools || null, configuration: serializedData.configuration || null, created_at: now, updated_at: now @@ -110,7 +162,13 @@ export class SessionService extends BaseService { const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse session.tools = await this.listMcpTools(session.agent_type, session.mcps) - session.slash_commands = await this.listSlashCommands(session.agent_type) + + // If slash_commands is not in database yet (e.g., first invoke before init message), + // fall back to builtin + local commands. Otherwise, use the merged commands from database. + if (!session.slash_commands || session.slash_commands.length === 0) { + session.slash_commands = await this.listSlashCommands(session.agent_type, agentId) + } + return session } diff --git a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts index 1c5c2ade6b..8f8c1df038 100644 --- a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts +++ b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts @@ -1,7 +1,7 @@ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' import { describe, expect, it } from 'vitest' -import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform' +import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform' const baseStreamMetadata = { parent_tool_use_id: null, @@ -10,6 +10,19 @@ const baseStreamMetadata = { const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}` +describe('stripLocalCommandTags', () => { + it('removes stdout wrapper while preserving inner text', () => { + const input = 'before echo "hi" after' + expect(stripLocalCommandTags(input)).toBe('before echo "hi" after') + }) + + it('strips multiple stdout/stderr blocks and leaves other content intact', () => { + const input = + 'line1\nkeep\nError' + expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError') + }) +}) + describe('Claude → AiSDK transform', () => { it('handles tool call streaming lifecycle', () => { const state = new ClaudeStreamState() diff --git a/src/main/services/agents/services/claudecode/commands.ts b/src/main/services/agents/services/claudecode/commands.ts index ce90e0978a..0ce4f4ccef 100644 --- a/src/main/services/agents/services/claudecode/commands.ts +++ b/src/main/services/agents/services/claudecode/commands.ts @@ -1,25 +1,12 @@ -import { SlashCommand } from '@types' +import type { SlashCommand } from '@types' export const builtinSlashCommands: SlashCommand[] = [ - { command: '/add-dir', description: 'Add additional working directories' }, - { command: '/agents', description: 'Manage custom AI subagents for specialized tasks' }, - { command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' }, { command: '/clear', description: 'Clear conversation history' }, { command: '/compact', description: 'Compact conversation with optional focus instructions' }, - { command: '/config', description: 'View/modify configuration' }, - { command: '/cost', description: 'Show token usage statistics' }, - { command: '/doctor', description: 'Checks the health of your Claude Code installation' }, - { command: '/help', description: 'Get usage help' }, - { command: '/init', description: 'Initialize project with CLAUDE.md guide' }, - { command: '/login', description: 'Switch Anthropic accounts' }, - { command: '/logout', description: 'Sign out from your Anthropic account' }, - { command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' }, - { command: '/memory', description: 'Edit CLAUDE.md memory files' }, - { command: '/model', description: 'Select or change the AI model' }, - { command: '/permissions', description: 'View or update permissions' }, - { command: '/pr_comments', description: 'View pull request comments' }, - { command: '/review', description: 'Request code review' }, - { command: '/status', description: 'View account and system statuses' }, - { command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' }, - { command: '/vim', description: 'Enter vim mode for alternating insert and command modes' } + { command: '/context', description: 'Visualize current context usage as a colored grid' }, + { + command: '/cost', + description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)' + }, + { command: '/todos', description: 'List current todo items' } ] diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 80cdae66cf..a8f3f54fa8 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -2,15 +2,17 @@ import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' -import { CanUseTool, McpHttpServerConfig, Options, query, 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' import { validateModelId } from '@main/apiServer/utils' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' -import { GetAgentSessionResponse } from '../..' -import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' +import type { GetAgentSessionResponse } from '../..' +import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' +import { sessionService } from '../SessionService' import { promptForToolApproval } from './tool-permissions' import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform' @@ -18,6 +20,7 @@ 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' +const NO_RESUME_COMMANDS = ['/clear'] type UserInputMessage = { type: 'user' @@ -105,7 +108,10 @@ class ClaudeCodeService implements AgentServiceInterface { ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey, ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost, ANTHROPIC_MODEL: modelInfo.modelId, - ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId, + ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId, + ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId, + // TODO: support set small model in UI + ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId, ELECTRON_RUN_AS_NODE: '1', ELECTRON_NO_ATTACH_CONSOLE: '1' } @@ -193,7 +199,7 @@ class ClaudeCodeService implements AgentServiceInterface { options.strictMcpConfig = true } - if (lastAgentSessionId) { + if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) { options.resume = lastAgentSessionId // TODO: use fork session when we support branching sessions // options.forkSession = true @@ -216,7 +222,15 @@ class ClaudeCodeService implements AgentServiceInterface { // Start async processing on the next tick so listeners can subscribe first setImmediate(() => { - this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => { + this.processSDKQuery( + userInputStream, + closeUserStream, + options, + aiStream, + errorChunks, + session.agent_id, + session.id + ).catch((error) => { logger.error('Unhandled Claude Code stream error', { error: error instanceof Error ? { name: error.name, message: error.message } : String(error) }) @@ -325,7 +339,9 @@ class ClaudeCodeService implements AgentServiceInterface { closePromptStream: () => void, options: Options, stream: ClaudeCodeStream, - errorChunks: string[] + errorChunks: string[], + agentId: string, + sessionId: string ): Promise { const jsonOutput: SDKMessage[] = [] let hasCompleted = false @@ -338,6 +354,62 @@ class ClaudeCodeService implements AgentServiceInterface { jsonOutput.push(message) + // Handle init message - merge builtin and SDK slash_commands + if (message.type === 'system' && message.subtype === 'init') { + const sdkSlashCommands = message.slash_commands || [] + logger.info('Received init message with slash commands', { + sessionId, + commands: sdkSlashCommands + }) + + try { + // Get builtin + local slash commands from BaseService + const existingCommands = await sessionService.listSlashCommands('claude-code', agentId) + + // Convert SDK slash_commands (string[]) to SlashCommand[] format + // Ensure all commands start with '/' + const sdkCommands = sdkSlashCommands.map((cmd) => { + const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}` + return { + command: normalizedCmd, + description: undefined + } + }) + + // Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name + const commandMap = new Map() + + for (const cmd of existingCommands) { + commandMap.set(cmd.command, cmd) + } + + for (const cmd of sdkCommands) { + if (!commandMap.has(cmd.command)) { + commandMap.set(cmd.command, cmd) + } + } + + const mergedCommands = Array.from(commandMap.values()) + + // Update session in database + await sessionService.updateSession(agentId, sessionId, { + slash_commands: mergedCommands + }) + + logger.info('Updated session with merged slash commands', { + sessionId, + existingCount: existingCommands.length, + sdkCount: sdkCommands.length, + totalCount: mergedCommands.length + }) + } catch (error) { + logger.error('Failed to update session slash_commands', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + if (message.type === 'assistant' || message.type === 'user') { logger.silly('claude response', { message, @@ -361,10 +433,19 @@ class ClaudeCodeService implements AgentServiceInterface { type: 'chunk', chunk }) + + // Close prompt stream when SDK signals completion or error + if (chunk.type === 'finish' || chunk.type === 'error') { + logger.info('Closing prompt stream as SDK signaled completion', { + chunkType: chunk.type, + reason: chunk.type === 'finish' ? 'finished' : 'error_occurred' + }) + closePromptStream() + logger.info('Prompt stream closed successfully') + } } } - hasCompleted = true const duration = Date.now() - startTime logger.debug('SDK query completed successfully', { diff --git a/src/main/services/agents/services/claudecode/tool-permissions.ts b/src/main/services/agents/services/claudecode/tool-permissions.ts index 1479b13ad1..c95f4c679e 100644 --- a/src/main/services/agents/services/claudecode/tool-permissions.ts +++ b/src/main/services/agents/services/claudecode/tool-permissions.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' -import { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' +import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { IpcChannel } from '@shared/IpcChannel' import { ipcMain } from 'electron' diff --git a/src/main/services/agents/services/claudecode/tools.ts b/src/main/services/agents/services/claudecode/tools.ts index 0785827cd5..483caded84 100644 --- a/src/main/services/agents/services/claudecode/tools.ts +++ b/src/main/services/agents/services/claudecode/tools.ts @@ -1,4 +1,4 @@ -import { Tool } from '@types' +import type { Tool } from '@types' // https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude export const builtinTools: Tool[] = [ diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index 4af3716c1d..41285175b4 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -20,7 +20,7 @@ * emitting `text-*` parts and a synthetic `finish-step`. */ -import { SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { loggerService } from '@logger' import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai' @@ -73,6 +73,23 @@ const emptyUsage: LanguageModelUsage = { */ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}` +/** + * Removes any local command stdout/stderr XML wrappers that should never surface to the UI. + */ +export const stripLocalCommandTags = (text: string): string => { + return text.replace(/(.*?)<\/local-command-\1>/gs, '$2') +} + +/** + * Filters out command-* tags from text content to prevent internal command + * messages from appearing in the user-facing UI. + * Removes tags like ... and ... + */ +const filterCommandTags = (text: string): string => { + const withoutLocalCommandTags = stripLocalCommandTags(text) + return withoutLocalCommandTags.replace(/]+>.*?<\/command-[^>]+>/gs, '').trim() +} + /** * Extracts provider metadata from the raw Claude message so we can surface it * on every emitted stream part for observability and debugging purposes. @@ -93,6 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => * blocks across calls so that incremental deltas can be correlated correctly. */ export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] { + logger.silly('Transforming SDKMessage', { message: sdkMessage }) switch (sdkMessage.type) { case 'assistant': return handleAssistantMessage(sdkMessage, state) @@ -126,7 +144,8 @@ function handleAssistantMessage( const isStreamingActive = state.hasActiveStep() if (typeof content === 'string') { - if (!content) { + const sanitizedContent = stripLocalCommandTags(content) + if (!sanitizedContent) { return chunks } @@ -148,7 +167,7 @@ function handleAssistantMessage( chunks.push({ type: 'text-delta', id: textId, - text: content, + text: sanitizedContent, providerMetadata }) chunks.push({ @@ -169,7 +188,10 @@ function handleAssistantMessage( switch (block.type) { case 'text': if (!isStreamingActive) { - textBlocks.push(block.text) + const sanitizedText = stripLocalCommandTags(block.text) + if (sanitizedText) { + textBlocks.push(sanitizedText) + } } break case 'tool_use': @@ -270,12 +292,17 @@ function handleUserMessage( const chunks: AgentStreamPart[] = [] const providerMetadata = sdkMessageToProviderMetadata(message) const content = message.message.content - + const isSynthetic = message.isSynthetic ?? false if (typeof content === 'string') { if (!content) { return chunks } + const filteredContent = filterCommandTags(content) + if (!filteredContent) { + return chunks + } + const id = message.uuid?.toString() || generateMessageId() chunks.push({ type: 'text-start', @@ -285,7 +312,7 @@ function handleUserMessage( chunks.push({ type: 'text-delta', id, - text: content, + text: filteredContent, providerMetadata }) chunks.push({ @@ -323,24 +350,30 @@ function handleUserMessage( providerExecuted: true }) } - } else if (block.type === 'text') { - const id = message.uuid?.toString() || generateMessageId() - chunks.push({ - type: 'text-start', - id, - providerMetadata - }) - chunks.push({ - type: 'text-delta', - id, - text: (block as { text: string }).text, - providerMetadata - }) - chunks.push({ - type: 'text-end', - id, - providerMetadata - }) + } else if (block.type === 'text' && !isSynthetic) { + const rawText = (block as { text: string }).text + const filteredText = filterCommandTags(rawText) + + // Only push text chunks if there's content after filtering + if (filteredText) { + const id = message.uuid?.toString() || generateMessageId() + chunks.push({ + type: 'text-start', + id, + providerMetadata + }) + chunks.push({ + type: 'text-delta', + id, + text: filteredText, + providerMetadata + }) + chunks.push({ + type: 'text-end', + id, + providerMetadata + }) + } } else { logger.warn('Unhandled user content block', { type: (block as any).type }) } @@ -517,6 +550,10 @@ function handleContentBlockDelta( logger.warn('Received text_delta for unknown block', { index }) return } + block.text = stripLocalCommandTags(block.text) + if (!block.text) { + break + } chunks.push({ type: 'text-delta', id: block.id, diff --git a/src/main/services/mcp/oauth/callback.ts b/src/main/services/mcp/oauth/callback.ts index 22d5b4c6bd..c13ecd5c07 100644 --- a/src/main/services/mcp/oauth/callback.ts +++ b/src/main/services/mcp/oauth/callback.ts @@ -1,12 +1,44 @@ import { loggerService } from '@logger' -import EventEmitter from 'events' +import { configManager } from '@main/services/ConfigManager' +import { locales } from '@main/utils/locales' +import type EventEmitter from 'events' import http from 'http' import { URL } from 'url' -import { OAuthCallbackServerOptions } from './types' +import type { OAuthCallbackServerOptions } from './types' const logger = loggerService.withContext('MCP:OAuthCallbackServer') +function getTranslation(key: string): string { + const language = configManager.getLanguage() + const localeData = locales[language] + + if (!localeData) { + logger.warn(`No locale data found for language: ${language}`) + return key + } + + const translations = localeData.translation as any + if (!translations) { + logger.warn(`No translations found for language: ${language}`) + return key + } + + const keys = key.split('.') + let value = translations + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k] + } else { + logger.warn(`Translation key not found: ${key} (failed at: ${k})`) + return key // fallback to key if translation not found + } + } + + return typeof value === 'string' ? value : key +} + export class CallBackServer { private server: Promise private events: EventEmitter @@ -28,6 +60,55 @@ export class CallBackServer { if (code) { // Emit the code event this.events.emit('auth-code-received', code) + // Send success response to browser + const title = getTranslation('settings.mcp.oauth.callback.title') + const message = getTranslation('settings.mcp.oauth.callback.message') + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(` + + + + + ${title} + + + +
+

${title}

+

${message}

+
+ + + `) + } else { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing authorization code') } } catch (error) { logger.error('Error processing OAuth callback:', error as Error) diff --git a/src/main/services/mcp/oauth/provider.ts b/src/main/services/mcp/oauth/provider.ts index 811ce8a275..29fdfc0c50 100644 --- a/src/main/services/mcp/oauth/provider.ts +++ b/src/main/services/mcp/oauth/provider.ts @@ -2,13 +2,17 @@ import path from 'node:path' import { loggerService } from '@logger' import { getConfigDir } from '@main/utils/file' -import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth' -import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth' +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth' +import type { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth' import open from 'open' import { sanitizeUrl } from 'strict-url-sanitise' import { JsonFileStorage } from './storage' -import { OAuthProviderOptions } from './types' +import type { OAuthProviderOptions } from './types' const logger = loggerService.withContext('MCP:OAuthClientProvider') diff --git a/src/main/services/mcp/oauth/storage.ts b/src/main/services/mcp/oauth/storage.ts index d2dbb589cc..1a872d1e1c 100644 --- a/src/main/services/mcp/oauth/storage.ts +++ b/src/main/services/mcp/oauth/storage.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens @@ -7,7 +7,8 @@ import { import fs from 'fs/promises' import path from 'path' -import { IOAuthStorage, OAuthStorageData, OAuthStorageSchema } from './types' +import type { IOAuthStorage, OAuthStorageData } from './types' +import { OAuthStorageSchema } from './types' const logger = loggerService.withContext('MCP:OAuthStorage') diff --git a/src/main/services/mcp/oauth/types.ts b/src/main/services/mcp/oauth/types.ts index f081fbe70c..1eecf08d4f 100644 --- a/src/main/services/mcp/oauth/types.ts +++ b/src/main/services/mcp/oauth/types.ts @@ -1,9 +1,9 @@ -import { +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' -import EventEmitter from 'events' +import type EventEmitter from 'events' import * as z from 'zod' export interface OAuthStorageData { diff --git a/src/main/services/memory/MemoryService.ts b/src/main/services/memory/MemoryService.ts index 85f182b686..3466e2c3c6 100644 --- a/src/main/services/memory/MemoryService.ts +++ b/src/main/services/memory/MemoryService.ts @@ -1,4 +1,5 @@ -import { Client, createClient } from '@libsql/client' +import type { Client } from '@libsql/client' +import { createClient } from '@libsql/client' import { loggerService } from '@logger' import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings' import type { diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index b2943e30ec..80cd547671 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import { isLinux } from '@main/constant' -import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' +import type { OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' +import { BuiltinOcrProviderIds } from '@types' import { ovOcrService } from './builtin/OvOcrService' import { ppocrService } from './builtin/PpocrService' diff --git a/src/main/services/ocr/builtin/OcrBaseService.ts b/src/main/services/ocr/builtin/OcrBaseService.ts index 9c36e79c3a..dabe2e50b8 100644 --- a/src/main/services/ocr/builtin/OcrBaseService.ts +++ b/src/main/services/ocr/builtin/OcrBaseService.ts @@ -1,4 +1,4 @@ -import { OcrHandler } from '@types' +import type { OcrHandler } from '@types' export abstract class OcrBaseService { abstract ocr: OcrHandler diff --git a/src/main/services/ocr/builtin/OvOcrService.ts b/src/main/services/ocr/builtin/OvOcrService.ts index 1650ca8832..052682be64 100644 --- a/src/main/services/ocr/builtin/OvOcrService.ts +++ b/src/main/services/ocr/builtin/OvOcrService.ts @@ -1,6 +1,8 @@ import { loggerService } from '@logger' import { isWin } from '@main/constant' -import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types' +import { HOME_CHERRY_DIR } from '@shared/config/constant' +import type { OcrOvConfig, OcrResult, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { exec } from 'child_process' import * as fs from 'fs' import * as os from 'os' @@ -12,7 +14,7 @@ import { OcrBaseService } from './OcrBaseService' const logger = loggerService.withContext('OvOcrService') const execAsync = promisify(exec) -const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat') +const PATH_BAT_FILE = path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr', 'run.npu.bat') export class OvOcrService extends OcrBaseService { constructor() { @@ -29,7 +31,7 @@ export class OvOcrService extends OcrBaseService { } private getOvOcrPath(): string { - return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr') } private getImgDir(): string { diff --git a/src/main/services/ocr/builtin/PpocrService.ts b/src/main/services/ocr/builtin/PpocrService.ts index 4b00be4bed..61c923c542 100644 --- a/src/main/services/ocr/builtin/PpocrService.ts +++ b/src/main/services/ocr/builtin/PpocrService.ts @@ -1,5 +1,6 @@ import { loadOcrImage } from '@main/utils/ocr' -import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types' +import type { ImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { net } from 'electron' import * as z from 'zod' diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts index b496df398e..f166718e4a 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,7 +1,8 @@ import { isLinux, isWin } from '@main/constant' import { loadOcrImage } from '@main/utils/ocr' import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' -import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types' +import type { ImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { OcrBaseService } from './OcrBaseService' diff --git a/src/main/services/ocr/builtin/TesseractService.ts b/src/main/services/ocr/builtin/TesseractService.ts index 9fd7bbcf01..8d41ce085d 100644 --- a/src/main/services/ocr/builtin/TesseractService.ts +++ b/src/main/services/ocr/builtin/TesseractService.ts @@ -2,12 +2,15 @@ import { loggerService } from '@logger' import { getIpCountry } from '@main/utils/ipService' import { loadOcrImage } from '@main/utils/ocr' import { MB } from '@shared/config/constant' -import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types' +import type { ImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { app } from 'electron' import fs from 'fs' import { isEqual } from 'lodash' import path from 'path' -import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' +import type { LanguageCode } from 'tesseract.js' +import type Tesseract from 'tesseract.js' +import { createWorker } from 'tesseract.js' import { OcrBaseService } from './OcrBaseService' diff --git a/src/main/services/remotefile/BaseFileService.ts b/src/main/services/remotefile/BaseFileService.ts index ff06eb0b44..49067b424d 100644 --- a/src/main/services/remotefile/BaseFileService.ts +++ b/src/main/services/remotefile/BaseFileService.ts @@ -1,4 +1,4 @@ -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' export abstract class BaseFileService { protected readonly provider: Provider diff --git a/src/main/services/remotefile/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts index 14f622757a..962214af51 100644 --- a/src/main/services/remotefile/FileServiceManager.ts +++ b/src/main/services/remotefile/FileServiceManager.ts @@ -1,6 +1,6 @@ -import { Provider } from '@types' +import type { Provider } from '@types' -import { BaseFileService } from './BaseFileService' +import type { BaseFileService } from './BaseFileService' import { GeminiService } from './GeminiService' import { MistralService } from './MistralService' import { OpenaiService } from './OpenAIService' diff --git a/src/main/services/remotefile/GeminiService.ts b/src/main/services/remotefile/GeminiService.ts index ba5a8fae80..16214dff5e 100644 --- a/src/main/services/remotefile/GeminiService.ts +++ b/src/main/services/remotefile/GeminiService.ts @@ -1,7 +1,8 @@ -import { File, Files, FileState, GoogleGenAI } from '@google/genai' +import type { File, Files } from '@google/genai' +import { FileState, GoogleGenAI } from '@google/genai' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' import { v4 as uuidv4 } from 'uuid' import { CacheService } from '../CacheService' diff --git a/src/main/services/remotefile/MistralService.ts b/src/main/services/remotefile/MistralService.ts index d3867a619d..6045e25972 100644 --- a/src/main/services/remotefile/MistralService.ts +++ b/src/main/services/remotefile/MistralService.ts @@ -2,8 +2,8 @@ import fs from 'node:fs/promises' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { Mistral } from '@mistralai/mistralai' -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { Mistral } from '@mistralai/mistralai' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' import { MistralClientManager } from '../MistralClientManager' import { BaseFileService } from './BaseFileService' diff --git a/src/main/services/remotefile/OpenAIService.ts b/src/main/services/remotefile/OpenAIService.ts index 734bf6f26a..59640f75a6 100644 --- a/src/main/services/remotefile/OpenAIService.ts +++ b/src/main/services/remotefile/OpenAIService.ts @@ -1,7 +1,7 @@ import OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' import * as fs from 'fs' import { CacheService } from '../CacheService' diff --git a/src/main/services/urlschema/mcp-install.ts b/src/main/services/urlschema/mcp-install.ts index ceb2e41ece..2d783255e4 100644 --- a/src/main/services/urlschema/mcp-install.ts +++ b/src/main/services/urlschema/mcp-install.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import { IpcChannel } from '@shared/IpcChannel' -import { MCPServer } from '@types' +import type { MCPServer } from '@types' import { windowService } from '../WindowService' @@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl') function installMCPServer(server: MCPServer) { const mainWindow = windowService.getMainWindow() + const now = Date.now() - if (!server.id) { - server.id = nanoid() + const payload: MCPServer = { + ...server, + id: server.id ?? nanoid(), + installSource: 'protocol', + isTrusted: false, + isActive: false, + trustedAt: undefined, + installedAt: server.installedAt ?? now } if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server) + mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload) } } diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 20305d1c9e..1432dccc8a 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -5,8 +5,9 @@ import os from 'node:os' import path from 'node:path' import { loggerService } from '@logger' -import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant' -import { FileMetadata, FileTypes, NotesTreeNode } from '@types' +import { audioExts, documentExts, HOME_CHERRY_DIR, imageExts, MB, textExts, videoExts } from '@shared/config/constant' +import type { FileMetadata, NotesTreeNode } from '@types' +import { FileTypes } from '@types' import chardet from 'chardet' import { app } from 'electron' import iconv from 'iconv-lite' @@ -159,7 +160,7 @@ export function getNotesDir() { } export function getConfigDir() { - return path.join(os.homedir(), '.cherrystudio', 'config') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'config') } export function getCacheDir() { @@ -171,7 +172,7 @@ export function getAppConfigDir(name: string) { } export function getMcpDir() { - return path.join(os.homedir(), '.cherrystudio', 'mcp') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'mcp') } /** diff --git a/src/main/utils/init.ts b/src/main/utils/init.ts index 63cf69e89b..20884b1eeb 100644 --- a/src/main/utils/init.ts +++ b/src/main/utils/init.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' import { isLinux, isPortable, isWin } from '@main/constant' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import { app } from 'electron' // Please don't import any other modules which is not node/electron built-in modules @@ -17,7 +18,7 @@ function hasWritePermission(path: string) { } function getConfigDir() { - return path.join(os.homedir(), '.cherrystudio', 'config') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'config') } export function initAppDataDir() { diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts index 493b837dd9..fdb5ff1743 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -1,4 +1,4 @@ -import { ImageFileMetadata } from '@types' +import type { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' const preprocessImage = async (buffer: Buffer): Promise => { diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index f028f2d3c7..f36e86861d 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import { spawn } from 'child_process' import fs from 'fs' import os from 'os' @@ -46,11 +47,11 @@ export async function getBinaryName(name: string): Promise { export async function getBinaryPath(name?: string): Promise { if (!name) { - return path.join(os.homedir(), '.cherrystudio', 'bin') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') } const binaryName = await getBinaryName(name) - const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binariesDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const binariesDirExists = fs.existsSync(binariesDir) return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName } diff --git a/src/main/utils/windowUtil.ts b/src/main/utils/windowUtil.ts index 4000156fff..454ce917f5 100644 --- a/src/main/utils/windowUtil.ts +++ b/src/main/utils/windowUtil.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron' +import type { BrowserWindow } from 'electron' import { isDev, isWin } from '../constant' diff --git a/src/main/utils/zoom.ts b/src/main/utils/zoom.ts index d91d411591..d75b31724e 100644 --- a/src/main/utils/zoom.ts +++ b/src/main/utils/zoom.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron' +import type { BrowserWindow } from 'electron' import { configManager } from '../services/ConfigManager' diff --git a/src/preload/index.ts b/src/preload/index.ts index 12aa9fd3b2..671284d88d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,13 +1,13 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import { electronAPI } from '@electron-toolkit/preload' -import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' -import { SpanContext } from '@opentelemetry/api' -import { TerminalConfig, UpgradeChannel } from '@shared/config/constant' +import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' +import type { SpanContext } from '@opentelemetry/api' +import type { TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { Notification } from '@types' -import { +import type { AddMemoryOptions, AssistantMessage, FileListResponse, @@ -33,8 +33,9 @@ import { ThemeMode, WebDavConfig } from '@types' -import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' -import { CreateDirectoryOptions } from 'webdav' +import type { OpenDialogOptions } from 'electron' +import { contextBridge, ipcRenderer, shell, webUtils } from 'electron' +import type { CreateDirectoryOptions } from 'webdav' import type { InstalledPlugin, @@ -47,6 +48,16 @@ import type { } from '../renderer/src/types/plugin' import type { ActionItem } from '../renderer/src/types/selectionTypes' +type DirectoryListOptions = { + recursive?: boolean + maxDepth?: number + includeHidden?: boolean + includeFiles?: boolean + includeDirectories?: boolean + maxEntries?: number + searchPattern?: string +} + export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) { if (spanContext) { const data = { type: 'trace', context: spanContext } @@ -200,6 +211,8 @@ const api = { openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath), getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath), + listDirectory: (dirPath: string, options?: DirectoryListOptions) => + ipcRenderer.invoke(IpcChannel.File_ListDirectory, dirPath, options), checkFileName: (dirPath: string, fileName: string, isFile: boolean) => ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile), validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath), @@ -525,7 +538,16 @@ const api = { getStatus: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus), start: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Start), restart: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Restart), - stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop), + onReady: (callback: () => void): (() => void) => { + const listener = () => { + callback() + } + ipcRenderer.on(IpcChannel.ApiServer_Ready, listener) + return () => { + ipcRenderer.removeListener(IpcChannel.ApiServer_Ready, listener) + } + } }, claudeCodePlugin: { listAvailable: (): Promise> => @@ -541,6 +563,13 @@ const api = { ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath), writeContent: (options: WritePluginContentOptions): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) + }, + webSocket: { + start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), + stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), + status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), + sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), + getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) } } diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts index 7e46ae82a8..a7c633130a 100644 --- a/src/preload/preload.d.ts +++ b/src/preload/preload.d.ts @@ -1,4 +1,4 @@ -import { ElectronAPI } from '@electron-toolkit/preload' +import type { ElectronAPI } from '@electron-toolkit/preload' import type { WindowApiType } from './index' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 78396c49e7..703015e30e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6,11 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' -import { ToastPortal } from './components/ToastPortal' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' import { CodeStyleProvider } from './context/CodeStyleProvider' -import { HeroUIProvider } from './context/HeroUIProvider' import { NotificationProvider } from './context/NotificationProvider' import StyleSheetManager from './context/StyleSheetManager' import { ThemeProvider } from './context/ThemeProvider' @@ -34,24 +32,21 @@ function App(): React.ReactElement { return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ) diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index edaebfa144..cb3c494f1b 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -1,6 +1,7 @@ import '@renderer/databases' -import { FC, useMemo } from 'react' +import type { FC } from 'react' +import { useMemo } from 'react' import { HashRouter, Route, Routes } from 'react-router-dom' import Sidebar from './components/app/Sidebar' diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 3f27f9440c..544ec443aa 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -4,8 +4,10 @@ */ import { loggerService } from '@logger' -import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' -import { Chunk, ChunkType } from '@renderer/types/chunk' +import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@renderer/types' +import { WebSearchSource } from '@renderer/types' +import type { Chunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { ProviderSpecificError } from '@renderer/types/provider-specific-error' import { formatErrorMessage } from '@renderer/utils/error' import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' @@ -28,18 +30,22 @@ export class AiSdkToChunkAdapter { private onSessionUpdate?: (sessionId: string) => void private responseStartTimestamp: number | null = null private firstTokenTimestamp: number | null = null + private hasTextContent = false + private getSessionWasCleared?: () => boolean constructor( private onChunk: (chunk: Chunk) => void, mcpTools: MCPTool[] = [], accumulate?: boolean, enableWebSearch?: boolean, - onSessionUpdate?: (sessionId: string) => void + onSessionUpdate?: (sessionId: string) => void, + getSessionWasCleared?: () => boolean ) { this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools) this.accumulate = accumulate this.enableWebSearch = enableWebSearch || false this.onSessionUpdate = onSessionUpdate + this.getSessionWasCleared = getSessionWasCleared } private markFirstTokenIfNeeded() { @@ -82,8 +88,9 @@ export class AiSdkToChunkAdapter { } this.resetTimingState() this.responseStartTimestamp = Date.now() - // Reset link converter state at the start of stream + // Reset state at the start of stream this.isFirstChunk = true + this.hasTextContent = false try { while (true) { @@ -127,6 +134,8 @@ export class AiSdkToChunkAdapter { const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue if (agentRawMessage.type === 'init' && agentRawMessage.session_id) { this.onSessionUpdate?.(agentRawMessage.session_id) + } else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) { + this.onSessionUpdate?.(agentRawMessage.session_id) } this.onChunk({ type: ChunkType.RAW, @@ -141,6 +150,7 @@ export class AiSdkToChunkAdapter { }) break case 'text-delta': { + this.hasTextContent = true const processedText = chunk.text || '' let finalText: string @@ -299,6 +309,25 @@ export class AiSdkToChunkAdapter { } case 'finish': { + // Check if session was cleared (e.g., /clear command) and no text was output + const sessionCleared = this.getSessionWasCleared?.() ?? false + if (sessionCleared && !this.hasTextContent) { + // Inject a "context cleared" message for the user + const clearMessage = '✨ Context cleared. Starting fresh conversation.' + this.onChunk({ + type: ChunkType.TEXT_START + }) + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: clearMessage + }) + this.onChunk({ + type: ChunkType.TEXT_COMPLETE, + text: clearMessage + }) + final.text = clearMessage + } + const usage = { completion_tokens: chunk.totalUsage?.outputTokens || 0, prompt_tokens: chunk.totalUsage?.inputTokens || 0, diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts index 4d18024f89..32c7e534e3 100644 --- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts +++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { processKnowledgeReferences } from '@renderer/services/KnowledgeService' -import { +import type { BaseTool, MCPCallToolResponse, MCPTool, @@ -14,7 +14,8 @@ import { MCPToolResultContent, NormalToolResponse } from '@renderer/types' -import { Chunk, ChunkType } from '@renderer/types/chunk' +import type { Chunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai' const logger = loggerService.withContext('ToolCallChunkHandler') diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index f6d6673cb9..434b2322cd 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -7,21 +7,23 @@ * 2. 暂时保持接口兼容性 */ +import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway' import { createExecutor } from '@cherrystudio/ai-core' import { loggerService } from '@logger' import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' import { addSpan, endSpan } from '@renderer/services/SpanManagerService' -import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' -import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' +import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' +import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } from '@renderer/types' import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes' import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils' import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic' -import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai' +import { gateway, type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai' import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter' import LegacyAiProvider from './legacy/index' -import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas' -import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder' +import type { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas' +import type { AiSdkMiddlewareConfig } from './middleware/AiSdkMiddlewareBuilder' +import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder' import { buildPlugins } from './plugins/PluginBuilder' import { createAiSdkProvider } from './provider/factory' import { @@ -438,6 +440,18 @@ export default class ModernAiProvider { // 代理其他方法到原有实现 public async models() { + if (this.actualProvider.id === SystemProviderIds['ai-gateway']) { + const formatModel = function (models: GatewayLanguageModelEntry[]): Model[] { + return models.map((m) => ({ + id: m.id, + name: m.name, + provider: 'gateway', + group: m.id.split('/')[0], + description: m.description ?? undefined + })) + } + return formatModel((await gateway.getAvailableModels()).models) + } return this.legacyProvider.models() } diff --git a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts index e7194c240b..bc416161c4 100644 --- a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts @@ -1,11 +1,11 @@ import { loggerService } from '@logger' import { isNewApiProvider } from '@renderer/config/providers' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient' -import { BaseApiClient } from './BaseApiClient' +import type { BaseApiClient } from './BaseApiClient' import { CherryAiAPIClient } from './cherryai/CherryAiAPIClient' import { GeminiAPIClient } from './gemini/GeminiAPIClient' import { VertexAPIClient } from './gemini/VertexAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 4a41db7c97..767cad1294 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -9,29 +9,31 @@ import { REFERENCE_PROMPT } from '@renderer/config/prompts' import { isSupportServiceTierProvider } from '@renderer/config/providers' import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio' import { getAssistantSettings } from '@renderer/services/AssistantService' -import { +import type { Assistant, - FileTypes, GenerateImageParams, - GroqServiceTiers, - isGroqServiceTier, - isOpenAIServiceTier, KnowledgeReference, MCPCallToolResponse, MCPTool, MCPToolResponse, MemoryItem, Model, - OpenAIServiceTiers, OpenAIVerbosity, Provider, - SystemProviderIds, ToolCallResponse, WebSearchProviderResponse, WebSearchResponse } from '@renderer/types' -import { Message } from '@renderer/types/newMessage' import { + FileTypes, + GroqServiceTiers, + isGroqServiceTier, + isOpenAIServiceTier, + OpenAIServiceTiers, + SystemProviderIds +} from '@renderer/types' +import type { Message } from '@renderer/types/newMessage' +import type { RequestOptions, SdkInstance, SdkMessageParam, @@ -49,8 +51,8 @@ import { defaultTimeout } from '@shared/config/constant' import { defaultAppHeaders } from '@shared/utils' import { isEmpty } from 'lodash' -import { CompletionsContext } from '../middleware/types' -import { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types' +import type { CompletionsContext } from '../middleware/types' +import type { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types' const logger = loggerService.withContext('BaseApiClient') diff --git a/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts index 36a207ecb3..fb5568a6e8 100644 --- a/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts @@ -1,4 +1,4 @@ -import { +import type { GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -7,7 +7,7 @@ import { Provider, ToolCallResponse } from '@renderer/types' -import { +import type { RequestOptions, SdkInstance, SdkMessageParam, @@ -19,13 +19,13 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { CompletionsContext } from '../middleware/types' -import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' +import type { CompletionsContext } from '../middleware/types' +import type { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' import { BaseApiClient } from './BaseApiClient' -import { GeminiAPIClient } from './gemini/GeminiAPIClient' -import { OpenAIAPIClient } from './openai/OpenAIApiClient' -import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' -import { RequestTransformer, ResponseChunkTransformer } from './types' +import type { GeminiAPIClient } from './gemini/GeminiAPIClient' +import type { OpenAIAPIClient } from './openai/OpenAIApiClient' +import type { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' +import type { RequestTransformer, ResponseChunkTransformer } from './types' /** * MixedAPIClient - 适用于可能含有多种接口类型的Provider diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts index b8870fdb7c..03ec1e1ea2 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts @@ -1,4 +1,4 @@ -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts index dd85730c36..bcff572410 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts @@ -6,7 +6,7 @@ import { VertexAPIClient } from '@renderer/aiCore/legacy/clients/gemini/VertexAP import { NewAPIClient } from '@renderer/aiCore/legacy/clients/newapi/NewAPIClient' import { OpenAIAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIResponseAPIClient' -import { EndpointType, Model, Provider } from '@renderer/types' +import type { EndpointType, Model, Provider } from '@renderer/types' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@renderer/config/models', () => ({ diff --git a/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts index 1149c04b35..a8a0ca5ac6 100644 --- a/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts @@ -1,8 +1,8 @@ import { isOpenAILLMModel } from '@renderer/config/models' -import { Model, Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' -import { BaseApiClient } from '../BaseApiClient' +import type { BaseApiClient } from '../BaseApiClient' import { GeminiAPIClient } from '../gemini/GeminiAPIClient' import { MixedBaseAPIClient } from '../MixedBaseApiClient' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts index 4f9bb28e41..15f3cf1007 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts @@ -1,5 +1,5 @@ -import Anthropic from '@anthropic-ai/sdk' -import { +import type Anthropic from '@anthropic-ai/sdk' +import type { Base64ImageSource, ImageBlockParam, MessageParam, @@ -8,7 +8,7 @@ import { ToolUseBlock, WebSearchTool20250305 } from '@anthropic-ai/sdk/resources' -import { +import type { ContentBlock, ContentBlockParam, MessageCreateParamsBase, @@ -23,27 +23,24 @@ import { WebSearchToolResultError } from '@anthropic-ai/sdk/resources/messages' import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages' -import AnthropicVertex from '@anthropic-ai/vertex-sdk' +import type AnthropicVertex from '@anthropic-ai/vertex-sdk' import { loggerService } from '@logger' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, isClaudeReasoningModel, isReasoningModel, isWebSearchModel } from '@renderer/config/models' import { getAssistantSettings } from '@renderer/services/AssistantService' import FileManager from '@renderer/services/FileManager' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { Assistant, - EFFORT_RATIO, - FileTypes, MCPCallToolResponse, MCPTool, MCPToolResponse, Model, Provider, - ToolCallResponse, - WebSearchSource + ToolCallResponse } from '@renderer/types' -import { - ChunkType, +import { EFFORT_RATIO, FileTypes, WebSearchSource } from '@renderer/types' +import type { ErrorChunk, LLMWebSearchCompleteChunk, LLMWebSearchInProgressChunk, @@ -53,8 +50,9 @@ import { ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { type Message } from '@renderer/types/newMessage' -import { +import type { AnthropicSdkMessageParam, AnthropicSdkParams, AnthropicSdkRawChunk, @@ -71,9 +69,9 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic' import { t } from 'i18next' -import { GenericChunk } from '../../middleware/schemas' +import type { GenericChunk } from '../../middleware/schemas' import { BaseApiClient } from '../BaseApiClient' -import { AnthropicStreamListener, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from '../types' +import type { AnthropicStreamListener, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from '../types' const logger = loggerService.withContext('AnthropicAPIClient') diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts index bb96ac90ae..2fe16e8875 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts @@ -1,8 +1,8 @@ -import Anthropic from '@anthropic-ai/sdk' +import type Anthropic from '@anthropic-ai/sdk' import AnthropicVertex from '@anthropic-ai/vertex-sdk' import { loggerService } from '@logger' import { getVertexAILocation, getVertexAIProjectId, getVertexAIServiceAccount } from '@renderer/hooks/useVertexAI' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { isEmpty } from 'lodash' import { AnthropicAPIClient } from './AnthropicAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts index 1de8a724c4..c4b0140579 100644 --- a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts @@ -1,25 +1,26 @@ import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from '@aws-sdk/client-bedrock' import { BedrockRuntimeClient, + type BedrockRuntimeClientConfig, ConverseCommand, InvokeModelCommand, InvokeModelWithResponseStreamCommand } from '@aws-sdk/client-bedrock-runtime' import { loggerService } from '@logger' -import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' +import type { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, isReasoningModel } from '@renderer/config/models' import { getAwsBedrockAccessKeyId, + getAwsBedrockApiKey, + getAwsBedrockAuthType, getAwsBedrockRegion, getAwsBedrockSecretAccessKey } from '@renderer/hooks/useAwsBedrock' import { getAssistantSettings } from '@renderer/services/AssistantService' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { Assistant, - EFFORT_RATIO, - FileTypes, GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -28,15 +29,11 @@ import { Provider, ToolCallResponse } from '@renderer/types' -import { - ChunkType, - MCPToolCreatedChunk, - TextDeltaChunk, - ThinkingDeltaChunk, - ThinkingStartChunk -} from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import { EFFORT_RATIO, FileTypes } from '@renderer/types' +import type { MCPToolCreatedChunk, TextDeltaChunk, ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { AwsBedrockSdkInstance, AwsBedrockSdkMessageParam, AwsBedrockSdkParams, @@ -58,7 +55,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi import { t } from 'i18next' import { BaseApiClient } from '../BaseApiClient' -import { RequestTransformer, ResponseChunkTransformer } from '../types' +import type { RequestTransformer, ResponseChunkTransformer } from '../types' const logger = loggerService.withContext('AwsBedrockAPIClient') @@ -81,32 +78,48 @@ export class AwsBedrockAPIClient extends BaseApiClient< } const region = getAwsBedrockRegion() - const accessKeyId = getAwsBedrockAccessKeyId() - const secretAccessKey = getAwsBedrockSecretAccessKey() + const authType = getAwsBedrockAuthType() if (!region) { - throw new Error('AWS region is required. Please configure AWS-Region in extra headers.') + throw new Error('AWS region is required. Please configure AWS region in settings.') } - if (!accessKeyId || !secretAccessKey) { - throw new Error('AWS credentials are required. Please configure AWS-Access-Key-ID and AWS-Secret-Access-Key.') + // Build client configuration based on auth type + let clientConfig: BedrockRuntimeClientConfig + + if (authType === 'iam') { + // IAM credentials authentication + const accessKeyId = getAwsBedrockAccessKeyId() + const secretAccessKey = getAwsBedrockSecretAccessKey() + + if (!accessKeyId || !secretAccessKey) { + throw new Error('AWS credentials are required. Please configure Access Key ID and Secret Access Key.') + } + + clientConfig = { + region, + credentials: { + accessKeyId, + secretAccessKey + } + } + } else { + // API Key authentication + const awsBedrockApiKey = getAwsBedrockApiKey() + + if (!awsBedrockApiKey) { + throw new Error('AWS Bedrock API Key is required. Please configure API Key in settings.') + } + + clientConfig = { + region, + token: { token: awsBedrockApiKey }, + authSchemePreference: ['httpBearerAuth'] + } } - const client = new BedrockRuntimeClient({ - region, - credentials: { - accessKeyId, - secretAccessKey - } - }) - - const bedrockClient = new BedrockClient({ - region, - credentials: { - accessKeyId, - secretAccessKey - } - }) + const client = new BedrockRuntimeClient(clientConfig) + const bedrockClient = new BedrockClient(clientConfig) this.sdkInstance = { client, bedrockClient, region } return this.sdkInstance diff --git a/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts index 08e4d9df34..b72e0a8829 100644 --- a/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts @@ -1,6 +1,6 @@ -import OpenAI from '@cherrystudio/openai' -import { Provider } from '@renderer/types' -import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk' +import type OpenAI from '@cherrystudio/openai' +import type { Provider } from '@renderer/types' +import type { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts index 33d34c7961..27e659c1af 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts @@ -1,14 +1,9 @@ -import { +import type { Content, - createPartFromUri, File, FunctionCall, GenerateContentConfig, GenerateImagesConfig, - GoogleGenAI, - HarmBlockThreshold, - HarmCategory, - Modality, Model as GeminiModel, Part, SafetySetting, @@ -16,6 +11,7 @@ import { ThinkingConfig, Tool } from '@google/genai' +import { createPartFromUri, GoogleGenAI, HarmBlockThreshold, HarmCategory, Modality } from '@google/genai' import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import { @@ -26,11 +22,9 @@ import { isVisionModel } from '@renderer/config/models' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { Assistant, - EFFORT_RATIO, FileMetadata, - FileTypes, FileUploadResponse, GenerateImageParams, MCPCallToolResponse, @@ -38,12 +32,13 @@ import { MCPToolResponse, Model, Provider, - ToolCallResponse, - WebSearchSource + ToolCallResponse } from '@renderer/types' -import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import { EFFORT_RATIO, FileTypes, WebSearchSource } from '@renderer/types' +import type { LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { GeminiOptions, GeminiSdkMessageParam, GeminiSdkParams, @@ -62,9 +57,9 @@ import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/u import { defaultTimeout, MB } from '@shared/config/constant' import { t } from 'i18next' -import { GenericChunk } from '../../middleware/schemas' +import type { GenericChunk } from '../../middleware/schemas' import { BaseApiClient } from '../BaseApiClient' -import { RequestTransformer, ResponseChunkTransformer } from '../types' +import type { RequestTransformer, ResponseChunkTransformer } from '../types' const logger = loggerService.withContext('GeminiAPIClient') diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts index 37e6677367..49a96a8f19 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts @@ -1,7 +1,7 @@ import { GoogleGenAI } from '@google/genai' import { loggerService } from '@logger' import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI' -import { Model, Provider, VertexProvider } from '@renderer/types' +import type { Model, Provider, VertexProvider } from '@renderer/types' import { isEmpty } from 'lodash' import { AnthropicVertexClient } from '../anthropic/AnthropicVertexClient' diff --git a/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts index 58b349a2be..f3e04e0d55 100644 --- a/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' -import { Model, Provider } from '@renderer/types' -import { NewApiModel } from '@renderer/types/sdk' +import type { Model, Provider } from '@renderer/types' +import type { NewApiModel } from '@renderer/types/sdk' import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' -import { BaseApiClient } from '../BaseApiClient' +import type { BaseApiClient } from '../BaseApiClient' import { GeminiAPIClient } from '../gemini/GeminiAPIClient' import { MixedBaseAPIClient } from '../MixedBaseApiClient' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index 618d9b461b..8ff25e356d 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -1,5 +1,6 @@ -import OpenAI, { AzureOpenAI } from '@cherrystudio/openai' -import { +import type { AzureOpenAI } from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' +import type { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool @@ -48,25 +49,28 @@ import { mapLanguageToQwenMTModel } from '@renderer/config/translate' import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' import { estimateTextTokens } from '@renderer/services/TokenService' // For Copilot token -import { +import type { Assistant, - EFFORT_RATIO, - FileTypes, - isSystemProvider, - isTranslateAssistant, MCPCallToolResponse, MCPTool, MCPToolResponse, Model, OpenAIServiceTier, Provider, + ToolCallResponse +} from '@renderer/types' +import { + EFFORT_RATIO, + FileTypes, + isSystemProvider, + isTranslateAssistant, SystemProviderIds, - ToolCallResponse, WebSearchSource } from '@renderer/types' -import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import type { TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { OpenAIExtraBody, OpenAIModality, OpenAISdkMessageParam, @@ -86,8 +90,8 @@ import { import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' import { t } from 'i18next' -import { GenericChunk } from '../../middleware/schemas' -import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' +import type { GenericChunk } from '../../middleware/schemas' +import type { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' import { OpenAIBaseClient } from './OpenAIBaseClient' const logger = loggerService.withContext('OpenAIApiClient') @@ -188,7 +192,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< extra_body: { google: { thinking_config: { - thinkingBudget: 0 + thinking_budget: 0 } } } @@ -323,8 +327,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient< extra_body: { google: { thinking_config: { - thinkingBudget: -1, - includeThoughts: true + thinking_budget: -1, + include_thoughts: true } } } @@ -334,8 +338,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient< extra_body: { google: { thinking_config: { - thinkingBudget: budgetTokens, - includeThoughts: true + thinking_budget: budgetTokens, + include_thoughts: true } } } @@ -666,7 +670,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) { suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}` } else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) { - suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinkingBudget}` + suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}` } // FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题 // 临时解决方案是强制poe用string content,但是其实poe部分支持array diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 8a0a3fe0f4..abd1793618 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -10,9 +10,9 @@ import { import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings } from '@renderer/services/AssistantService' import store from '@renderer/store' -import { SettingsState } from '@renderer/store/settings' -import { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' -import { +import type { SettingsState } from '@renderer/store/settings' +import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' +import type { OpenAIResponseSdkMessageParam, OpenAIResponseSdkParams, OpenAIResponseSdkRawChunk, diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts index 5d13d6ff70..b9131be661 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts @@ -1,8 +1,8 @@ import OpenAI, { AzureOpenAI } from '@cherrystudio/openai' -import { ResponseInput } from '@cherrystudio/openai/resources/responses/responses' +import type { ResponseInput } from '@cherrystudio/openai/resources/responses/responses' import { loggerService } from '@logger' -import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' -import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types' +import type { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' +import type { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types' import { isGPT5SeriesModel, isOpenAIChatCompletionOnlyModel, @@ -14,21 +14,20 @@ import { } from '@renderer/config/models' import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { FileMetadata, - FileTypes, MCPCallToolResponse, MCPTool, MCPToolResponse, Model, OpenAIServiceTier, Provider, - ToolCallResponse, - WebSearchSource + ToolCallResponse } from '@renderer/types' +import { FileTypes, WebSearchSource } from '@renderer/types' import { ChunkType } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import type { Message } from '@renderer/types/newMessage' +import type { OpenAIResponseSdkMessageParam, OpenAIResponseSdkParams, OpenAIResponseSdkRawChunk, @@ -48,7 +47,7 @@ import { MB } from '@shared/config/constant' import { t } from 'i18next' import { isEmpty } from 'lodash' -import { RequestTransformer, ResponseChunkTransformer } from '../types' +import type { RequestTransformer, ResponseChunkTransformer } from '../types' import { OpenAIAPIClient } from './OpenAIApiClient' import { OpenAIBaseClient } from './OpenAIBaseClient' diff --git a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts index 5dc91550a0..179bb54a1e 100644 --- a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts @@ -1,7 +1,8 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' -import { objectKeys, Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' +import { objectKeys } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts index 57b54b9618..345496e156 100644 --- a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts @@ -1,7 +1,7 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' -import { Model, Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/types.ts b/src/renderer/src/aiCore/legacy/clients/types.ts index 6d10f4285c..bf7b129d93 100644 --- a/src/renderer/src/aiCore/legacy/clients/types.ts +++ b/src/renderer/src/aiCore/legacy/clients/types.ts @@ -1,8 +1,8 @@ -import Anthropic from '@anthropic-ai/sdk' -import OpenAI from '@cherrystudio/openai' -import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types' -import { Provider } from '@renderer/types' -import { +import type Anthropic from '@anthropic-ai/sdk' +import type OpenAI from '@cherrystudio/openai' +import type { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types' +import type { Provider } from '@renderer/types' +import type { AnthropicSdkRawChunk, OpenAIResponseSdkRawChunk, OpenAIResponseSdkRawOutput, @@ -15,8 +15,8 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { CompletionsParams, GenericChunk } from '../middleware/schemas' -import { CompletionsContext } from '../middleware/types' +import type { CompletionsParams, GenericChunk } from '../middleware/schemas' +import type { CompletionsContext } from '../middleware/types' /** * 原始流监听器接口 diff --git a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts index c04e08fb7c..ea6c141e31 100644 --- a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts @@ -1,7 +1,7 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' -import { Provider } from '@renderer/types' -import { GenerateImageParams } from '@renderer/types' +import type { Provider } from '@renderer/types' +import type { GenerateImageParams } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/index.ts b/src/renderer/src/aiCore/legacy/index.ts index adc81f03ad..da6cdb6726 100644 --- a/src/renderer/src/aiCore/legacy/index.ts +++ b/src/renderer/src/aiCore/legacy/index.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' import { ApiClientFactory } from '@renderer/aiCore/legacy/clients/ApiClientFactory' -import { BaseApiClient } from '@renderer/aiCore/legacy/clients/BaseApiClient' +import type { BaseApiClient } from '@renderer/aiCore/legacy/clients/BaseApiClient' import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@renderer/config/models' import { getProviderByModel } from '@renderer/services/AssistantService' import { withSpanResult } from '@renderer/services/SpanManagerService' -import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' +import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' import type { GenerateImageParams, Model, Provider } from '@renderer/types' import type { RequestOptions, SdkModel } from '@renderer/types/sdk' import { isSupportedToolUse } from '@renderer/utils/mcp-tools' diff --git a/src/renderer/src/aiCore/legacy/middleware/builder.ts b/src/renderer/src/aiCore/legacy/middleware/builder.ts index 2ea20d4937..1d0b9d136d 100644 --- a/src/renderer/src/aiCore/legacy/middleware/builder.ts +++ b/src/renderer/src/aiCore/legacy/middleware/builder.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { DefaultCompletionsNamedMiddlewares } from './register' -import { BaseContext, CompletionsMiddleware, MethodMiddleware } from './types' +import type { BaseContext, CompletionsMiddleware, MethodMiddleware } from './types' const logger = loggerService.withContext('aiCore:MiddlewareBuilder') diff --git a/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts index a733e45d70..5f24797813 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts @@ -1,8 +1,9 @@ import { loggerService } from '@logger' -import { Chunk, ChunkType, ErrorChunk } from '@renderer/types/chunk' +import type { Chunk, ErrorChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { addAbortController, removeAbortController } from '@renderer/utils/abortController' -import { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsParams, CompletionsResult } from '../schemas' import type { CompletionsContext, CompletionsMiddleware } from '../types' const logger = loggerService.withContext('aiCore:AbortHandlerMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts index dde98cbd1e..7d6a7f631a 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' import { isZhipuModel } from '@renderer/config/models' import { getStoreProviders } from '@renderer/hooks/useStore' -import { Chunk } from '@renderer/types/chunk' +import type { Chunk } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext } from '../types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext } from '../types' import { createErrorChunk } from '../utils' const logger = loggerService.withContext('ErrorHandlerMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts index 57498b97fb..0325e4e21a 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' -import { Usage } from '@renderer/types' +import type { Usage } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'FinalChunkConsumerAndNotifierMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts index acd371d777..480cbbc39f 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' -import { BaseContext, MethodMiddleware, MiddlewareAPI } from '../types' +import type { BaseContext, MethodMiddleware, MiddlewareAPI } from '../types' const logger = loggerService.withContext('LoggingMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/composer.ts b/src/renderer/src/aiCore/legacy/middleware/composer.ts index 82b9fd1704..97bbf0a38d 100644 --- a/src/renderer/src/aiCore/legacy/middleware/composer.ts +++ b/src/renderer/src/aiCore/legacy/middleware/composer.ts @@ -1,5 +1,5 @@ import { withSpanResult } from '@renderer/services/SpanManagerService' -import { +import type { RequestOptions, SdkInstance, SdkMessageParam, @@ -10,16 +10,10 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { BaseApiClient } from '../clients' -import { CompletionsParams, CompletionsResult } from './schemas' -import { - BaseContext, - CompletionsContext, - CompletionsMiddleware, - MethodMiddleware, - MIDDLEWARE_CONTEXT_SYMBOL, - MiddlewareAPI -} from './types' +import type { BaseApiClient } from '../clients' +import type { CompletionsParams, CompletionsResult } from './schemas' +import type { BaseContext, CompletionsContext, CompletionsMiddleware, MethodMiddleware, MiddlewareAPI } from './types' +import { MIDDLEWARE_CONTEXT_SYMBOL } from './types' /** * Creates the initial context for a method call, populating method-specific fields. / diff --git a/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts index fc0327925e..6affa5a565 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' -import { MCPCallToolResponse, MCPTool, MCPToolResponse, Model } from '@renderer/types' -import { ChunkType, MCPToolCreatedChunk } from '@renderer/types/chunk' -import { SdkMessageParam, SdkRawOutput, SdkToolCall } from '@renderer/types/sdk' +import type { MCPCallToolResponse, MCPTool, MCPToolResponse, Model } from '@renderer/types' +import type { MCPToolCreatedChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { SdkMessageParam, SdkRawOutput, SdkToolCall } from '@renderer/types/sdk' import { callBuiltInTool, callMCPTool, @@ -12,8 +13,8 @@ import { } from '@renderer/utils/mcp-tools' import { confirmSameNameTools, requestToolConfirmation, setToolIdToNameMapping } from '@renderer/utils/userConfirmation' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'McpToolChunkMiddleware' const MAX_TOOL_RECURSION_DEPTH = 20 // 防止无限递归 diff --git a/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts index 0d59ad9de6..04bfd751e2 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts @@ -1,9 +1,9 @@ import { AnthropicAPIClient } from '@renderer/aiCore/legacy/clients/anthropic/AnthropicAPIClient' -import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' +import type { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' -import { AnthropicStreamListener } from '../../clients/types' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { AnthropicStreamListener } from '../../clients/types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'RawStreamListenerMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts index 850da8306d..bdab7b8783 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts @@ -1,9 +1,9 @@ import { loggerService } from '@logger' -import { SdkRawChunk } from '@renderer/types/sdk' +import type { SdkRawChunk } from '@renderer/types/sdk' -import { ResponseChunkTransformerContext } from '../../clients/types' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { ResponseChunkTransformerContext } from '../../clients/types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ResponseTransformMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts index 8bb5266319..b6dc13e602 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts @@ -1,8 +1,8 @@ -import { SdkRawChunk } from '@renderer/types/sdk' +import type { SdkRawChunk } from '@renderer/types/sdk' import { asyncGeneratorToReadableStream, createSingleChunkReadableStream } from '@renderer/utils/stream' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' import { isAsyncIterable } from '../utils' export const MIDDLEWARE_NAME = 'StreamAdapterMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts index 41157bf504..837244981a 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts @@ -1,8 +1,8 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'TextChunkMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts index 2149d8fe79..5920cdc0ea 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts @@ -1,8 +1,9 @@ import { loggerService } from '@logger' -import { ChunkType, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk' +import type { ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ThinkChunkMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts index 71831a3ef6..ebc86f5a5e 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts @@ -1,8 +1,8 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'TransformCoreToSdkParamsMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts index ae346af836..3365b163b6 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts @@ -2,8 +2,8 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' const logger = loggerService.withContext('WebSearchMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts index 40ab43c561..0df303e41e 100644 --- a/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts @@ -1,4 +1,4 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { toFile } from '@cherrystudio/openai/uploads' import { isDedicatedImageGenerationModel } from '@renderer/config/models' import FileManager from '@renderer/services/FileManager' @@ -6,9 +6,9 @@ import { ChunkType } from '@renderer/types/chunk' import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { defaultTimeout } from '@shared/config/constant' -import { BaseApiClient } from '../../clients/BaseApiClient' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { BaseApiClient } from '../../clients/BaseApiClient' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ImageGenerationMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts index 447b9d2f23..dea679eaac 100644 --- a/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -1,17 +1,18 @@ import { loggerService } from '@logger' -import { Model } from '@renderer/types' -import { - ChunkType, +import type { Model } from '@renderer/types' +import type { TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { getLowerBaseModelName } from '@renderer/utils' -import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction' +import type { TagConfig } from '@renderer/utils/tagExtraction' +import { TagExtractor } from '@renderer/utils/tagExtraction' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' const logger = loggerService.withContext('ThinkingTagExtractionMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts index 1f559bf5ad..38d842e08d 100644 --- a/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts @@ -1,11 +1,13 @@ import { loggerService } from '@logger' -import { MCPTool } from '@renderer/types' -import { ChunkType, MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk' +import type { MCPTool } from '@renderer/types' +import type { MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { parseToolUse } from '@renderer/utils/mcp-tools' -import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction' +import type { TagConfig } from '@renderer/utils/tagExtraction' +import { TagExtractor } from '@renderer/utils/tagExtraction' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ToolUseExtractionMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/index.ts b/src/renderer/src/aiCore/legacy/middleware/index.ts index 64be4edd44..66213c33b8 100644 --- a/src/renderer/src/aiCore/legacy/middleware/index.ts +++ b/src/renderer/src/aiCore/legacy/middleware/index.ts @@ -1,4 +1,4 @@ -import { CompletionsMiddleware, MethodMiddleware } from './types' +import type { CompletionsMiddleware, MethodMiddleware } from './types' // /** // * Wraps a provider instance with middlewares. diff --git a/src/renderer/src/aiCore/legacy/middleware/schemas.ts b/src/renderer/src/aiCore/legacy/middleware/schemas.ts index ce89934f02..9119d818db 100644 --- a/src/renderer/src/aiCore/legacy/middleware/schemas.ts +++ b/src/renderer/src/aiCore/legacy/middleware/schemas.ts @@ -1,9 +1,9 @@ -import { Assistant, MCPTool } from '@renderer/types' -import { Chunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { SdkRawChunk, SdkRawOutput } from '@renderer/types/sdk' +import type { Assistant, MCPTool } from '@renderer/types' +import type { Chunk } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { SdkRawChunk, SdkRawOutput } from '@renderer/types/sdk' -import { ProcessingState } from './types' +import type { ProcessingState } from './types' // ============================================================================ // Core Request Types - 核心请求结构 diff --git a/src/renderer/src/aiCore/legacy/middleware/types.ts b/src/renderer/src/aiCore/legacy/middleware/types.ts index 0a7dbe390b..3762035107 100644 --- a/src/renderer/src/aiCore/legacy/middleware/types.ts +++ b/src/renderer/src/aiCore/legacy/middleware/types.ts @@ -1,6 +1,6 @@ -import { MCPToolResponse, Metrics, Usage, WebSearchResponse } from '@renderer/types' -import { Chunk, ErrorChunk } from '@renderer/types/chunk' -import { +import type { MCPToolResponse, Metrics, Usage, WebSearchResponse } from '@renderer/types' +import type { Chunk, ErrorChunk } from '@renderer/types/chunk' +import type { SdkInstance, SdkMessageParam, SdkParams, @@ -10,8 +10,8 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { BaseApiClient } from '../clients' -import { CompletionsParams, CompletionsResult } from './schemas' +import type { BaseApiClient } from '../clients' +import type { CompletionsParams, CompletionsResult } from './schemas' /** * Symbol to uniquely identify middleware context objects. diff --git a/src/renderer/src/aiCore/legacy/middleware/utils.ts b/src/renderer/src/aiCore/legacy/middleware/utils.ts index 12a2fe651d..32e94e16b6 100644 --- a/src/renderer/src/aiCore/legacy/middleware/utils.ts +++ b/src/renderer/src/aiCore/legacy/middleware/utils.ts @@ -1,4 +1,5 @@ -import { ChunkType, ErrorChunk } from '@renderer/types/chunk' +import type { ErrorChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' /** * Creates an ErrorChunk object with a standardized structure. diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index b10607b670..3f14917cdd 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,10 +1,12 @@ -import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' +import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models' import { isSupportEnableThinkingProvider } from '@renderer/config/providers' -import { type Assistant, MCPTool, type Message, type Model, type Provider } from '@renderer/types' +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 { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' +import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' import { isEmpty } from 'lodash' import { isOpenRouterGeminiGenerateImageModel } from '../utils/image' diff --git a/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts b/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts index 9d7d933bc1..3e5624983c 100644 --- a/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' const logger = loggerService.withContext('noThinkMiddleware') diff --git a/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts b/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts index 0110d9a4f0..792192b931 100644 --- a/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts @@ -1,4 +1,4 @@ -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' /** * Returns a LanguageModelMiddleware that ensures the OpenRouter provider is configured to support both diff --git a/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts b/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts index 34515a42c9..931831a1c6 100644 --- a/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts @@ -1,4 +1,4 @@ -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' /** * Qwen Thinking Middleware diff --git a/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts b/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts index 6d3ba37d1d..7bb00aff55 100644 --- a/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' const logger = loggerService.withContext('toolChoiceMiddleware') diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index c249142330..eb46eb7524 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -1,10 +1,10 @@ -import { AiPlugin } from '@cherrystudio/ai-core' +import type { AiPlugin } from '@cherrystudio/ai-core' import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' -import { Assistant } from '@renderer/types' +import type { Assistant } from '@renderer/types' -import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder' +import type { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder' import { searchOrchestrationPlugin } from './searchOrchestrationPlugin' import { createTelemetryPlugin } from './telemetryPlugin' diff --git a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts index 7e662ddee6..6be577f194 100644 --- a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts +++ b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts @@ -18,7 +18,8 @@ import { getDefaultModel, getProviderByModel } from '@renderer/services/Assistan import store from '@renderer/store' import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory' import type { Assistant } from '@renderer/types' -import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract' +import type { ExtractResults } from '@renderer/utils/extract' +import { extractInfoFromXML } from '@renderer/utils/extract' import type { LanguageModel, ModelMessage } from 'ai' import { generateText } from 'ai' import { isEmpty } from 'lodash' diff --git a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts index 0f06091c5a..485d339d25 100644 --- a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts +++ b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts @@ -8,10 +8,11 @@ import { definePlugin } from '@cherrystudio/ai-core' import { loggerService } from '@logger' -import { Context, context as otelContext, Span, SpanContext, trace, Tracer } from '@opentelemetry/api' +import type { Context, Span, SpanContext, Tracer } from '@opentelemetry/api' +import { context as otelContext, trace } from '@opentelemetry/api' import { currentSpan } from '@renderer/services/SpanManagerService' import { webTraceService } from '@renderer/services/WebTraceService' -import { Assistant } from '@renderer/types' +import type { Assistant } from '@renderer/types' import { AiSdkSpanAdapter } from '../trace/AiSdkSpanAdapter' diff --git a/src/renderer/src/aiCore/prepareParams/fileProcessor.ts b/src/renderer/src/aiCore/prepareParams/fileProcessor.ts index 49048122c2..5ee8812238 100644 --- a/src/renderer/src/aiCore/prepareParams/fileProcessor.ts +++ b/src/renderer/src/aiCore/prepareParams/fileProcessor.ts @@ -8,7 +8,7 @@ import { loggerService } from '@logger' import { getProviderByModel } from '@renderer/services/AssistantService' import type { FileMetadata, Message, Model } from '@renderer/types' import { FileTypes } from '@renderer/types' -import { FileMessageBlock } from '@renderer/types/newMessage' +import type { FileMessageBlock } from '@renderer/types/newMessage' import { findFileBlocks } from '@renderer/utils/messageUtils/find' import type { FilePart, TextPart } from 'ai' diff --git a/src/renderer/src/aiCore/prepareParams/messageConverter.ts b/src/renderer/src/aiCore/prepareParams/messageConverter.ts index 46cacb5b74..72f387d9a4 100644 --- a/src/renderer/src/aiCore/prepareParams/messageConverter.ts +++ b/src/renderer/src/aiCore/prepareParams/messageConverter.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models' import type { Message, Model } from '@renderer/types' -import { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage' +import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage' import { findFileBlocks, findImageBlocks, diff --git a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts index 4a3c3f4bbf..b6e4b25843 100644 --- a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts +++ b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts @@ -85,6 +85,19 @@ export function supportsLargeFileUpload(model: Model): boolean { }) } +/** + * 检查模型是否支持TopP + */ +export function supportsTopP(model: Model): boolean { + const provider = getProviderByModel(model) + + if (provider?.type === 'anthropic' || model?.endpoint_type === 'anthropic') { + return false + } + + return true +} + /** * 获取提供商特定的文件大小限制 */ diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index c693ed235d..397c481cf3 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -7,7 +7,7 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge' import { vertex } from '@ai-sdk/google-vertex/edge' -import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' +import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas' import { loggerService } from '@logger' import { @@ -21,7 +21,7 @@ import { } from '@renderer/config/models' import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' import store from '@renderer/store' -import { CherryWebSearchConfig } from '@renderer/store/websearch' +import type { CherryWebSearchConfig } from '@renderer/store/websearch' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' @@ -34,6 +34,7 @@ import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' import { getAnthropicThinkingBudget } from '../utils/reasoning' import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' +import { supportsTopP } from './modelCapabilities' import { getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') @@ -176,20 +177,27 @@ export async function buildStreamTextParams( messages: sdkMessages, maxOutputTokens: maxTokens, temperature: getTemperature(assistant, model), - topP: getTopP(assistant, model), abortSignal: options.requestOptions?.signal, headers: options.requestOptions?.headers, providerOptions, stopWhen: stepCountIs(20), maxRetries: 0 } + + if (supportsTopP(model)) { + params.topP = getTopP(assistant, model) + } + if (tools) { params.tools = tools } + if (assistant.prompt) { params.system = await replacePromptVariables(assistant.prompt, model.name) } + logger.debug('params', params) + return { params, modelId: model.id, diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index eb6e73c8ae..39786231e6 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -21,10 +21,45 @@ vi.mock('@renderer/store', () => ({ } })) +vi.mock('@renderer/utils/api', () => ({ + formatApiHost: vi.fn((host, isSupportedAPIVersion = true) => { + if (isSupportedAPIVersion === false) { + return host // Return host as-is when isSupportedAPIVersion is false + } + return `${host}/v1` // Default behavior when isSupportedAPIVersion is true + }), + routeToEndpoint: vi.fn((host) => ({ + baseURL: host, + endpoint: '/chat/completions' + })) +})) + +vi.mock('@renderer/config/providers', async (importOriginal) => { + const actual = (await importOriginal()) as any + return { + ...actual, + isCherryAIProvider: vi.fn(), + isPerplexityProvider: vi.fn(), + isAnthropicProvider: vi.fn(() => false), + isAzureOpenAIProvider: vi.fn(() => false), + isGeminiProvider: vi.fn(() => false), + isNewApiProvider: vi.fn(() => false) + } +}) + +vi.mock('@renderer/hooks/useVertexAI', () => ({ + isVertexProvider: vi.fn(() => false), + isVertexAIConfigured: vi.fn(() => false), + createVertexProvider: vi.fn() +})) + +import { isCherryAIProvider, isPerplexityProvider } from '@renderer/config/providers' +import { getProviderByModel } from '@renderer/services/AssistantService' import type { Model, Provider } from '@renderer/types' +import { formatApiHost } from '@renderer/utils/api' import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' -import { providerToAiSdkConfig } from '../providerConfig' +import { getActualProvider, providerToAiSdkConfig } from '../providerConfig' const createWindowKeyv = () => { const store = new Map() @@ -46,11 +81,31 @@ const createCopilotProvider = (): Provider => ({ isSystem: true }) -const createModel = (id: string, name = id): Model => ({ +const createModel = (id: string, name = id, provider = 'copilot'): Model => ({ id, name, - provider: 'copilot', - group: 'copilot' + provider, + group: provider +}) + +const createCherryAIProvider = (): Provider => ({ + id: 'cherryai', + type: 'openai', + name: 'CherryAI', + apiKey: 'test-key', + apiHost: 'https://api.cherryai.com', + models: [], + isSystem: false +}) + +const createPerplexityProvider = (): Provider => ({ + id: 'perplexity', + type: 'openai', + name: 'Perplexity', + apiKey: 'test-key', + apiHost: 'https://api.perplexity.ai', + models: [], + isSystem: false }) describe('Copilot responses routing', () => { @@ -87,3 +142,134 @@ describe('Copilot responses routing', () => { expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id']) }) }) + +describe('CherryAI provider configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + it('formats CherryAI provider apiHost with false parameter', () => { + const provider = createCherryAIProvider() + const model = createModel('gpt-4', 'GPT-4', 'cherryai') + + // Mock the functions to simulate CherryAI provider detection + vi.mocked(isCherryAIProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider which should trigger formatProviderApiHost + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with false as the second parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.cherryai.com', false) + expect(actualProvider.apiHost).toBe('https://api.cherryai.com') + }) + + it('does not format non-CherryAI provider with false parameter', () => { + const provider = { + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: false + } as Provider + const model = createModel('gpt-4', 'GPT-4', 'openai') + + // Mock the functions to simulate non-CherryAI provider + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with default parameters (true) + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') + }) + + it('handles CherryAI provider with empty apiHost', () => { + const provider = createCherryAIProvider() + provider.apiHost = '' + const model = createModel('gpt-4', 'GPT-4', 'cherryai') + + vi.mocked(isCherryAIProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + const actualProvider = getActualProvider(model) + + expect(formatApiHost).toHaveBeenCalledWith('', false) + expect(actualProvider.apiHost).toBe('') + }) +}) + +describe('Perplexity provider configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + it('formats Perplexity provider apiHost with false parameter', () => { + const provider = createPerplexityProvider() + const model = createModel('sonar', 'Sonar', 'perplexity') + + // Mock the functions to simulate Perplexity provider detection + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider which should trigger formatProviderApiHost + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with false as the second parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.perplexity.ai', false) + expect(actualProvider.apiHost).toBe('https://api.perplexity.ai') + }) + + it('does not format non-Perplexity provider with false parameter', () => { + const provider = { + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: false + } as Provider + const model = createModel('gpt-4', 'GPT-4', 'openai') + + // Mock the functions to simulate non-Perplexity provider + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(false) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with default parameters (true) + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') + }) + + it('handles Perplexity provider with empty apiHost', () => { + const provider = createPerplexityProvider() + provider.apiHost = '' + const model = createModel('sonar', 'Sonar', 'perplexity') + + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + const actualProvider = getActualProvider(model) + + expect(formatApiHost).toHaveBeenCalledWith('', false) + expect(actualProvider.apiHost).toBe('') + }) +}) diff --git a/src/renderer/src/aiCore/provider/config/aihubmix.ts b/src/renderer/src/aiCore/provider/config/aihubmix.ts index 819e9cd28b..8feed89909 100644 --- a/src/renderer/src/aiCore/provider/config/aihubmix.ts +++ b/src/renderer/src/aiCore/provider/config/aihubmix.ts @@ -2,7 +2,7 @@ * AiHubMix规则集 */ import { isOpenAILLMModel } from '@renderer/config/models' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { provider2Provider, startsWith } from './helper' import type { RuleSet } from './types' @@ -52,7 +52,7 @@ const AIHUBMIX_RULES: RuleSet = { } } ], - fallbackRule: (provider: Provider) => provider + fallbackRule: (provider: Provider) => extraProviderConfig(provider) } export const aihubmixProviderCreator = provider2Provider.bind(null, AIHUBMIX_RULES) diff --git a/src/renderer/src/aiCore/provider/config/newApi.ts b/src/renderer/src/aiCore/provider/config/newApi.ts index 5277495cdb..97de62597d 100644 --- a/src/renderer/src/aiCore/provider/config/newApi.ts +++ b/src/renderer/src/aiCore/provider/config/newApi.ts @@ -1,7 +1,7 @@ /** * NewAPI规则集 */ -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { endpointIs, provider2Provider } from './helper' import type { RuleSet } from './types' diff --git a/src/renderer/src/aiCore/provider/factory.ts b/src/renderer/src/aiCore/provider/factory.ts index 62211100fe..569b5628cd 100644 --- a/src/renderer/src/aiCore/provider/factory.ts +++ b/src/renderer/src/aiCore/provider/factory.ts @@ -1,7 +1,7 @@ import { hasProviderConfigByAlias, type ProviderId, resolveProviderConfigId } from '@cherrystudio/ai-core/provider' import { createProvider as createProviderCore } from '@cherrystudio/ai-core/provider' import { loggerService } from '@logger' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import type { Provider as AiSdkProvider } from 'ai' import { initializeNewProviders } from './providerInitialization' @@ -84,6 +84,8 @@ export async function createAiSdkProvider(config) { config.providerId = `${config.providerId}-chat` } else if (config.providerId === 'azure' && config.options?.mode === 'responses') { config.providerId = `${config.providerId}-responses` + } else if (config.providerId === 'cherryin' && config.options?.mode === 'chat') { + config.providerId = 'cherryin-chat' } localProvider = await createProviderCore(config.providerId, config.options) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 15323751c2..4eb1ffeed7 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -9,11 +9,15 @@ import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' import { isAnthropicProvider, isAzureOpenAIProvider, + isCherryAIProvider, isGeminiProvider, - isNewApiProvider + isNewApiProvider, + isPerplexityProvider } from '@renderer/config/providers' import { getAwsBedrockAccessKeyId, + getAwsBedrockApiKey, + getAwsBedrockAuthType, getAwsBedrockRegion, getAwsBedrockSecretAccessKey } from '@renderer/hooks/useAwsBedrock' @@ -98,6 +102,10 @@ function formatProviderApiHost(provider: Provider): Provider { formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost) } else if (isVertexProvider(formatted)) { formatted.apiHost = formatVertexApiHost(formatted) + } else if (isCherryAIProvider(formatted)) { + formatted.apiHost = formatApiHost(formatted.apiHost, false) + } else if (isPerplexityProvider(formatted)) { + formatted.apiHost = formatApiHost(formatted.apiHost, false) } else { formatted.apiHost = formatApiHost(formatted.apiHost) } @@ -163,7 +171,7 @@ export function providerToAiSdkConfig( extraOptions.endpoint = endpoint if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) { extraOptions.mode = 'responses' - } else if (aiSdkProviderId === 'openai') { + } else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) { extraOptions.mode = 'chat' } @@ -192,9 +200,15 @@ export function providerToAiSdkConfig( // bedrock if (aiSdkProviderId === 'bedrock') { + const authType = getAwsBedrockAuthType() extraOptions.region = getAwsBedrockRegion() - extraOptions.accessKeyId = getAwsBedrockAccessKeyId() - extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey() + + if (authType === 'apiKey') { + extraOptions.apiKey = getAwsBedrockApiKey() + } else { + extraOptions.accessKeyId = getAwsBedrockAccessKeyId() + extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey() + } } // google-vertex if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') { diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index 665f2bd05c..baf400508a 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -71,6 +71,21 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [ creatorFunctionName: 'createHuggingFace', supportsImageGeneration: true, aliases: ['hf', 'hugging-face'] + }, + { + id: 'ai-gateway', + name: 'AI Gateway', + import: () => import('@ai-sdk/gateway'), + creatorFunctionName: 'createGateway', + supportsImageGeneration: true, + aliases: ['gateway'] + }, + { + id: 'cerebras', + name: 'Cerebras', + import: () => import('@ai-sdk/cerebras'), + creatorFunctionName: 'createCerebras', + supportsImageGeneration: false } ] as const diff --git a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts index f3a4761894..9a1a94f891 100644 --- a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts +++ b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts @@ -1,7 +1,7 @@ import { REFERENCE_PROMPT } from '@renderer/config/prompts' import { processKnowledgeSearch } from '@renderer/services/KnowledgeService' import type { Assistant, KnowledgeReference } from '@renderer/types' -import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract' +import type { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract' import { type InferToolInput, type InferToolOutput, tool } from 'ai' import { isEmpty } from 'lodash' import * as z from 'zod' diff --git a/src/renderer/src/aiCore/tools/WebSearchTool.ts b/src/renderer/src/aiCore/tools/WebSearchTool.ts index 61d5d3b2c1..9545b64be7 100644 --- a/src/renderer/src/aiCore/tools/WebSearchTool.ts +++ b/src/renderer/src/aiCore/tools/WebSearchTool.ts @@ -1,7 +1,7 @@ import { REFERENCE_PROMPT } from '@renderer/config/prompts' import WebSearchService from '@renderer/services/WebSearchService' -import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' -import { ExtractResults } from '@renderer/utils/extract' +import type { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' +import type { ExtractResults } from '@renderer/utils/extract' import { type InferToolInput, type InferToolOutput, tool } from 'ai' import * as z from 'zod' diff --git a/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts b/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts index fc844b5fe5..732397de40 100644 --- a/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts +++ b/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts @@ -6,8 +6,9 @@ */ import { loggerService } from '@logger' -import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' -import { Span, SpanKind, SpanStatusCode } from '@opentelemetry/api' +import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' +import type { Span } from '@opentelemetry/api' +import { SpanKind, SpanStatusCode } from '@opentelemetry/api' const logger = loggerService.withContext('AiSdkSpanAdapter') diff --git a/src/renderer/src/aiCore/utils/image.ts b/src/renderer/src/aiCore/utils/image.ts index 43d916640a..37dbe76a2c 100644 --- a/src/renderer/src/aiCore/utils/image.ts +++ b/src/renderer/src/aiCore/utils/image.ts @@ -1,4 +1,5 @@ -import { isSystemProvider, Model, Provider, SystemProviderIds } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' +import { isSystemProvider, SystemProviderIds } from '@renderer/types' export function buildGeminiGenerateImageParams(): Record { return { diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts index 9606d9ea6e..84bc661aa0 100644 --- a/src/renderer/src/aiCore/utils/mcp.ts +++ b/src/renderer/src/aiCore/utils/mcp.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' -import { MCPTool, MCPToolResponse } from '@renderer/types' +import type { MCPTool, MCPToolResponse } from '@renderer/types' import { callMCPTool, getMcpServerByTool, isToolAutoApproved } from '@renderer/utils/mcp-tools' import { requestToolConfirmation } from '@renderer/utils/userConfirmation' import { type Tool, type ToolSet } from 'ai' import { jsonSchema, tool } from 'ai' -import { JSONSchema7 } from 'json-schema' +import type { JSONSchema7 } from 'json-schema' const logger = loggerService.withContext('MCP-utils') diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 087a9ef157..88f556438b 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -2,15 +2,13 @@ import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-c import { isOpenAIModel, isQwenMTModel, isSupportFlexServiceTierModel } from '@renderer/config/models' import { isSupportServiceTierProvider } from '@renderer/config/providers' import { mapLanguageToQwenMTModel } from '@renderer/config/translate' +import type { Assistant, Model, Provider } from '@renderer/types' import { - Assistant, GroqServiceTiers, isGroqServiceTier, isOpenAIServiceTier, isTranslateAssistant, - Model, OpenAIServiceTiers, - Provider, SystemProviderIds } from '@renderer/types' import { t } from 'i18next' @@ -19,6 +17,7 @@ import { getAiSdkProviderId } from '../provider/factory' import { buildGeminiGenerateImageParams } from './image' import { getAnthropicReasoningParams, + getBedrockReasoningParams, getCustomParameters, getGeminiReasoningParams, getOpenAIReasoningParams, @@ -114,6 +113,9 @@ export function buildProviderOptions( } break } + case 'cherryin': + providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider) + break default: throw new Error(`Unsupported base provider ${baseProviderId}`) } @@ -129,6 +131,9 @@ export function buildProviderOptions( case 'google-vertex-anthropic': providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities) break + case 'bedrock': + providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities) + break default: // 对于其他 provider,使用通用的构建逻辑 providerSpecificOptions = { @@ -146,11 +151,12 @@ export function buildProviderOptions( ...providerSpecificOptions, ...getCustomParameters(assistant) } - // vertex需要映射到google或anthropic + const rawProviderKey = { 'google-vertex': 'google', - 'google-vertex-anthropic': 'anthropic' + 'google-vertex-anthropic': 'anthropic', + 'ai-gateway': 'gateway' }[rawProviderId] || rawProviderId // 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions } @@ -268,6 +274,60 @@ function buildXAIProviderOptions( return providerOptions } +function buildCherryInProviderOptions( + assistant: Assistant, + model: Model, + capabilities: { + enableReasoning: boolean + enableWebSearch: boolean + enableGenerateImage: boolean + }, + actualProvider: Provider +): Record { + const serviceTierSetting = getServiceTier(model, actualProvider) + + switch (actualProvider.type) { + case 'openai': + return { + ...buildOpenAIProviderOptions(assistant, model, capabilities), + serviceTier: serviceTierSetting + } + + case 'anthropic': + return buildAnthropicProviderOptions(assistant, model, capabilities) + + case 'gemini': + return buildGeminiProviderOptions(assistant, model, capabilities) + } + return {} +} + +/** + * Build Bedrock providerOptions + */ +function buildBedrockProviderOptions( + assistant: Assistant, + model: Model, + capabilities: { + enableReasoning: boolean + enableWebSearch: boolean + enableGenerateImage: boolean + } +): Record { + const { enableReasoning } = capabilities + let providerOptions: Record = {} + + if (enableReasoning) { + const reasoningParams = getBedrockReasoningParams(assistant, model) + providerOptions = { + ...providerOptions, + ...reasoningParams + } + } + + return providerOptions +} + /** * 构建通用的 providerOptions(用于其他 provider) */ diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 86a762897f..d0b6f1df25 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -30,9 +30,10 @@ import { import { isSupportEnableThinkingProvider } from '@renderer/config/providers' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' -import { SettingsState } from '@renderer/store/settings' -import { Assistant, EFFORT_RATIO, isSystemProvider, Model, SystemProviderIds } from '@renderer/types' -import { ReasoningEffortOptionalParams } from '@renderer/types/sdk' +import type { SettingsState } from '@renderer/store/settings' +import type { Assistant, Model } from '@renderer/types' +import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types' +import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk' import { toInteger } from 'lodash' const logger = loggerService.withContext('reasoning') @@ -97,7 +98,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin extra_body: { google: { thinking_config: { - thinkingBudget: 0 + thinking_budget: 0 } } } @@ -108,6 +109,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin // use thinking, doubao, zhipu, etc. if (isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenZhipuModel(model)) { + if (provider.id === SystemProviderIds.cerebras) { + return { + disable_reasoning: true + } + } return { thinking: { type: 'disabled' } } } @@ -258,8 +264,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin extra_body: { google: { thinking_config: { - thinkingBudget: -1, - includeThoughts: true + thinking_budget: -1, + include_thoughts: true } } } @@ -269,8 +275,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin extra_body: { google: { thinking_config: { - thinkingBudget: budgetTokens, - includeThoughts: true + thinking_budget: budgetTokens ?? -1, + include_thoughts: true } } } @@ -305,6 +311,9 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return {} } if (isSupportedThinkingTokenZhipuModel(model)) { + if (provider.id === SystemProviderIds.cerebras) { + return {} + } return { thinking: { type: 'enabled' } } } @@ -417,6 +426,8 @@ export function getAnthropicReasoningParams(assistant: Assistant, model: Model): /** * 获取 Gemini 推理参数 * 从 GeminiAPIClient 中提取的逻辑 + * 注意:Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递 + * 而在 Google 官方提供的 OpenAI 兼容端点中则使用蛇形命名法 thinking_budget */ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Record { if (!isReasoningModel(model)) { @@ -484,6 +495,34 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor } } +/** + * Get Bedrock reasoning parameters + */ +export function getBedrockReasoningParams(assistant: Assistant, model: Model): Record { + if (!isReasoningModel(model)) { + return {} + } + + const reasoningEffort = assistant?.settings?.reasoning_effort + + if (reasoningEffort === undefined) { + return {} + } + + // Only apply thinking budget for Claude reasoning models + if (!isSupportedThinkingTokenClaudeModel(model)) { + return {} + } + + const budgetTokens = getAnthropicThinkingBudget(assistant, model) + return { + reasoningConfig: { + type: 'enabled', + budgetTokens: budgetTokens + } + } +} + /** * 获取自定义参数 * 从 assistant 设置中提取自定义参数 diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index 0ab41d5ad3..02619b54cf 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -1,12 +1,12 @@ -import { +import type { AnthropicSearchConfig, OpenAISearchConfig, WebSearchPluginConfig } from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper' -import { BaseProviderId } from '@cherrystudio/ai-core/provider' +import type { BaseProviderId } from '@cherrystudio/ai-core/provider' import { isOpenAIDeepResearchModel, isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models' -import { CherryWebSearchConfig } from '@renderer/store/websearch' -import { Model } from '@renderer/types' +import type { CherryWebSearchConfig } from '@renderer/store/websearch' +import type { Model } from '@renderer/types' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' export function getWebSearchParams(model: Model): Record { @@ -107,6 +107,11 @@ export function buildProviderBuiltinWebSearchConfig( } } } + case 'cherryin': { + const _providerId = + { 'openai-response': 'openai', openai: 'openai-chat' }[model?.endpoint_type ?? ''] ?? model?.endpoint_type + return buildProviderBuiltinWebSearchConfig(_providerId, webSearchConfig, model) + } default: { return {} } diff --git a/src/renderer/src/api/agent.ts b/src/renderer/src/api/agent.ts index ecc772464d..cc997e77b5 100644 --- a/src/renderer/src/api/agent.ts +++ b/src/renderer/src/api/agent.ts @@ -1,37 +1,40 @@ import { loggerService } from '@logger' import { formatAgentServerError } from '@renderer/utils/error' -import { +import type { AddAgentForm, - AgentServerErrorSchema, ApiModelsFilter, ApiModelsResponse, - ApiModelsResponseSchema, CreateAgentRequest, CreateAgentResponse, - CreateAgentResponseSchema, CreateAgentSessionResponse, - CreateAgentSessionResponseSchema, CreateSessionForm, CreateSessionRequest, GetAgentResponse, - GetAgentResponseSchema, GetAgentSessionResponse, - GetAgentSessionResponseSchema, ListAgentSessionsResponse, - ListAgentSessionsResponseSchema, - type ListAgentsResponse, - ListAgentsResponseSchema, ListOptions, - objectEntries, - objectKeys, UpdateAgentForm, UpdateAgentRequest, UpdateAgentResponse, - UpdateAgentResponseSchema, UpdateSessionForm, UpdateSessionRequest } from '@types' -import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios' +import { + AgentServerErrorSchema, + ApiModelsResponseSchema, + CreateAgentResponseSchema, + CreateAgentSessionResponseSchema, + GetAgentResponseSchema, + GetAgentSessionResponseSchema, + ListAgentSessionsResponseSchema, + type ListAgentsResponse, + ListAgentsResponseSchema, + objectEntries, + objectKeys, + UpdateAgentResponseSchema +} from '@types' +import type { Axios, AxiosRequestConfig } from 'axios' +import axios, { isAxiosError } from 'axios' import { ZodError } from 'zod' type ApiVersion = 'v1' diff --git a/src/renderer/src/assets/images/providers/cerebras.webp b/src/renderer/src/assets/images/providers/cerebras.webp new file mode 100644 index 0000000000..1c21b6ff26 Binary files /dev/null and b/src/renderer/src/assets/images/providers/cerebras.webp differ diff --git a/src/renderer/src/assets/images/providers/mcprouter.webp b/src/renderer/src/assets/images/providers/mcprouter.webp new file mode 100644 index 0000000000..7d6557fafc Binary files /dev/null and b/src/renderer/src/assets/images/providers/mcprouter.webp differ diff --git a/src/renderer/src/assets/images/providers/sophnet.svg b/src/renderer/src/assets/images/providers/sophnet.svg new file mode 100644 index 0000000000..aae1e03239 --- /dev/null +++ b/src/renderer/src/assets/images/providers/sophnet.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/images/providers/vercel.svg b/src/renderer/src/assets/images/providers/vercel.svg new file mode 100644 index 0000000000..486cb95787 --- /dev/null +++ b/src/renderer/src/assets/images/providers/vercel.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/src/renderer/src/assets/styles/index.css b/src/renderer/src/assets/styles/index.css index eaa984270f..ed1eb555a3 100644 --- a/src/renderer/src/assets/styles/index.css +++ b/src/renderer/src/assets/styles/index.css @@ -41,11 +41,11 @@ body, margin: 0; } -/* #root { +#root { display: flex; flex-direction: row; flex: 1; -} */ +} body { display: flex; diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css index f05b01b65c..5cd4046689 100644 --- a/src/renderer/src/assets/styles/tailwind.css +++ b/src/renderer/src/assets/styles/tailwind.css @@ -1,10 +1,6 @@ @import 'tailwindcss' source('../../../../renderer'); @import 'tw-animate-css'; -/* heroui */ -@plugin '../../hero.ts'; -@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'; - @custom-variant dark (&:is(.dark *)); /* 如需自定义: @@ -156,11 +152,6 @@ body { @apply bg-background text-foreground; } - - /* To disable drag title bar on toast. tailwind css doesn't provide such class name. */ - .hero-toast { - -webkit-app-region: no-drag; - } } :root { diff --git a/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts b/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts index 86ec67b760..d910a15796 100644 --- a/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts +++ b/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts @@ -1,4 +1,5 @@ -import { ActionTool, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { useToolManager } from '@renderer/components/ActionTools' import { act, renderHook } from '@testing-library/react' import { useState } from 'react' import { describe, expect, it } from 'vitest' diff --git a/src/renderer/src/components/ActionTools/constants.ts b/src/renderer/src/components/ActionTools/constants.ts index c2b4966e5f..bade7d123a 100644 --- a/src/renderer/src/components/ActionTools/constants.ts +++ b/src/renderer/src/components/ActionTools/constants.ts @@ -1,4 +1,4 @@ -import { ActionToolSpec } from './types' +import type { ActionToolSpec } from './types' export const TOOL_SPECS: Record = { // Core tools diff --git a/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx b/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx index 3481b92797..f998470403 100644 --- a/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx +++ b/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx @@ -3,7 +3,8 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { ImagePreviewService } from '@renderer/services/ImagePreviewService' import { download as downloadFile } from '@renderer/utils/download' import { svgToPngBlob, svgToSvgBlob } from '@renderer/utils/image' -import { RefObject, useCallback, useEffect, useRef } from 'react' +import type { RefObject } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('usePreviewToolHandlers') diff --git a/src/renderer/src/components/ActionTools/hooks/useToolManager.ts b/src/renderer/src/components/ActionTools/hooks/useToolManager.ts index ae73fcdb5d..b3d85aaa2f 100644 --- a/src/renderer/src/components/ActionTools/hooks/useToolManager.ts +++ b/src/renderer/src/components/ActionTools/hooks/useToolManager.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import { ActionTool, ToolRegisterProps } from '../types' +import type { ActionTool, ToolRegisterProps } from '../types' export const useToolManager = (setTools?: ToolRegisterProps['setTools']) => { // 注册工具,如果已存在同ID工具则替换 diff --git a/src/renderer/src/components/ApiModelLabel.tsx b/src/renderer/src/components/ApiModelLabel.tsx deleted file mode 100644 index c101689ff9..0000000000 --- a/src/renderer/src/components/ApiModelLabel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Avatar, cn } from '@heroui/react' -import { getModelLogoById } from '@renderer/config/models' -import { ApiModel } from '@renderer/types' -import React from 'react' - -import Ellipsis from './Ellipsis' - -export interface ModelLabelProps extends Omit, 'children'> { - model?: ApiModel - classNames?: { - container?: string - avatar?: string - modelName?: string - divider?: string - providerName?: string - } -} - -export const ApiModelLabel: React.FC = ({ model, className, classNames, ...props }) => { - return ( -
- - {model?.name} - | - {model?.provider_name} -
- ) -} diff --git a/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx b/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx index 6735d86a4e..649f619cba 100644 --- a/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx +++ b/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx @@ -1,4 +1,4 @@ -import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react' +import { Button, Popover } from 'antd' import React from 'react' import EmojiPicker from '../EmojiPicker' @@ -10,13 +10,10 @@ type Props = { export const EmojiAvatarWithPicker: React.FC = ({ emoji, onPick }) => { return ( - - - ) } diff --git a/src/renderer/src/components/Avatar/ModelAvatar.tsx b/src/renderer/src/components/Avatar/ModelAvatar.tsx index 04e8615fbb..ef4f37c2d1 100644 --- a/src/renderer/src/components/Avatar/ModelAvatar.tsx +++ b/src/renderer/src/components/Avatar/ModelAvatar.tsx @@ -1,8 +1,9 @@ import { getModelLogo } from '@renderer/config/models' -import { Model } from '@renderer/types' -import { Avatar, AvatarProps } from 'antd' +import type { Model } from '@renderer/types' +import type { AvatarProps } from 'antd' +import { Avatar } from 'antd' import { first } from 'lodash' -import { FC } from 'react' +import type { FC } from 'react' interface Props { model?: Model diff --git a/src/renderer/src/components/Buttons/ActionIconButton.tsx b/src/renderer/src/components/Buttons/ActionIconButton.tsx index 1448008090..bf3a6d288d 100644 --- a/src/renderer/src/components/Buttons/ActionIconButton.tsx +++ b/src/renderer/src/components/Buttons/ActionIconButton.tsx @@ -1,5 +1,6 @@ -import { cn } from '@heroui/react' -import { Button, ButtonProps } from 'antd' +import { cn } from '@renderer/utils' +import type { ButtonProps } from 'antd' +import { Button } from 'antd' import React, { memo } from 'react' interface ActionIconButtonProps extends ButtonProps { diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 9f5ab59988..8751382044 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -1,11 +1,12 @@ import { CodeOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' -import { ThemeMode } from '@renderer/types' +import type { ThemeMode } from '@renderer/types' import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' import { Button } from 'antd' import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react' -import { FC, useState } from 'react' +import type { FC } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ClipLoader } from 'react-spinners' import styled, { keyframes } from 'styled-components' diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 9453866f20..2cd8171d08 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,4 +1,5 @@ -import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' +import type { CodeEditorHandles } from '@renderer/components/CodeEditor' +import CodeEditor from '@renderer/components/CodeEditor' import { CopyIcon, FilePngIcon } from '@renderer/components/Icons' import { isMac } from '@renderer/config/constant' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' diff --git a/src/renderer/src/components/CodeBlockView/StatusBar.tsx b/src/renderer/src/components/CodeBlockView/StatusBar.tsx index defd070ac8..72589b9045 100644 --- a/src/renderer/src/components/CodeBlockView/StatusBar.tsx +++ b/src/renderer/src/components/CodeBlockView/StatusBar.tsx @@ -1,5 +1,6 @@ import { Flex } from 'antd' -import { FC, memo, ReactNode } from 'react' +import type { FC, ReactNode } from 'react' +import { memo } from 'react' import styled from 'styled-components' interface Props { diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 939095262e..2ba94d0ef2 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -1,6 +1,7 @@ import { loggerService } from '@logger' -import { ActionTool } from '@renderer/components/ActionTools' -import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' +import type { ActionTool } from '@renderer/components/ActionTools' +import type { CodeEditorHandles } from '@renderer/components/CodeEditor' +import CodeEditor from '@renderer/components/CodeEditor' import { CodeToolbar, useCopyTool, @@ -14,7 +15,7 @@ import { } from '@renderer/components/CodeToolbar' import CodeViewer from '@renderer/components/CodeViewer' import ImageViewer from '@renderer/components/ImageViewer' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useSettings } from '@renderer/hooks/useSettings' import { pyodideService } from '@renderer/services/PyodideService' @@ -27,7 +28,7 @@ import styled, { css } from 'styled-components' import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants' import StatusBar from './StatusBar' -import { ViewMode } from './types' +import type { ViewMode } from './types' const logger = loggerService.withContext('CodeBlockView') diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index 65c18a5a0f..b502c6e9da 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -1,7 +1,8 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' -import { Extension, keymap } from '@uiw/react-codemirror' +import type { Extension } from '@uiw/react-codemirror' +import { keymap } from '@uiw/react-codemirror' import { useCallback, useEffect, useMemo, useState } from 'react' import { getNormalizedExtension } from './utils' diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 31c4ce798c..128e1b1fe4 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -1,6 +1,7 @@ import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' -import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' +import type { BasicSetupOptions, Extension } from '@uiw/react-codemirror' +import CodeMirror, { Annotation, EditorView } from '@uiw/react-codemirror' import diff from 'fast-diff' import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { memo } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx index 045d242158..2a5f43f7f1 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx @@ -1,4 +1,4 @@ -import { ActionTool } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx index 5c38de461b..4f75409c8a 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx @@ -1,4 +1,4 @@ -import { ActionTool } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx index 2b39950eab..67c8cc48f3 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx @@ -1,5 +1,5 @@ import { useCopyTool } from '@renderer/components/CodeToolbar/hooks/useCopyTool' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx index 0181dfc5fe..0cc5d8c4ee 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx @@ -1,5 +1,5 @@ import { useDownloadTool } from '@renderer/components/CodeToolbar/hooks/useDownloadTool' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx index fbe52bbb35..ef408c562f 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx @@ -1,4 +1,4 @@ -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { useSplitViewTool } from '@renderer/components/CodeToolbar/hooks/useSplitViewTool' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx index 9bac34c57a..a35a063426 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx @@ -1,4 +1,4 @@ -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { useViewSourceTool } from '@renderer/components/CodeToolbar/hooks/useViewSourceTool' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/button.tsx b/src/renderer/src/components/CodeToolbar/button.tsx index 1488752726..874407aed8 100644 --- a/src/renderer/src/components/CodeToolbar/button.tsx +++ b/src/renderer/src/components/CodeToolbar/button.tsx @@ -1,4 +1,4 @@ -import { ActionTool } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' import { Dropdown, Tooltip } from 'antd' import { memo, useMemo } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx index ea928df4fd..0f5d3f08e8 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx @@ -1,6 +1,7 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { CopyIcon } from '@renderer/components/Icons' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { Check, Image } from 'lucide-react' import { useCallback, useEffect } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx index 397c51c921..835c01efbb 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx @@ -1,6 +1,7 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { FilePngIcon, FileSvgIcon } from '@renderer/components/Icons' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { Download, FileCode } from 'lucide-react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx index 6428a9c543..0cf2e25008 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx @@ -1,4 +1,5 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx index 4c46681a4d..0da586d4cf 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx @@ -1,4 +1,5 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { LoadingIcon } from '@renderer/components/Icons' import { CirclePlay } from 'lucide-react' import { useEffect } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx index c847b6ca90..62895bbdd4 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx @@ -1,5 +1,6 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' -import { CodeEditorHandles } from '@renderer/components/CodeEditor' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { CodeEditorHandles } from '@renderer/components/CodeEditor' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { Check, SaveIcon } from 'lucide-react' import { useCallback, useEffect } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx index 63367d692f..64b3c0af44 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx @@ -1,5 +1,6 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { Square, SquareSplitHorizontal } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx index a3a6da0152..fa6d71ea67 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx @@ -1,5 +1,6 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { CodeXml, Eye, SquarePen } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx index bea1e4a5b5..d4bfa6e273 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx @@ -1,4 +1,5 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/toolbar.tsx b/src/renderer/src/components/CodeToolbar/toolbar.tsx index 7b17a6f0e8..7b78d8e536 100644 --- a/src/renderer/src/components/CodeToolbar/toolbar.tsx +++ b/src/renderer/src/components/CodeToolbar/toolbar.tsx @@ -1,4 +1,4 @@ -import { ActionTool } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' import { HStack } from '@renderer/components/Layout' import { Tooltip } from 'antd' import { EllipsisVertical } from 'lucide-react' diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx index ac7a14e0ac..af60633672 100644 --- a/src/renderer/src/components/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer.tsx @@ -6,7 +6,7 @@ import { getReactStyleFromToken } from '@renderer/utils/shiki' import { useVirtualizer } from '@tanstack/react-virtual' import { debounce } from 'lodash' import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { ThemedToken } from 'shiki/core' +import type { ThemedToken } from 'shiki/core' import styled from 'styled-components' interface CodeViewerProps { diff --git a/src/renderer/src/components/CollapsibleSearchBar.tsx b/src/renderer/src/components/CollapsibleSearchBar.tsx index 04b838e37a..2b7eb1e277 100644 --- a/src/renderer/src/components/CollapsibleSearchBar.tsx +++ b/src/renderer/src/components/CollapsibleSearchBar.tsx @@ -1,5 +1,6 @@ import i18n from '@renderer/i18n' -import { Input, InputRef, Tooltip } from 'antd' +import type { InputRef } from 'antd' +import { Input, Tooltip } from 'antd' import { Search } from 'lucide-react' import { motion } from 'motion/react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react' diff --git a/src/renderer/src/components/ConfirmDialog.tsx b/src/renderer/src/components/ConfirmDialog.tsx index 3f2313b178..a3ffa5e270 100644 --- a/src/renderer/src/components/ConfirmDialog.tsx +++ b/src/renderer/src/components/ConfirmDialog.tsx @@ -1,6 +1,6 @@ -import { Button } from '@heroui/react' -import { CheckIcon, XIcon } from 'lucide-react' -import { FC } from 'react' +import { CheckOutlined, CloseOutlined } from '@ant-design/icons' +import { Button } from 'antd' +import type { FC } from 'react' import { createPortal } from 'react-dom' interface Props { @@ -28,12 +28,22 @@ const ConfirmDialog: FC = ({ x, y, message, onConfirm, onCancel }) => {
{message}
- - +
diff --git a/src/renderer/src/components/CopyButton.tsx b/src/renderer/src/components/CopyButton.tsx index cfa80a02c5..173a1fddf8 100644 --- a/src/renderer/src/components/CopyButton.tsx +++ b/src/renderer/src/components/CopyButton.tsx @@ -1,6 +1,6 @@ import { Tooltip } from 'antd' import { Copy } from 'lucide-react' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 8362d8a479..4e8e7a9494 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,7 +1,8 @@ import { Collapse } from 'antd' import { merge } from 'lodash' import { ChevronRight } from 'lucide-react' -import { FC, memo, useMemo, useState } from 'react' +import type { FC } from 'react' +import { memo, useMemo, useState } from 'react' interface CustomCollapseProps { label: React.ReactNode diff --git a/src/renderer/src/components/DividerWithText.tsx b/src/renderer/src/components/DividerWithText.tsx index 764550381f..30f287e9f8 100644 --- a/src/renderer/src/components/DividerWithText.tsx +++ b/src/renderer/src/components/DividerWithText.tsx @@ -1,4 +1,5 @@ -import React, { CSSProperties } from 'react' +import type { CSSProperties } from 'react' +import React from 'react' import styled from 'styled-components' interface DividerWithTextProps { diff --git a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts index a9ffd3d889..c2f566aad2 100644 --- a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts +++ b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts @@ -1,4 +1,4 @@ -import { DropResult } from '@hello-pangea/dnd' +import type { DropResult } from '@hello-pangea/dnd' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/DraggableList/list.tsx b/src/renderer/src/components/DraggableList/list.tsx index fbb5f29762..62a7c636c0 100644 --- a/src/renderer/src/components/DraggableList/list.tsx +++ b/src/renderer/src/components/DraggableList/list.tsx @@ -1,15 +1,14 @@ -import { - DragDropContext, - Draggable, - Droppable, +import type { DroppableProps, DropResult, OnDragEndResponder, OnDragStartResponder, ResponderProvided } from '@hello-pangea/dnd' +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' import { droppableReorder } from '@renderer/utils' -import { HTMLAttributes, Key, useCallback } from 'react' +import type { HTMLAttributes, Key } from 'react' +import { useCallback } from 'react' interface Props { list: T[] diff --git a/src/renderer/src/components/DraggableList/useDraggableReorder.ts b/src/renderer/src/components/DraggableList/useDraggableReorder.ts index 5438635cd6..7f7ed902cf 100644 --- a/src/renderer/src/components/DraggableList/useDraggableReorder.ts +++ b/src/renderer/src/components/DraggableList/useDraggableReorder.ts @@ -1,5 +1,6 @@ -import { DropResult } from '@hello-pangea/dnd' -import { Key, useCallback, useMemo } from 'react' +import type { DropResult } from '@hello-pangea/dnd' +import type { Key } from 'react' +import { useCallback, useMemo } from 'react' interface UseDraggableReorderParams { /** 原始的、完整的数据列表 */ diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx index b2efe7c247..e6f08e4c71 100644 --- a/src/renderer/src/components/DraggableList/virtual-list.tsx +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -1,13 +1,11 @@ -import { - DragDropContext, - Draggable, - Droppable, +import type { DroppableProps, DropResult, OnDragEndResponder, OnDragStartResponder, ResponderProvided } from '@hello-pangea/dnd' +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' import Scrollbar from '@renderer/components/Scrollbar' import { droppableReorder } from '@renderer/utils' import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' diff --git a/src/renderer/src/components/EditableNumber/index.tsx b/src/renderer/src/components/EditableNumber/index.tsx index 220cf5fb57..6102a3a897 100644 --- a/src/renderer/src/components/EditableNumber/index.tsx +++ b/src/renderer/src/components/EditableNumber/index.tsx @@ -1,5 +1,6 @@ import { InputNumber } from 'antd' -import { FC, useEffect, useRef, useState } from 'react' +import type { FC } from 'react' +import { useEffect, useRef, useState } from 'react' import styled from 'styled-components' export interface EditableNumberProps { @@ -19,6 +20,7 @@ export interface EditableNumberProps { suffix?: string prefix?: string align?: 'start' | 'center' | 'end' + formatter?: (value: number | null) => string | number } const EditableNumber: FC = ({ @@ -35,7 +37,8 @@ const EditableNumber: FC = ({ style, className, size = 'middle', - align = 'end' + align = 'end', + formatter }) => { const [isEditing, setIsEditing] = useState(false) const [inputValue, setInputValue] = useState(value) @@ -89,7 +92,7 @@ const EditableNumber: FC = ({ changeOnBlur={changeOnBlur} /> - {value ?? placeholder} + {formatter ? formatter(value ?? null) : (value ?? placeholder)} ) diff --git a/src/renderer/src/components/EmojiIcon.tsx b/src/renderer/src/components/EmojiIcon.tsx index 6cd06b8715..e8a787c9b0 100644 --- a/src/renderer/src/components/EmojiIcon.tsx +++ b/src/renderer/src/components/EmojiIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import type { FC } from 'react' import styled from 'styled-components' interface EmojiIconProps { diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index 69ac7ccdaa..8ba9d3e967 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -1,7 +1,8 @@ import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url' import { useTheme } from '@renderer/context/ThemeProvider' import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill' -import { FC, useEffect, useRef } from 'react' +import type { FC } from 'react' +import { useEffect, useRef } from 'react' interface Props { onEmojiClick: (emoji: string) => void diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx index 1ee3967e9c..838c6f136b 100644 --- a/src/renderer/src/components/ErrorBoundary.tsx +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -1,8 +1,9 @@ -import { Button } from '@heroui/button' import { formatErrorMessage } from '@renderer/utils/error' +import { Button } from 'antd' import { Alert, Space } from 'antd' -import { ComponentType, ReactNode } from 'react' -import { ErrorBoundary, FallbackProps } from 'react-error-boundary' +import type { ComponentType, ReactNode } from 'react' +import type { FallbackProps } from 'react-error-boundary' +import { ErrorBoundary } from 'react-error-boundary' import { useTranslation } from 'react-i18next' import styled from 'styled-components' const DefaultFallback: ComponentType = (props: FallbackProps): ReactNode => { @@ -23,10 +24,10 @@ const DefaultFallback: ComponentType = (props: FallbackProps): Re type="error" action={ - - diff --git a/src/renderer/src/components/FreeTrialModelTag.tsx b/src/renderer/src/components/FreeTrialModelTag.tsx index 2e294d9303..ad142ae0cf 100644 --- a/src/renderer/src/components/FreeTrialModelTag.tsx +++ b/src/renderer/src/components/FreeTrialModelTag.tsx @@ -1,8 +1,8 @@ import { getProviderLabel } from '@renderer/i18n/label' import NavigationService from '@renderer/services/NavigationService' -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { ArrowUpRight } from 'lucide-react' -import { FC, MouseEvent } from 'react' +import type { FC, MouseEvent } from 'react' import styled from 'styled-components' import IndicatorLight from './IndicatorLight' diff --git a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx index 9dce1c21be..2807549e28 100644 --- a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx +++ b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx @@ -3,7 +3,7 @@ import { Flex, Tooltip, Typography } from 'antd' import React, { memo } from 'react' import styled from 'styled-components' -import { HealthResult } from './types' +import type { HealthResult } from './types' import { useHealthStatus } from './useHealthStatus' export interface HealthStatusIndicatorProps { diff --git a/src/renderer/src/components/HealthStatusIndicator/types.ts b/src/renderer/src/components/HealthStatusIndicator/types.ts index f87376b781..87bff48c1c 100644 --- a/src/renderer/src/components/HealthStatusIndicator/types.ts +++ b/src/renderer/src/components/HealthStatusIndicator/types.ts @@ -1,4 +1,4 @@ -import { HealthStatus } from '@renderer/types/healthCheck' +import type { HealthStatus } from '@renderer/types/healthCheck' /** * 用于展示单个健康检查结果的必要数据 diff --git a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx index 456961c97f..1027324eeb 100644 --- a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx +++ b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx @@ -3,7 +3,7 @@ import { Flex } from 'antd' import React from 'react' import { useTranslation } from 'react-i18next' -import { HealthResult } from './types' +import type { HealthResult } from './types' interface UseHealthStatusProps { results: HealthResult[] diff --git a/src/renderer/src/components/HighlightText.tsx b/src/renderer/src/components/HighlightText.tsx index debf02c924..d24b9c607c 100644 --- a/src/renderer/src/components/HighlightText.tsx +++ b/src/renderer/src/components/HighlightText.tsx @@ -1,4 +1,5 @@ -import { FC, memo, useMemo } from 'react' +import type { FC } from 'react' +import { memo, useMemo } from 'react' interface HighlightTextProps { text: string diff --git a/src/renderer/src/components/HorizontalScrollContainer/index.tsx b/src/renderer/src/components/HorizontalScrollContainer/index.tsx index fdc890d2e2..ec9f7a1043 100644 --- a/src/renderer/src/components/HorizontalScrollContainer/index.tsx +++ b/src/renderer/src/components/HorizontalScrollContainer/index.tsx @@ -1,5 +1,5 @@ -import { cn } from '@heroui/react' import Scrollbar from '@renderer/components/Scrollbar' +import { cn } from '@renderer/utils' import { ChevronRight } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import styled from 'styled-components' diff --git a/src/renderer/src/components/Icons/FileIcons.tsx b/src/renderer/src/components/Icons/FileIcons.tsx index 0fbfcdebf5..fd095335a8 100644 --- a/src/renderer/src/components/Icons/FileIcons.tsx +++ b/src/renderer/src/components/Icons/FileIcons.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, SVGProps } from 'react' +import type { CSSProperties, SVGProps } from 'react' interface BaseFileIconProps extends SVGProps { size?: string | number diff --git a/src/renderer/src/components/Icons/MinAppIcon.tsx b/src/renderer/src/components/Icons/MinAppIcon.tsx index 98974da745..58da46a723 100644 --- a/src/renderer/src/components/Icons/MinAppIcon.tsx +++ b/src/renderer/src/components/Icons/MinAppIcon.tsx @@ -1,6 +1,6 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' -import { MinAppType } from '@renderer/types' -import { FC } from 'react' +import type { MinAppType } from '@renderer/types' +import type { FC } from 'react' interface Props { app: MinAppType diff --git a/src/renderer/src/components/Icons/OcrIcon.tsx b/src/renderer/src/components/Icons/OcrIcon.tsx index 41367445a7..9f73867a0c 100644 --- a/src/renderer/src/components/Icons/OcrIcon.tsx +++ b/src/renderer/src/components/Icons/OcrIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import type { FC } from 'react' const OcrIcon: FC, HTMLElement>> = (props) => { return diff --git a/src/renderer/src/components/Icons/ReasoningIcon.tsx b/src/renderer/src/components/Icons/ReasoningIcon.tsx index 4f98f5735c..8d7aa94fed 100644 --- a/src/renderer/src/components/Icons/ReasoningIcon.tsx +++ b/src/renderer/src/components/Icons/ReasoningIcon.tsx @@ -1,5 +1,6 @@ import { Tooltip } from 'antd' -import React, { FC } from 'react' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index b17f7397c0..ad503f0e38 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -1,6 +1,6 @@ import { lightbulbVariants } from '@renderer/utils/motionVariants' import { motion } from 'motion/react' -import { SVGProps } from 'react' +import type { SVGProps } from 'react' export const StreamlineGoodHealthAndWellBeing = ( props: SVGProps & { diff --git a/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx b/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx index 6cebfa3332..7efa37a66f 100644 --- a/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx +++ b/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx @@ -1,4 +1,4 @@ -import { SVGProps } from 'react' +import type { SVGProps } from 'react' export function SvgSpinners180Ring(props: SVGProps & { size?: number | string }) { const { size = '1em', ...svgProps } = props diff --git a/src/renderer/src/components/Icons/ToolIcon.tsx b/src/renderer/src/components/Icons/ToolIcon.tsx index 69f8da260c..2d7b5d6146 100644 --- a/src/renderer/src/components/Icons/ToolIcon.tsx +++ b/src/renderer/src/components/Icons/ToolIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import type { FC } from 'react' const ToolIcon: FC, HTMLElement>> = (props) => { return diff --git a/src/renderer/src/components/Icons/ToolsCallingIcon.tsx b/src/renderer/src/components/Icons/ToolsCallingIcon.tsx index 7a591d2316..36b831e715 100644 --- a/src/renderer/src/components/Icons/ToolsCallingIcon.tsx +++ b/src/renderer/src/components/Icons/ToolsCallingIcon.tsx @@ -1,6 +1,7 @@ import { ToolOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' -import React, { FC } from 'react' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/Icons/VisionIcon.tsx b/src/renderer/src/components/Icons/VisionIcon.tsx index 4ab4c408c1..d1f2b4e180 100644 --- a/src/renderer/src/components/Icons/VisionIcon.tsx +++ b/src/renderer/src/components/Icons/VisionIcon.tsx @@ -1,6 +1,7 @@ import { Tooltip } from 'antd' import { ImageIcon } from 'lucide-react' -import React, { FC } from 'react' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/Icons/WebSearchIcon.tsx b/src/renderer/src/components/Icons/WebSearchIcon.tsx index 6dc99000ae..156ea1307b 100644 --- a/src/renderer/src/components/Icons/WebSearchIcon.tsx +++ b/src/renderer/src/components/Icons/WebSearchIcon.tsx @@ -1,6 +1,7 @@ import { GlobalOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' -import React, { FC } from 'react' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx index 179babeaf6..757a694419 100644 --- a/src/renderer/src/components/ImageViewer.tsx +++ b/src/renderer/src/components/ImageViewer.tsx @@ -10,7 +10,8 @@ import { } from '@ant-design/icons' import { loggerService } from '@logger' import { download } from '@renderer/utils/download' -import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd' +import type { ImageProps as AntImageProps } from 'antd' +import { Dropdown, Image as AntImage, Space } from 'antd' import { Base64 } from 'js-base64' import { DownloadIcon, ImageIcon } from 'lucide-react' import mime from 'mime' diff --git a/src/renderer/src/components/InfoPopover.tsx b/src/renderer/src/components/InfoPopover.tsx index 888aefc702..04ea1e7c3d 100644 --- a/src/renderer/src/components/InfoPopover.tsx +++ b/src/renderer/src/components/InfoPopover.tsx @@ -1,4 +1,5 @@ -import { Popover, PopoverProps } from 'antd' +import type { PopoverProps } from 'antd' +import { Popover } from 'antd' import { Info } from 'lucide-react' type InheritedPopoverProps = Omit diff --git a/src/renderer/src/components/InputEmbeddingDimension.tsx b/src/renderer/src/components/InputEmbeddingDimension.tsx index 056ebaea50..8e6357a91d 100644 --- a/src/renderer/src/components/InputEmbeddingDimension.tsx +++ b/src/renderer/src/components/InputEmbeddingDimension.tsx @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import AiProvider from '@renderer/aiCore' import { RefreshIcon } from '@renderer/components/Icons' import { useProvider } from '@renderer/hooks/useProvider' -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { getErrorMessage } from '@renderer/utils' import { Button, InputNumber, Space, Tooltip } from 'antd' import { memo, useCallback, useMemo, useState } from 'react' diff --git a/src/renderer/src/components/LanguageSelect.tsx b/src/renderer/src/components/LanguageSelect.tsx index cc18294712..e9799096a7 100644 --- a/src/renderer/src/components/LanguageSelect.tsx +++ b/src/renderer/src/components/LanguageSelect.tsx @@ -1,8 +1,10 @@ import { UNKNOWN } from '@renderer/config/translate' import useTranslate from '@renderer/hooks/useTranslate' -import { TranslateLanguage, TranslateLanguageCode } from '@renderer/types' -import { Select, SelectProps, Space } from 'antd' -import { ReactNode, useCallback, useMemo } from 'react' +import type { TranslateLanguage, TranslateLanguageCode } from '@renderer/types' +import type { SelectProps } from 'antd' +import { Select, Space } from 'antd' +import type { ReactNode } from 'react' +import { useCallback, useMemo } from 'react' export type LanguageOption = { value: TranslateLanguageCode diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index 2574573627..3ab38bcb41 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -1,5 +1,5 @@ import { Typography } from 'antd' -import { ReactNode } from 'react' +import type { ReactNode } from 'react' import styled from 'styled-components' interface ListItemProps { diff --git a/src/renderer/src/components/MarkdownEditor/index.tsx b/src/renderer/src/components/MarkdownEditor/index.tsx index 427ff1ccc8..5b5ab445e9 100644 --- a/src/renderer/src/components/MarkdownEditor/index.tsx +++ b/src/renderer/src/components/MarkdownEditor/index.tsx @@ -1,6 +1,7 @@ import 'katex/dist/katex.min.css' -import React, { FC, useEffect, useState } from 'react' +import type { FC } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' import rehypeKatex from 'rehype-katex' diff --git a/src/renderer/src/components/MaxContextCount.tsx b/src/renderer/src/components/MaxContextCount.tsx index be9c9f293c..b07564da71 100644 --- a/src/renderer/src/components/MaxContextCount.tsx +++ b/src/renderer/src/components/MaxContextCount.tsx @@ -1,6 +1,6 @@ import { MAX_CONTEXT_COUNT } from '@renderer/config/constant' import { Infinity as InfinityIcon } from 'lucide-react' -import { CSSProperties } from 'react' +import type { CSSProperties } from 'react' type Props = { maxContext: number diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx index 5833ed9b1a..b6a623d309 100644 --- a/src/renderer/src/components/MinApp/MinApp.tsx +++ b/src/renderer/src/components/MinApp/MinApp.tsx @@ -7,10 +7,10 @@ import { useMinapps } from '@renderer/hooks/useMinapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition } from '@renderer/hooks/useSettings' import { setOpenedKeepAliveMinapps } from '@renderer/store/runtime' -import { MinAppType } from '@renderer/types' +import type { MinAppType } from '@renderer/types' import type { MenuProps } from 'antd' import { Dropdown } from 'antd' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { useNavigate } from 'react-router-dom' diff --git a/src/renderer/src/components/MinApp/MinAppTabsPool.tsx b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx index af2c255f5f..5c5633636a 100644 --- a/src/renderer/src/components/MinApp/MinAppTabsPool.tsx +++ b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx @@ -3,7 +3,7 @@ import WebviewContainer from '@renderer/components/MinApp/WebviewContainer' import { useRuntime } from '@renderer/hooks/useRuntime' import { useNavbarPosition } from '@renderer/hooks/useSettings' import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager' -import { WebviewTag } from 'electron' +import type { WebviewTag } from 'electron' import React, { useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import styled from 'styled-components' diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 57e5141048..8e361f0bc7 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -23,11 +23,11 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMinappsOpenLinkExternal } from '@renderer/store/settings' -import { MinAppType } from '@renderer/types' +import type { MinAppType } from '@renderer/types' import { delay } from '@renderer/utils' import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager' import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd' -import { WebviewTag } from 'electron' +import type { WebviewTag } from 'electron' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BeatLoader from 'react-spinners/BeatLoader' diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index 545772ef08..66bb9e554d 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import { useSettings } from '@renderer/hooks/useSettings' -import { WebviewTag } from 'electron' +import type { WebviewTag } from 'electron' import { memo, useEffect, useRef } from 'react' const logger = loggerService.withContext('WebviewContainer') diff --git a/src/renderer/src/components/ModelIdWithTags.tsx b/src/renderer/src/components/ModelIdWithTags.tsx index bf902ae1c4..76e7f99e15 100644 --- a/src/renderer/src/components/ModelIdWithTags.tsx +++ b/src/renderer/src/components/ModelIdWithTags.tsx @@ -1,4 +1,4 @@ -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { Tooltip, Typography } from 'antd' import { memo } from 'react' import styled from 'styled-components' diff --git a/src/renderer/src/components/ModelSelectButton.tsx b/src/renderer/src/components/ModelSelectButton.tsx index d803f5dbdb..00f081bb13 100644 --- a/src/renderer/src/components/ModelSelectButton.tsx +++ b/src/renderer/src/components/ModelSelectButton.tsx @@ -1,5 +1,6 @@ -import { Model } from '@renderer/types' -import { Button, Tooltip, TooltipProps } from 'antd' +import type { Model } from '@renderer/types' +import type { TooltipProps } from 'antd' +import { Button, Tooltip } from 'antd' import { useCallback, useMemo } from 'react' import ModelAvatar from './Avatar/ModelAvatar' diff --git a/src/renderer/src/components/ModelSelector.tsx b/src/renderer/src/components/ModelSelector.tsx index 98fa195fb6..12666b4d4b 100644 --- a/src/renderer/src/components/ModelSelector.tsx +++ b/src/renderer/src/components/ModelSelector.tsx @@ -1,11 +1,12 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { matchKeywordsInString } from '@renderer/utils' import { getFancyProviderName } from '@renderer/utils/naming' -import { Avatar, Select, SelectProps } from 'antd' +import type { SelectProps } from 'antd' +import { Avatar, Select } from 'antd' import { sortBy } from 'lodash' -import { BaseSelectRef } from 'rc-select' +import type { BaseSelectRef } from 'rc-select' import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 263292dcad..93e52f6a0c 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -7,9 +7,10 @@ import { isWebSearchModel } from '@renderer/config/models' import i18n from '@renderer/i18n' -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { isFreeModel } from '@renderer/utils/model' -import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { FC } from 'react' +import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' import { diff --git a/src/renderer/src/components/OAuth/OAuthButton.tsx b/src/renderer/src/components/OAuth/OAuthButton.tsx index 3368f60afe..b8e485a8e6 100644 --- a/src/renderer/src/components/OAuth/OAuthButton.tsx +++ b/src/renderer/src/components/OAuth/OAuthButton.tsx @@ -1,5 +1,5 @@ import { getProviderLabel } from '@renderer/i18n/label' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { oauthWith302AI, oauthWithAihubmix, @@ -8,8 +8,9 @@ import { oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth' -import { Button, ButtonProps } from 'antd' -import { FC } from 'react' +import type { ButtonProps } from 'antd' +import { Button } from 'antd' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' interface Props extends ButtonProps { diff --git a/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx b/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx new file mode 100644 index 0000000000..4b3f969a26 --- /dev/null +++ b/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx @@ -0,0 +1,119 @@ +import { TopView } from '@renderer/components/TopView' +import { cn } from '@renderer/utils' +import { Modal } from 'antd' +import { Bot, MessageSquare } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +type OptionType = 'assistant' | 'agent' + +interface ShowParams { + onSelect: (type: OptionType) => void +} + +interface Props extends ShowParams { + resolve: (data: { type?: OptionType }) => void +} + +const PopupContainer: React.FC = ({ onSelect, resolve }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const [hoveredOption, setHoveredOption] = useState(null) + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + const handleSelect = (type: OptionType) => { + setOpen(false) + onSelect(type) + resolve({ type }) + } + + AddAssistantOrAgentPopup.hide = onCancel + + return ( + +
+ {/* Assistant Option */} + + + {/* Agent Option */} + +
+
+ ) +} + +const TopViewKey = 'AddAssistantOrAgentPopup' + +export default class AddAssistantOrAgentPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise<{ type?: OptionType }>((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index e795de400f..8c10f0f6bf 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -5,9 +5,10 @@ import { useTimer } from '@renderer/hooks/useTimer' import { useSystemAssistantPresets } from '@renderer/pages/store/assistants/presets' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import { Assistant, AssistantPreset } from '@renderer/types' +import type { Assistant, AssistantPreset } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Divider, Input, InputRef, Modal, Tag } from 'antd' +import type { InputRef } from 'antd' +import { Divider, Input, Modal, Tag } from 'antd' import { take } from 'lodash' import { Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts index e69341a864..f28e034bda 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts @@ -3,23 +3,18 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup' import { checkApi } from '@renderer/services/ApiService' import WebSearchService from '@renderer/services/WebSearchService' -import { - isPreprocessProviderId, - isWebSearchProviderId, - Model, - PreprocessProvider, - Provider, - WebSearchProvider -} from '@renderer/types' -import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck' +import type { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import { isPreprocessProviderId, isWebSearchProviderId } from '@renderer/types' +import type { ApiKeyConnectivity, ApiKeyWithStatus } from '@renderer/types/healthCheck' +import { HealthStatus } from '@renderer/types/healthCheck' import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' import { formatErrorMessage } from '@renderer/utils/error' -import { TFunction } from 'i18next' +import type { TFunction } from 'i18next' import { isEmpty } from 'lodash' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types' +import type { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types' interface UseApiKeysProps { provider: ApiProvider diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx index 83c9389935..3ddcc383cd 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx @@ -1,15 +1,17 @@ import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator' import { EditIcon } from '@renderer/components/Icons' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' -import { ApiKeyWithStatus } from '@renderer/types/healthCheck' +import type { ApiKeyWithStatus } from '@renderer/types/healthCheck' import { maskApiKey } from '@renderer/utils/api' -import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd' +import type { InputRef } from 'antd' +import { Button, Flex, Input, List, Popconfirm, Tooltip, Typography } from 'antd' import { Check, Minus, X } from 'lucide-react' -import { FC, memo, useEffect, useRef, useState } from 'react' +import type { FC } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { ApiKeyValidity } from './types' +import type { ApiKeyValidity } from './types' export interface ApiKeyItemProps { keyStatus: ApiKeyWithStatus diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx index 86076b4ca8..6611416333 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx @@ -6,17 +6,19 @@ import { useProvider } from '@renderer/hooks/useProvider' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import { SettingHelpText } from '@renderer/pages/settings' import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import { PreprocessProviderId, WebSearchProviderId } from '@renderer/types' -import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck' +import type { PreprocessProviderId, WebSearchProviderId } from '@renderer/types' +import type { ApiKeyWithStatus } from '@renderer/types/healthCheck' +import { HealthStatus } from '@renderer/types/healthCheck' import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd' import { Plus } from 'lucide-react' -import { FC, useState } from 'react' +import type { FC } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { isLlmProvider, useApiKeys } from './hook' import ApiKeyItem from './item' -import { ApiProvider, UpdateApiProviderFunc } from './types' +import type { ApiProvider, UpdateApiProviderFunc } from './types' interface ApiKeyListProps { provider: ApiProvider diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts index ad713b40fb..f90c4240d5 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts @@ -1,4 +1,4 @@ -import { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import type { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' /** * API key 格式有效性 diff --git a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx new file mode 100644 index 0000000000..cbe51ac614 --- /dev/null +++ b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx @@ -0,0 +1,553 @@ +import { loggerService } from '@logger' +import { AppLogo } from '@renderer/config/env' +import { SettingHelpText, SettingRow } from '@renderer/pages/settings' +import type { WebSocketCandidatesResponse } from '@shared/config/types' +import { Alert, Button, Modal, Progress, Spin } from 'antd' +import { QRCodeSVG } from 'qrcode.react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { TopView } from '../TopView' + +const logger = loggerService.withContext('ExportToPhoneLanPopup') + +interface Props { + resolve: (data: any) => void +} + +type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error' +type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error' + +const LoadingQRCode: React.FC = () => { + const { t } = useTranslation() + return ( +
+ + + {t('settings.data.export_to_phone.lan.generating_qr')} + +
+ ) +} + +const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => { + const { t } = useTranslation() + return ( +
+ + + {t('settings.data.export_to_phone.lan.scan_qr')} + +
+ ) +} + +const ConnectingAnimation: React.FC = () => { + const { t } = useTranslation() + return ( +
+
+ + + {t('settings.data.export_to_phone.lan.status.connecting')} + +
+
+ ) +} + +const ConnectedDisplay: React.FC = () => { + const { t } = useTranslation() + return ( +
+
+ 📱 + + {t('settings.data.export_to_phone.lan.connected')} + +
+
+ ) +} + +const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => { + const { t } = useTranslation() + return ( +
+ ⚠️ + + {t('settings.data.export_to_phone.lan.connection_failed')} + + {error && {error}} +
+ ) +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [isOpen, setIsOpen] = useState(true) + const [connectionPhase, setConnectionPhase] = useState('initializing') + const [transferPhase, setTransferPhase] = useState('idle') + const [qrCodeValue, setQrCodeValue] = useState('') + const [selectedFolderPath, setSelectedFolderPath] = useState(null) + const [sendProgress, setSendProgress] = useState(0) + const [error, setError] = useState(null) + const [autoCloseCountdown, setAutoCloseCountdown] = useState(null) + + const { t } = useTranslation() + + // 派生状态 + const isConnected = connectionPhase === 'connected' + const canSend = isConnected && selectedFolderPath && transferPhase === 'idle' + const isSending = transferPhase === 'preparing' || transferPhase === 'sending' + + // 状态文本映射 + const connectionStatusText = useMemo(() => { + const statusMap = { + initializing: t('settings.data.export_to_phone.lan.status.initializing'), + waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'), + connecting: t('settings.data.export_to_phone.lan.status.connecting'), + connected: t('settings.data.export_to_phone.lan.status.connected'), + disconnected: t('settings.data.export_to_phone.lan.status.disconnected'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[connectionPhase] + }, [connectionPhase, t]) + + const transferStatusText = useMemo(() => { + const statusMap = { + idle: '', + preparing: t('settings.data.export_to_phone.lan.status.preparing'), + sending: t('settings.data.export_to_phone.lan.status.sending'), + completed: t('settings.data.export_to_phone.lan.status.completed'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[transferPhase] + }, [transferPhase, t]) + + // 状态样式映射 + const connectionStatusStyles = useMemo(() => { + const styleMap = { + initializing: { + bg: 'var(--color-background-mute)', + border: 'var(--color-border-mute)' + }, + waiting_qr_scan: { + bg: 'var(--color-primary-mute)', + border: 'var(--color-primary-soft)' + }, + connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' }, + connected: { + bg: 'var(--color-status-success)', + border: 'var(--color-status-success)' + }, + disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' }, + error: { bg: 'var(--color-error)', border: 'var(--color-error)' } + } + return styleMap[connectionPhase] + }, [connectionPhase]) + + const initWebSocket = useCallback(async () => { + try { + setConnectionPhase('initializing') + await window.api.webSocket.start() + const { port, ip } = await window.api.webSocket.status() + + if (ip && port) { + const candidatesData = await window.api.webSocket.getAllCandidates() + + const optimizeConnectionInfo = () => { + const ipToNumber = (ip: string) => { + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) + } + + const compressedData = [ + 'CSA', + ipToNumber(ip), + candidatesData.map((candidate: WebSocketCandidatesResponse) => ipToNumber(candidate.host)), + port, // 端口号 + Date.now() % 86400000 + ] + + return compressedData + } + + const compressedData = optimizeConnectionInfo() + const qrCodeValue = JSON.stringify(compressedData) + setQrCodeValue(qrCodeValue) + setConnectionPhase('waiting_qr_scan') + } else { + setError(t('settings.data.export_to_phone.lan.error.no_ip')) + setConnectionPhase('error') + } + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setConnectionPhase('error') + logger.error('Failed to initialize WebSocket:', error as Error) + } + }, [t]) + + const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => { + logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`) + if (data.connected) { + setConnectionPhase('connected') + setError(null) + } else { + setConnectionPhase('disconnected') + } + }, []) + + const handleMessageReceived = useCallback((_event: any, data: any) => { + logger.info(`Received message from mobile: ${JSON.stringify(data)}`) + }, []) + + const handleSendProgress = useCallback( + (_event: any, data: { progress: number }) => { + const progress = data.progress + setSendProgress(progress) + + if (transferPhase === 'preparing' && progress > 0) { + setTransferPhase('sending') + } + + if (progress >= 100) { + setTransferPhase('completed') + // 启动 3 秒倒计时自动关闭 + setAutoCloseCountdown(3) + } + }, + [transferPhase] + ) + + const handleSelectZip = useCallback(async () => { + const result = await window.api.file.select() + if (result) { + setSelectedFolderPath(result[0].path) + } + }, []) + + const handleSendZip = useCallback(async () => { + if (!selectedFolderPath) { + setError(t('settings.data.export_to_phone.lan.error.no_file')) + return + } + + setTransferPhase('preparing') + setError(null) + setSendProgress(0) + + try { + logger.info(`Starting file transfer: ${selectedFolderPath}`) + await window.api.webSocket.sendFile(selectedFolderPath) + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setTransferPhase('error') + logger.error('Failed to send file:', error as Error) + } + }, [selectedFolderPath, t]) + + // 尝试关闭弹窗 - 如果正在传输则显示确认 + const handleCancel = useCallback(() => { + if (isSending) { + window.modal.confirm({ + title: t('settings.data.export_to_phone.lan.confirm_close_title'), + content: t('settings.data.export_to_phone.lan.confirm_close_message'), + centered: true, + okButtonProps: { + danger: true + }, + okText: t('settings.data.export_to_phone.lan.force_close'), + onOk: () => setIsOpen(false) + }) + } else { + setIsOpen(false) + } + }, [isSending, t]) + + // 清理并关闭 + const handleClose = useCallback(async () => { + try { + // 主动断开 WebSocket 连接 + if (isConnected || connectionPhase !== 'disconnected') { + logger.info('Closing popup, stopping WebSocket') + await window.api.webSocket.stop() + } + } catch (error) { + logger.error('Failed to stop WebSocket on close:', error as Error) + } + resolve({}) + }, [resolve, isConnected, connectionPhase]) + + useEffect(() => { + initWebSocket() + + const removeClientConnectedListener = window.electron.ipcRenderer.on( + 'websocket-client-connected', + handleClientConnected + ) + const removeMessageReceivedListener = window.electron.ipcRenderer.on( + 'websocket-message-received', + handleMessageReceived + ) + const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress) + + return () => { + removeClientConnectedListener() + removeMessageReceivedListener() + removeSendProgressListener() + window.api.webSocket.stop() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 自动关闭倒计时 + useEffect(() => { + if (autoCloseCountdown === null) return + + if (autoCloseCountdown <= 0) { + logger.debug('Auto-closing popup after transfer completion') + setIsOpen(false) + return + } + + const timer = setTimeout(() => { + setAutoCloseCountdown(autoCloseCountdown - 1) + }, 1000) + + return () => clearTimeout(timer) + }, [autoCloseCountdown]) + + // 状态指示器组件 + const StatusIndicator = useCallback( + () => ( +
+ {connectionStatusText} +
+ ), + [connectionStatusStyles, connectionStatusText] + ) + + // 二维码显示组件 - 使用显式条件渲染以避免类型不匹配 + const QRCodeDisplay = useCallback(() => { + switch (connectionPhase) { + case 'waiting_qr_scan': + case 'disconnected': + return + case 'initializing': + return + case 'connecting': + return + case 'connected': + return + case 'error': + return + default: + return null + } + }, [connectionPhase, qrCodeValue, error]) + + // 传输进度组件 + const TransferProgress = useCallback(() => { + if (!isSending && transferPhase !== 'completed') return null + + return ( +
+
+
+ + {t('settings.data.export_to_phone.lan.transfer_progress')} + + + {transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`} + +
+ + +
+
+ ) + }, [isSending, transferPhase, sendProgress, t]) + + const AutoCloseCountdown = useCallback(() => { + if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null + + return ( +
+ {t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })} +
+ ) + }, [transferPhase, autoCloseCountdown, t]) + + // 错误显示组件 + const ErrorDisplay = useCallback(() => { + if (!error || transferPhase !== 'error') return null + + return ( +
+ ❌ {error} +
+ ) + }, [error, transferPhase]) + + return ( + + + + + + + + + + + + +
+ + +
+
+ + + {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} + + + + + +
+ ) +} + +const TopViewKey = 'ExportToPhoneLanPopup' + +export default class ExportToPhoneLanPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/GeneralPopup.tsx b/src/renderer/src/components/Popups/GeneralPopup.tsx index 3307b10162..f68d132a1d 100644 --- a/src/renderer/src/components/Popups/GeneralPopup.tsx +++ b/src/renderer/src/components/Popups/GeneralPopup.tsx @@ -1,6 +1,8 @@ import { TopView } from '@renderer/components/TopView' -import { Modal, ModalProps } from 'antd' -import { ReactNode, useState } from 'react' +import type { ModalProps } from 'antd' +import { Modal } from 'antd' +import type { ReactNode } from 'react' +import { useState } from 'react' interface ShowParams extends ModalProps { content: ReactNode diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx index 4560ac74e2..7594d6a200 100644 --- a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -1,9 +1,9 @@ import { CopyIcon, DeleteIcon } from '@renderer/components/Icons' import { useChatContext } from '@renderer/hooks/useChatContext' -import { Topic } from '@renderer/types' +import type { Topic } from '@renderer/types' import { Button, Tooltip } from 'antd' import { Save, X } from 'lucide-react' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx index 9a00e311cf..f84fcb96bb 100644 --- a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx +++ b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx @@ -1,4 +1,5 @@ -import { ObsidianProcessingMethod, PopupContainer } from '@renderer/components/ObsidianExportDialog' +import type { ObsidianProcessingMethod } from '@renderer/components/ObsidianExportDialog' +import { PopupContainer } from '@renderer/components/ObsidianExportDialog' import { TopView } from '@renderer/components/TopView' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx index 0d254d3fb9..fe70d32e86 100644 --- a/src/renderer/src/components/Popups/PromptPopup.tsx +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -1,6 +1,7 @@ import { Input, Modal } from 'antd' -import { TextAreaProps } from 'antd/es/input' -import { ReactNode, useRef, useState } from 'react' +import type { TextAreaProps } from 'antd/es/input' +import type { ReactNode } from 'react' +import { useRef, useState } from 'react' import { Box } from '../Layout' import { TopView } from '../TopView' diff --git a/src/renderer/src/components/Popups/RichEditPopup.tsx b/src/renderer/src/components/Popups/RichEditPopup.tsx index ed7c3d407c..de5a949480 100644 --- a/src/renderer/src/components/Popups/RichEditPopup.tsx +++ b/src/renderer/src/components/Popups/RichEditPopup.tsx @@ -1,6 +1,7 @@ import RichEditor from '@renderer/components/RichEditor' -import { RichEditorRef } from '@renderer/components/RichEditor/types' -import { Modal, ModalProps } from 'antd' +import type { RichEditorRef } from '@renderer/components/RichEditor/types' +import type { ModalProps } from 'antd' +import { Modal } from 'antd' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index cea3aca7cb..892c30e331 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -2,18 +2,16 @@ import { loggerService } from '@logger' import CustomTag from '@renderer/components/Tags/CustomTag' import { TopView } from '@renderer/components/TopView' import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge' -import { Topic } from '@renderer/types' -import { Message } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' +import type { Topic } from '@renderer/types' +import type { Message } from '@renderer/types/newMessage' +import type { NotesTreeNode } from '@renderer/types/note' +import type { ContentType, MessageContentStats, TopicContentStats } from '@renderer/utils/knowledge' import { analyzeMessageContent, analyzeTopicContent, CONTENT_TYPES, - ContentType, - MessageContentStats, processMessageContent, - processTopicContent, - TopicContentStats + processTopicContent } from '@renderer/utils/knowledge' import { Flex, Form, Modal, Select, Tooltip, Typography } from 'antd' import { Check, CircleHelp } from 'lucide-react' diff --git a/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx index aec91bd803..19fe49e1b9 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx @@ -8,7 +8,7 @@ import { VisionTag, WebSearchTag } from '@renderer/components/Tags/Model' -import { ModelTag } from '@renderer/types' +import type { ModelTag } from '@renderer/types' import { Flex } from 'antd' import React, { startTransition, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx index 3871e593d7..df4dbb0485 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx @@ -7,7 +7,8 @@ import { getModelLogoById } from '@renderer/config/models' import { useApiModels } from '@renderer/hooks/agents/useModels' import { getModelUniqId } from '@renderer/services/ModelService' import { getProviderNameById } from '@renderer/services/ProviderService' -import { AdaptedApiModel, ApiModel, ApiModelsFilter, Model, ModelType, objectEntries } from '@renderer/types' +import type { AdaptedApiModel, ApiModel, ApiModelsFilter, Model, ModelType } from '@renderer/types' +import { objectEntries } from '@renderer/types' import { classNames, filterModelsByKeywords } from '@renderer/utils' import { apiModelAdapter, getModelTags } from '@renderer/utils/model' import { Avatar, Divider, Empty, Modal } from 'antd' @@ -27,7 +28,7 @@ import styled from 'styled-components' import { useModelTagFilter } from './filters' import SelectModelSearchBar from './searchbar' import TagFilterSection from './TagFilterSection' -import { FlatListApiItem, FlatListApiModel } from './types' +import type { FlatListApiItem, FlatListApiModel } from './types' const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 diff --git a/src/renderer/src/components/Popups/SelectModelPopup/filters.ts b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts index d2ee6c7742..f43d86f79e 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/filters.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts @@ -6,7 +6,8 @@ import { isVisionModel, isWebSearchModel } from '@renderer/config/models' -import { Model, ModelTag, objectEntries } from '@renderer/types' +import type { Model, ModelTag } from '@renderer/types' +import { objectEntries } from '@renderer/types' import { isFreeModel } from '@renderer/utils/model' import { useCallback, useMemo, useState } from 'react' diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index 60ebc3fe77..eb982ffe9d 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -8,7 +8,8 @@ import { getModelLogo } from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, ModelType, objectEntries, Provider } from '@renderer/types' +import type { Model, ModelType, Provider } from '@renderer/types' +import { objectEntries } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' import { getModelTags } from '@renderer/utils/model' import { Avatar, Divider, Empty, Modal, Tooltip } from 'antd' @@ -30,7 +31,7 @@ import styled from 'styled-components' import { useModelTagFilter } from './filters' import SelectModelSearchBar from './searchbar' import TagFilterSection from './TagFilterSection' -import { FlatListItem, FlatListModel } from './types' +import type { FlatListItem, FlatListModel } from './types' const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 diff --git a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx index ab641cf14b..8f8b981704 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx @@ -1,5 +1,6 @@ import { HStack } from '@renderer/components/Layout' -import { Input, InputRef } from 'antd' +import type { InputRef } from 'antd' +import { Input } from 'antd' import { Search } from 'lucide-react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 6c6e3c2cac..a811930fc3 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -1,5 +1,5 @@ -import { AdaptedApiModel, Model } from '@renderer/types' -import { ReactNode } from 'react' +import type { AdaptedApiModel, Model } from '@renderer/types' +import type { ReactNode } from 'react' /** * 滚动触发来源类型 diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx index 49dca0254a..6ae6cb575b 100644 --- a/src/renderer/src/components/Popups/TextEditPopup.tsx +++ b/src/renderer/src/components/Popups/TextEditPopup.tsx @@ -3,10 +3,11 @@ import { loggerService } from '@logger' import { useSettings } from '@renderer/hooks/useSettings' import useTranslate from '@renderer/hooks/useTranslate' import { translateText } from '@renderer/services/TranslateService' -import { Modal, ModalProps } from 'antd' +import type { ModalProps } from 'antd' +import { Modal } from 'antd' import TextArea from 'antd/es/input/TextArea' -import { TextAreaProps } from 'antd/lib/input' -import { TextAreaRef } from 'antd/lib/input/TextArea' +import type { TextAreaProps } from 'antd/lib/input' +import type { TextAreaRef } from 'antd/lib/input/TextArea' import { Languages } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/Popups/UpdateDialogPopup.tsx b/src/renderer/src/components/Popups/UpdateDialogPopup.tsx new file mode 100644 index 0000000000..29afcc0d24 --- /dev/null +++ b/src/renderer/src/components/Popups/UpdateDialogPopup.tsx @@ -0,0 +1,205 @@ +import { loggerService } from '@logger' +import { TopView } from '@renderer/components/TopView' +import { handleSaveData } from '@renderer/store' +import { Button, Modal } from 'antd' +import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' +import styled from 'styled-components' + +const logger = loggerService.withContext('UpdateDialog') + +interface ShowParams { + releaseInfo: UpdateInfo | null +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ releaseInfo, resolve }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const [isInstalling, setIsInstalling] = useState(false) + + useEffect(() => { + if (releaseInfo) { + logger.info('Update dialog opened', { version: releaseInfo.version }) + } + }, [releaseInfo]) + + const handleInstall = async () => { + setIsInstalling(true) + try { + await handleSaveData() + await window.api.quitAndInstall() + setOpen(false) + } catch (error) { + logger.error('Failed to save data before update', error as Error) + setIsInstalling(false) + window.toast.error(t('update.saveDataError')) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + UpdateDialogPopup.hide = onCancel + + const releaseNotes = releaseInfo?.releaseNotes + + return ( + +

{t('update.title')}

+

{t('update.message').replace('{{version}}', releaseInfo?.version || '')}

+ + } + open={open} + onCancel={onCancel} + afterClose={onClose} + transitionName="animation-move-down" + centered + width={720} + footer={[ + , + + ]}> + + + + {typeof releaseNotes === 'string' + ? releaseNotes + : Array.isArray(releaseNotes) + ? releaseNotes + .map((note: ReleaseNoteInfo) => note.note) + .filter(Boolean) + .join('\n\n') + : t('update.noReleaseNotes')} + + + +
+ ) +} + +const TopViewKey = 'UpdateDialogPopup' + +export default class UpdateDialogPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} + +const ModalHeaderWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--color-text-1); + } + + p { + margin: 0; + font-size: 14px; + color: var(--color-text-2); + } +` + +const ModalBodyWrapper = styled.div` + max-height: 450px; + overflow-y: auto; + padding: 12px 0; +` + +const ReleaseNotesWrapper = styled.div` + background-color: var(--color-bg-2); + border-radius: 8px; + + p { + margin: 0 0 12px 0; + color: var(--color-text-2); + font-size: 14px; + line-height: 1.6; + + &:last-child { + margin-bottom: 0; + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 16px 0 8px 0; + color: var(--color-text-1); + font-weight: 600; + + &:first-child { + margin-top: 0; + } + } + + ul, + ol { + margin: 8px 0; + padding-left: 24px; + color: var(--color-text-2); + } + + li { + margin: 4px 0; + } + + code { + padding: 2px 6px; + background-color: var(--color-bg-3); + border-radius: 4px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 13px; + } + + pre { + padding: 12px; + background-color: var(--color-bg-3); + border-radius: 6px; + overflow-x: auto; + + code { + padding: 0; + background-color: transparent; + } + } +` diff --git a/src/renderer/src/components/Popups/VideoPopup.tsx b/src/renderer/src/components/Popups/VideoPopup.tsx index 3cb33b43c3..06b3f1bcd8 100644 --- a/src/renderer/src/components/Popups/VideoPopup.tsx +++ b/src/renderer/src/components/Popups/VideoPopup.tsx @@ -1,7 +1,7 @@ import { UploadOutlined } from '@ant-design/icons' import FileManager from '@renderer/services/FileManager' import { loggerService } from '@renderer/services/LoggerService' -import { FileMetadata } from '@renderer/types' +import type { FileMetadata } from '@renderer/types' import { mime2type, uuid } from '@renderer/utils' import { Modal, Space, Upload } from 'antd' import type { UploadFile } from 'antd/es/upload/interface' diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 609593493a..d504699399 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -1,43 +1,32 @@ -import { - Button, - Form, - Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Select, - SelectedItemProps, - SelectItem, - Textarea, - useDisclosure -} from '@heroui/react' import { loggerService } from '@logger' -import type { Selection } from '@react-types/shared' import ClaudeIcon from '@renderer/assets/images/models/claude.png' +import { ErrorBoundary } from '@renderer/components/ErrorBoundary' +import { TopView } from '@renderer/components/TopView' import { permissionModeCards } from '@renderer/config/agent' -import { agentModelFilter, getModelLogoById } from '@renderer/config/models' import { useAgents } from '@renderer/hooks/agents/useAgents' -import { useApiModels } from '@renderer/hooks/agents/useModels' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' -import { +import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton' +import type { AddAgentForm, - AgentConfigurationSchema, AgentEntity, AgentType, + ApiModel, BaseAgentForm, - isAgentType, PermissionMode, Tool, UpdateAgentForm } from '@renderer/types' +import { AgentConfigurationSchema, isAgentType } from '@renderer/types' +import { Avatar, Button, Input, Modal, Select } from 'antd' import { AlertTriangleIcon } from 'lucide-react' -import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ChangeEvent, FormEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' -import { ErrorBoundary } from '../../ErrorBoundary' -import { BaseOption, ModelOption, Option, renderOption } from './shared' +import type { BaseOption } from './shared' + +const { TextArea } = Input const logger = loggerService.withContext('AddAgentPopup') @@ -47,8 +36,6 @@ interface AgentTypeOption extends BaseOption { name: AgentEntity['name'] } -type Option = AgentTypeOption | ModelOption - type AgentWithTools = AgentEntity & { tools?: Tool[] } const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({ @@ -63,57 +50,37 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({ configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {}) }) -type Props = { +interface ShowParams { agent?: AgentWithTools - isOpen: boolean - onClose: () => void + afterSubmit?: (a: AgentEntity) => void } -/** - * Modal component for creating or editing an agent. - * - * Either trigger or isOpen and onClose is given. - * @param agent - Optional agent entity for editing mode. - * @param isOpen - Optional controlled modal open state. From useDisclosure. - * @param onClose - Optional callback when modal closes. From useDisclosure. - * @returns Modal component for agent creation/editing - */ -export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _onClose }) => { - const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose }) +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const { t } = useTranslation() + const [open, setOpen] = useState(true) const loadingRef = useRef(false) - // const { setTimeoutTimer } = useTimer() const { addAgent } = useAgents() const { updateAgent } = useUpdateAgent() - // hard-coded. We only support anthropic for now. - const { models } = useApiModels({ providerType: 'anthropic' }) const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) useEffect(() => { - if (isOpen) { + if (open) { setForm(buildAgentForm(agent)) } - }, [agent, isOpen]) + }, [agent, open]) const selectedPermissionMode = form.configuration?.permission_mode ?? 'default' - const onPermissionModeChange = useCallback((keys: Selection) => { - if (keys === 'all') { - return - } - - const [first] = Array.from(keys) - if (!first) { - return - } - + const onPermissionModeChange = useCallback((value: PermissionMode) => { setForm((prev) => { const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {}) - const nextMode = first as PermissionMode - - if (parsedConfiguration.permission_mode === nextMode) { + if (parsedConfiguration.permission_mode === value) { if (!prev.configuration) { return { ...prev, @@ -127,7 +94,7 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ ...prev, configuration: { ...parsedConfiguration, - permission_mode: nextMode + permission_mode: value } } }) @@ -148,55 +115,57 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ [] ) - const agentOptions: AgentTypeOption[] = useMemo( + const agentOptions = useMemo( () => - agentConfig.map( - (option) => - ({ - ...option, - rendered: