diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..cf0ef66028 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +/src/renderer/src/store/ @0xfullex +/src/main/services/ConfigManager.ts @0xfullex +/packages/shared/IpcChannel.ts @0xfullex +/src/main/ipc.ts @0xfullex \ No newline at end of file diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 29cfd1fda0..140d6208fc 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -2,8 +2,8 @@ name: Auto I18N env: API_KEY: ${{ secrets.TRANSLATE_API_KEY }} - MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}} - BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}} + MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}} + BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}} on: pull_request: @@ -35,7 +35,7 @@ jobs: # 在临时目录安装依赖 mkdir -p /tmp/translation-deps cd /tmp/translation-deps - echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-tailwindcss": "^0.6.14"}}' > package.json + echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json npm install --no-package-lock # 设置 NODE_PATH 让项目能找到这些依赖 @@ -45,7 +45,7 @@ jobs: run: npx tsx scripts/auto-translate-i18n.ts - name: 🔍 Format - run: cd /tmp/translation-deps && npx prettier --write --config /home/runner/work/cherry-studio/cherry-studio/.prettierrc /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/ + 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/ - name: 🔄 Commit changes run: | diff --git a/.github/workflows/claude-translator.yml b/.github/workflows/claude-translator.yml index ab2b6f7e4f..ff317f8532 100644 --- a/.github/workflows/claude-translator.yml +++ b/.github/workflows/claude-translator.yml @@ -1,6 +1,6 @@ name: Claude Translator concurrency: - group: translator-${{ github.event.comment.id || github.event.issue.number }} + group: translator-${{ github.event.comment.id || github.event.issue.number || github.event.review.id }} cancel-in-progress: false on: @@ -8,14 +8,18 @@ on: types: [opened] issue_comment: types: [created, edited] + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] jobs: translate: if: | (github.event_name == 'issues') || - (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') && - ((github.event_name == 'issue_comment' && github.event.action == 'created' && !contains(github.event.comment.body, 'This issue was translated by Claude')) || - (github.event_name == 'issue_comment' && github.event.action == 'edited')) + (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') || + (github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') || + (github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot') runs-on: ubuntu-latest permissions: contents: read @@ -37,23 +41,44 @@ jobs: # Now `contents: read` is safe for files, but we could make a fine-grained token to control it. # See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md github_token: ${{ secrets.TOKEN_GITHUB_WRITE }} - allowed_non_write_users: '*' + allowed_non_write_users: "*" claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*)' + claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)" prompt: | - 你是一个多语言翻译助手。请完成以下任务: + 你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件: + + - issues + - issue_comment + - pull_request_review + - pull_request_review_comment + + 请完成以下任务: + + 1. 获取当前事件的完整信息。 + + - 如果当前事件是 issues,就获取该 issues 的信息。 + - 如果当前事件是 issue_comment,就获取该 comment 的信息。 + - 如果当前事件是 pull_request_review,就获取该 review 的信息。 + - 如果当前事件是 pull_request_review_comment,就获取该 comment 的信息。 - 1. 获取当前issue/comment的完整信息 2. 智能检测内容。 - 1. 如果是已经遵循格式要求翻译过的issue/comment,检查翻译内容和原始内容是否匹配。若不匹配,则重新翻译一次令其匹配,并遵循格式要求;若匹配,则跳过任务。 - 2. 如果是未翻译过的issue/comment,检查其内容语言。若不是英文,则翻译成英文;若已经是英文,则跳过任务。 + + - 如果获取到的信息是已经遵循格式要求翻译过的内容,则检查翻译内容和原始内容是否匹配。若不匹配,则重新翻译一次令其匹配,并遵循格式要求; + - 如果获取到的信息是未翻译过的内容,检查其内容语言。若不是英文,则翻译成英文; + - 如果获取到的信息是部分翻译为英文的内容,则将其翻译为英文; + - 如果获取到的信息包含了对已翻译内容的引用,则将引用内容清理为仅含英文的内容。引用的内容不能够包含"This xxx was translated by Claude"和"Original Content`等内容。 + - 如果获取到的信息包含了其他类型的引用,即对非 Claude 翻译的内容的引用,则直接照原样引用,不进行翻译。 + - 如果获取到的信息是通过邮件回复的内容,则在翻译时应当将邮件内容的引用放到最后。在原始内容和翻译内容中只需要回复的内容本身,不要包含对邮件内容的引用。 + - 如果获取到的信息本身不需要任何处理,则跳过任务。 + 3. 格式要求: + - 标题:英文翻译(如果非英文) - 内容格式: > [!NOTE] - > This issue/comment was translated by Claude. + > This issue/comment/review was translated by Claude. - [英文翻译内容] + [翻译内容] ---
@@ -62,15 +87,21 @@ jobs:
4. 使用gh工具更新: + - 根据环境信息中的Event类型选择正确的命令: - - 如果Event是'issues':gh issue edit [ISSUE_NUMBER] --title "[英文标题]" --body "[翻译内容 + 原始内容]" - - 如果Event是'issue_comment':gh api -X PATCH /repos/[REPO]/issues/comments/[COMMENT_ID] -f body="[翻译内容 + 原始内容]" + - 如果 Event 是 'issues': gh issue edit [ISSUE_NUMBER] --title "[英文标题]" --body "[翻译内容 + 原始内容]" + - 如果 Event 是 'issue_comment': gh api -X PATCH /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }} -f body="[翻译内容 + 原始内容]" + - 如果 Event 是 'pull_request_review': gh api -X PUT /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews/${{ github.event.review.id }} -f body="[翻译内容]" + - 如果 Event 是 'pull_request_review_comment': gh api -X PATCH /repos/${{ github.repository }}/pulls/comments/${{ github.event.comment.id }} -f body="[翻译内容 + 原始内容]" 环境信息: - Event: ${{ github.event_name }} - Issue Number: ${{ github.event.issue.number }} - Repository: ${{ github.repository }} - - Comment ID: ${{ github.event.comment.id || 'N/A' }} (only available for comment events) + - (Review) Comment ID: ${{ github.event.comment.id || 'N/A' }} + - Pull Request Number: ${{ github.event.pull_request.number || 'N/A' }} + - Review ID: ${{ github.event.review.id || 'N/A' }} + 使用以下命令获取完整信息: gh issue view ${{ github.event.issue.number }} --json title,body,comments diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml new file mode 100644 index 0000000000..fae32c7477 --- /dev/null +++ b/.github/workflows/delete-branch.yml @@ -0,0 +1,22 @@ +name: Delete merged branch +on: + pull_request: + types: + - closed + +jobs: + delete-branch: + runs-on: ubuntu-latest + permissions: + contents: write + if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Delete merged branch + uses: actions/github-script@v7 + with: + script: | + github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${context.payload.pull_request.head.ref}`, + }) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index ce07892bc4..42d0d66150 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -98,10 +98,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 - MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Mac if: matrix.os == 'macos-latest' @@ -110,15 +110,15 @@ jobs: env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ vars.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 - MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Windows if: matrix.os == 'windows-latest' @@ -127,10 +127,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 - MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Rename artifacts with nightly format shell: bash diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 5e757573e6..4f462db95c 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -9,12 +9,15 @@ on: branches: - main - develop + - v2 + types: [ready_for_review, synchronize, opened] jobs: build: runs-on: ubuntu-latest env: PRCI: true + if: github.event.pull_request.draft == false steps: - name: Check out Git repository @@ -45,12 +48,12 @@ jobs: - name: Install Dependencies run: yarn install - - name: Format Check - run: yarn format:check - - name: Lint Check run: yarn test:lint + - name: Format Check + run: yarn format:check + - name: Type Check run: yarn typecheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7428aa031e..0ca1eb0146 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,10 +85,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 - MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Mac if: matrix.os == 'macos-latest' @@ -98,15 +98,15 @@ jobs: env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ vars.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 - MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Windows if: matrix.os == 'windows-latest' @@ -115,10 +115,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 - MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Release uses: ncipollo/release-action@v1 diff --git a/.gitignore b/.gitignore index 39b5630926..f4cc92ce30 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ dist out mcp_server stats.html +.eslintcache # ENV .env @@ -53,6 +54,8 @@ local .qwen/* .trae/* .claude-code-router/* +.codebuddy/* +.zed/* CLAUDE.local.md # vitest diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..0a6a9764b7 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,215 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": {}, + "env": { + "es2022": true + }, + "globals": {}, + "ignorePatterns": [ + "node_modules/**", + "build/**", + "dist/**", + "out/**", + "local/**", + ".yarn/**", + ".gitignore", + "scripts/cloudflare-worker.js", + "src/main/integration/nutstore/sso/lib/**", + "src/main/integration/cherryai/index.js", + "src/main/integration/nutstore/sso/lib/**", + "src/renderer/src/ui/**", + "packages/**/dist", + "eslint.config.mjs" + ], + "overrides": [ + // set different env + { + "env": { + "node": true + }, + "files": ["src/main/**", "resources/scripts/**", "scripts/**", "playwright.config.ts", "electron.vite.config.ts"] + }, + { + "env": { + "browser": true + }, + "files": [ + "src/renderer/**/*.{ts,tsx}", + "packages/aiCore/**", + "packages/extension-table-plus/**", + "resources/js/**" + ] + }, + { + "env": { + "node": true, + "vitest": true + }, + "files": ["**/__tests__/*.test.{ts,tsx}", "tests/**"] + }, + { + "env": { + "browser": true, + "node": true + }, + "files": ["src/preload/**"] + } + ], + // 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-vars": ["error", { "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", + "oxc/bad-min-max-func": "warn", + "oxc/bad-object-literal-comparison": "warn", + "oxc/bad-replace-all-arg": "warn", + "oxc/const-comparisons": "warn", + "oxc/double-comparisons": "warn", + "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/uninvoked-array-callback": "warn", + "require-yield": "error", + "typescript/await-thenable": "warn", + // "typescript/ban-ts-comment": "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-extra-non-null-assertion": "error", + "typescript/no-floating-promises": "warn", + "typescript/no-for-in-array": "warn", + "typescript/no-implied-eval": "warn", + "typescript/no-meaningless-void-operator": "warn", + "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-redundant-type-constituents": "warn", + "typescript/no-require-imports": "off", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-parameter-property-assignment": "warn", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unsafe-unary-minus": "warn", + "typescript/no-useless-empty-export": "warn", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/require-array-sort-compare": "warn", + "typescript/restrict-template-expressions": "warn", + "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-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-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-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/prefer-set-size": "warn", + "unicorn/prefer-string-starts-ends-with": "warn", + "use-isnan": "error", + "valid-typeof": "error" + }, + "settings": { + "jsdoc": { + "augmentsExtendsReplacesDocs": false, + "exemptDestructuredRootsFromChecks": false, + "ignoreInternal": false, + "ignorePrivate": false, + "ignoreReplacesDocs": true, + "implementsReplacesDocs": false, + "overrideReplacesDocs": true, + "tagNamePreference": {} + }, + "jsx-a11y": { + "attributes": {}, + "components": {}, + "polymorphicPropName": null + }, + "next": { + "rootDir": [] + }, + "react": { + "formComponents": [], + "linkComponents": [] + } + } +} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 0e96c6c2fe..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,12 +0,0 @@ -out -dist -pnpm-lock.yaml -LICENSE.md -tsconfig.json -tsconfig.*.json -CHANGELOG*.md -agents.json -src/renderer/src/integration/nutstore/sso/lib -AGENT.md -src/main/integration/ -.yarn/releases/ diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 7a06761104..0000000000 --- a/.prettierrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "bracketSameLine": true, - "endOfLine": "lf", - "jsonRecursiveSort": true, - "jsonSortOrder": "{\"*\": \"lexical\"}", - "plugins": ["prettier-plugin-sort-json", "prettier-plugin-tailwindcss"], - "printWidth": 120, - "semi": false, - "singleQuote": true, - "tailwindFunctions": ["clsx"], - "tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css", - "trailingComma": "none" -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index cde2c60935..79046aa441 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,11 @@ { "recommendations": [ "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", "editorconfig.editorconfig", - "lokalise.i18n-ally" + "lokalise.i18n-ally", + "bradlc.vscode-tailwindcss", + "vitest.explorer", + "oxc.oxc-vscode", + "biomejs.biome" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 3dd634507f..141179f38c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,30 +1,32 @@ { "[css]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[markdown]": { "files.trimTrailingWhitespace": false }, "[scss]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", "source.fixAll.eslint": "explicit", + "source.fixAll.oxc": "explicit", "source.organizeImports": "never" }, "editor.formatOnSave": true, diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch new file mode 100644 index 0000000000..f8868aa916 --- /dev/null +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch @@ -0,0 +1,13 @@ +diff --git a/dist/index.mjs b/dist/index.mjs +index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { + + // src/get-model-path.ts + function getModelPath(modelId) { +- return modelId.includes("/") ? modelId : `models/${modelId}`; ++ return modelId?.includes("models/") ? modelId : `models/${modelId}`; + } + + // src/google-generative-ai-options.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index e1e4cf05ca..f127d5c149 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,3 +5,5 @@ httpTimeout: 300000 nodeLinker: node-modules yarnPath: .yarn/releases/yarn-4.9.1.cjs +npmRegistryServer: https://registry.npmjs.org +npmPublishRegistry: https://registry.npmjs.org diff --git a/CLAUDE.md b/CLAUDE.md index e7feaa981d..58b01fe853 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Prerequisites**: Node.js v22.x.x or higher, Yarn 4.9.1 - **Setup Yarn**: `corepack enable && corepack prepare yarn@4.9.1 --activate` - **Install Dependencies**: `yarn install` +- **Add New Dependencies**: `yarn add -D` for renderer-specific dependencies, `yarn add` for others. ### Development @@ -21,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Run E2E Tests**: `yarn test:e2e` - Playwright end-to-end tests - **Type Check**: `yarn typecheck` - Checks TypeScript for both node and web - **Lint**: `yarn lint` - ESLint with auto-fix -- **Format**: `yarn format` - Prettier formatting +- **Format**: `yarn format` - Biome formatting ### Build & Release diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000000..b86350d70d --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,97 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "assist": { + // to sort json + "actions": { + "source": { + "organizeImports": "on", + "useSortedKeys": { + "level": "on", + "options": { + "sortOrder": "lexicographic" + } + } + } + }, + "enabled": true, + "includes": ["**/*.json", "!*.json", "!**/package.json"] + }, + "css": { + "formatter": { + "quoteStyle": "single" + } + }, + "files": { "ignoreUnknown": false }, + "formatter": { + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "enabled": true, + "expand": "auto", + "formatWithErrors": true, + "includes": [ + "**", + "!out/**", + "!**/dist/**", + "!build/**", + "!.yarn/**", + "!.github/**", + "!.husky/**", + "!.vscode/**", + "!*.yaml", + "!*.yml", + "!*.mjs", + "!*.cjs", + "!*.md", + "!*.json", + "!src/main/integration/**", + "!**/tailwind.css", + "!**/package.json" + ], + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 120, + "useEditorconfig": true + }, + "html": { "formatter": { "selfCloseVoidElements": "always" } }, + "javascript": { + "formatter": { + "arrowParentheses": "always", + "attributePosition": "auto", + // To minimize changes in this PR as much as possible, it's set to true. However, setting it to false would make it more convenient to add attributes at the end. + "bracketSameLine": true, + "bracketSpacing": true, + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "quoteStyle": "single", + "semicolons": "asNeeded", + "trailingCommas": "none" + } + }, + "json": { + "parser": { + "allowComments": true + } + }, + "linter": { + "enabled": true, + "includes": ["!**/tailwind.css", "src/renderer/**/*.{tsx,ts}"], + // only enable sorted tailwind css rule. used as formatter instead of linter + "rules": { + "nursery": { + // to sort tailwind css classes + "useSortedClasses": { + "fix": "safe", + "level": "warn", + "options": { + "functions": ["cn"] + } + } + }, + "recommended": false, + "suspicious": "off" + } + }, + "vcs": { "clientKind": "git", "enabled": false, "useIgnoreFile": false } +} diff --git a/docs/dev.md b/docs/dev.md index 721f557245..0fdff640ec 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -2,7 +2,9 @@ ## IDE Setup -[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) +- Editor: [Cursor](https://www.cursor.com/), etc. Any VS Code compatible editor. +- Linter: [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) +- Formatter: [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) ## Project Setup diff --git a/electron-builder.yml b/electron-builder.yml index f2dc2b9f7c..9ffd01f002 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -24,52 +24,52 @@ protocols: schemes: - cherrystudio files: - - '**/*' - - '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}' - - '!electron.vite.config.{js,ts,mjs,cjs}}' - - '!**/{.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}' - - '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}' - - '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}' - - '!**/{.editorconfig,.jekyll-metadata}' - - '!src' - - '!scripts' - - '!local' - - '!docs' - - '!packages' - - '!.swc' - - '!.bin' - - '!._*' - - '!*.log' - - '!stats.html' - - '!*.md' - - '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}' - - '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}' - - '!**/{test,tests,__tests__,powered-test,coverage}/**' - - '!**/{example,examples}/**' - - '!**/*.{spec,test}.{js,jsx,ts,tsx}' - - '!**/*.min.*.map' - - '!**/*.d.ts' - - '!**/dist/es6/**' - - '!**/dist/demo/**' - - '!**/amd/**' - - '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}' - - '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}' - - '!node_modules/rollup-plugin-visualizer' - - '!node_modules/js-tiktoken' - - '!node_modules/@tavily/core/node_modules/js-tiktoken' - - '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}' - - '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}' - - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds - - '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir - - '!node_modules/selection-hook/src' # we don't need source files - - '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files - - '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files - - '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files - - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files + - "**/*" + - "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}" + - "!electron.vite.config.{js,ts,mjs,cjs}}" + - "!**/{.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}" + - "!**/{.editorconfig,.jekyll-metadata}" + - "!src" + - "!scripts" + - "!local" + - "!docs" + - "!packages" + - "!.swc" + - "!.bin" + - "!._*" + - "!*.log" + - "!stats.html" + - "!*.md" + - "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}" + - "!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}" + - "!**/{test,tests,__tests__,powered-test,coverage}/**" + - "!**/{example,examples}/**" + - "!**/*.{spec,test}.{js,jsx,ts,tsx}" + - "!**/*.min.*.map" + - "!**/*.d.ts" + - "!**/dist/es6/**" + - "!**/dist/demo/**" + - "!**/amd/**" + - "!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}" + - "!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}" + - "!node_modules/rollup-plugin-visualizer" + - "!node_modules/js-tiktoken" + - "!node_modules/@tavily/core/node_modules/js-tiktoken" + - "!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}" + - "!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}" + - "!node_modules/selection-hook/prebuilds/**/*" # we rebuild .node, don't use prebuilds + - "!node_modules/selection-hook/node_modules" # we don't need what in the node_modules dir + - "!node_modules/selection-hook/src" # we don't need source files + - "!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}" # we don't need source files + - "!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}" # we don't need source files + - "!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}" # we don't need source files + - "!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}" # filter .node build files asarUnpack: - resources/** - - '**/*.{metal,exp,lib}' - - 'node_modules/@img/sharp-libvips-*/**' + - "**/*.{metal,exp,lib}" + - "node_modules/@img/sharp-libvips-*/**" win: executableName: Cherry Studio artifactName: ${productName}-${version}-${arch}-setup.${ext} @@ -95,7 +95,7 @@ mac: entitlementsInherit: build/entitlements.mac.plist notarize: false artifactName: ${productName}-${version}-${arch}.${ext} - minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0 + minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0 extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. - NSMicrophoneUsageDescription: Application requests access to the device's microphone. @@ -120,7 +120,7 @@ linux: rpm: # Workaround for electron build issue on rpm package: # https://github.com/electron/forge/issues/3594 - fpm: ['--rpm-rpmbuild-define=_build_id_links none'] + fpm: ["--rpm-rpmbuild-define=_build_id_links none"] publish: provider: generic url: https://releases.cherry-ai.com @@ -132,25 +132,7 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - ✨ 新功能: - - 重构知识库模块,提升文档处理能力和搜索性能 - - 新增 PaddleOCR 支持,增强文档识别能力 - - 支持自定义窗口控制按钮样式 - - 新增 AI SDK 包,扩展 AI 能力集成 - - 支持标签页拖拽重排序功能 - - 增强笔记编辑器的同步和日志功能 - - 🔧 性能优化: - - 优化 MCP 服务的日志记录和错误处理 - - 改进 WebView 服务的 User-Agent 处理 - - 优化迷你应用的标题栏样式和状态栏适配 - - 重构依赖管理,清理和优化 package.json - - 🐛 问题修复: - - 修复输入栏无限状态更新循环问题 - - 修复窗口控制提示框的鼠标悬停延迟 - - 修复翻译输入框粘贴多内容源的处理 - - 修复导航服务初始化时序问题 - - 修复 MCP 通过 JSON 添加时的参数转换 - - 修复模型作用域服务器同步时的 URL 格式 - - 标准化工具提示图标样式 + Optimized note-taking feature, now able to quickly rename by modifying the title + Fixed issue where CherryAI free model could not be used + Fixed issue where VertexAI proxy address could not be called normally + Fixed issue where built-in tools from service providers could not be called normally diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 83f82b5f4c..aa2bc13de6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,7 +4,9 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { resolve } from 'path' import { visualizer } from 'rollup-plugin-visualizer' -import pkg from './package.json' assert { type: 'json' } +// assert not supported by biome +// import pkg from './package.json' assert { type: 'json' } +import pkg from './package.json' const visualizerPlugin = (type: 'renderer' | 'main') => { return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] @@ -32,6 +34,10 @@ export default defineConfig({ output: { manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包 inlineDynamicImports: true // 内联所有动态导入,这是关键配置 + }, + onwarn(warning, warn) { + if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return + warn(warning) } }, sourcemap: isDev @@ -109,6 +115,10 @@ export default defineConfig({ selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'), traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html') + }, + onwarn(warning, warn) { + if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return + warn(warning) } } }, diff --git a/eslint.config.mjs b/eslint.config.mjs index f3a96ca225..be4a95cd60 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,8 +1,8 @@ -import electronConfigPrettier from '@electron-toolkit/eslint-config-prettier' import tseslint from '@electron-toolkit/eslint-config-ts' import eslint from '@eslint/js' import eslintReact from '@eslint-react/eslint-plugin' import { defineConfig } from 'eslint/config' +import oxlint from 'eslint-plugin-oxlint' import reactHooks from 'eslint-plugin-react-hooks' import simpleImportSort from 'eslint-plugin-simple-import-sort' import unusedImports from 'eslint-plugin-unused-imports' @@ -10,7 +10,6 @@ import unusedImports from 'eslint-plugin-unused-imports' export default defineConfig([ eslint.configs.recommended, tseslint.configs.recommended, - electronConfigPrettier, eslintReact.configs['recommended-typescript'], reactHooks.configs['recommended-latest'], { @@ -26,7 +25,6 @@ export default defineConfig([ 'simple-import-sort/exports': 'error', 'unused-imports/no-unused-imports': 'error', '@eslint-react/no-prop-types': 'error', - 'prettier/prettier': ['error'] } }, // Configuration for ensuring compatibility with the original ESLint(8.x) rules @@ -50,10 +48,31 @@ export default defineConfig([ '@eslint-react/no-children-to-array': 'off' } }, + { + ignores: [ + 'node_modules/**', + 'build/**', + 'dist/**', + 'out/**', + 'local/**', + '.yarn/**', + '.gitignore', + 'scripts/cloudflare-worker.js', + 'src/main/integration/nutstore/sso/lib/**', + 'src/main/integration/cherryai/index.js', + 'src/main/integration/nutstore/sso/lib/**', + 'src/renderer/src/ui/**', + 'packages/**/dist' + ] + }, + // turn off oxlint supported rules. + ...oxlint.configs['flat/eslint'], + ...oxlint.configs['flat/typescript'], + ...oxlint.configs['flat/unicorn'], { // LoggerService Custom Rules - only apply to src directory files: ['src/**/*.{ts,tsx,js,jsx}'], - ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*'], + ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'], rules: { 'no-restricted-syntax': [ process.env.PRCI ? 'error' : 'warn', @@ -112,21 +131,4 @@ export default defineConfig([ 'i18n/no-template-in-t': 'warn' } }, - { - ignores: [ - 'node_modules/**', - 'build/**', - 'dist/**', - 'out/**', - 'local/**', - '.yarn/**', - '.gitignore', - 'scripts/cloudflare-worker.js', - 'src/main/integration/nutstore/sso/lib/**', - 'src/main/integration/cherryin/index.js', - 'src/main/integration/nutstore/sso/lib/**', - 'src/renderer/src/ui/**', - 'packages/**/dist' - ] - } ]) diff --git a/package.json b/package.json index 153be63374..2e6b6251e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.6.0-beta.7", + "version": "1.6.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -48,8 +48,8 @@ "analyze:renderer": "VISUALIZER_RENDERER=true yarn build", "analyze:main": "VISUALIZER_MAIN=true yarn build", "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", - "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", - "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", + "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", + "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", "check:i18n": "tsx scripts/check-i18n.ts", "sync:i18n": "tsx scripts/sync-i18n.ts", "update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", @@ -63,13 +63,16 @@ "test:ui": "vitest --ui", "test:watch": "vitest", "test:e2e": "yarn playwright test", - "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", + "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache", "test:scripts": "vitest scripts", - "format": "prettier --write .", - "format:check": "prettier --check .", - "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", + "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n", + "format": "biome format --write && biome lint --write", + "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", - "claude": "dotenv -e .env -- claude" + "claude": "dotenv -e .env -- claude", + "release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public", + "release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public", + "release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public" }, "dependencies": { "@libsql/client": "0.14.0", @@ -77,7 +80,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", "@strongtz/win32-arm64-msvc": "^0.4.7", "express": "^5.1.0", - "faiss-node": "^0.5.1", + "font-list": "^2.0.0", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", @@ -94,16 +97,18 @@ "@agentic/exa": "^7.3.3", "@agentic/searxng": "^7.3.3", "@agentic/tavily": "^7.3.3", - "@ai-sdk/amazon-bedrock": "^3.0.0", - "@ai-sdk/google-vertex": "^3.0.25", - "@ai-sdk/mistral": "^2.0.0", + "@ai-sdk/amazon-bedrock": "^3.0.21", + "@ai-sdk/google-vertex": "^3.0.27", + "@ai-sdk/mistral": "^2.0.14", + "@ai-sdk/perplexity": "^2.0.9", "@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", - "@cherrystudio/ai-core": "workspace:*", + "@biomejs/biome": "2.2.4", + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18", "@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "^0.1.31", @@ -121,7 +126,6 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/tsconfig": "^1.0.1", @@ -135,9 +139,6 @@ "@heroui/react": "^2.8.3", "@kangfenmao/keyv-storage": "^0.1.0", "@langchain/community": "^0.3.50", - "@langchain/core": "^0.3.68", - "@langchain/ollama": "^0.2.1", - "@langchain/openai": "^0.6.7", "@mistralai/mistralai": "^1.7.5", "@modelcontextprotocol/sdk": "^1.17.5", "@mozilla/readability": "^0.6.0", @@ -202,6 +203,7 @@ "@types/tinycolor2": "^1", "@types/turndown": "^5.0.5", "@types/word-extractor": "^1", + "@typescript/native-preview": "latest", "@uiw/codemirror-extensions-langs": "^4.25.1", "@uiw/codemirror-themes-all": "^4.25.1", "@uiw/react-codemirror": "^4.25.1", @@ -213,7 +215,7 @@ "@viz-js/lang-dot": "^1.0.5", "@viz-js/viz": "^3.14.0", "@xyflow/react": "^12.4.4", - "ai": "^5.0.38", + "ai": "^5.0.44", "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", @@ -247,6 +249,7 @@ "emoji-picker-element": "^1.22.1", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "eslint": "^9.22.0", + "eslint-plugin-oxlint": "^1.15.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", @@ -282,13 +285,12 @@ "notion-helper": "^1.3.22", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", + "oxlint": "^1.15.0", + "oxlint-tsgolint": "^0.2.0", "p-queue": "^8.1.0", "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", "playwright": "^1.52.0", - "prettier": "^3.5.3", - "prettier-plugin-sort-json": "^4.1.1", - "prettier-plugin-tailwindcss": "^0.6.14", "proxy-agent": "^6.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -330,7 +332,7 @@ "tsx": "^4.20.3", "turndown-plugin-gfm": "^1.0.2", "tw-animate-css": "^1.3.8", - "typescript": "^5.6.2", + "typescript": "~5.8.2", "undici": "6.21.2", "unified": "^11.0.5", "uuid": "^10.0.0", @@ -366,16 +368,17 @@ "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "undici": "6.21.2", "vite": "npm:rolldown-vite@latest", - "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch" + "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.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ - "prettier --write", + "biome format --write --no-errors-on-unmatched", "eslint --fix" ], "*.{json,yml,yaml,css,html}": [ - "prettier --write" + "biome format --write --no-errors-on-unmatched" ] } } diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index e61db529e2..93bf7b6414 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -1,6 +1,6 @@ { "name": "@cherrystudio/ai-core", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.18", "description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK", "main": "dist/index.js", "module": "dist/index.mjs", @@ -36,15 +36,14 @@ "ai": "^5.0.26" }, "dependencies": { - "@ai-sdk/anthropic": "^2.0.5", - "@ai-sdk/azure": "^2.0.16", - "@ai-sdk/deepseek": "^1.0.9", - "@ai-sdk/google": "^2.0.13", - "@ai-sdk/openai": "^2.0.26", - "@ai-sdk/openai-compatible": "^1.0.9", + "@ai-sdk/anthropic": "^2.0.17", + "@ai-sdk/azure": "^2.0.30", + "@ai-sdk/deepseek": "^1.0.17", + "@ai-sdk/openai": "^2.0.30", + "@ai-sdk/openai-compatible": "^1.0.17", "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^3.0.4", - "@ai-sdk/xai": "^2.0.9", + "@ai-sdk/provider-utils": "^3.0.9", + "@ai-sdk/xai": "^2.0.18", "zod": "^4.1.5" }, "devDependencies": { diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index 4350e9241b..ffeb15185c 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -59,7 +59,7 @@ export function createGoogleOptions(options: ExtractProviderOptions<'google'>) { /** * 创建OpenRouter供应商选项的便捷函数 */ -export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'>) { +export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'> | Record) { return createProviderOptions('openrouter', options) } diff --git a/packages/aiCore/src/core/options/openrouter.ts b/packages/aiCore/src/core/options/openrouter.ts deleted file mode 100644 index b351f8fda1..0000000000 --- a/packages/aiCore/src/core/options/openrouter.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type OpenRouterProviderOptions = { - models?: string[] - - /** - * https://openrouter.ai/docs/use-cases/reasoning-tokens - * One of `max_tokens` or `effort` is required. - * If `exclude` is true, reasoning will be removed from the response. Default is false. - */ - reasoning?: { - exclude?: boolean - } & ( - | { - max_tokens: number - } - | { - effort: 'high' | 'medium' | 'low' - } - ) - - /** - * A unique identifier representing your end-user, which can - * help OpenRouter to monitor and detect abuse. - */ - user?: string - - extraBody?: Record - - /** - * Enable usage accounting to get detailed token usage information. - * https://openrouter.ai/docs/use-cases/usage-accounting - */ - usage?: { - /** - * When true, includes token usage information in the response. - */ - include: boolean - } -} diff --git a/packages/aiCore/src/core/options/types.ts b/packages/aiCore/src/core/options/types.ts index 724dc30698..8571fd2296 100644 --- a/packages/aiCore/src/core/options/types.ts +++ b/packages/aiCore/src/core/options/types.ts @@ -2,9 +2,8 @@ import { type AnthropicProviderOptions } from '@ai-sdk/anthropic' import { type GoogleGenerativeAIProviderOptions } from '@ai-sdk/google' import { type OpenAIResponsesProviderOptions } from '@ai-sdk/openai' import { type SharedV2ProviderMetadata } from '@ai-sdk/provider' - -import { type OpenRouterProviderOptions } from './openrouter' -import { type XaiProviderOptions } from './xai' +import { type XaiProviderOptions } from '@ai-sdk/xai' +import { type OpenRouterProviderOptions } from '@openrouter/ai-sdk-provider' export type ProviderOptions = SharedV2ProviderMetadata[T] diff --git a/packages/aiCore/src/core/options/xai.ts b/packages/aiCore/src/core/options/xai.ts deleted file mode 100644 index 7fe5672778..0000000000 --- a/packages/aiCore/src/core/options/xai.ts +++ /dev/null @@ -1,86 +0,0 @@ -// copy from @ai-sdk/xai/xai-chat-options.ts -// 如果@ai-sdk/xai暴露出了xaiProviderOptions就删除这个文件 - -import { z } from 'zod' - -const webSourceSchema = z.object({ - type: z.literal('web'), - country: z.string().length(2).optional(), - excludedWebsites: z.array(z.string()).max(5).optional(), - allowedWebsites: z.array(z.string()).max(5).optional(), - safeSearch: z.boolean().optional() -}) - -const xSourceSchema = z.object({ - type: z.literal('x'), - xHandles: z.array(z.string()).optional() -}) - -const newsSourceSchema = z.object({ - type: z.literal('news'), - country: z.string().length(2).optional(), - excludedWebsites: z.array(z.string()).max(5).optional(), - safeSearch: z.boolean().optional() -}) - -const rssSourceSchema = z.object({ - type: z.literal('rss'), - links: z.array(z.url()).max(1) // currently only supports one RSS link -}) - -const searchSourceSchema = z.discriminatedUnion('type', [ - webSourceSchema, - xSourceSchema, - newsSourceSchema, - rssSourceSchema -]) - -export const xaiProviderOptions = z.object({ - /** - * reasoning effort for reasoning models - * only supported by grok-3-mini and grok-3-mini-fast models - */ - reasoningEffort: z.enum(['low', 'high']).optional(), - - searchParameters: z - .object({ - /** - * search mode preference - * - "off": disables search completely - * - "auto": model decides whether to search (default) - * - "on": always enables search - */ - mode: z.enum(['off', 'auto', 'on']), - - /** - * whether to return citations in the response - * defaults to true - */ - returnCitations: z.boolean().optional(), - - /** - * start date for search data (ISO8601 format: YYYY-MM-DD) - */ - fromDate: z.string().optional(), - - /** - * end date for search data (ISO8601 format: YYYY-MM-DD) - */ - toDate: z.string().optional(), - - /** - * maximum number of search results to consider - * defaults to 20 - */ - maxSearchResults: z.number().min(1).max(50).optional(), - - /** - * data sources to search from - * defaults to ["web", "x"] if not specified - */ - sources: z.array(searchSourceSchema).optional() - }) - .optional() -}) - -export type XaiProviderOptions = z.infer diff --git a/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts index cb581e17c6..09a741d9f2 100644 --- a/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts @@ -24,7 +24,6 @@ export const googleToolsPlugin = (config?: ToolConfig) => if (!typedParams.tools) { typedParams.tools = {} } - // 使用类型安全的方式遍历配置 ;(Object.keys(config) as ToolConfigKey[]).forEach((key) => { if (config[key] && key in toolNameMap && key in google.tools) { diff --git a/packages/aiCore/src/core/plugins/built-in/index.ts b/packages/aiCore/src/core/plugins/built-in/index.ts index e7dcae8738..1f8916b09a 100644 --- a/packages/aiCore/src/core/plugins/built-in/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/index.ts @@ -7,5 +7,9 @@ export const BUILT_IN_PLUGIN_PREFIX = 'built-in:' export { googleToolsPlugin } from './googleToolsPlugin' export { createLoggingPlugin } from './logging' export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin' -export type { PromptToolUseConfig, ToolUseRequestContext, ToolUseResult } from './toolUsePlugin/type' -export { webSearchPlugin } from './webSearchPlugin' +export type { + PromptToolUseConfig, + ToolUseRequestContext, + ToolUseResult +} from './toolUsePlugin/type' +export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin' 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 fce028f5cd..a2cc7d9aff 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => { return params } - context.mcpTools = params.tools + // 分离 provider-defined 和其他类型的工具 + const providerDefinedTools: ToolSet = {} + const promptTools: ToolSet = {} - // 构建系统提示符 + for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) { + if (tool.type === 'provider-defined') { + // provider-defined 类型的工具保留在 tools 参数中 + providerDefinedTools[toolName] = tool + } else { + // 其他工具转换为 prompt 模式 + promptTools[toolName] = tool + } + } + + // 只有当有非 provider-defined 工具时才保存到 context + if (Object.keys(promptTools).length > 0) { + context.mcpTools = promptTools + } + + // 构建系统提示符(只包含非 provider-defined 工具) const userSystemPrompt = typeof params.system === 'string' ? params.system : '' - const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools) + const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools) let systemMessage: string | null = systemPrompt if (config.createSystemMessage) { // 🎯 如果用户提供了自定义处理函数,使用它 systemMessage = config.createSystemMessage(systemPrompt, params, context) } - // 移除 tools,改为 prompt 模式 + // 保留 provider-defined tools,移除其他 tools const transformedParams = { ...params, ...(systemMessage ? { system: systemMessage } : {}), - tools: undefined + tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined } context.originalParams = transformedParams return transformedParams @@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => { let textBuffer = '' // let stepId = '' + // 如果没有需要 prompt 模式处理的工具,直接返回原始流 if (!context.mcpTools) { - throw new Error('No tools available') + return new TransformStream() } // 从 context 中获取或初始化 usage 累加器 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 a7c7187bca..95c2cdda2c 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -1,15 +1,19 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' +import { InferToolInput, InferToolOutput } from 'ai' import { ProviderOptionsMap } from '../../../options/types' +import { OpenRouterSearchConfig } from './openrouter' /** * 从 AI SDK 的工具函数中提取参数类型,以确保类型安全。 */ -type OpenAISearchConfig = Parameters[0] -type AnthropicSearchConfig = Parameters[0] -type GoogleSearchConfig = Parameters[0] +export type OpenAISearchConfig = NonNullable[0]> +export type OpenAISearchPreviewConfig = NonNullable[0]> +export type AnthropicSearchConfig = NonNullable[0]> +export type GoogleSearchConfig = NonNullable[0]> +export type XAISearchConfig = NonNullable /** * 插件初始化时接收的完整配置对象 @@ -18,10 +22,12 @@ type GoogleSearchConfig = Parameters[0] */ export interface WebSearchPluginConfig { openai?: OpenAISearchConfig + 'openai-chat'?: OpenAISearchPreviewConfig anthropic?: AnthropicSearchConfig xai?: ProviderOptionsMap['xai']['searchParameters'] google?: GoogleSearchConfig 'google-vertex'?: GoogleSearchConfig + openrouter?: OpenRouterSearchConfig } /** @@ -31,6 +37,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { google: {}, 'google-vertex': {}, openai: {}, + 'openai-chat': {}, xai: { mode: 'on', returnCitations: true, @@ -39,29 +46,44 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { }, anthropic: { maxUses: 5 + }, + openrouter: { + plugins: [ + { + id: 'web', + max_results: 5 + } + ] } } export type WebSearchToolOutputSchema = { // Anthropic 工具 - 手动定义 - anthropicWebSearch: Array<{ - url: string - title: string - pageAge: string | null - encryptedContent: string - type: string - }> + anthropic: InferToolOutput> // OpenAI 工具 - 基于实际输出 - openaiWebSearch: { + // TODO: 上游定义不规范,是unknown + // openai: InferToolOutput> + openai: { + status: 'completed' | 'failed' + } + 'openai-chat': { status: 'completed' | 'failed' } - // Google 工具 - googleSearch: { + // TODO: 上游定义不规范,是unknown + // google: InferToolOutput> + google: { webSearchQueries?: string[] groundingChunks?: Array<{ web?: { uri: string; title: string } }> } } + +export type WebSearchToolInputSchema = { + anthropic: InferToolInput> + openai: InferToolInput> + google: InferToolInput> + 'openai-chat': InferToolInput> +} 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 d941e0d0c7..abd3ce3e2c 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -6,7 +6,7 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' -import { createXaiOptions, mergeProviderOptions } from '../../../options' +import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import { definePlugin } from '../../' import type { AiRequestContext } from '../../types' import { DEFAULT_WEB_SEARCH_CONFIG, WebSearchPluginConfig } from './helper' @@ -31,6 +31,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR } 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) { @@ -56,6 +63,14 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR } 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/openrouter.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts new file mode 100644 index 0000000000..ebf1e7bf9a --- /dev/null +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts @@ -0,0 +1,26 @@ +export type OpenRouterSearchConfig = { + plugins?: Array<{ + id: 'web' + /** + * Maximum number of search results to include (default: 5) + */ + max_results?: number + /** + * Custom search prompt to guide the search query + */ + search_prompt?: string + }> + /** + * Built-in web search options for models that support native web search + */ + web_search_options?: { + /** + * Maximum number of search results to include + */ + max_results?: number + /** + * Custom search prompt to guide the search query + */ + search_prompt?: string + } +} diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts index e4b8d8aa64..83338cf057 100644 --- a/packages/aiCore/src/core/providers/schemas.ts +++ b/packages/aiCore/src/core/providers/schemas.ts @@ -9,7 +9,9 @@ import { createDeepSeek } from '@ai-sdk/deepseek' import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai' import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import { LanguageModelV2 } from '@ai-sdk/provider' import { createXai } from '@ai-sdk/xai' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' import { customProvider, Provider } from 'ai' import { z } from 'zod' @@ -25,7 +27,8 @@ export const baseProviderIds = [ 'xai', 'azure', 'azure-responses', - 'deepseek' + 'deepseek', + 'openrouter' ] as const /** @@ -38,10 +41,14 @@ export const baseProviderIdSchema = z.enum(baseProviderIds) */ export type BaseProviderId = z.infer +export const isBaseProvider = (id: ProviderId): id is BaseProviderId => { + return baseProviderIdSchema.safeParse(id).success +} + type BaseProvider = { id: BaseProviderId name: string - creator: (options: any) => Provider + creator: (options: any) => Provider | LanguageModelV2 supportsImageGeneration: boolean } @@ -119,6 +126,12 @@ export const baseProviders = [ name: 'DeepSeek', creator: createDeepSeek, supportsImageGeneration: false + }, + { + id: 'openrouter', + name: 'OpenRouter', + creator: createOpenRouter, + supportsImageGeneration: true } ] as const satisfies BaseProvider[] diff --git a/packages/aiCore/tsconfig.json b/packages/aiCore/tsconfig.json index 9ee30166c1..110b2106e0 100644 --- a/packages/aiCore/tsconfig.json +++ b/packages/aiCore/tsconfig.json @@ -1,26 +1,21 @@ { "compilerOptions": { - "target": "ES2020", + "allowSyntheticDefaultImports": true, + "declaration": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "bundler", - "declaration": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, "noEmitOnError": false, - "experimentalDecorators": true, - "emitDecoratorMetadata": true + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} diff --git a/packages/extension-table-plus/package.json b/packages/extension-table-plus/package.json index d34c25ccd7..9601c1f8b8 100755 --- a/packages/extension-table-plus/package.json +++ b/packages/extension-table-plus/package.json @@ -67,13 +67,13 @@ "dist" ], "devDependencies": { + "@biomejs/biome": "2.2.4", "@tiptap/core": "^3.2.0", "@tiptap/pm": "^3.2.0", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", - "prettier": "^3.5.3", "tsdown": "^0.13.3" }, "peerDependencies": { @@ -87,7 +87,7 @@ }, "scripts": { "build": "tsdown", - "lint": "prettier ./src/ --write && eslint --fix ./src/" + "lint": "biome format ./src/ --write && eslint --fix ./src/" }, "packageManager": "yarn@4.9.1" } diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 0bf971b8fc..8d997d9852 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -8,6 +8,7 @@ export enum IpcChannel { App_ShowUpdateDialog = 'app:show-update-dialog', App_CheckForUpdate = 'app:check-for-update', App_Reload = 'app:reload', + App_Quit = 'app:quit', App_Info = 'app:info', App_Proxy = 'app:proxy', App_SetLaunchToTray = 'app:set-launch-to-tray', @@ -38,6 +39,7 @@ export enum IpcChannel { App_GetDiskInfo = 'app:get-disk-info', App_SetFullScreen = 'app:set-full-screen', App_IsFullScreen = 'app:is-full-screen', + App_GetSystemFonts = 'app:get-system-fonts', App_MacIsProcessTrusted = 'app:mac-is-process-trusted', App_MacRequestProcessTrust = 'app:mac-request-process-trust', @@ -321,10 +323,14 @@ export enum IpcChannel { // CodeTools CodeTools_Run = 'code-tools:run', + CodeTools_GetAvailableTerminals = 'code-tools:get-available-terminals', + CodeTools_SetCustomTerminalPath = 'code-tools:set-custom-terminal-path', + CodeTools_GetCustomTerminalPath = 'code-tools:get-custom-terminal-path', + CodeTools_RemoveCustomTerminalPath = 'code-tools:remove-custom-terminal-path', // OCR OCR_ocr = 'ocr:ocr', - // Cherryin - Cherryin_GetSignature = 'cherryin:get-signature' + // CherryAI + Cherryai_GetSignature = 'cherryai:get-signature' } diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 18246cd1e7..3ffe88f08a 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -216,5 +216,256 @@ export enum codeTools { qwenCode = 'qwen-code', claudeCode = 'claude-code', geminiCli = 'gemini-cli', - openaiCodex = 'openai-codex' + openaiCodex = 'openai-codex', + iFlowCli = 'iflow-cli' } + +export enum terminalApps { + systemDefault = 'Terminal', + iterm2 = 'iTerm2', + kitty = 'kitty', + alacritty = 'Alacritty', + wezterm = 'WezTerm', + ghostty = 'Ghostty', + tabby = 'Tabby', + // Windows terminals + windowsTerminal = 'WindowsTerminal', + powershell = 'PowerShell', + cmd = 'CMD', + wsl = 'WSL' +} + +export interface TerminalConfig { + id: string + name: string + bundleId?: string + customPath?: string // For user-configured terminal paths on Windows +} + +export interface TerminalConfigWithCommand extends TerminalConfig { + command: (directory: string, fullCommand: string) => { command: string; args: string[] } +} + +export const MACOS_TERMINALS: TerminalConfig[] = [ + { + id: terminalApps.systemDefault, + name: 'Terminal', + bundleId: 'com.apple.Terminal' + }, + { + id: terminalApps.iterm2, + name: 'iTerm2', + bundleId: 'com.googlecode.iterm2' + }, + { + id: terminalApps.kitty, + name: 'kitty', + bundleId: 'net.kovidgoyal.kitty' + }, + { + id: terminalApps.alacritty, + name: 'Alacritty', + bundleId: 'org.alacritty' + }, + { + id: terminalApps.wezterm, + name: 'WezTerm', + bundleId: 'com.github.wez.wezterm' + }, + { + id: terminalApps.ghostty, + name: 'Ghostty', + bundleId: 'com.mitchellh.ghostty' + }, + { + id: terminalApps.tabby, + name: 'Tabby', + bundleId: 'org.tabby' + } +] + +export const WINDOWS_TERMINALS: TerminalConfig[] = [ + { + id: terminalApps.cmd, + name: 'Command Prompt' + }, + { + id: terminalApps.powershell, + name: 'PowerShell' + }, + { + id: terminalApps.windowsTerminal, + name: 'Windows Terminal' + }, + { + id: terminalApps.wsl, + name: 'WSL (Ubuntu/Debian)' + }, + { + id: terminalApps.alacritty, + name: 'Alacritty' + }, + { + id: terminalApps.wezterm, + name: 'WezTerm' + } +] + +export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ + { + id: terminalApps.cmd, + name: 'Command Prompt', + command: (_: string, fullCommand: string) => ({ + command: 'cmd', + args: ['/c', 'start', 'cmd', '/k', fullCommand] + }) + }, + { + id: terminalApps.powershell, + name: 'PowerShell', + command: (_: string, fullCommand: string) => ({ + command: 'cmd', + args: ['/c', 'start', 'powershell', '-NoExit', '-Command', `& '${fullCommand}'`] + }) + }, + { + id: terminalApps.windowsTerminal, + name: 'Windows Terminal', + command: (_: string, fullCommand: string) => ({ + command: 'wt', + args: ['cmd', '/k', fullCommand] + }) + }, + { + id: terminalApps.wsl, + name: 'WSL (Ubuntu/Debian)', + command: (_: string, fullCommand: string) => { + // Start WSL in a new window and execute the batch file from within WSL using cmd.exe + // The batch file will run in Windows context but output will be in WSL terminal + return { + command: 'cmd', + args: ['/c', 'start', 'wsl', '-e', 'bash', '-c', `cmd.exe /c '${fullCommand}' ; exec bash`] + } + } + }, + { + id: terminalApps.alacritty, + name: 'Alacritty', + customPath: '', // Will be set by user in settings + command: (_: string, fullCommand: string) => ({ + command: 'alacritty', // Will be replaced with customPath if set + args: ['-e', 'cmd', '/k', fullCommand] + }) + }, + { + id: terminalApps.wezterm, + name: 'WezTerm', + customPath: '', // Will be set by user in settings + command: (_: string, fullCommand: string) => ({ + command: 'wezterm', // Will be replaced with customPath if set + args: ['start', 'cmd', '/k', fullCommand] + }) + } +] + +// Helper function to escape strings for AppleScript +const escapeForAppleScript = (str: string): string => { + // In AppleScript strings, backslashes and double quotes need to be escaped + // When passed through osascript -e with single quotes, we need: + // 1. Backslash: \ -> \\ + // 2. Double quote: " -> \" + return str + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/"/g, '\\"') // Then escape double quotes +} + +export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ + { + id: terminalApps.systemDefault, + name: 'Terminal', + bundleId: 'com.apple.Terminal', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'` + ] + }) + }, + { + id: terminalApps.iterm2, + name: 'iTerm2', + bundleId: 'com.googlecode.iterm2', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'` + ] + }) + }, + { + id: terminalApps.kitty, + name: 'kitty', + bundleId: 'net.kovidgoyal.kitty', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'` + ] + }) + }, + { + id: terminalApps.alacritty, + name: 'Alacritty', + bundleId: 'org.alacritty', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'` + ] + }) + }, + { + id: terminalApps.wezterm, + name: 'WezTerm', + bundleId: 'com.github.wez.wezterm', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'` + ] + }) + }, + { + id: terminalApps.ghostty, + name: 'Ghostty', + bundleId: 'com.mitchellh.ghostty', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'` + ] + }) + }, + { + id: terminalApps.tabby, + name: 'Tabby', + bundleId: 'org.tabby', + command: (_directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `if pgrep -x "Tabby" > /dev/null; then + open -na Tabby --args open && sleep 0.3 + else + open -na Tabby --args open && sleep 2 + fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'` + ] + }) + } +] diff --git a/resources/cherry-studio/privacy-en.html b/resources/cherry-studio/privacy-en.html new file mode 100644 index 0000000000..612f18a70f --- /dev/null +++ b/resources/cherry-studio/privacy-en.html @@ -0,0 +1,252 @@ + + + + + + + Privacy Policy + + + + + +
+

Privacy Policy

+ +

+ Welcome to Cherry Studio (hereinafter referred to as "the Software" or "we"). We highly value your privacy + protection. This Privacy Policy explains how we process and protect your personal information and data. + Please read and understand this policy carefully before using the Software: +

+ +

1. Information We Collect

+

To optimize user experience and improve software quality, we may only collect the following anonymous, + non-personal information:

+
    +
  • Software version information
  • +
  • Activity and usage frequency of software features
  • +
  • Anonymous crash and error log information
  • +
+

The above information is completely anonymous, does not involve any personal identity data, and cannot be + linked to your personal information.

+ +

2. Information We Do Not Collect

+

To maximize the protection of your privacy and security, we explicitly commit that we:

+
    +
  • Will not collect, save, transmit, or process model service API Key information you enter into the + Software
  • +
  • Will not collect, save, transmit, or process any conversation data generated during your use of the + Software, including but not limited to chat content, instruction information, knowledge base + information, vector data, and other custom content
  • +
  • Will not collect, save, transmit, or process any sensitive information that can identify personal + identity
  • +
+ +

3. Data Interaction Description

+

+ The Software uses API Keys from third-party model service providers that you apply for and configure + yourself to complete model calls and conversation functions. The model services you use (such as large + models, API interfaces, etc.) are directly provided by third-party providers of your choice. We do not + intervene, monitor, or interfere with the data transmission process. +

+

+ Data interactions between you and third-party model services are governed by the privacy policies and user + agreements of third-party service providers. We recommend that you fully understand the privacy terms of + relevant service providers before use. +

+ +

4. Local Data Security Protection

+

The Software is a localized application, and all data is stored on your local device by default. We have + taken the following measures to ensure data security:

+
    +
  • Conversation records, configuration information, and other data are only saved on your local device
  • +
  • Data import/export functions are provided to facilitate your independent management and backup of data +
  • +
  • Your local data will not be uploaded to any server or cloud storage
  • +
+ +

5. Third-Party Services

+

+ When using the Software, you may access third-party services (such as AI model APIs, translation services, + etc.). The use of these third-party services is governed by their respective terms of service and privacy + policies. We strongly recommend that you carefully read and understand the relevant terms before use. +

+ +

6. User Rights

+

You have complete control over your data:

+
    +
  • You can view, modify, and delete all locally stored data at any time
  • +
  • You can choose whether to enable specific features or services
  • +
  • You can stop using the Software and delete all related data at any time
  • +
+ +

7. Children's Privacy Protection

+

The Software is not intended for minors under 18 years of age. If you are a minor, please use the Software + under the guidance of a guardian.

+ +

8. Privacy Policy Updates

+

+ We may update this Privacy Policy based on legal requirements or changes in product features. The updated + policy will be published in the Software and you will be notified before it takes effect. If you do not + agree with the updated terms, you can choose to stop using the Software. +

+ +

9. Contact Us

+

If you have any questions, suggestions, or complaints about this Privacy Policy, please contact us through + the following methods:

+ + + +
+ + + \ No newline at end of file diff --git a/resources/cherry-studio/privacy-zh.html b/resources/cherry-studio/privacy-zh.html new file mode 100644 index 0000000000..db4195f68c --- /dev/null +++ b/resources/cherry-studio/privacy-zh.html @@ -0,0 +1,230 @@ + + + + + + + 隐私协议 + + + + + +
+

隐私协议

+ +

+ 欢迎使用 Cherry Studio(以下简称"本软件"或"我们")。我们高度重视您的隐私保护,本隐私协议将说明我们如何处理与保护您的个人信息和数据。请在使用本软件前仔细阅读并理解本协议: +

+ +

一、我们收集的信息范围

+

为了优化用户体验和提升软件质量,我们仅可能会匿名收集以下非个人化信息:

+
    +
  • 软件版本信息;
  • +
  • 软件功能的活跃度、使用频次;
  • +
  • 匿名的崩溃、错误日志信息;
  • +
+

上述信息完全匿名,不会涉及任何个人身份数据,也无法关联到您的个人信息。

+ +

二、我们不会收集的任何信息

+

为了最大限度保护您的隐私安全,我们明确承诺:

+
    +
  • 不会收集、保存、传输或处理您输入到本软件中的模型服务 API Key 信息;
  • +
  • 不会收集、保存、传输或处理您在使用本软件过程中产生的任何对话数据,包括但不限于聊天内容、指令信息、知识库信息、向量数据及其他自定义内容;
  • +
  • 不会收集、保存、传输或处理任何可识别个人身份的敏感信息。
  • +
+ +

三、数据交互说明

+

+ 本软件采用您自行申请并配置的第三方模型服务提供商的 API Key,以完成相关模型的调用与对话功能。您使用的模型服务(例如大模型、API 接口等)由您选择的第三方提供商直接提供,我们不会介入、监控或干扰数据传输过程。 +

+

+ 您与第三方模型服务之间的数据交互受第三方服务提供商的隐私政策和用户协议约束,我们建议您在使用前充分了解相关服务商的隐私条款。 +

+ +

四、本地数据的安全保护

+

本软件为本地化应用程序,所有数据默认存储在您的本地设备上。我们采取了以下措施保障数据安全:

+
    +
  • 对话记录、配置信息等数据仅保存在您的本地设备中;
  • +
  • 提供数据导入/导出功能,方便您自主管理和备份数据;
  • +
  • 不会将您的本地数据上传至任何服务器或云端存储。
  • +
+ +

五、第三方服务

+

+ 在使用本软件过程中,您可能会接入第三方服务(如 AI 模型 API、翻译服务等)。这些第三方服务的使用受其各自的服务条款和隐私政策约束。我们强烈建议您在使用前仔细阅读并理解相关条款。 +

+ +

六、用户权利

+

您对自己的数据拥有完全的控制权:

+
    +
  • 您可以随时查看、修改、删除本地存储的所有数据;
  • +
  • 您可以选择是否启用特定功能或服务;
  • +
  • 您可以随时停止使用本软件并删除所有相关数据。
  • +
+ +

七、儿童隐私保护

+

本软件不面向 18 岁以下的未成年人提供服务。如果您是未成年人,请在监护人的指导下使用本软件。

+ +

八、隐私政策的更新

+

+ 我们可能会根据法律法规要求或产品功能的变化更新本隐私协议。更新后的协议将在软件中发布,并在生效前通知您。如果您不同意更新后的条款,您可以选择停止使用本软件。 +

+ +

九、联系我们

+

如果您对本隐私协议有任何疑问、建议或投诉,请通过以下方式联系我们:

+ + + +
+ + + \ No newline at end of file diff --git a/scripts/update-languages.ts b/scripts/update-languages.ts index 58640637b1..b84bb1f94c 100644 --- a/scripts/update-languages.ts +++ b/scripts/update-languages.ts @@ -75,17 +75,17 @@ export const languages: Record = ${languagesObjectString}; } /** - * Formats a file using Prettier. + * Formats a file using Biome. * @param filePath The path to the file to format. */ -async function formatWithPrettier(filePath: string): Promise { - console.log('🎨 Formatting file with Prettier...') +async function format(filePath: string): Promise { + console.log('🎨 Formatting file with Biome...') try { - await execAsync(`yarn prettier --write ${filePath}`) - console.log('✅ Prettier formatting complete.') + await execAsync(`yarn biome format --write ${filePath}`) + console.log('✅ Biome formatting complete.') } catch (e: any) { - console.error('❌ Prettier formatting failed:', e.stdout || e.stderr) - throw new Error('Prettier formatting failed.') + console.error('❌ Biome formatting failed:', e.stdout || e.stderr) + throw new Error('Biome formatting failed.') } } @@ -116,7 +116,7 @@ async function updateLanguagesFile(): Promise { await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8') console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`) - await formatWithPrettier(LANGUAGES_FILE_PATH) + await format(LANGUAGES_FILE_PATH) await checkTypeScript(LANGUAGES_FILE_PATH) console.log('🎉 Successfully updated languages.ts file!') diff --git a/src/main/apiServer/middleware/error.ts b/src/main/apiServer/middleware/error.ts index 65eef5e43d..6aa1819ddd 100644 --- a/src/main/apiServer/middleware/error.ts +++ b/src/main/apiServer/middleware/error.ts @@ -4,7 +4,7 @@ import { loggerService } from '../../services/LoggerService' const logger = loggerService.withContext('ApiServerErrorHandler') -// eslint-disable-next-line @typescript-eslint/no-unused-vars +// oxlint-disable-next-line @typescript-eslint/no-unused-vars export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => { logger.error('API Server Error:', err) diff --git a/src/main/config.ts b/src/main/config.ts index 0cffcd1768..b4859bafb0 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -21,4 +21,4 @@ export const titleBarOverlayLight = { symbolColor: '#000' } -global.CHERRYIN_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYIN_CLIENT_SECRET +global.CHERRYAI_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYAI_CLIENT_SECRET diff --git a/src/main/integration/cherryai/index.js b/src/main/integration/cherryai/index.js new file mode 100644 index 0000000000..eccd3b85bf --- /dev/null +++ b/src/main/integration/cherryai/index.js @@ -0,0 +1 @@ +var _0xe15d9a;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0xe15d9a=(988194^988194)+(417607^417603);var _0x9b_0x742=(247379^247387)+(371889^371892);const CLIENT_ID="\u0063\u0068\u0065\u0072\u0072\u0079\u002D\u0073\u0074\u0075\u0064\u0069\u006F";_0x9b_0x742=(202849^202856)+(796590^796585);var _0xa971e=(422203^422203)+(167917^167919);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0xa971e=(607707^607705)+(127822^127823);const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0041\u0049\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{'\u006D\u0065\u0074\u0068\u006F\u0064':method,'\u0070\u0061\u0074\u0068':path,'\u0071\u0075\u0065\u0072\u0079':query='','\u0062\u006F\u0064\u0079':body=''}=options;var _0x99a7f=(735625^735624)+(520507^520508);const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(351300^352172))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();_0x99a7f=376728^376729;var _0x733a=(876666^876671)+(658949^658944);let bodyString='';_0x733a="kgclcd".split("").reverse().join("");if(body){if(typeof body==="tcejbo".split("").reverse().join("")){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}var _0xd8edff;const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];_0xd8edff=(929945^929951)+(569907^569915);var _0x9g3c3b=(705579^705579)+(981211^981209);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x9g3c3b=527497^527499;var _0x95b35f=(811203^811200)+(628072^628076);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);_0x95b35f=104120^104112;hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0xd0f6g;const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("xeh".split("").reverse().join(""));_0xd0f6g=(615019^615018)+(266997^266992);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],"\u0058\u002D\u0054\u0069\u006D\u0065\u0073\u0074\u0061\u006D\u0070":timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,"generateSignature":generateSignature}; \ No newline at end of file diff --git a/src/main/integration/cherryin/index.js b/src/main/integration/cherryin/index.js deleted file mode 100644 index af185389eb..0000000000 --- a/src/main/integration/cherryin/index.js +++ /dev/null @@ -1 +0,0 @@ -var _0x6gg;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0x6gg='\u006D\u006F\u006C\u006A\u0065\u0065';var _0x111cbe;const CLIENT_ID="oiduts-yrrehc".split("").reverse().join("");_0x111cbe=(977158^977167)+(164595^164594);var _0x6d6adc=(756649^756650)+(497587^497587);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0x6d6adc=233169^233176;const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0049\u004E\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{"method":method,"path":path,"query":query='',"body":body=''}=options;const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(110765^111429))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();var _0xe08cc=(212246^212244)+(773521^773523);let bodyString='';_0xe08cc=(606778^606776)+(962748^962740);if(body){if(typeof body==="\u006F\u0062\u006A\u0065\u0063\u0074"){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];var _0x5693g=(936664^936668)+(685268^685277);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x5693g=(266582^266576)+(337322^337315);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0x5fba=(354480^354481)+(537437^537434);const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("\u0068\u0065\u0078");_0x5fba=(249614^249610)+(915906^915914);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],'X-Timestamp':timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,'\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065':generateSignature}; \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e943940fed..20b1763a28 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,17 +4,17 @@ import path from 'node:path' import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' -import { generateSignature } from '@main/integration/cherryin' +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 { IpcChannel } from '@shared/IpcChannel' -import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' +import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types' import checkDiskSpace from 'check-disk-space' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' -import { Notification } from 'src/renderer/src/types/notification' +import fontList from 'font-list' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' @@ -27,7 +27,7 @@ import DxtService from './services/DxtService' import { ExportService } from './services/ExportService' import { fileStorage as fileManager } from './services/FileStorage' import FileService from './services/FileSystemService' -import KnowledgeService from './services/knowledge/KnowledgeService' +import KnowledgeService from './services/KnowledgeService' import mcpService from './services/MCPService' import MemoryService from './services/memory/MemoryService' import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService' @@ -126,6 +126,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { }) ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload()) + ipcMain.handle(IpcChannel.App_Quit, () => app.quit()) ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url)) // Update @@ -219,6 +220,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return mainWindow.isFullScreen() }) + // Get System Fonts + ipcMain.handle(IpcChannel.App_GetSystemFonts, async () => { + try { + const fonts = await fontList.getFonts() + return fonts.map((font: string) => font.replace(/^"(.*)"$/, '$1')).filter((font: string) => font.length > 0) + } catch (error) { + logger.error('Failed to get system fonts:', error as Error) + return [] + } + }) + ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { configManager.set(key, value, isNotify) }) @@ -816,10 +828,22 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // CodeTools ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) + ipcMain.handle(IpcChannel.CodeTools_GetAvailableTerminals, () => codeToolsService.getAvailableTerminalsForPlatform()) + ipcMain.handle(IpcChannel.CodeTools_SetCustomTerminalPath, (_, terminalId: string, path: string) => + codeToolsService.setCustomTerminalPath(terminalId, path) + ) + ipcMain.handle(IpcChannel.CodeTools_GetCustomTerminalPath, (_, terminalId: string) => + codeToolsService.getCustomTerminalPath(terminalId) + ) + ipcMain.handle(IpcChannel.CodeTools_RemoveCustomTerminalPath, (_, terminalId: string) => + codeToolsService.removeCustomTerminalPath(terminalId) + ) // OCR - ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters) => ocrService.ocr(...args)) + ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) => + ocrService.ocr(file, provider) + ) - // CherryIN - ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params)) + // CherryAI + ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params)) } diff --git a/src/main/knowledge/embedjs/loader/index.ts b/src/main/knowledge/embedjs/loader/index.ts index 6d9b4c7ace..4b38418194 100644 --- a/src/main/knowledge/embedjs/loader/index.ts +++ b/src/main/knowledge/embedjs/loader/index.ts @@ -139,9 +139,9 @@ export async function addFileLoader( if (jsonParsed) { loaderReturn = await ragApplication.addLoader(new JsonLoader({ object: jsonObject }), forceReload) - break } // fallthrough - JSON 解析失败时作为文本处理 + // oxlint-disable-next-line no-fallthrough 利用switch特性,刻意不break default: // 文本类型处理(默认) // 如果是其他文本类型且尚未读取文件,则读取文件 diff --git a/src/main/knowledge/embedjs/loader/odLoader.ts b/src/main/knowledge/embedjs/loader/odLoader.ts index ad2a0e119d..03825bf4db 100644 --- a/src/main/knowledge/embedjs/loader/odLoader.ts +++ b/src/main/knowledge/embedjs/loader/odLoader.ts @@ -11,7 +11,7 @@ export enum OdType { OdtLoader = 'OdtLoader', OdsLoader = 'OdsLoader', OdpLoader = 'OdpLoader', - undefined = 'undefined' + Undefined = 'undefined' } export class OdLoader extends BaseLoader<{ type: string }> { diff --git a/src/main/knowledge/langchain/embeddings/EmbeddingsFactory.ts b/src/main/knowledge/langchain/embeddings/EmbeddingsFactory.ts deleted file mode 100644 index d2879aa349..0000000000 --- a/src/main/knowledge/langchain/embeddings/EmbeddingsFactory.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { VoyageEmbeddings } from '@langchain/community/embeddings/voyage' -import type { Embeddings } from '@langchain/core/embeddings' -import { OllamaEmbeddings } from '@langchain/ollama' -import { AzureOpenAIEmbeddings, OpenAIEmbeddings } from '@langchain/openai' -import { ApiClient, SystemProviderIds } from '@types' - -import { isJinaEmbeddingsModel, JinaEmbeddings } from './JinaEmbeddings' - -export default class EmbeddingsFactory { - static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): Embeddings { - const batchSize = 10 - const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient - if (provider === SystemProviderIds.ollama) { - let baseUrl = baseURL - if (baseURL.includes('v1/')) { - baseUrl = baseURL.replace('v1/', '') - } - const headers = apiKey - ? { - Authorization: `Bearer ${apiKey}` - } - : undefined - return new OllamaEmbeddings({ - model: model, - baseUrl, - ...headers - }) - } else if (provider === SystemProviderIds.voyageai) { - return new VoyageEmbeddings({ - modelName: model, - apiKey, - outputDimension: dimensions, - batchSize - }) - } - if (isJinaEmbeddingsModel(model)) { - return new JinaEmbeddings({ - model, - apiKey, - batchSize, - dimensions, - baseUrl: baseURL - }) - } - if (apiVersion !== undefined) { - return new AzureOpenAIEmbeddings({ - azureOpenAIApiKey: apiKey, - azureOpenAIApiVersion: apiVersion, - azureOpenAIApiDeploymentName: model, - azureOpenAIEndpoint: baseURL, - dimensions, - batchSize - }) - } - return new OpenAIEmbeddings({ - model, - apiKey, - dimensions, - batchSize, - configuration: { baseURL } - }) - } -} diff --git a/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts b/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts deleted file mode 100644 index f0380ff360..0000000000 --- a/src/main/knowledge/langchain/embeddings/JinaEmbeddings.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { Embeddings, type EmbeddingsParams } from '@langchain/core/embeddings' -import { chunkArray } from '@langchain/core/utils/chunk_array' -import { getEnvironmentVariable } from '@langchain/core/utils/env' -import { z } from 'zod' - -const jinaModelSchema = z.union([ - z.literal('jina-clip-v2'), - z.literal('jina-embeddings-v3'), - z.literal('jina-colbert-v2'), - z.literal('jina-clip-v1'), - z.literal('jina-colbert-v1-en'), - z.literal('jina-embeddings-v2-base-es'), - z.literal('jina-embeddings-v2-base-code'), - z.literal('jina-embeddings-v2-base-de'), - z.literal('jina-embeddings-v2-base-zh'), - z.literal('jina-embeddings-v2-base-en') -]) - -type JinaModel = z.infer - -export const isJinaEmbeddingsModel = (model: string): model is JinaModel => { - return jinaModelSchema.safeParse(model).success -} - -interface JinaEmbeddingsParams extends EmbeddingsParams { - /** Model name to use */ - model: JinaModel - - baseUrl?: string - - /** - * Timeout to use when making requests to Jina. - */ - timeout?: number - - /** - * The maximum number of documents to embed in a single request. - */ - batchSize?: number - - /** - * Whether to strip new lines from the input text. - */ - stripNewLines?: boolean - - /** - * The dimensions of the embedding. - */ - dimensions?: number - - /** - * Scales the embedding so its Euclidean (L2) norm becomes 1, preserving direction. Useful when downstream involves dot-product, classification, visualization.. - */ - normalized?: boolean -} - -type JinaMultiModelInput = - | { - text: string - image?: never - } - | { - image: string - text?: never - } - -type JinaEmbeddingsInput = string | JinaMultiModelInput - -interface EmbeddingCreateParams { - model: JinaEmbeddingsParams['model'] - - /** - * input can be strings or JinaMultiModelInputs,if you want embed image,you should use JinaMultiModelInputs - */ - input: JinaEmbeddingsInput[] - dimensions: number - task?: 'retrieval.query' | 'retrieval.passage' -} - -interface EmbeddingResponse { - model: string - object: string - usage: { - total_tokens: number - prompt_tokens: number - } - data: { - object: string - index: number - embedding: number[] - }[] -} - -interface EmbeddingErrorResponse { - detail: string -} - -export class JinaEmbeddings extends Embeddings implements JinaEmbeddingsParams { - model: JinaEmbeddingsParams['model'] = 'jina-clip-v2' - - batchSize = 24 - - baseUrl = 'https://api.jina.ai/v1/embeddings' - - stripNewLines = true - - dimensions = 1024 - - apiKey: string - - constructor( - fields?: Partial & { - apiKey?: string - } - ) { - const fieldsWithDefaults = { maxConcurrency: 2, ...fields } - super(fieldsWithDefaults) - - const apiKey = - fieldsWithDefaults?.apiKey || getEnvironmentVariable('JINA_API_KEY') || getEnvironmentVariable('JINA_AUTH_TOKEN') - - if (!apiKey) throw new Error('Jina API key not found') - - this.apiKey = apiKey - this.baseUrl = fieldsWithDefaults?.baseUrl ? `${fieldsWithDefaults?.baseUrl}embeddings` : this.baseUrl - this.model = fieldsWithDefaults?.model ?? this.model - this.dimensions = fieldsWithDefaults?.dimensions ?? this.dimensions - this.batchSize = fieldsWithDefaults?.batchSize ?? this.batchSize - this.stripNewLines = fieldsWithDefaults?.stripNewLines ?? this.stripNewLines - } - - private doStripNewLines(input: JinaEmbeddingsInput[]) { - if (this.stripNewLines) { - return input.map((i) => { - if (typeof i === 'string') { - return i.replace(/\n/g, ' ') - } - if (i.text) { - return { text: i.text.replace(/\n/g, ' ') } - } - return i - }) - } - return input - } - - async embedDocuments(input: JinaEmbeddingsInput[]): Promise { - const batches = chunkArray(this.doStripNewLines(input), this.batchSize) - const batchRequests = batches.map((batch) => { - const params = this.getParams(batch) - return this.embeddingWithRetry(params) - }) - - const batchResponses = await Promise.all(batchRequests) - const embeddings: number[][] = [] - - for (let i = 0; i < batchResponses.length; i += 1) { - const batch = batches[i] - const batchResponse = batchResponses[i] || [] - for (let j = 0; j < batch.length; j += 1) { - embeddings.push(batchResponse[j]) - } - } - - return embeddings - } - - async embedQuery(input: JinaEmbeddingsInput): Promise { - const params = this.getParams(this.doStripNewLines([input]), true) - - const embeddings = (await this.embeddingWithRetry(params)) || [[]] - return embeddings[0] - } - - private getParams(input: JinaEmbeddingsInput[], query?: boolean): EmbeddingCreateParams { - return { - model: this.model, - input, - dimensions: this.dimensions, - task: query ? 'retrieval.query' : this.model === 'jina-clip-v2' ? undefined : 'retrieval.passage' - } - } - - private async embeddingWithRetry(body: EmbeddingCreateParams) { - const response = await fetch(this.baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}` - }, - body: JSON.stringify(body) - }) - const embeddingData: EmbeddingResponse | EmbeddingErrorResponse = await response.json() - if ('detail' in embeddingData && embeddingData.detail) { - throw new Error(`${embeddingData.detail}`) - } - return (embeddingData as EmbeddingResponse).data.map(({ embedding }) => embedding) - } -} diff --git a/src/main/knowledge/langchain/embeddings/TextEmbeddings.ts b/src/main/knowledge/langchain/embeddings/TextEmbeddings.ts deleted file mode 100644 index b788070d4d..0000000000 --- a/src/main/knowledge/langchain/embeddings/TextEmbeddings.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Embeddings as BaseEmbeddings } from '@langchain/core/embeddings' -import { TraceMethod } from '@mcp-trace/trace-core' -import { ApiClient } from '@types' - -import EmbeddingsFactory from './EmbeddingsFactory' - -export default class TextEmbeddings { - private sdk: BaseEmbeddings - constructor({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }) { - this.sdk = EmbeddingsFactory.create({ - embedApiClient, - dimensions - }) - } - - @TraceMethod({ spanName: 'embedDocuments', tag: 'Embeddings' }) - public async embedDocuments(texts: string[]): Promise { - return this.sdk.embedDocuments(texts) - } - - @TraceMethod({ spanName: 'embedQuery', tag: 'Embeddings' }) - public async embedQuery(text: string): Promise { - return this.sdk.embedQuery(text) - } -} diff --git a/src/main/knowledge/langchain/loader/MarkdownLoader.ts b/src/main/knowledge/langchain/loader/MarkdownLoader.ts deleted file mode 100644 index 287c9a0bda..0000000000 --- a/src/main/knowledge/langchain/loader/MarkdownLoader.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { BaseDocumentLoader } from '@langchain/core/document_loaders/base' -import { Document } from '@langchain/core/documents' -import { readTextFileWithAutoEncoding } from '@main/utils/file' -import MarkdownIt from 'markdown-it' - -export class MarkdownLoader extends BaseDocumentLoader { - private path: string - private md: MarkdownIt - - constructor(path: string) { - super() - this.path = path - this.md = new MarkdownIt() - } - public async load(): Promise { - const content = await readTextFileWithAutoEncoding(this.path) - return this.parseMarkdown(content) - } - - private parseMarkdown(content: string): Document[] { - const tokens = this.md.parse(content, {}) - const documents: Document[] = [] - - let currentSection: { - heading?: string - level?: number - content: string - startLine?: number - } = { content: '' } - - let i = 0 - while (i < tokens.length) { - const token = tokens[i] - - if (token.type === 'heading_open') { - // Save previous section if it has content - if (currentSection.content.trim()) { - documents.push( - new Document({ - pageContent: currentSection.content.trim(), - metadata: { - source: this.path, - heading: currentSection.heading || 'Introduction', - level: currentSection.level || 0, - startLine: currentSection.startLine || 0 - } - }) - ) - } - - // Start new section - const level = parseInt(token.tag.slice(1)) // Extract number from h1, h2, etc. - const headingContent = tokens[i + 1]?.content || '' - - currentSection = { - heading: headingContent, - level: level, - content: '', - startLine: token.map?.[0] || 0 - } - - // Skip heading_open, inline, heading_close tokens - i += 3 - continue - } - - // Add token content to current section - if (token.content) { - currentSection.content += token.content - } - - // Add newlines for block tokens - if (token.block && token.type !== 'heading_close') { - currentSection.content += '\n' - } - - i++ - } - - // Add the last section - if (currentSection.content.trim()) { - documents.push( - new Document({ - pageContent: currentSection.content.trim(), - metadata: { - source: this.path, - heading: currentSection.heading || 'Introduction', - level: currentSection.level || 0, - startLine: currentSection.startLine || 0 - } - }) - ) - } - - return documents - } -} diff --git a/src/main/knowledge/langchain/loader/NoteLoader.ts b/src/main/knowledge/langchain/loader/NoteLoader.ts deleted file mode 100644 index d0339a6ce7..0000000000 --- a/src/main/knowledge/langchain/loader/NoteLoader.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseDocumentLoader } from '@langchain/core/document_loaders/base' -import { Document } from '@langchain/core/documents' - -export class NoteLoader extends BaseDocumentLoader { - private text: string - private sourceUrl?: string - constructor( - public _text: string, - public _sourceUrl?: string - ) { - super() - this.text = _text - this.sourceUrl = _sourceUrl - } - - /** - * A protected method that takes a `raw` string as a parameter and returns - * a promise that resolves to an array containing the raw text as a single - * element. - * @param raw The raw text to be parsed. - * @returns A promise that resolves to an array containing the raw text as a single element. - */ - protected async parse(raw: string): Promise { - return [raw] - } - - public async load(): Promise { - const metadata = { source: this.sourceUrl || 'note' } - const parsed = await this.parse(this.text) - parsed.forEach((pageContent, i) => { - if (typeof pageContent !== 'string') { - throw new Error(`Expected string, at position ${i} got ${typeof pageContent}`) - } - }) - - return parsed.map( - (pageContent, i) => - new Document({ - pageContent, - metadata: - parsed.length === 1 - ? metadata - : { - ...metadata, - line: i + 1 - } - }) - ) - } -} diff --git a/src/main/knowledge/langchain/loader/YoutubeLoader.ts b/src/main/knowledge/langchain/loader/YoutubeLoader.ts deleted file mode 100644 index 671793d3c7..0000000000 --- a/src/main/knowledge/langchain/loader/YoutubeLoader.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { BaseDocumentLoader } from '@langchain/core/document_loaders/base' -import { Document } from '@langchain/core/documents' -import { Innertube } from 'youtubei.js' - -// ... (接口定义 YoutubeConfig 和 VideoMetadata 保持不变) - -/** - * Configuration options for the YoutubeLoader class. Includes properties - * such as the videoId, language, and addVideoInfo. - */ -interface YoutubeConfig { - videoId: string - language?: string - addVideoInfo?: boolean - // 新增一个选项,用于控制输出格式 - transcriptFormat?: 'text' | 'srt' -} - -/** - * Metadata of a YouTube video. Includes properties such as the source - * (videoId), description, title, view_count, author, and category. - */ -interface VideoMetadata { - source: string - description?: string - title?: string - view_count?: number - author?: string - category?: string -} - -/** - * A document loader for loading data from YouTube videos. It uses the - * youtubei.js library to fetch the transcript and video metadata. - * @example - * ```typescript - * const loader = new YoutubeLoader({ - * videoId: "VIDEO_ID", - * language: "en", - * addVideoInfo: true, - * transcriptFormat: "srt" // 获取 SRT 格式 - * }); - * const docs = await loader.load(); - * console.log(docs[0].pageContent); - * ``` - */ -export class YoutubeLoader extends BaseDocumentLoader { - private videoId: string - private language?: string - private addVideoInfo: boolean - // 新增格式化选项的私有属性 - private transcriptFormat: 'text' | 'srt' - - constructor(config: YoutubeConfig) { - super() - this.videoId = config.videoId - this.language = config?.language - this.addVideoInfo = config?.addVideoInfo ?? false - // 初始化格式化选项,默认为 'text' 以保持向后兼容 - this.transcriptFormat = config?.transcriptFormat ?? 'text' - } - - /** - * Extracts the videoId from a YouTube video URL. - * @param url The URL of the YouTube video. - * @returns The videoId of the YouTube video. - */ - private static getVideoID(url: string): string { - const match = url.match(/.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#&?]*).*/) - if (match !== null && match[1].length === 11) { - return match[1] - } else { - throw new Error('Failed to get youtube video id from the url') - } - } - - /** - * Creates a new instance of the YoutubeLoader class from a YouTube video - * URL. - * @param url The URL of the YouTube video. - * @param config Optional configuration options for the YoutubeLoader instance, excluding the videoId. - * @returns A new instance of the YoutubeLoader class. - */ - static createFromUrl(url: string, config?: Omit): YoutubeLoader { - const videoId = YoutubeLoader.getVideoID(url) - return new YoutubeLoader({ ...config, videoId }) - } - - /** - * [新增] 辅助函数:将毫秒转换为 SRT 时间戳格式 (HH:MM:SS,ms) - * @param ms 毫秒数 - * @returns 格式化后的时间字符串 - */ - private static formatTimestamp(ms: number): string { - const totalSeconds = Math.floor(ms / 1000) - const hours = Math.floor(totalSeconds / 3600) - .toString() - .padStart(2, '0') - const minutes = Math.floor((totalSeconds % 3600) / 60) - .toString() - .padStart(2, '0') - const seconds = (totalSeconds % 60).toString().padStart(2, '0') - const milliseconds = (ms % 1000).toString().padStart(3, '0') - return `${hours}:${minutes}:${seconds},${milliseconds}` - } - - /** - * Loads the transcript and video metadata from the specified YouTube - * video. It can return the transcript as plain text or in SRT format. - * @returns An array of Documents representing the retrieved data. - */ - async load(): Promise { - const metadata: VideoMetadata = { - source: this.videoId - } - - try { - const youtube = await Innertube.create({ - lang: this.language, - retrieve_player: false - }) - - const info = await youtube.getInfo(this.videoId) - const transcriptData = await info.getTranscript() - - if (!transcriptData.transcript.content?.body?.initial_segments) { - throw new Error('Transcript segments not found in the response.') - } - - const segments = transcriptData.transcript.content.body.initial_segments - - let pageContent: string - - // 根据 transcriptFormat 选项决定如何格式化字幕 - if (this.transcriptFormat === 'srt') { - // [修改] 将字幕片段格式化为 SRT 格式 - pageContent = segments - .map((segment, index) => { - const srtIndex = index + 1 - const startTime = YoutubeLoader.formatTimestamp(Number(segment.start_ms)) - const endTime = YoutubeLoader.formatTimestamp(Number(segment.end_ms)) - const text = segment.snippet?.text || '' // 使用 segment.snippet.text - - return `${srtIndex}\n${startTime} --> ${endTime}\n${text}` - }) - .join('\n\n') // 每个 SRT 块之间用两个换行符分隔 - } else { - // [原始逻辑] 拼接为纯文本 - pageContent = segments.map((segment) => segment.snippet?.text || '').join(' ') - } - - if (this.addVideoInfo) { - const basicInfo = info.basic_info - metadata.description = basicInfo.short_description - metadata.title = basicInfo.title - metadata.view_count = basicInfo.view_count - metadata.author = basicInfo.author - } - - const document = new Document({ - pageContent, - metadata - }) - - return [document] - } catch (e: unknown) { - throw new Error(`Failed to get YouTube video transcription: ${(e as Error).message}`) - } - } -} diff --git a/src/main/knowledge/langchain/loader/index.ts b/src/main/knowledge/langchain/loader/index.ts deleted file mode 100644 index dd89b5534c..0000000000 --- a/src/main/knowledge/langchain/loader/index.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { DocxLoader } from '@langchain/community/document_loaders/fs/docx' -import { EPubLoader } from '@langchain/community/document_loaders/fs/epub' -import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf' -import { PPTXLoader } from '@langchain/community/document_loaders/fs/pptx' -import { CheerioWebBaseLoader } from '@langchain/community/document_loaders/web/cheerio' -import { SitemapLoader } from '@langchain/community/document_loaders/web/sitemap' -import { FaissStore } from '@langchain/community/vectorstores/faiss' -import { Document } from '@langchain/core/documents' -import { loggerService } from '@logger' -import { UrlSource } from '@main/utils/knowledge' -import { LoaderReturn } from '@shared/config/types' -import { FileMetadata, FileTypes, KnowledgeBaseParams } from '@types' -import { randomUUID } from 'crypto' -import { JSONLoader } from 'langchain/document_loaders/fs/json' -import { TextLoader } from 'langchain/document_loaders/fs/text' - -import { SplitterFactory } from '../splitter' -import { MarkdownLoader } from './MarkdownLoader' -import { NoteLoader } from './NoteLoader' -import { YoutubeLoader } from './YoutubeLoader' - -const logger = loggerService.withContext('KnowledgeService File Loader') - -type LoaderInstance = - | TextLoader - | PDFLoader - | PPTXLoader - | DocxLoader - | JSONLoader - | EPubLoader - | CheerioWebBaseLoader - | YoutubeLoader - | SitemapLoader - | NoteLoader - | MarkdownLoader - -/** - * 为文档数组中的每个文档的 metadata 添加类型信息。 - */ -function formatDocument(docs: Document[], type: string): Document[] { - return docs.map((doc) => ({ - ...doc, - metadata: { - ...doc.metadata, - type: type - } - })) -} - -/** - * 通用文档处理管道 - */ -async function processDocuments( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - docs: Document[], - loaderType: string, - splitterType?: string -): Promise { - const formattedDocs = formatDocument(docs, loaderType) - const splitter = SplitterFactory.create({ - chunkSize: base.chunkSize, - chunkOverlap: base.chunkOverlap, - ...(splitterType && { type: splitterType }) - }) - - const splitterResults = await splitter.splitDocuments(formattedDocs) - const ids = splitterResults.map(() => randomUUID()) - - await vectorStore.addDocuments(splitterResults, { ids }) - - return { - entriesAdded: splitterResults.length, - uniqueId: ids[0] || '', - uniqueIds: ids, - loaderType - } -} - -/** - * 通用加载器执行函数 - */ -async function executeLoader( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - loaderInstance: LoaderInstance, - loaderType: string, - identifier: string, - splitterType?: string -): Promise { - const emptyResult: LoaderReturn = { - entriesAdded: 0, - uniqueId: '', - uniqueIds: [], - loaderType - } - - try { - const docs = await loaderInstance.load() - return await processDocuments(base, vectorStore, docs, loaderType, splitterType) - } catch (error) { - logger.error(`Error loading or processing ${identifier} with loader ${loaderType}: ${error}`) - return emptyResult - } -} - -/** - * 文件扩展名到加载器的映射 - */ -const FILE_LOADER_MAP: Record LoaderInstance; type: string }> = { - '.pdf': { loader: PDFLoader, type: 'pdf' }, - '.txt': { loader: TextLoader, type: 'text' }, - '.pptx': { loader: PPTXLoader, type: 'pptx' }, - '.docx': { loader: DocxLoader, type: 'docx' }, - '.doc': { loader: DocxLoader, type: 'doc' }, - '.json': { loader: JSONLoader, type: 'json' }, - '.epub': { loader: EPubLoader, type: 'epub' }, - '.md': { loader: MarkdownLoader, type: 'markdown' } -} - -export async function addFileLoader( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - file: FileMetadata -): Promise { - const fileExt = file.ext.toLowerCase() - const loaderConfig = FILE_LOADER_MAP[fileExt] - - if (!loaderConfig) { - // 默认使用文本加载器 - const loaderInstance = new TextLoader(file.path) - const type = fileExt.replace('.', '') || 'unknown' - return executeLoader(base, vectorStore, loaderInstance, type, file.path) - } - - const loaderInstance = new loaderConfig.loader(file.path) - return executeLoader(base, vectorStore, loaderInstance, loaderConfig.type, file.path) -} - -export async function addWebLoader( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - url: string, - source: UrlSource -): Promise { - let loaderInstance: CheerioWebBaseLoader | YoutubeLoader | undefined - let splitterType: string | undefined - - switch (source) { - case 'normal': - loaderInstance = new CheerioWebBaseLoader(url) - break - case 'youtube': - loaderInstance = YoutubeLoader.createFromUrl(url, { - addVideoInfo: true, - transcriptFormat: 'srt' - }) - splitterType = 'srt' - break - } - - if (!loaderInstance) { - return { - entriesAdded: 0, - uniqueId: '', - uniqueIds: [], - loaderType: source - } - } - - return executeLoader(base, vectorStore, loaderInstance, source, url, splitterType) -} - -export async function addSitemapLoader( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - url: string -): Promise { - const loaderInstance = new SitemapLoader(url) - return executeLoader(base, vectorStore, loaderInstance, 'sitemap', url) -} - -export async function addNoteLoader( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - content: string, - sourceUrl: string -): Promise { - const loaderInstance = new NoteLoader(content, sourceUrl) - return executeLoader(base, vectorStore, loaderInstance, 'note', sourceUrl) -} - -export async function addVideoLoader( - base: KnowledgeBaseParams, - vectorStore: FaissStore, - files: FileMetadata[] -): Promise { - const srtFile = files.find((f) => f.type === FileTypes.TEXT) - const videoFile = files.find((f) => f.type === FileTypes.VIDEO) - - const emptyResult: LoaderReturn = { - entriesAdded: 0, - uniqueId: '', - uniqueIds: [], - loaderType: 'video' - } - - if (!srtFile || !videoFile) { - return emptyResult - } - - try { - const loaderInstance = new TextLoader(srtFile.path) - const originalDocs = await loaderInstance.load() - - const docsWithVideoMeta = originalDocs.map( - (doc) => - new Document({ - ...doc, - metadata: { - ...doc.metadata, - video: { - path: videoFile.path, - name: videoFile.origin_name - } - } - }) - ) - - return await processDocuments(base, vectorStore, docsWithVideoMeta, 'video', 'srt') - } catch (error) { - logger.error(`Error loading or processing file ${srtFile.path} with loader video: ${error}`) - return emptyResult - } -} diff --git a/src/main/knowledge/langchain/retriever/index.ts b/src/main/knowledge/langchain/retriever/index.ts deleted file mode 100644 index 7f673d1deb..0000000000 --- a/src/main/knowledge/langchain/retriever/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { BM25Retriever } from '@langchain/community/retrievers/bm25' -import { FaissStore } from '@langchain/community/vectorstores/faiss' -import { BaseRetriever } from '@langchain/core/retrievers' -import { loggerService } from '@main/services/LoggerService' -import { type KnowledgeBaseParams } from '@types' -import { type Document } from 'langchain/document' -import { EnsembleRetriever } from 'langchain/retrievers/ensemble' - -const logger = loggerService.withContext('RetrieverFactory') -export class RetrieverFactory { - /** - * 根据提供的参数创建一个 LangChain 检索器 (Retriever)。 - * @param base 知识库配置参数。 - * @param vectorStore 一个已初始化的向量存储实例。 - * @param documents 文档列表,用于初始化 BM25Retriever。 - * @returns 返回一个 BaseRetriever 实例。 - */ - public createRetriever(base: KnowledgeBaseParams, vectorStore: FaissStore, documents: Document[]): BaseRetriever { - const retrieverType = base.retriever?.mode ?? 'hybrid' - const retrieverWeight = base.retriever?.weight ?? 0.5 - const searchK = base.documentCount ?? 5 - - logger.info(`Creating retriever of type: ${retrieverType} with k=${searchK}`) - - switch (retrieverType) { - case 'bm25': - if (documents.length === 0) { - throw new Error('BM25Retriever requires documents, but none were provided or found.') - } - logger.info('Create BM25 Retriever') - return BM25Retriever.fromDocuments(documents, { k: searchK }) - - case 'hybrid': { - if (documents.length === 0) { - logger.warn('No documents provided for BM25 part of hybrid search. Falling back to vector search only.') - return vectorStore.asRetriever(searchK) - } - - const vectorstoreRetriever = vectorStore.asRetriever(searchK) - const bm25Retriever = BM25Retriever.fromDocuments(documents, { k: searchK }) - - logger.info('Create Hybrid Retriever') - return new EnsembleRetriever({ - retrievers: [bm25Retriever, vectorstoreRetriever], - weights: [retrieverWeight, 1 - retrieverWeight] - }) - } - - case 'vector': - default: - logger.info('Create Vector Retriever') - return vectorStore.asRetriever(searchK) - } - } -} diff --git a/src/main/knowledge/langchain/splitter/SrtSplitter.ts b/src/main/knowledge/langchain/splitter/SrtSplitter.ts deleted file mode 100644 index 2e7d47da76..0000000000 --- a/src/main/knowledge/langchain/splitter/SrtSplitter.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Document } from '@langchain/core/documents' -import { TextSplitter, TextSplitterParams } from 'langchain/text_splitter' - -// 定义一个接口来表示解析后的单个字幕片段 -interface SrtSegment { - text: string - startTime: number // in seconds - endTime: number // in seconds -} - -// 辅助函数:将 SRT 时间戳字符串 (HH:MM:SS,ms) 转换为秒 -function srtTimeToSeconds(time: string): number { - const parts = time.split(':') - const secondsAndMs = parts[2].split(',') - const hours = parseInt(parts[0], 10) - const minutes = parseInt(parts[1], 10) - const seconds = parseInt(secondsAndMs[0], 10) - const milliseconds = parseInt(secondsAndMs[1], 10) - - return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000 -} - -export class SrtSplitter extends TextSplitter { - constructor(fields?: Partial) { - // 传入 chunkSize 和 chunkOverlap - super(fields) - } - splitText(): Promise { - throw new Error('Method not implemented.') - } - - // 核心方法:重写 splitDocuments 来实现自定义逻辑 - async splitDocuments(documents: Document[]): Promise { - const allChunks: Document[] = [] - - for (const doc of documents) { - // 1. 解析 SRT 内容 - const segments = this.parseSrt(doc.pageContent) - if (segments.length === 0) continue - - // 2. 将字幕片段组合成块 - const chunks = this.mergeSegmentsIntoChunks(segments, doc.metadata) - allChunks.push(...chunks) - } - - return allChunks - } - - // 辅助方法:解析整个 SRT 字符串 - private parseSrt(srt: string): SrtSegment[] { - const segments: SrtSegment[] = [] - const blocks = srt.trim().split(/\n\n/) - - for (const block of blocks) { - const lines = block.split('\n') - if (lines.length < 3) continue - - const timeMatch = lines[1].match(/(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/) - if (!timeMatch) continue - - const startTime = srtTimeToSeconds(timeMatch[1]) - const endTime = srtTimeToSeconds(timeMatch[2]) - const text = lines.slice(2).join(' ').trim() - - segments.push({ text, startTime, endTime }) - } - - return segments - } - - // 辅助方法:将解析后的片段合并成每 5 段一个块 - private mergeSegmentsIntoChunks(segments: SrtSegment[], baseMetadata: Record): Document[] { - const chunks: Document[] = [] - let currentChunkText = '' - let currentChunkStartTime = 0 - let currentChunkEndTime = 0 - let segmentCount = 0 - - for (const segment of segments) { - if (segmentCount === 0) { - currentChunkStartTime = segment.startTime - } - - currentChunkText += (currentChunkText ? ' ' : '') + segment.text - currentChunkEndTime = segment.endTime - segmentCount++ - - // 当累积到 5 段时,创建一个新的 Document - if (segmentCount === 5) { - const metadata: Record = { - ...baseMetadata, - startTime: currentChunkStartTime, - endTime: currentChunkEndTime - } - if (baseMetadata.source_url) { - metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s` - } - chunks.push( - new Document({ - pageContent: currentChunkText, - metadata - }) - ) - - // 重置计数器和临时变量 - currentChunkText = '' - currentChunkStartTime = 0 - currentChunkEndTime = 0 - segmentCount = 0 - } - } - - // 如果还有剩余的片段,创建最后一个 Document - if (segmentCount > 0) { - const metadata: Record = { - ...baseMetadata, - startTime: currentChunkStartTime, - endTime: currentChunkEndTime - } - if (baseMetadata.source_url) { - metadata.source_url_with_timestamp = `${baseMetadata.source_url}?t=${Math.floor(currentChunkStartTime)}s` - } - chunks.push( - new Document({ - pageContent: currentChunkText, - metadata - }) - ) - } - - return chunks - } -} diff --git a/src/main/knowledge/langchain/splitter/index.ts b/src/main/knowledge/langchain/splitter/index.ts deleted file mode 100644 index 62ca1c9e90..0000000000 --- a/src/main/knowledge/langchain/splitter/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { RecursiveCharacterTextSplitter, TextSplitter } from '@langchain/textsplitters' - -import { SrtSplitter } from './SrtSplitter' - -export type SplitterConfig = { - chunkSize?: number - chunkOverlap?: number - type?: 'recursive' | 'srt' | string -} -export class SplitterFactory { - /** - * Creates a TextSplitter instance based on the provided configuration. - * @param config - The configuration object specifying the splitter type and its parameters. - * @returns An instance of a TextSplitter, or null if no splitting is required. - */ - public static create(config: SplitterConfig): TextSplitter { - switch (config.type) { - case 'srt': - return new SrtSplitter({ - chunkSize: config.chunkSize, - chunkOverlap: config.chunkOverlap - }) - case 'recursive': - default: - return new RecursiveCharacterTextSplitter({ - chunkSize: config.chunkSize, - chunkOverlap: config.chunkOverlap - }) - } - } -} diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index bdfb8e3cc8..66b88bce84 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -17,6 +17,13 @@ import { windowService } from './WindowService' const logger = loggerService.withContext('AppUpdater') +// Language markers constants for multi-language release notes +const LANG_MARKERS = { + EN_START: '', + ZH_CN_START: '', + END: '' +} as const + export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater private releaseInfo: UpdateInfo | undefined @@ -30,7 +37,8 @@ export default class AppUpdater { autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate() autoUpdater.requestHeaders = { ...autoUpdater.requestHeaders, - 'User-Agent': generateUserAgent() + 'User-Agent': generateUserAgent(), + 'X-Client-Id': configManager.getClientId() } autoUpdater.on('error', (error) => { @@ -40,7 +48,8 @@ export default class AppUpdater { autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => { logger.info('update available', releaseInfo) - windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo) + const processedReleaseInfo = this.processReleaseInfo(releaseInfo) + windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo) }) // 检测到不需要更新时 @@ -55,9 +64,10 @@ export default class AppUpdater { // 当需要更新的内容下载完成后 autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => { - windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo) - this.releaseInfo = releaseInfo - logger.info('update downloaded', releaseInfo) + const processedReleaseInfo = this.processReleaseInfo(releaseInfo) + windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo) + this.releaseInfo = processedReleaseInfo + logger.info('update downloaded', processedReleaseInfo) }) if (isWin) { @@ -270,16 +280,99 @@ export default class AppUpdater { }) } + /** + * Check if release notes contain multi-language markers + */ + private hasMultiLanguageMarkers(releaseNotes: string): boolean { + return releaseNotes.includes(LANG_MARKERS.EN_START) + } + + /** + * Parse multi-language release notes and return the appropriate language version + * @param releaseNotes - Release notes string with language markers + * @returns Parsed release notes for the user's language + * + * Expected format: + * English contentChinese content + */ + private parseMultiLangReleaseNotes(releaseNotes: string): string { + try { + const language = configManager.getLanguage() + const isChineseUser = language === 'zh-CN' || language === 'zh-TW' + + // Create regex patterns using constants + const enPattern = new RegExp( + `${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` + ) + const zhPattern = new RegExp( + `${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` + ) + + // Extract language sections + const enMatch = releaseNotes.match(enPattern) + const zhMatch = releaseNotes.match(zhPattern) + + // Return appropriate language version with proper fallback + if (isChineseUser && zhMatch) { + return zhMatch[1].trim() + } else if (enMatch) { + return enMatch[1].trim() + } else { + // Clean fallback: remove all language markers + logger.warn('Failed to extract language-specific release notes, using cleaned fallback') + return releaseNotes + .replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '') + .trim() + } + } catch (error) { + logger.error('Failed to parse multi-language release notes', error as Error) + // Return original notes as safe fallback + return releaseNotes + } + } + + /** + * Process release info to handle multi-language release notes + * @param releaseInfo - Original release info from updater + * @returns Processed release info with localized release notes + */ + private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo { + const processedInfo = { ...releaseInfo } + + // Handle multi-language release notes in string format + if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') { + // Check if it contains multi-language markers + if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) { + processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes) + } + } + + return processedInfo + } + + /** + * Format release notes for display + * @param releaseNotes - Release notes in various formats + * @returns Formatted string for display + */ private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string { if (!releaseNotes) { return '' } if (typeof releaseNotes === 'string') { + // Check if it contains multi-language markers + if (this.hasMultiLanguageMarkers(releaseNotes)) { + return this.parseMultiLangReleaseNotes(releaseNotes) + } return releaseNotes } - return releaseNotes.map((note) => note.note).join('\n') + if (Array.isArray(releaseNotes)) { + return releaseNotes.map((note) => note.note).join('\n') + } + + return '' } } interface GithubReleaseInfo { diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 12da4896be..486e58c212 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -3,11 +3,20 @@ import os from 'node:os' import path from 'node:path' import { loggerService } from '@logger' -import { isWin } from '@main/constant' +import { isMac, isWin } from '@main/constant' import { removeEnvProxy } from '@main/utils' import { isUserInChina } from '@main/utils/ipService' import { getBinaryName } from '@main/utils/process' -import { codeTools } from '@shared/config/constant' +import { + codeTools, + MACOS_TERMINALS, + MACOS_TERMINALS_WITH_COMMANDS, + terminalApps, + TerminalConfig, + TerminalConfigWithCommand, + WINDOWS_TERMINALS, + WINDOWS_TERMINALS_WITH_COMMANDS +} from '@shared/config/constant' import { spawn } from 'child_process' import { promisify } from 'util' @@ -22,7 +31,10 @@ interface VersionInfo { class CodeToolsService { private versionCache: Map = new Map() + private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null + private customTerminalPaths: Map = new Map() // Store user-configured terminal paths private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache + private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals constructor() { this.getBunPath = this.getBunPath.bind(this) @@ -32,6 +44,23 @@ class CodeToolsService { this.getVersionInfo = this.getVersionInfo.bind(this) this.updatePackage = this.updatePackage.bind(this) this.run = this.run.bind(this) + + if (isMac || isWin) { + this.preloadTerminals() + } + } + + /** + * Preload available terminals in background + */ + private async preloadTerminals(): Promise { + try { + logger.info('Preloading available terminals...') + await this.getAvailableTerminals() + logger.info('Terminal preloading completed') + } catch (error) { + logger.warn('Terminal preloading failed:', error as Error) + } } public async getBunPath() { @@ -51,6 +80,8 @@ class CodeToolsService { return '@openai/codex' case codeTools.qwenCode: return '@qwen-code/qwen-code' + case codeTools.iFlowCli: + return '@iflow-ai/iflow-cli' default: throw new Error(`Unsupported CLI tool: ${cliTool}`) } @@ -66,15 +97,265 @@ class CodeToolsService { return 'codex' case codeTools.qwenCode: return 'qwen' + case codeTools.iFlowCli: + return 'iflow' default: throw new Error(`Unsupported CLI tool: ${cliTool}`) } } + /** + * Check if a single terminal is available + */ + private async checkTerminalAvailability(terminal: TerminalConfig): Promise { + try { + if (isMac && terminal.bundleId) { + // macOS: Check if application is installed via bundle ID with timeout + const { stdout } = await execAsync(`mdfind "kMDItemCFBundleIdentifier == '${terminal.bundleId}'"`, { + timeout: 3000 + }) + if (stdout.trim()) { + return terminal + } + } else if (isWin) { + // Windows: Check terminal availability + return await this.checkWindowsTerminalAvailability(terminal) + } else { + // TODO: Check if terminal is available in linux + await execAsync(`which ${terminal.id}`, { timeout: 2000 }) + return terminal + } + } catch (error) { + logger.debug(`Terminal ${terminal.id} not available:`, error as Error) + } + return null + } + + /** + * Check Windows terminal availability (simplified - user configured paths) + */ + private async checkWindowsTerminalAvailability(terminal: TerminalConfig): Promise { + try { + switch (terminal.id) { + case terminalApps.cmd: + // CMD is always available on Windows + return terminal + + case terminalApps.powershell: + // Check for PowerShell in PATH + try { + await execAsync('powershell -Command "Get-Host"', { timeout: 3000 }) + return terminal + } catch { + try { + await execAsync('pwsh -Command "Get-Host"', { timeout: 3000 }) + return terminal + } catch { + return null + } + } + + case terminalApps.windowsTerminal: + // Check for Windows Terminal via where command (doesn't launch the terminal) + try { + await execAsync('where wt', { timeout: 3000 }) + return terminal + } catch { + return null + } + + case terminalApps.wsl: + // Check for WSL + try { + await execAsync('wsl --status', { timeout: 3000 }) + return terminal + } catch { + return null + } + + default: + // For other terminals (Alacritty, WezTerm), check if user has configured custom path + return await this.checkCustomTerminalPath(terminal) + } + } catch (error) { + logger.debug(`Windows terminal ${terminal.id} not available:`, error as Error) + return null + } + } + + /** + * Check if user has configured custom path for terminal + */ + private async checkCustomTerminalPath(terminal: TerminalConfig): Promise { + // Check if user has configured custom path + const customPath = this.customTerminalPaths.get(terminal.id) + if (customPath && fs.existsSync(customPath)) { + try { + await execAsync(`"${customPath}" --version`, { timeout: 3000 }) + return { ...terminal, customPath } + } catch { + return null + } + } + + // Fallback to PATH check + try { + const command = terminal.id === terminalApps.alacritty ? 'alacritty' : 'wezterm' + await execAsync(`${command} --version`, { timeout: 3000 }) + return terminal + } catch { + return null + } + } + + /** + * Set custom path for a terminal (called from settings UI) + */ + public setCustomTerminalPath(terminalId: string, path: string): void { + logger.info(`Setting custom path for terminal ${terminalId}: ${path}`) + this.customTerminalPaths.set(terminalId, path) + // Clear terminals cache to force refresh + this.terminalsCache = null + } + + /** + * Get custom path for a terminal + */ + public getCustomTerminalPath(terminalId: string): string | undefined { + return this.customTerminalPaths.get(terminalId) + } + + /** + * Remove custom path for a terminal + */ + public removeCustomTerminalPath(terminalId: string): void { + logger.info(`Removing custom path for terminal ${terminalId}`) + this.customTerminalPaths.delete(terminalId) + // Clear terminals cache to force refresh + this.terminalsCache = null + } + + /** + * Get available terminals (with caching and parallel checking) + */ + private async getAvailableTerminals(): Promise { + const now = Date.now() + + // Check cache first + if (this.terminalsCache && now - this.terminalsCache.timestamp < this.TERMINALS_CACHE_DURATION) { + logger.info(`Using cached terminals list (${this.terminalsCache.terminals.length} terminals)`) + return this.terminalsCache.terminals + } + + logger.info('Checking available terminals in parallel...') + const startTime = Date.now() + + // Get terminal list based on platform + const terminalList = isWin ? WINDOWS_TERMINALS : MACOS_TERMINALS + + // Check all terminals in parallel + const terminalPromises = terminalList.map((terminal) => this.checkTerminalAvailability(terminal)) + + try { + // Wait for all checks to complete with a global timeout + const results = await Promise.allSettled( + terminalPromises.map((p) => + Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]) + ) + ) + + const availableTerminals: TerminalConfig[] = [] + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value) { + availableTerminals.push(result.value as TerminalConfig) + } else if (result.status === 'rejected') { + logger.debug(`Terminal check failed for ${MACOS_TERMINALS[index].id}:`, result.reason) + } + }) + + const endTime = Date.now() + logger.info( + `Terminal availability check completed in ${endTime - startTime}ms, found ${availableTerminals.length} terminals` + ) + + // Cache the results + this.terminalsCache = { + terminals: availableTerminals, + timestamp: now + } + + return availableTerminals + } catch (error) { + logger.error('Error checking terminal availability:', error as Error) + // Return cached result if available, otherwise empty array + return this.terminalsCache?.terminals || [] + } + } + + /** + * Get terminal config by ID, fallback to system default + */ + private async getTerminalConfig(terminalId?: string): Promise { + const availableTerminals = await this.getAvailableTerminals() + const terminalCommands = isWin ? WINDOWS_TERMINALS_WITH_COMMANDS : MACOS_TERMINALS_WITH_COMMANDS + const defaultTerminal = isWin ? terminalApps.cmd : terminalApps.systemDefault + + if (terminalId) { + let requestedTerminal = terminalCommands.find( + (t) => t.id === terminalId && availableTerminals.some((at) => at.id === t.id) + ) + + if (requestedTerminal) { + // Apply custom path if configured + const customPath = this.customTerminalPaths.get(terminalId) + if (customPath && isWin) { + requestedTerminal = this.applyCustomPath(requestedTerminal, customPath) + } + return requestedTerminal + } else { + logger.warn(`Requested terminal ${terminalId} not available, falling back to system default`) + } + } + + // Fallback to system default Terminal + const systemTerminal = terminalCommands.find( + (t) => t.id === defaultTerminal && availableTerminals.some((at) => at.id === t.id) + ) + if (systemTerminal) { + return systemTerminal + } + + // If even system Terminal is not found, return the first available + const firstAvailable = terminalCommands.find((t) => availableTerminals.some((at) => at.id === t.id)) + if (firstAvailable) { + return firstAvailable + } + + // Last resort fallback + return terminalCommands.find((t) => t.id === defaultTerminal)! + } + + /** + * Apply custom path to terminal configuration + */ + private applyCustomPath(terminal: TerminalConfigWithCommand, customPath: string): TerminalConfigWithCommand { + return { + ...terminal, + customPath, + command: (directory: string, fullCommand: string) => { + const originalCommand = terminal.command(directory, fullCommand) + return { + ...originalCommand, + command: customPath // Replace command with custom path + } + } + } + } + private async isPackageInstalled(cliTool: string): Promise { const executableName = await this.getCliExecutableName(cliTool) const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) // Ensure bin directory exists if (!fs.existsSync(binDir)) { @@ -101,7 +382,7 @@ class CodeToolsService { try { const executableName = await this.getCliExecutableName(cliTool) const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 }) // Extract version number from output (format may vary by tool) @@ -187,6 +468,17 @@ class CodeToolsService { } } + /** + * Get available terminals for the current platform + */ + public async getAvailableTerminalsForPlatform(): Promise { + if (isMac || isWin) { + return this.getAvailableTerminals() + } + // For other platforms, return empty array for now + return [] + } + /** * Update a CLI tool to the latest version */ @@ -198,10 +490,9 @@ class CodeToolsService { const bunInstallPath = path.join(os.homedir(), '.cherrystudio') const registryUrl = await this.getNpmRegistryUrl() - const installEnvPrefix = - process.platform === 'win32' - ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` - : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` + const installEnvPrefix = isWin + ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` + : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` logger.info(`Executing update command: ${updateCommand}`) @@ -237,7 +528,7 @@ class CodeToolsService { _model: string, directory: string, env: Record, - options: { autoUpdateToLatest?: boolean } = {} + options: { autoUpdateToLatest?: boolean; terminal?: string } = {} ) { logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`) logger.debug(`Environment variables:`, Object.keys(env)) @@ -247,7 +538,7 @@ class CodeToolsService { const bunPath = await this.getBunPath() const executableName = await this.getCliExecutableName(cliTool) const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) logger.debug(`Package name: ${packageName}`) logger.debug(`Bun path: ${bunPath}`) @@ -291,7 +582,13 @@ class CodeToolsService { // Build environment variable prefix (based on platform) const buildEnvPrefix = (isWindows: boolean) => { - if (Object.keys(env).length === 0) return '' + if (Object.keys(env).length === 0) { + logger.info('No environment variables to set') + return '' + } + + logger.info('Setting environment variables:', Object.keys(env)) + logger.info('Environment variable values:', env) if (isWindows) { // Windows uses set command @@ -300,14 +597,48 @@ class CodeToolsService { .join(' && ') } else { // Unix-like systems use export command - return Object.entries(env) - .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`) + const validEntries = Object.entries(env).filter(([key, value]) => { + if (!key || key.trim() === '') { + return false + } + if (value === undefined || value === null) { + return false + } + return true + }) + + const envCommands = validEntries + .map(([key, value]) => { + const sanitizedValue = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + const exportCmd = `export ${key}="${sanitizedValue}"` + logger.info(`Setting env var: ${key}="${sanitizedValue}"`) + logger.info(`Export command: ${exportCmd}`) + return exportCmd + }) .join(' && ') + return envCommands } } - // Build command to execute let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"` + + // Add configuration parameters for OpenAI Codex + if (cliTool === codeTools.openaiCodex && env.OPENAI_MODEL_PROVIDER && env.OPENAI_MODEL_PROVIDER != 'openai') { + const provider = env.OPENAI_MODEL_PROVIDER + const model = env.OPENAI_MODEL + // delete the latest / + const baseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '') + + const configParams = [ + `--config model_provider="${provider}"`, + `--config model="${model}"`, + `--config model_providers.${provider}.name="${provider}"`, + `--config model_providers.${provider}.base_url="${baseUrl}"`, + `--config model_providers.${provider}.env_key="OPENAI_API_KEY"` + ].join(' ') + baseCommand = `${baseCommand} ${configParams}` + } + const bunInstallPath = path.join(os.homedir(), '.cherrystudio') if (isInstalled) { @@ -329,20 +660,20 @@ class CodeToolsService { switch (platform) { case 'darwin': { - // macOS - Use osascript to launch terminal and execute command directly, without showing startup command + // macOS - Support multiple terminals const envPrefix = buildEnvPrefix(false) - const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand - // Combine directory change with the main command to ensure they execute in the same shell session - const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}` - terminalCommand = 'osascript' - terminalArgs = [ - '-e', - `tell application "Terminal" - do script "${fullCommand.replace(/"/g, '\\"')}" - activate -end tell` - ] + const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand + + // Combine directory change with the main command to ensure they execute in the same shell session + const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}` + + const terminalConfig = await this.getTerminalConfig(options.terminal) + logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`) + + const { command: cmd, args } = terminalConfig.command(directory, fullCommand) + terminalCommand = cmd + terminalArgs = args break } case 'win32': { @@ -402,9 +733,23 @@ end tell` throw new Error(`Failed to create launch script: ${error}`) } - // Launch bat file - Use safest start syntax, no title parameter - terminalCommand = 'cmd' - terminalArgs = ['/c', 'start', batFilePath] + // Use selected terminal configuration + const terminalConfig = await this.getTerminalConfig(options.terminal) + logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`) + + // Get command and args from terminal configuration + // Pass the bat file path as the command to execute + const fullCommand = batFilePath + const { command: cmd, args } = terminalConfig.command(directory, fullCommand) + + // Override if it's a custom terminal with a custom path + if (terminalConfig.customPath) { + terminalCommand = terminalConfig.customPath + terminalArgs = args + } else { + terminalCommand = cmd + terminalArgs = args + } // Set cleanup task (delete temp file after 5 minutes) setTimeout(() => { diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 5f5be2c723..3cab0bf91d 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -2,6 +2,7 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/ import { LanguageVarious, Shortcut, ThemeMode } from '@types' import { app } from 'electron' import Store from 'electron-store' +import { v4 as uuidv4 } from 'uuid' import { locales } from '../utils/locales' @@ -27,7 +28,8 @@ export enum ConfigKeys { SelectionAssistantFilterList = 'selectionAssistantFilterList', DisableHardwareAcceleration = 'disableHardwareAcceleration', Proxy = 'proxy', - EnableDeveloperMode = 'enableDeveloperMode' + EnableDeveloperMode = 'enableDeveloperMode', + ClientId = 'clientId' } export class ConfigManager { @@ -241,6 +243,17 @@ export class ConfigManager { this.set(ConfigKeys.EnableDeveloperMode, value) } + getClientId(): string { + let clientId = this.get(ConfigKeys.ClientId) + + if (!clientId) { + clientId = uuidv4() + this.set(ConfigKeys.ClientId, clientId) + } + + return clientId + } + set(key: string, value: unknown, isNotify: boolean = false) { this.store.set(key, value) isNotify && this.notifySubscribers(key, value) diff --git a/src/main/services/ExportService.ts b/src/main/services/ExportService.ts index 94812c64d2..f827f32d25 100644 --- a/src/main/services/ExportService.ts +++ b/src/main/services/ExportService.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-case-declarations */ +/* oxlint-disable no-case-declarations */ // ExportService import fs from 'node:fs' diff --git a/src/main/services/knowledge/EmbedJsFramework.ts b/src/main/services/KnowledgeService.ts similarity index 53% rename from src/main/services/knowledge/EmbedJsFramework.ts rename to src/main/services/KnowledgeService.ts index 64ac77434e..1ccd25008d 100644 --- a/src/main/services/knowledge/EmbedJsFramework.ts +++ b/src/main/services/KnowledgeService.ts @@ -1,3 +1,18 @@ +/** + * Knowledge Service - Manages knowledge bases using RAG (Retrieval-Augmented Generation) + * + * This service handles creation, management, and querying of knowledge bases from various sources + * including files, directories, URLs, sitemaps, and notes. + * + * Features: + * - Concurrent task processing with workload management + * - Multiple data source support + * - Vector database integration + * + * For detailed documentation, see: + * @see {@link ../../../docs/technical/KnowledgeService.md} + */ + import * as fs from 'node:fs' import path from 'node:path' @@ -9,32 +24,87 @@ import { loggerService } from '@logger' import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings' import { addFileLoader } from '@main/knowledge/embedjs/loader' import { NoteLoader } from '@main/knowledge/embedjs/loader/noteLoader' -import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService' +import PreprocessProvider from '@main/knowledge/preprocess/PreprocessProvider' +import Reranker from '@main/knowledge/reranker/Reranker' +import { fileStorage } from '@main/services/FileStorage' +import { windowService } from '@main/services/WindowService' +import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' +import { TraceMethod } from '@mcp-trace/trace-core' import { MB } from '@shared/config/constant' -import { LoaderReturn } from '@shared/config/types' +import type { LoaderReturn } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' -import { FileMetadata, KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types' import { v4 as uuidv4 } from 'uuid' -import { windowService } from '../WindowService' -import { - IKnowledgeFramework, - KnowledgeBaseAddItemOptionsNonNullableAttribute, - LoaderDoneReturn, - LoaderTask, - LoaderTaskItem, - LoaderTaskItemState -} from './IKnowledgeFramework' - const logger = loggerService.withContext('MainKnowledgeService') -export class EmbedJsFramework implements IKnowledgeFramework { - private storageDir: string - private ragApplications: Map = new Map() - private pendingDeleteFile: string - private dbInstances: Map = new Map() +export interface KnowledgeBaseAddItemOptions { + base: KnowledgeBaseParams + item: KnowledgeItem + forceReload?: boolean + userId?: string +} +interface KnowledgeBaseAddItemOptionsNonNullableAttribute { + base: KnowledgeBaseParams + item: KnowledgeItem + forceReload: boolean + userId: string +} + +interface EvaluateTaskWorkload { + workload: number +} + +type LoaderDoneReturn = LoaderReturn | null + +enum LoaderTaskItemState { + PENDING, + PROCESSING, + DONE +} + +interface LoaderTaskItem { + state: LoaderTaskItemState + task: () => Promise + evaluateTaskWorkload: EvaluateTaskWorkload +} + +interface LoaderTask { + loaderTasks: LoaderTaskItem[] + loaderDoneReturn: LoaderDoneReturn +} + +interface LoaderTaskOfSet { + loaderTasks: Set + loaderDoneReturn: LoaderDoneReturn +} + +interface QueueTaskItem { + taskPromise: () => Promise + resolve: () => void + evaluateTaskWorkload: EvaluateTaskWorkload +} + +const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => { + return { + loaderTasks: new Set(loaderTask.loaderTasks), + loaderDoneReturn: loaderTask.loaderDoneReturn + } +} + +class KnowledgeService { + private storageDir = path.join(getDataPath(), 'KnowledgeBase') + private pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json') + // Byte based + private workload = 0 + private processingItemCount = 0 + private knowledgeItemProcessingQueueMappingPromise: Map void> = new Map() + private ragApplications: Map = new Map() + private dbInstances: Map = new Map() + private static MAXIMUM_WORKLOAD = 80 * MB + private static MAXIMUM_PROCESSING_ITEM_COUNT = 30 private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', @@ -43,9 +113,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { status: 'failed' } - constructor(storageDir: string) { - this.storageDir = storageDir - this.pendingDeleteFile = path.join(this.storageDir, 'knowledge_pending_delete.json') + constructor() { this.initStorageDir() this.cleanupOnStartup() } @@ -160,28 +228,33 @@ export class EmbedJsFramework implements IKnowledgeFramework { logger.info(`Startup cleanup completed: ${deletedCount}/${pendingDeleteIds.length} knowledge bases deleted`) } - private async getRagApplication(base: KnowledgeBaseParams): Promise { - if (this.ragApplications.has(base.id)) { - return this.ragApplications.get(base.id)! + private getRagApplication = async ({ + id, + embedApiClient, + dimensions, + documentCount + }: KnowledgeBaseParams): Promise => { + if (this.ragApplications.has(id)) { + return this.ragApplications.get(id)! } let ragApplication: RAGApplication const embeddings = new Embeddings({ - embedApiClient: base.embedApiClient, - dimensions: base.dimensions + embedApiClient, + dimensions }) try { - const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, base.id) }) + const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) }) // Save database instance for later closing - this.dbInstances.set(base.id, libSqlDb) + this.dbInstances.set(id, libSqlDb) ragApplication = await new RAGApplicationBuilder() .setModel('NO_MODEL') .setEmbeddingModel(embeddings) .setVectorDatabase(libSqlDb) - .setSearchResultCount(base.documentCount || 30) + .setSearchResultCount(documentCount || 30) .build() - this.ragApplications.set(base.id, ragApplication) + this.ragApplications.set(id, ragApplication) } catch (e) { logger.error('Failed to create RAGApplication:', e as Error) throw new Error(`Failed to create RAGApplication: ${e}`) @@ -189,14 +262,17 @@ export class EmbedJsFramework implements IKnowledgeFramework { return ragApplication } - async initialize(base: KnowledgeBaseParams): Promise { + + public create = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise => { await this.getRagApplication(base) } - async reset(base: KnowledgeBaseParams): Promise { - const ragApp = await this.getRagApplication(base) - await ragApp.reset() + + public reset = async (_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise => { + const ragApplication = await this.getRagApplication(base) + await ragApplication.reset() } - async delete(id: string): Promise { + + public async delete(_: Electron.IpcMainInvokeEvent, id: string): Promise { logger.debug(`delete id: ${id}`) await this.cleanupKnowledgeResources(id) @@ -209,41 +285,15 @@ export class EmbedJsFramework implements IKnowledgeFramework { this.pendingDeleteManager.add(id) } } - getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask { - const { item } = options - const getRagApplication = () => this.getRagApplication(options.base) - switch (item.type) { - case 'file': - return this.fileTask(getRagApplication, options) - case 'directory': - return this.directoryTask(getRagApplication, options) - case 'url': - return this.urlTask(getRagApplication, options) - case 'sitemap': - return this.sitemapTask(getRagApplication, options) - case 'note': - return this.noteTask(getRagApplication, options) - default: - return { - loaderTasks: [], - loaderDoneReturn: null - } - } - } - async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise { - const ragApp = await this.getRagApplication(options.base) - for (const id of options.uniqueIds) { - await ragApp.deleteLoader(id) - } + private maximumLoad() { + return ( + this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT || + this.workload >= KnowledgeService.MAXIMUM_WORKLOAD + ) } - async search(options: { search: string; base: KnowledgeBaseParams }): Promise { - const ragApp = await this.getRagApplication(options.base) - return await ragApp.search(options.search) - } - private fileTask( - getRagApplication: () => Promise, + ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute ): LoaderTask { const { base, item, forceReload, userId } = options @@ -256,8 +306,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { task: async () => { try { // Add preprocessing logic - const ragApplication = await getRagApplication() - const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId) + const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId) // Use processed file for loading return addFileLoader(ragApplication, fileToProcess, base, forceReload) @@ -268,7 +317,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { .catch((e) => { logger.error(`Error in addFileLoader for ${file.name}: ${e}`) const errorResult: LoaderReturn = { - ...EmbedJsFramework.ERROR_LOADER_RETURN, + ...KnowledgeService.ERROR_LOADER_RETURN, message: e.message, messageSource: 'embedding' } @@ -278,7 +327,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { } catch (e: any) { logger.error(`Preprocessing failed for ${file.name}: ${e}`) const errorResult: LoaderReturn = { - ...EmbedJsFramework.ERROR_LOADER_RETURN, + ...KnowledgeService.ERROR_LOADER_RETURN, message: e.message, messageSource: 'preprocess' } @@ -295,7 +344,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { return loaderTask } private directoryTask( - getRagApplication: () => Promise, + ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute ): LoaderTask { const { base, item, forceReload } = options @@ -322,9 +371,8 @@ export class EmbedJsFramework implements IKnowledgeFramework { for (const file of files) { loaderTasks.push({ state: LoaderTaskItemState.PENDING, - task: async () => { - const ragApplication = await getRagApplication() - return addFileLoader(ragApplication, file, base, forceReload) + task: () => + addFileLoader(ragApplication, file, base, forceReload) .then((result) => { loaderDoneReturn.entriesAdded += 1 processedFiles += 1 @@ -335,12 +383,11 @@ export class EmbedJsFramework implements IKnowledgeFramework { .catch((err) => { logger.error('Failed to add dir loader:', err) return { - ...EmbedJsFramework.ERROR_LOADER_RETURN, + ...KnowledgeService.ERROR_LOADER_RETURN, message: `Failed to add dir loader: ${err.message}`, messageSource: 'embedding' } - }) - }, + }), evaluateTaskWorkload: { workload: file.size } }) } @@ -352,7 +399,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { } private urlTask( - getRagApplication: () => Promise, + ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute ): LoaderTask { const { base, item, forceReload } = options @@ -362,8 +409,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { loaderTasks: [ { state: LoaderTaskItemState.PENDING, - task: async () => { - const ragApplication = await getRagApplication() + task: () => { const loaderReturn = ragApplication.addLoader( new WebLoader({ urlOrContent: content, @@ -387,7 +433,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { .catch((err) => { logger.error('Failed to add url loader:', err) return { - ...EmbedJsFramework.ERROR_LOADER_RETURN, + ...KnowledgeService.ERROR_LOADER_RETURN, message: `Failed to add url loader: ${err.message}`, messageSource: 'embedding' } @@ -402,7 +448,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { } private sitemapTask( - getRagApplication: () => Promise, + ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute ): LoaderTask { const { base, item, forceReload } = options @@ -412,9 +458,8 @@ export class EmbedJsFramework implements IKnowledgeFramework { loaderTasks: [ { state: LoaderTaskItemState.PENDING, - task: async () => { - const ragApplication = await getRagApplication() - return ragApplication + task: () => + ragApplication .addLoader( new SitemapLoader({ url: content, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any, forceReload @@ -432,12 +477,11 @@ export class EmbedJsFramework implements IKnowledgeFramework { .catch((err) => { logger.error('Failed to add sitemap loader:', err) return { - ...EmbedJsFramework.ERROR_LOADER_RETURN, + ...KnowledgeService.ERROR_LOADER_RETURN, message: `Failed to add sitemap loader: ${err.message}`, messageSource: 'embedding' } - }) - }, + }), evaluateTaskWorkload: { workload: 20 * MB } } ], @@ -447,7 +491,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { } private noteTask( - getRagApplication: () => Promise, + ragApplication: RAGApplication, options: KnowledgeBaseAddItemOptionsNonNullableAttribute ): LoaderTask { const { base, item, forceReload } = options @@ -460,8 +504,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { loaderTasks: [ { state: LoaderTaskItemState.PENDING, - task: async () => { - const ragApplication = await getRagApplication() + task: () => { const loaderReturn = ragApplication.addLoader( new NoteLoader({ text: content, @@ -484,7 +527,7 @@ export class EmbedJsFramework implements IKnowledgeFramework { .catch((err) => { logger.error('Failed to add note loader:', err) return { - ...EmbedJsFramework.ERROR_LOADER_RETURN, + ...KnowledgeService.ERROR_LOADER_RETURN, message: `Failed to add note loader: ${err.message}`, messageSource: 'embedding' } @@ -497,4 +540,199 @@ export class EmbedJsFramework implements IKnowledgeFramework { } return loaderTask } + + private processingQueueHandle() { + const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => { + const queueTaskList: QueueTaskItem[] = [] + that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) { + for (const item of task.loaderTasks) { + if (this.maximumLoad()) { + break that + } + + const { state, task: taskPromise, evaluateTaskWorkload } = item + + if (state !== LoaderTaskItemState.PENDING) { + continue + } + + const { workload } = evaluateTaskWorkload + this.workload += workload + this.processingItemCount += 1 + item.state = LoaderTaskItemState.PROCESSING + queueTaskList.push({ + taskPromise: () => + taskPromise().then(() => { + this.workload -= workload + this.processingItemCount -= 1 + task.loaderTasks.delete(item) + if (task.loaderTasks.size === 0) { + this.knowledgeItemProcessingQueueMappingPromise.delete(task) + resolve() + } + this.processingQueueHandle() + }), + resolve: () => {}, + evaluateTaskWorkload + }) + } + } + return queueTaskList + } + const subTasks = getSubtasksUntilMaximumLoad() + if (subTasks.length > 0) { + const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise()) + Promise.all(subTaskPromises).then(() => { + subTasks.forEach(({ resolve }) => resolve()) + }) + } + } + + private appendProcessingQueue(task: LoaderTask): Promise { + return new Promise((resolve) => { + this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => { + resolve(task.loaderDoneReturn!) + }) + }) + } + + public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise => { + return new Promise((resolve) => { + const { base, item, forceReload = false, userId = '' } = options + const optionsNonNullableAttribute = { base, item, forceReload, userId } + this.getRagApplication(base) + .then((ragApplication) => { + const task = (() => { + switch (item.type) { + case 'file': + return this.fileTask(ragApplication, optionsNonNullableAttribute) + case 'directory': + return this.directoryTask(ragApplication, optionsNonNullableAttribute) + case 'url': + return this.urlTask(ragApplication, optionsNonNullableAttribute) + case 'sitemap': + return this.sitemapTask(ragApplication, optionsNonNullableAttribute) + case 'note': + return this.noteTask(ragApplication, optionsNonNullableAttribute) + default: + return null + } + })() + + if (task) { + this.appendProcessingQueue(task).then(() => { + resolve(task.loaderDoneReturn!) + }) + this.processingQueueHandle() + } else { + resolve({ + ...KnowledgeService.ERROR_LOADER_RETURN, + message: 'Unsupported item type', + messageSource: 'embedding' + }) + } + }) + .catch((err) => { + logger.error('Failed to add item:', err) + resolve({ + ...KnowledgeService.ERROR_LOADER_RETURN, + message: `Failed to add item: ${err.message}`, + messageSource: 'embedding' + }) + }) + }) + } + + @TraceMethod({ spanName: 'remove', tag: 'Knowledge' }) + public async remove( + _: Electron.IpcMainInvokeEvent, + { uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams } + ): Promise { + const ragApplication = await this.getRagApplication(base) + logger.debug(`Remove Item UniqueId: ${uniqueId}`) + for (const id of uniqueIds) { + await ragApplication.deleteLoader(id) + } + } + + @TraceMethod({ spanName: 'RagSearch', tag: 'Knowledge' }) + public async search( + _: Electron.IpcMainInvokeEvent, + { search, base }: { search: string; base: KnowledgeBaseParams } + ): Promise { + const ragApplication = await this.getRagApplication(base) + return await ragApplication.search(search) + } + + @TraceMethod({ spanName: 'rerank', tag: 'Knowledge' }) + public async rerank( + _: Electron.IpcMainInvokeEvent, + { search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] } + ): Promise { + if (results.length === 0) { + return results + } + return await new Reranker(base).rerank(search, results) + } + + public getStorageDir = (): string => { + return this.storageDir + } + + private preprocessing = async ( + file: FileMetadata, + base: KnowledgeBaseParams, + item: KnowledgeItem, + userId: string + ): Promise => { + let fileToProcess: FileMetadata = file + if (base.preprocessProvider && file.ext.toLowerCase() === '.pdf') { + try { + const provider = new PreprocessProvider(base.preprocessProvider.provider, userId) + const filePath = fileStorage.getFilePathById(file) + // Check if file has already been preprocessed + const alreadyProcessed = await provider.checkIfAlreadyProcessed(file) + if (alreadyProcessed) { + logger.debug(`File already preprocess processed, using cached result: ${filePath}`) + return alreadyProcessed + } + + // Execute preprocessing + logger.debug(`Starting preprocess processing for scanned PDF: ${filePath}`) + const { processedFile, quota } = await provider.parseFile(item.id, file) + fileToProcess = processedFile + const mainWindow = windowService.getMainWindow() + mainWindow?.webContents.send('file-preprocess-finished', { + itemId: item.id, + quota: quota + }) + } catch (err) { + logger.error(`Preprocess processing failed: ${err}`) + // If preprocessing fails, use original file + // fileToProcess = file + throw new Error(`Preprocess processing failed: ${err}`) + } + } + + return fileToProcess + } + + public checkQuota = async ( + _: Electron.IpcMainInvokeEvent, + base: KnowledgeBaseParams, + userId: string + ): Promise => { + try { + if (base.preprocessProvider && base.preprocessProvider.type === 'preprocess') { + const provider = new PreprocessProvider(base.preprocessProvider.provider, userId) + return await provider.checkQuota() + } + throw new Error('No preprocess provider configured') + } catch (err) { + logger.error(`Failed to check quota: ${err}`) + throw new Error(`Failed to check quota: ${err}`) + } + } } + +export default new KnowledgeService() diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 710867da88..073a7f3435 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -235,7 +235,7 @@ class McpService { try { await inMemoryServer.connect(serverTransport) getServerLogger(server).debug(`In-memory server started`) - } catch (error: Error | any) { + } catch (error: any) { getServerLogger(server).error(`Error starting in-memory server`, error as Error) throw new Error(`Failed to start in-memory server: ${error.message}`) } @@ -419,7 +419,7 @@ class McpService { const transport = await initTransport() try { await client.connect(transport) - } catch (error: Error | any) { + } catch (error: any) { if ( error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized')) @@ -852,7 +852,7 @@ class McpService { return { contents: contents } - } catch (error: Error | any) { + } catch (error: any) { getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error) throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`) } diff --git a/src/main/services/MistralClientManager.ts b/src/main/services/MistralClientManager.ts index fa4aa53df8..c2efe35d1d 100644 --- a/src/main/services/MistralClientManager.ts +++ b/src/main/services/MistralClientManager.ts @@ -5,7 +5,7 @@ export class MistralClientManager { private static instance: MistralClientManager private client: Mistral | null = null - // eslint-disable-next-line @typescript-eslint/no-empty-function + // oxlint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} public static getInstance(): MistralClientManager { diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts index 2ceb12ee40..fa9261aa7e 100644 --- a/src/main/services/NotificationService.ts +++ b/src/main/services/NotificationService.ts @@ -1,5 +1,5 @@ +import { Notification } from '@types' import { Notification as ElectronNotification } from 'electron' -import { Notification } from 'src/renderer/src/types/notification' import { windowService } from './WindowService' diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 860324bc62..0e188c1009 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -235,7 +235,7 @@ export class ProxyManager { https.request = this.bindHttpMethod(this.originalHttpsRequest, agent) } - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + // oxlint-disable-next-line @typescript-eslint/no-unsafe-function-type private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) { return (...args: any[]) => { let url: string | URL | undefined diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 9b8a176a34..66aed098e7 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -256,7 +256,7 @@ export class WindowService { private setupWebContentsHandlers(mainWindow: BrowserWindow) { mainWindow.webContents.on('will-navigate', (event, url) => { - if (url.includes('localhost:5173')) { + if (url.includes('localhost:517')) { return } @@ -275,7 +275,8 @@ export class WindowService { 'https://aihubmix.com/topup', 'https://aihubmix.com/statistics', 'https://dash.302.ai/sso/login', - 'https://dash.302.ai/charge' + 'https://dash.302.ai/charge', + 'https://www.aiionly.com/login' ] if (oauthProviderUrls.some((link) => url.startsWith(link))) { diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts new file mode 100644 index 0000000000..bb6a7827cb --- /dev/null +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -0,0 +1,319 @@ +import { UpdateInfo } from 'builder-util-runtime' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock dependencies +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn() + }) + } +})) + +vi.mock('../ConfigManager', () => ({ + configManager: { + getLanguage: vi.fn(), + getAutoUpdate: vi.fn(() => false), + getTestPlan: vi.fn(() => false), + getTestChannel: vi.fn(), + getClientId: vi.fn(() => 'test-client-id') + } +})) + +vi.mock('../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn() + } +})) + +vi.mock('@main/constant', () => ({ + isWin: false +})) + +vi.mock('@main/utils/ipService', () => ({ + getIpCountry: vi.fn(() => 'US') +})) + +vi.mock('@main/utils/locales', () => ({ + locales: { + en: { translation: { update: {} } }, + 'zh-CN': { translation: { update: {} } } + } +})) + +vi.mock('@main/utils/systemInfo', () => ({ + generateUserAgent: vi.fn(() => 'test-user-agent') +})) + +vi.mock('electron', () => ({ + app: { + isPackaged: true, + getVersion: vi.fn(() => '1.0.0'), + getPath: vi.fn(() => '/test/path') + }, + dialog: { + showMessageBox: vi.fn() + }, + BrowserWindow: vi.fn(), + net: { + fetch: vi.fn() + } +})) + +vi.mock('electron-updater', () => ({ + autoUpdater: { + logger: null, + forceDevUpdateConfig: false, + autoDownload: false, + autoInstallOnAppQuit: false, + requestHeaders: {}, + on: vi.fn(), + setFeedURL: vi.fn(), + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + channel: '', + allowDowngrade: false, + disableDifferentialDownload: false, + currentVersion: '1.0.0' + }, + Logger: vi.fn(), + NsisUpdater: vi.fn(), + AppUpdater: vi.fn() +})) + +// Import after mocks +import AppUpdater from '../AppUpdater' +import { configManager } from '../ConfigManager' + +describe('AppUpdater', () => { + let appUpdater: AppUpdater + + beforeEach(() => { + vi.clearAllMocks() + appUpdater = new AppUpdater() + }) + + describe('parseMultiLangReleaseNotes', () => { + const sampleReleaseNotes = ` +🚀 New Features: +- Feature A +- Feature B + +🎨 UI Improvements: +- Improvement A + +🚀 新功能: +- 功能 A +- 功能 B + +🎨 界面改进: +- 改进 A +` + + it('should return Chinese notes for zh-CN users', () => { + vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN') + + const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) + + expect(result).toContain('新功能') + expect(result).toContain('功能 A') + expect(result).not.toContain('New Features') + }) + + it('should return Chinese notes for zh-TW users', () => { + vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW') + + const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) + + expect(result).toContain('新功能') + expect(result).toContain('功能 A') + expect(result).not.toContain('New Features') + }) + + it('should return English notes for non-Chinese users', () => { + vi.mocked(configManager.getLanguage).mockReturnValue('en-US') + + const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) + + expect(result).toContain('New Features') + expect(result).toContain('Feature A') + expect(result).not.toContain('新功能') + }) + + it('should return English notes for other language users', () => { + vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU') + + const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) + + expect(result).toContain('New Features') + expect(result).not.toContain('新功能') + }) + + it('should handle missing language sections gracefully', () => { + const malformedNotes = 'Simple release notes without markers' + + const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes) + + expect(result).toBe('Simple release notes without markers') + }) + + it('should handle malformed markers', () => { + const malformedNotes = `English only` + vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN') + + const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes) + + // Should clean up markers and return cleaned content + expect(result).toContain('English only') + expect(result).not.toContain('Test' + + const result = (appUpdater as any).hasMultiLanguageMarkers(notes) + + expect(result).toBe(true) + }) + + it('should return false when no markers are present', () => { + const notes = 'Simple text without markers' + + const result = (appUpdater as any).hasMultiLanguageMarkers(notes) + + expect(result).toBe(false) + }) + }) + + describe('processReleaseInfo', () => { + it('should process multi-language release notes in string format', () => { + vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN') + + const releaseInfo = { + version: '1.0.0', + files: [], + path: '', + sha512: '', + releaseDate: new Date().toISOString(), + releaseNotes: `English notes中文说明` + } as UpdateInfo + + const result = (appUpdater as any).processReleaseInfo(releaseInfo) + + expect(result.releaseNotes).toBe('中文说明') + }) + + it('should not process release notes without markers', () => { + const releaseInfo = { + version: '1.0.0', + files: [], + path: '', + sha512: '', + releaseDate: new Date().toISOString(), + releaseNotes: 'Simple release notes' + } as UpdateInfo + + const result = (appUpdater as any).processReleaseInfo(releaseInfo) + + expect(result.releaseNotes).toBe('Simple release notes') + }) + + it('should handle array format release notes', () => { + const releaseInfo = { + version: '1.0.0', + files: [], + path: '', + sha512: '', + releaseDate: new Date().toISOString(), + releaseNotes: [ + { version: '1.0.0', note: 'Note 1' }, + { version: '1.0.1', note: 'Note 2' } + ] + } as UpdateInfo + + const result = (appUpdater as any).processReleaseInfo(releaseInfo) + + expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes) + }) + + it('should handle null release notes', () => { + const releaseInfo = { + version: '1.0.0', + files: [], + path: '', + sha512: '', + releaseDate: new Date().toISOString(), + releaseNotes: null + } as UpdateInfo + + const result = (appUpdater as any).processReleaseInfo(releaseInfo) + + expect(result.releaseNotes).toBeNull() + }) + }) + + describe('formatReleaseNotes', () => { + it('should format string release notes with markers', () => { + vi.mocked(configManager.getLanguage).mockReturnValue('en-US') + const notes = `English中文` + + const result = (appUpdater as any).formatReleaseNotes(notes) + + expect(result).toBe('English') + }) + + it('should format string release notes without markers', () => { + const notes = 'Simple notes' + + const result = (appUpdater as any).formatReleaseNotes(notes) + + expect(result).toBe('Simple notes') + }) + + it('should format array release notes', () => { + const notes = [ + { version: '1.0.0', note: 'Note 1' }, + { version: '1.0.1', note: 'Note 2' } + ] + + const result = (appUpdater as any).formatReleaseNotes(notes) + + expect(result).toBe('Note 1\nNote 2') + }) + + it('should handle null release notes', () => { + const result = (appUpdater as any).formatReleaseNotes(null) + + expect(result).toBe('') + }) + + it('should handle undefined release notes', () => { + const result = (appUpdater as any).formatReleaseNotes(undefined) + + expect(result).toBe('') + }) + }) +}) diff --git a/src/main/services/knowledge/IKnowledgeFramework.ts b/src/main/services/knowledge/IKnowledgeFramework.ts deleted file mode 100644 index 2afbbec713..0000000000 --- a/src/main/services/knowledge/IKnowledgeFramework.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { LoaderReturn } from '@shared/config/types' -import { KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types' - -export interface KnowledgeBaseAddItemOptions { - base: KnowledgeBaseParams - item: KnowledgeItem - forceReload?: boolean - userId?: string -} - -export interface KnowledgeBaseAddItemOptionsNonNullableAttribute { - base: KnowledgeBaseParams - item: KnowledgeItem - forceReload: boolean - userId: string -} - -export interface EvaluateTaskWorkload { - workload: number -} - -export type LoaderDoneReturn = LoaderReturn | null - -export enum LoaderTaskItemState { - PENDING, - PROCESSING, - DONE -} - -export interface LoaderTaskItem { - state: LoaderTaskItemState - task: () => Promise - evaluateTaskWorkload: EvaluateTaskWorkload -} - -export interface LoaderTask { - loaderTasks: LoaderTaskItem[] - loaderDoneReturn: LoaderDoneReturn -} - -export interface LoaderTaskOfSet { - loaderTasks: Set - loaderDoneReturn: LoaderDoneReturn -} - -export interface QueueTaskItem { - taskPromise: () => Promise - resolve: () => void - evaluateTaskWorkload: EvaluateTaskWorkload -} - -export const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => { - return { - loaderTasks: new Set(loaderTask.loaderTasks), - loaderDoneReturn: loaderTask.loaderDoneReturn - } -} - -export interface IKnowledgeFramework { - /** 为给定知识库初始化框架资源 */ - initialize(base: KnowledgeBaseParams): Promise - /** 重置知识库,删除其所有内容 */ - reset(base: KnowledgeBaseParams): Promise - /** 删除与知识库关联的资源,包括文件 */ - delete(id: string): Promise - /** 生成用于添加条目的任务对象,由队列处理 */ - getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask - /** 从知识库中删除特定条目 */ - remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise - /** 搜索知识库 */ - search(options: { search: string; base: KnowledgeBaseParams }): Promise -} diff --git a/src/main/services/knowledge/KnowledgeFrameworkFactory.ts b/src/main/services/knowledge/KnowledgeFrameworkFactory.ts deleted file mode 100644 index cf26749564..0000000000 --- a/src/main/services/knowledge/KnowledgeFrameworkFactory.ts +++ /dev/null @@ -1,48 +0,0 @@ -import path from 'node:path' - -import { KnowledgeBaseParams } from '@types' -import { app } from 'electron' - -import { EmbedJsFramework } from './EmbedJsFramework' -import { IKnowledgeFramework } from './IKnowledgeFramework' -import { LangChainFramework } from './LangChainFramework' -class KnowledgeFrameworkFactory { - private static instance: KnowledgeFrameworkFactory - private frameworks: Map = new Map() - private storageDir: string - - private constructor(storageDir: string) { - this.storageDir = storageDir - } - - public static getInstance(storageDir: string): KnowledgeFrameworkFactory { - if (!KnowledgeFrameworkFactory.instance) { - KnowledgeFrameworkFactory.instance = new KnowledgeFrameworkFactory(storageDir) - } - return KnowledgeFrameworkFactory.instance - } - - public getFramework(base: KnowledgeBaseParams): IKnowledgeFramework { - const frameworkType = base.framework || 'embedjs' // 如果未指定,默认为 embedjs - if (this.frameworks.has(frameworkType)) { - return this.frameworks.get(frameworkType)! - } - let framework: IKnowledgeFramework - switch (frameworkType) { - case 'langchain': - framework = new LangChainFramework(this.storageDir) - break - case 'embedjs': - default: - framework = new EmbedJsFramework(this.storageDir) - break - } - - this.frameworks.set(frameworkType, framework) - return framework - } -} - -export const knowledgeFrameworkFactory = KnowledgeFrameworkFactory.getInstance( - path.join(app.getPath('userData'), 'Data', 'KnowledgeBase') -) diff --git a/src/main/services/knowledge/KnowledgeService.ts b/src/main/services/knowledge/KnowledgeService.ts deleted file mode 100644 index 199f597eed..0000000000 --- a/src/main/services/knowledge/KnowledgeService.ts +++ /dev/null @@ -1,190 +0,0 @@ -import * as fs from 'node:fs' -import path from 'node:path' - -import { loggerService } from '@logger' -import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService' -import Reranker from '@main/knowledge/reranker/Reranker' -import { TraceMethod } from '@mcp-trace/trace-core' -import { MB } from '@shared/config/constant' -import { LoaderReturn } from '@shared/config/types' -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' -import { app } from 'electron' - -import { - KnowledgeBaseAddItemOptions, - LoaderTask, - loaderTaskIntoOfSet, - LoaderTaskItemState, - LoaderTaskOfSet, - QueueTaskItem -} from './IKnowledgeFramework' -import { knowledgeFrameworkFactory } from './KnowledgeFrameworkFactory' - -const logger = loggerService.withContext('MainKnowledgeService') - -class KnowledgeService { - private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase') - - private workload = 0 - private processingItemCount = 0 - private knowledgeItemProcessingQueueMappingPromise: Map void> = new Map() - private static MAXIMUM_WORKLOAD = 80 * MB - private static MAXIMUM_PROCESSING_ITEM_COUNT = 30 - private static ERROR_LOADER_RETURN: LoaderReturn = { - entriesAdded: 0, - uniqueId: '', - uniqueIds: [''], - loaderType: '', - status: 'failed' - } - - constructor() { - this.initStorageDir() - } - - private initStorageDir = (): void => { - if (!fs.existsSync(this.storageDir)) { - fs.mkdirSync(this.storageDir, { recursive: true }) - } - } - - private maximumLoad() { - return ( - this.processingItemCount >= KnowledgeService.MAXIMUM_PROCESSING_ITEM_COUNT || - this.workload >= KnowledgeService.MAXIMUM_WORKLOAD - ) - } - - private processingQueueHandle() { - const getSubtasksUntilMaximumLoad = (): QueueTaskItem[] => { - const queueTaskList: QueueTaskItem[] = [] - that: for (const [task, resolve] of this.knowledgeItemProcessingQueueMappingPromise) { - for (const item of task.loaderTasks) { - if (this.maximumLoad()) { - break that - } - - const { state, task: taskPromise, evaluateTaskWorkload } = item - - if (state !== LoaderTaskItemState.PENDING) { - continue - } - - const { workload } = evaluateTaskWorkload - this.workload += workload - this.processingItemCount += 1 - item.state = LoaderTaskItemState.PROCESSING - queueTaskList.push({ - taskPromise: () => - taskPromise().then(() => { - this.workload -= workload - this.processingItemCount -= 1 - task.loaderTasks.delete(item) - if (task.loaderTasks.size === 0) { - this.knowledgeItemProcessingQueueMappingPromise.delete(task) - resolve() - } - this.processingQueueHandle() - }), - resolve: () => {}, - evaluateTaskWorkload - }) - } - } - return queueTaskList - } - const subTasks = getSubtasksUntilMaximumLoad() - if (subTasks.length > 0) { - const subTaskPromises = subTasks.map(({ taskPromise }) => taskPromise()) - Promise.all(subTaskPromises).then(() => { - subTasks.forEach(({ resolve }) => resolve()) - }) - } - } - - private appendProcessingQueue(task: LoaderTask): Promise { - return new Promise((resolve) => { - this.knowledgeItemProcessingQueueMappingPromise.set(loaderTaskIntoOfSet(task), () => { - resolve(task.loaderDoneReturn!) - }) - }) - } - - public async create(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise { - logger.info(`Creating knowledge base: ${JSON.stringify(base)}`) - const framework = knowledgeFrameworkFactory.getFramework(base) - await framework.initialize(base) - } - public async reset(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams): Promise { - const framework = knowledgeFrameworkFactory.getFramework(base) - await framework.reset(base) - } - - public async delete(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, id: string): Promise { - logger.info(`Deleting knowledge base: ${JSON.stringify(base)}`) - const framework = knowledgeFrameworkFactory.getFramework(base) - await framework.delete(id) - } - - public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise => { - logger.info(`Adding item to knowledge base: ${JSON.stringify(options)}`) - return new Promise((resolve) => { - const { base, item, forceReload = false, userId = '' } = options - const framework = knowledgeFrameworkFactory.getFramework(base) - - const task = framework.getLoaderTask({ base, item, forceReload, userId }) - - if (task) { - this.appendProcessingQueue(task).then(() => { - resolve(task.loaderDoneReturn!) - }) - this.processingQueueHandle() - } else { - resolve({ - ...KnowledgeService.ERROR_LOADER_RETURN, - message: 'Unsupported item type', - messageSource: 'embedding' - }) - } - }) - } - - public async remove( - _: Electron.IpcMainInvokeEvent, - { uniqueIds, base }: { uniqueIds: string[]; base: KnowledgeBaseParams } - ): Promise { - logger.info(`Removing items from knowledge base: ${JSON.stringify({ uniqueIds, base })}`) - const framework = knowledgeFrameworkFactory.getFramework(base) - await framework.remove({ uniqueIds, base }) - } - public async search( - _: Electron.IpcMainInvokeEvent, - { search, base }: { search: string; base: KnowledgeBaseParams } - ): Promise { - logger.info(`Searching knowledge base: ${JSON.stringify({ search, base })}`) - const framework = knowledgeFrameworkFactory.getFramework(base) - return framework.search({ search, base }) - } - - @TraceMethod({ spanName: 'rerank', tag: 'Knowledge' }) - public async rerank( - _: Electron.IpcMainInvokeEvent, - { search, base, results }: { search: string; base: KnowledgeBaseParams; results: KnowledgeSearchResult[] } - ): Promise { - logger.info(`Reranking knowledge base: ${JSON.stringify({ search, base, results })}`) - if (results.length === 0) { - return results - } - return await new Reranker(base).rerank(search, results) - } - - public getStorageDir = (): string => { - return this.storageDir - } - - public async checkQuota(_: Electron.IpcMainInvokeEvent, base: KnowledgeBaseParams, userId: string): Promise { - return preprocessingService.checkQuota(base, userId) - } -} - -export default new KnowledgeService() diff --git a/src/main/services/knowledge/LangChainFramework.ts b/src/main/services/knowledge/LangChainFramework.ts deleted file mode 100644 index a3c48e1e09..0000000000 --- a/src/main/services/knowledge/LangChainFramework.ts +++ /dev/null @@ -1,557 +0,0 @@ -import * as fs from 'node:fs' -import path from 'node:path' - -import { FaissStore } from '@langchain/community/vectorstores/faiss' -import type { Document } from '@langchain/core/documents' -import { loggerService } from '@logger' -import TextEmbeddings from '@main/knowledge/langchain/embeddings/TextEmbeddings' -import { - addFileLoader, - addNoteLoader, - addSitemapLoader, - addVideoLoader, - addWebLoader -} from '@main/knowledge/langchain/loader' -import { RetrieverFactory } from '@main/knowledge/langchain/retriever' -import { preprocessingService } from '@main/knowledge/preprocess/PreprocessingService' -import { getAllFiles } from '@main/utils/file' -import { getUrlSource } from '@main/utils/knowledge' -import { MB } from '@shared/config/constant' -import { LoaderReturn } from '@shared/config/types' -import { IpcChannel } from '@shared/IpcChannel' -import { - FileMetadata, - isKnowledgeDirectoryItem, - isKnowledgeFileItem, - isKnowledgeNoteItem, - isKnowledgeSitemapItem, - isKnowledgeUrlItem, - isKnowledgeVideoItem, - KnowledgeBaseParams, - KnowledgeSearchResult -} from '@types' -import { uuidv4 } from 'zod' - -import { windowService } from '../WindowService' -import { - IKnowledgeFramework, - KnowledgeBaseAddItemOptionsNonNullableAttribute, - LoaderDoneReturn, - LoaderTask, - LoaderTaskItem, - LoaderTaskItemState -} from './IKnowledgeFramework' - -const logger = loggerService.withContext('LangChainFramework') - -export class LangChainFramework implements IKnowledgeFramework { - private storageDir: string - - private static ERROR_LOADER_RETURN: LoaderReturn = { - entriesAdded: 0, - uniqueId: '', - uniqueIds: [''], - loaderType: '', - status: 'failed' - } - - constructor(storageDir: string) { - this.storageDir = storageDir - this.initStorageDir() - } - private initStorageDir = (): void => { - if (!fs.existsSync(this.storageDir)) { - fs.mkdirSync(this.storageDir, { recursive: true }) - } - } - - private async createDatabase(base: KnowledgeBaseParams): Promise { - const dbPath = path.join(this.storageDir, base.id) - const embeddings = this.getEmbeddings(base) - const vectorStore = new FaissStore(embeddings, {}) - - const mockDocument: Document = { - pageContent: 'Create Database Document', - metadata: {} - } - - await vectorStore.addDocuments([mockDocument], { ids: ['1'] }) - await vectorStore.save(dbPath) - await vectorStore.delete({ ids: ['1'] }) - await vectorStore.save(dbPath) - } - - private getEmbeddings(base: KnowledgeBaseParams): TextEmbeddings { - return new TextEmbeddings({ - embedApiClient: base.embedApiClient, - dimensions: base.dimensions - }) - } - - private async getVectorStore(base: KnowledgeBaseParams): Promise { - const embeddings = this.getEmbeddings(base) - const vectorStore = await FaissStore.load(path.join(this.storageDir, base.id), embeddings) - - return vectorStore - } - - async initialize(base: KnowledgeBaseParams): Promise { - await this.createDatabase(base) - } - async reset(base: KnowledgeBaseParams): Promise { - const dbPath = path.join(this.storageDir, base.id) - if (fs.existsSync(dbPath)) { - fs.rmSync(dbPath, { recursive: true }) - } - // 立即重建空索引,避免随后加载时报错 - await this.createDatabase(base) - } - - async delete(id: string): Promise { - const dbPath = path.join(this.storageDir, id) - if (fs.existsSync(dbPath)) { - fs.rmSync(dbPath, { recursive: true }) - } - } - getLoaderTask(options: KnowledgeBaseAddItemOptionsNonNullableAttribute): LoaderTask { - const { item } = options - const getStore = () => this.getVectorStore(options.base) - switch (item.type) { - case 'file': - return this.fileTask(getStore, options) - case 'directory': - return this.directoryTask(getStore, options) - case 'url': - return this.urlTask(getStore, options) - case 'sitemap': - return this.sitemapTask(getStore, options) - case 'note': - return this.noteTask(getStore, options) - case 'video': - return this.videoTask(getStore, options) - default: - return { - loaderTasks: [], - loaderDoneReturn: null - } - } - } - async remove(options: { uniqueIds: string[]; base: KnowledgeBaseParams }): Promise { - const { uniqueIds, base } = options - const vectorStore = await this.getVectorStore(base) - logger.info(`[ KnowledgeService Remove Item UniqueIds: ${uniqueIds}]`) - - await vectorStore.delete({ ids: uniqueIds }) - await vectorStore.save(path.join(this.storageDir, base.id)) - } - async search(options: { search: string; base: KnowledgeBaseParams }): Promise { - const { search, base } = options - logger.info(`search base: ${JSON.stringify(base)}`) - - try { - const vectorStore = await this.getVectorStore(base) - - // 如果是 bm25 或 hybrid 模式,则从数据库获取所有文档 - const documents: Document[] = await this.getAllDocuments(base) - if (documents.length === 0) return [] - - const retrieverFactory = new RetrieverFactory() - const retriever = retrieverFactory.createRetriever(base, vectorStore, documents) - - const results = await retriever.invoke(search) - logger.info(`Search Results: ${JSON.stringify(results)}`) - - // VectorStoreRetriever 和 EnsembleRetriever 会将分数附加到 metadata.score - // BM25Retriever 默认不返回分数,所以我们需要处理这种情况 - return results.map((item) => { - return { - pageContent: item.pageContent, - metadata: item.metadata, - // 如果 metadata 中没有 score,提供一个默认值 - score: typeof item.metadata.score === 'number' ? item.metadata.score : 0 - } - }) - } catch (error: any) { - logger.error(`Error during search in knowledge base ${base.id}: ${error.message}`) - return [] - } - } - - private fileTask( - getVectorStore: () => Promise, - options: KnowledgeBaseAddItemOptionsNonNullableAttribute - ): LoaderTask { - const { base, item, userId } = options - - if (!isKnowledgeFileItem(item)) { - logger.error(`Invalid item type for fileTask: expected 'file', got '${item.type}'`) - return { - loaderTasks: [], - loaderDoneReturn: { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Invalid item type: expected 'file', got '${item.type}'`, - messageSource: 'validation' - } - } - } - - const file = item.content - - const loaderTask: LoaderTask = { - loaderTasks: [ - { - state: LoaderTaskItemState.PENDING, - task: async () => { - try { - const vectorStore = await getVectorStore() - - // 添加预处理逻辑 - const fileToProcess: FileMetadata = await preprocessingService.preprocessFile(file, base, item, userId) - - // 使用处理后的文件进行加载 - return addFileLoader(base, vectorStore, fileToProcess) - .then((result) => { - loaderTask.loaderDoneReturn = result - return result - }) - .then(async () => { - await vectorStore.save(path.join(this.storageDir, base.id)) - }) - .catch((e) => { - logger.error(`Error in addFileLoader for ${file.name}: ${e}`) - const errorResult: LoaderReturn = { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: e.message, - messageSource: 'embedding' - } - loaderTask.loaderDoneReturn = errorResult - return errorResult - }) - } catch (e: any) { - logger.error(`Preprocessing failed for ${file.name}: ${e}`) - const errorResult: LoaderReturn = { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: e.message, - messageSource: 'preprocess' - } - loaderTask.loaderDoneReturn = errorResult - return errorResult - } - }, - evaluateTaskWorkload: { workload: file.size } - } - ], - loaderDoneReturn: null - } - - return loaderTask - } - private directoryTask( - getVectorStore: () => Promise, - options: KnowledgeBaseAddItemOptionsNonNullableAttribute - ): LoaderTask { - const { base, item } = options - - if (!isKnowledgeDirectoryItem(item)) { - logger.error(`Invalid item type for directoryTask: expected 'directory', got '${item.type}'`) - return { - loaderTasks: [], - loaderDoneReturn: { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Invalid item type: expected 'directory', got '${item.type}'`, - messageSource: 'validation' - } - } - } - - const directory = item.content - const files = getAllFiles(directory) - const totalFiles = files.length - let processedFiles = 0 - - const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => { - const mainWindow = windowService.getMainWindow() - mainWindow?.webContents.send(IpcChannel.DirectoryProcessingPercent, { - itemId: item.id, - percent: (processedFiles / totalFiles) * 100 - }) - } - - const loaderDoneReturn: LoaderDoneReturn = { - entriesAdded: 0, - uniqueId: `DirectoryLoader_${uuidv4()}`, - uniqueIds: [], - loaderType: 'DirectoryLoader' - } - const loaderTasks: LoaderTaskItem[] = [] - for (const file of files) { - loaderTasks.push({ - state: LoaderTaskItemState.PENDING, - task: async () => { - const vectorStore = await getVectorStore() - return addFileLoader(base, vectorStore, file) - .then((result) => { - loaderDoneReturn.entriesAdded += 1 - processedFiles += 1 - sendDirectoryProcessingPercent(totalFiles, processedFiles) - loaderDoneReturn.uniqueIds.push(result.uniqueId) - return result - }) - .then(async () => { - await vectorStore.save(path.join(this.storageDir, base.id)) - }) - .catch((err) => { - logger.error(err) - return { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Failed to add dir loader: ${err.message}`, - messageSource: 'embedding' - } - }) - }, - evaluateTaskWorkload: { workload: file.size } - }) - } - - return { - loaderTasks, - loaderDoneReturn - } - } - - private urlTask( - getVectorStore: () => Promise, - options: KnowledgeBaseAddItemOptionsNonNullableAttribute - ): LoaderTask { - const { base, item } = options - - if (!isKnowledgeUrlItem(item)) { - logger.error(`Invalid item type for urlTask: expected 'url', got '${item.type}'`) - return { - loaderTasks: [], - loaderDoneReturn: { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Invalid item type: expected 'url', got '${item.type}'`, - messageSource: 'validation' - } - } - } - - const url = item.content - - const loaderTask: LoaderTask = { - loaderTasks: [ - { - state: LoaderTaskItemState.PENDING, - task: async () => { - // 使用处理后的网页进行加载 - const vectorStore = await getVectorStore() - return addWebLoader(base, vectorStore, url, getUrlSource(url)) - .then((result) => { - loaderTask.loaderDoneReturn = result - return result - }) - .then(async () => { - await vectorStore.save(path.join(this.storageDir, base.id)) - }) - .catch((e) => { - logger.error(`Error in addWebLoader for ${url}: ${e}`) - const errorResult: LoaderReturn = { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: e.message, - messageSource: 'embedding' - } - loaderTask.loaderDoneReturn = errorResult - return errorResult - }) - }, - evaluateTaskWorkload: { workload: 2 * MB } - } - ], - loaderDoneReturn: null - } - return loaderTask - } - - private sitemapTask( - getVectorStore: () => Promise, - options: KnowledgeBaseAddItemOptionsNonNullableAttribute - ): LoaderTask { - const { base, item } = options - - if (!isKnowledgeSitemapItem(item)) { - logger.error(`Invalid item type for sitemapTask: expected 'sitemap', got '${item.type}'`) - return { - loaderTasks: [], - loaderDoneReturn: { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Invalid item type: expected 'sitemap', got '${item.type}'`, - messageSource: 'validation' - } - } - } - - const url = item.content - - const loaderTask: LoaderTask = { - loaderTasks: [ - { - state: LoaderTaskItemState.PENDING, - task: async () => { - // 使用处理后的网页进行加载 - const vectorStore = await getVectorStore() - return addSitemapLoader(base, vectorStore, url) - .then((result) => { - loaderTask.loaderDoneReturn = result - return result - }) - .then(async () => { - await vectorStore.save(path.join(this.storageDir, base.id)) - }) - .catch((e) => { - logger.error(`Error in addWebLoader for ${url}: ${e}`) - const errorResult: LoaderReturn = { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: e.message, - messageSource: 'embedding' - } - loaderTask.loaderDoneReturn = errorResult - return errorResult - }) - }, - evaluateTaskWorkload: { workload: 2 * MB } - } - ], - loaderDoneReturn: null - } - return loaderTask - } - - private noteTask( - getVectorStore: () => Promise, - options: KnowledgeBaseAddItemOptionsNonNullableAttribute - ): LoaderTask { - const { base, item } = options - - if (!isKnowledgeNoteItem(item)) { - logger.error(`Invalid item type for noteTask: expected 'note', got '${item.type}'`) - return { - loaderTasks: [], - loaderDoneReturn: { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Invalid item type: expected 'note', got '${item.type}'`, - messageSource: 'validation' - } - } - } - - const content = item.content - const sourceUrl = item.sourceUrl ?? '' - - logger.info(`noteTask ${content}, ${sourceUrl}`) - - const encoder = new TextEncoder() - const contentBytes = encoder.encode(content) - const loaderTask: LoaderTask = { - loaderTasks: [ - { - state: LoaderTaskItemState.PENDING, - task: async () => { - // 使用处理后的笔记进行加载 - const vectorStore = await getVectorStore() - return addNoteLoader(base, vectorStore, content, sourceUrl) - .then((result) => { - loaderTask.loaderDoneReturn = result - return result - }) - .then(async () => { - await vectorStore.save(path.join(this.storageDir, base.id)) - }) - .catch((e) => { - logger.error(`Error in addNoteLoader for ${sourceUrl}: ${e}`) - const errorResult: LoaderReturn = { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: e.message, - messageSource: 'embedding' - } - loaderTask.loaderDoneReturn = errorResult - return errorResult - }) - }, - evaluateTaskWorkload: { workload: contentBytes.length } - } - ], - loaderDoneReturn: null - } - return loaderTask - } - - private videoTask( - getVectorStore: () => Promise, - options: KnowledgeBaseAddItemOptionsNonNullableAttribute - ): LoaderTask { - const { base, item } = options - - if (!isKnowledgeVideoItem(item)) { - logger.error(`Invalid item type for videoTask: expected 'video', got '${item.type}'`) - return { - loaderTasks: [], - loaderDoneReturn: { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: `Invalid item type: expected 'video', got '${item.type}'`, - messageSource: 'validation' - } - } - } - - const files = item.content - - const loaderTask: LoaderTask = { - loaderTasks: [ - { - state: LoaderTaskItemState.PENDING, - task: async () => { - const vectorStore = await getVectorStore() - return addVideoLoader(base, vectorStore, files) - .then((result) => { - loaderTask.loaderDoneReturn = result - return result - }) - .then(async () => { - await vectorStore.save(path.join(this.storageDir, base.id)) - }) - .catch((e) => { - logger.error(`Preprocessing failed for ${files[0].name}: ${e}`) - const errorResult: LoaderReturn = { - ...LangChainFramework.ERROR_LOADER_RETURN, - message: e.message, - messageSource: 'preprocess' - } - loaderTask.loaderDoneReturn = errorResult - return errorResult - }) - }, - evaluateTaskWorkload: { workload: files[0].size } - } - ], - loaderDoneReturn: null - } - return loaderTask - } - - private async getAllDocuments(base: KnowledgeBaseParams): Promise { - logger.info(`Fetching all documents from database for knowledge base: ${base.id}`) - - try { - const results = (await this.getVectorStore(base)).docstore._docs - - const documents: Document[] = Array.from(results.values()) - logger.info(`Fetched ${documents.length} documents for BM25/Hybrid retriever.`) - return documents - } catch (e) { - logger.error(`Could not fetch documents from database for base ${base.id}: ${e}`) - // 如果表不存在或查询失败,返回空数组 - return [] - } - } -} diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts index 34a8bb8ce9..b496df398e 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,13 +1,7 @@ import { isLinux, isWin } from '@main/constant' import { loadOcrImage } from '@main/utils/ocr' import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' -import { - ImageFileMetadata, - isImageFileMetadata as isImageFileMetadata, - OcrResult, - OcrSystemConfig, - SupportedOcrFile -} from '@types' +import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types' import { OcrBaseService } from './OcrBaseService' diff --git a/src/main/services/remotefile/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts index f456ba285d..14f622757a 100644 --- a/src/main/services/remotefile/FileServiceManager.ts +++ b/src/main/services/remotefile/FileServiceManager.ts @@ -9,7 +9,7 @@ export class FileServiceManager { private static instance: FileServiceManager private services: Map = new Map() - // eslint-disable-next-line @typescript-eslint/no-empty-function + // oxlint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} static getInstance(): FileServiceManager { diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index f9363a500e..20305d1c9e 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import * as fs from 'node:fs' import { readFile } from 'node:fs/promises' import os from 'node:os' @@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr if (entry.isDirectory() && options.includeDirectories) { const stats = await fs.promises.stat(entryPath) + const externalDirPath = entryPath.replace(/\\/g, '/') const dirTreeNode: NotesTreeNode = { - id: uuidv4(), + id: createHash('sha1').update(externalDirPath).digest('hex'), name: entry.name, treePath: treePath, - externalPath: entryPath, + externalPath: externalDirPath, createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), type: 'folder', @@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr ? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}` : `/${nameWithoutExt}` + const externalFilePath = entryPath.replace(/\\/g, '/') const fileTreeNode: NotesTreeNode = { - id: uuidv4(), + id: createHash('sha1').update(externalFilePath).digest('hex'), name: name, treePath: fileTreePath, - externalPath: entryPath, + externalPath: externalFilePath, createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), type: 'file' @@ -420,7 +423,7 @@ export function sanitizeFilename(fileName: string, replacement = '_'): string { // 移除或替换非法字符 let sanitized = fileName - // eslint-disable-next-line no-control-regex + // oxlint-disable-next-line no-control-regex .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符 .replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名 .replace(/[\s.]+$/, '') // 移除末尾的空格和点 diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index 4fea14c9fe..6b2ac42788 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -36,13 +36,14 @@ export function debounce(func: (...args: any[]) => void, wait: number, immediate } } -export function dumpPersistState() { - const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}') - for (const key in persistState) { - persistState[key] = JSON.parse(persistState[key]) - } - return JSON.stringify(persistState) -} +// NOTE: It's an unused function. localStorage should not be accessed in main process. +// export function dumpPersistState() { +// const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}') +// for (const key in persistState) { +// persistState[key] = JSON.parse(persistState[key]) +// } +// return JSON.stringify(persistState) +// } export const runAsyncFunction = async (fn: () => void) => { await fn() diff --git a/src/preload/index.ts b/src/preload/index.ts index 257456308b..f9b7bab5da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,10 +1,11 @@ import { electronAPI } from '@electron-toolkit/preload' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { SpanContext } from '@opentelemetry/api' -import { UpgradeChannel } from '@shared/config/constant' +import { TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' +import type { Notification } from '@types' import { AddMemoryOptions, AssistantMessage, @@ -28,7 +29,6 @@ import { WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' -import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' import type { ActionItem } from '../renderer/src/types/selectionTypes' @@ -47,6 +47,7 @@ const api = { getDiskInfo: (directoryPath: string): Promise<{ free: number; size: number } | null> => ipcRenderer.invoke(IpcChannel.App_GetDiskInfo, directoryPath), reload: () => ipcRenderer.invoke(IpcChannel.App_Reload), + quit: () => ipcRenderer.invoke(IpcChannel.App_Quit), setProxy: (proxy: string | undefined, bypassRules?: string) => ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules), checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), @@ -84,6 +85,7 @@ const api = { ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data), setFullScreen: (value: boolean): Promise => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value), isFullScreen: (): Promise => ipcRenderer.invoke(IpcChannel.App_IsFullScreen), + getSystemFonts: (): Promise => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts), mac: { isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted), requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust) @@ -439,16 +441,24 @@ const api = { model: string, directory: string, env: Record, - options?: { autoUpdateToLatest?: boolean } - ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options) + options?: { autoUpdateToLatest?: boolean; terminal?: string } + ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options), + getAvailableTerminals: (): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_GetAvailableTerminals), + setCustomTerminalPath: (terminalId: string, path: string): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_SetCustomTerminalPath, terminalId, path), + getCustomTerminalPath: (terminalId: string): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_GetCustomTerminalPath, terminalId), + removeCustomTerminalPath: (terminalId: string): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_RemoveCustomTerminalPath, terminalId) }, ocr: { ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider) }, - cherryin: { + cherryai: { generateSignature: (params: { method: string; path: string; query: string; body: Record }) => - ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params) + ipcRenderer.invoke(IpcChannel.Cherryai_GetSignature, params) }, windowControls: { minimize: (): Promise => ipcRenderer.invoke(IpcChannel.Windows_Minimize), @@ -475,13 +485,10 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) } catch (error) { - // eslint-disable-next-line no-restricted-syntax console.error('[Preload]Failed to expose APIs:', error as Error) } } else { - // @ts-ignore (define in dts) window.electron = electronAPI - // @ts-ignore (define in dts) window.api = api } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 019c6324d9..78396c49e7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,5 @@ import '@renderer/databases' -import { HeroUIProvider } from '@heroui/react' import { loggerService } from '@logger' import store, { persistor } from '@renderer/store' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -11,6 +10,7 @@ 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,7 +34,7 @@ function App(): React.ReactElement { return ( - + diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index a28000d9dd..fb68cedb23 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -4,24 +4,15 @@ */ import { loggerService } from '@logger' -import { MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' +import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' import { Chunk, ChunkType } from '@renderer/types/chunk' +import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import type { TextStreamPart, ToolSet } from 'ai' import { ToolCallChunkHandler } from './handleToolCallChunk' const logger = loggerService.withContext('AiSdkToChunkAdapter') -export interface CherryStudioChunk { - type: 'text-delta' | 'text-complete' | 'tool-call' | 'tool-result' | 'finish' | 'error' - text?: string - toolCall?: any - toolResult?: any - finishReason?: string - usage?: any - error?: any -} - /** * AI SDK 到 Cherry Studio Chunk 适配器类 * 处理 fullStream 到 Cherry Studio chunk 的转换 @@ -29,13 +20,18 @@ export interface CherryStudioChunk { export class AiSdkToChunkAdapter { toolCallHandler: ToolCallChunkHandler private accumulate: boolean | undefined + private isFirstChunk = true + private enableWebSearch: boolean = false + constructor( private onChunk: (chunk: Chunk) => void, mcpTools: MCPTool[] = [], - accumulate?: boolean + accumulate?: boolean, + enableWebSearch?: boolean ) { this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools) this.accumulate = accumulate + this.enableWebSearch = enableWebSearch || false } /** @@ -65,11 +61,24 @@ export class AiSdkToChunkAdapter { webSearchResults: [], reasoningId: '' } + // Reset link converter state at the start of stream + this.isFirstChunk = true + try { while (true) { const { done, value } = await reader.read() if (done) { + // Flush any remaining content from link converter buffer if web search is enabled + if (this.enableWebSearch) { + const remainingText = flushLinkConverterBuffer() + if (remainingText) { + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: remainingText + }) + } + } break } @@ -87,7 +96,7 @@ export class AiSdkToChunkAdapter { */ private convertAndEmitChunk( chunk: TextStreamPart, - final: { text: string; reasoningContent: string; webSearchResults: any[]; reasoningId: string } + final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string } ) { logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk) switch (chunk.type) { @@ -97,17 +106,44 @@ export class AiSdkToChunkAdapter { type: ChunkType.TEXT_START }) break - case 'text-delta': - if (this.accumulate) { - final.text += chunk.text || '' + case 'text-delta': { + const processedText = chunk.text || '' + let finalText: string + + // Only apply link conversion if web search is enabled + if (this.enableWebSearch) { + const result = convertLinks(processedText, this.isFirstChunk) + + if (this.isFirstChunk) { + this.isFirstChunk = false + } + + // Handle buffered content + if (result.hasBufferedContent) { + finalText = result.text + } else { + finalText = result.text || processedText + } } else { - final.text = chunk.text || '' + // Without web search, just use the original text + finalText = processedText + } + + if (this.accumulate) { + final.text += finalText + } else { + final.text = finalText + } + + // Only emit chunk if there's text to send + if (finalText) { + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: this.accumulate ? final.text : finalText + }) } - this.onChunk({ - type: ChunkType.TEXT_DELTA, - text: final.text || '' - }) break + } case 'text-end': this.onChunk({ type: ChunkType.TEXT_COMPLETE, @@ -127,15 +163,13 @@ export class AiSdkToChunkAdapter { final.reasoningContent += chunk.text || '' this.onChunk({ type: ChunkType.THINKING_DELTA, - text: final.reasoningContent || '', - thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0 + text: final.reasoningContent || '' }) break case 'reasoning-end': this.onChunk({ type: ChunkType.THINKING_COMPLETE, - text: (chunk.providerMetadata?.metadata?.thinking_content as string) || '', - thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0 + text: final.reasoningContent || '' }) final.reasoningContent = '' break @@ -200,7 +234,7 @@ export class AiSdkToChunkAdapter { [WebSearchSource.ANTHROPIC]: WebSearchSource.ANTHROPIC, [WebSearchSource.OPENROUTER]: WebSearchSource.OPENROUTER, [WebSearchSource.GEMINI]: WebSearchSource.GEMINI, - [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY, + // [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY, [WebSearchSource.QWEN]: WebSearchSource.QWEN, [WebSearchSource.HUNYUAN]: WebSearchSource.HUNYUAN, [WebSearchSource.ZHIPU]: WebSearchSource.ZHIPU, @@ -268,18 +302,9 @@ export class AiSdkToChunkAdapter { // === 源和文件相关事件 === case 'source': if (chunk.sourceType === 'url') { - // if (final.webSearchResults.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { sourceType: _, ...rest } = chunk final.webSearchResults.push(rest) - // } - // this.onChunk({ - // type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - // llm_web_search: { - // source: WebSearchSource.AISDK, - // results: final.webSearchResults - // } - // }) } break case 'file': diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts index 57cd974a2c..39aefc6a5f 100644 --- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts +++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts @@ -298,8 +298,29 @@ export class ToolCallChunkHandler { type: ChunkType.MCP_TOOL_COMPLETE, responses: [toolResponse] }) + + const images: string[] = [] + for (const content of toolResponse.response?.content || []) { + if (content.type === 'image' && content.data) { + images.push(`data:${content.mimeType};base64,${content.data}`) + } + } + + if (images.length) { + this.onChunk({ + type: ChunkType.IMAGE_CREATED + }) + this.onChunk({ + type: ChunkType.IMAGE_COMPLETE, + image: { + type: 'base64', + images: images + } + }) + } } } + handleToolError( chunk: { type: 'tool-error' diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 8da8e8c7c1..0225d15d26 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -284,7 +284,7 @@ export default class ModernAiProvider { // 创建带有中间件的执行器 if (config.onChunk) { const accumulate = this.model!.supported_text_delta !== false // true and undefined - const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate) + const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate, config.enableWebSearch) const streamResult = await executor.streamText({ ...params, diff --git a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts index 31a911533e..f7b4a80f46 100644 --- a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts @@ -1,11 +1,12 @@ import { loggerService } from '@logger' +import { isNewApiProvider } from '@renderer/config/providers' import { Provider } from '@renderer/types' import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient' import { BaseApiClient } from './BaseApiClient' -import { CherryinAPIClient } from './cherryin/CherryinAPIClient' +import { CherryAiAPIClient } from './cherryai/CherryAiAPIClient' import { GeminiAPIClient } from './gemini/GeminiAPIClient' import { VertexAPIClient } from './gemini/VertexAPIClient' import { NewAPIClient } from './newapi/NewAPIClient' @@ -34,8 +35,8 @@ export class ApiClientFactory { let instance: BaseApiClient // 首先检查特殊的 Provider ID - if (provider.id === 'cherryin') { - instance = new CherryinAPIClient(provider) as BaseApiClient + if (provider.id === 'cherryai') { + instance = new CherryAiAPIClient(provider) as BaseApiClient return instance } @@ -45,7 +46,7 @@ export class ApiClientFactory { return instance } - if (provider.id === 'new-api') { + if (isNewApiProvider(provider)) { logger.debug(`Creating NewAPIClient for provider: ${provider.id}`) instance = new NewAPIClient(provider) as BaseApiClient return instance diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 430b9e4df7..9fb687a265 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -84,7 +84,7 @@ export abstract class BaseApiClient< * 用于判断客户端是否支持特定功能,避免instanceof检查的类型收窄问题 * 对于装饰器模式的客户端(如AihubmixAPIClient),应该返回其内部实际使用的客户端类型 */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars public getClientCompatibilityType(_model?: Model): string[] { // 默认返回类的名称 return [this.constructor.name] 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 081469516b..550486afb2 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts @@ -67,7 +67,9 @@ vi.mock('@renderer/config/models', () => ({ silicon: [], defaultModel: [] }, - isOpenAIModel: vi.fn(() => false) + isOpenAIModel: vi.fn(() => false), + glm45FlashModel: {}, + qwen38bModel: {} })) describe('ApiClientFactory', () => { 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 343bc4d544..dd85730c36 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 @@ -35,12 +35,8 @@ vi.mock('@renderer/config/models', () => ({ findTokenLimit: vi.fn().mockReturnValue(4096), isFunctionCallingModel: vi.fn().mockReturnValue(false), DEFAULT_MAX_TOKENS: 4096, - glm45FlashModel: { - id: 'glm-4.5-flash', - name: 'GLM-4.5-Flash', - provider: 'cherryin', - group: 'GLM-4.5' - } + qwen38bModel: {}, + glm45FlashModel: {} })) vi.mock('@renderer/services/AssistantService', () => ({ diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts index dea98f2808..aef3e07905 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts @@ -177,7 +177,7 @@ export class AnthropicAPIClient extends BaseApiClient< } // @ts-ignore sdk未提供 - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars override async generateImage(generateImageParams: GenerateImageParams): Promise { return [] } diff --git a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts index 9bcadd0c55..1de8a724c4 100644 --- a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts @@ -455,7 +455,7 @@ export class AwsBedrockAPIClient extends BaseApiClient< } // @ts-ignore sdk未提供 - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars override async generateImage(_generateImageParams: GenerateImageParams): Promise { return [] } diff --git a/src/renderer/src/aiCore/legacy/clients/cherryin/CherryinAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts similarity index 86% rename from src/renderer/src/aiCore/legacy/clients/cherryin/CherryinAPIClient.ts rename to src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts index bf3ed7d718..8f8969bd59 100644 --- a/src/renderer/src/aiCore/legacy/clients/cherryin/CherryinAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts @@ -4,7 +4,7 @@ import OpenAI from 'openai' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' -export class CherryinAPIClient extends OpenAIAPIClient { +export class CherryAiAPIClient extends OpenAIAPIClient { constructor(provider: Provider) { super(provider) } @@ -17,7 +17,7 @@ export class CherryinAPIClient extends OpenAIAPIClient { options = options || {} options.headers = options.headers || {} - const signature = await window.api.cherryin.generateSignature({ + const signature = await window.api.cherryai.generateSignature({ method: 'POST', path: '/chat/completions', query: '', @@ -34,7 +34,7 @@ export class CherryinAPIClient extends OpenAIAPIClient { } override getClientCompatibilityType(): string[] { - return ['CherryinAPIClient'] + return ['CherryAiAPIClient'] } public async listModels(): Promise { @@ -43,7 +43,7 @@ export class CherryinAPIClient extends OpenAIAPIClient { const created = Date.now() return models.map((id) => ({ id, - owned_by: 'cherryin', + owned_by: 'cherryai', object: 'model' as const, created })) diff --git a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts index 684d550698..fd282ac815 100644 --- a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts @@ -11,7 +11,7 @@ export class PPIOAPIClient extends OpenAIAPIClient { super(provider) } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars override getClientCompatibilityType(_model?: Model): string[] { return ['OpenAIAPIClient'] } diff --git a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts index d80c9d2f83..dde98cbd1e 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import { isZhipuModel } from '@renderer/config/models' -import store from '@renderer/store' +import { getStoreProviders } from '@renderer/hooks/useStore' import { Chunk } from '@renderer/types/chunk' import { CompletionsParams, CompletionsResult } from '../schemas' @@ -87,7 +87,7 @@ function handleError(error: any, params: CompletionsParams): any { * 2. 绘画功能(enableGenerateImage为true)使用通用错误处理 */ function handleZhipuError(error: any): any { - const provider = store.getState().llm.providers.find((p) => p.id === 'zhipu') + const provider = getStoreProviders().find((p) => p.id === 'zhipu') const logger = loggerService.withContext('handleZhipuError') // 定义错误模式映射 diff --git a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts index dd79ef457d..acd371d777 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts @@ -44,7 +44,7 @@ const stringifyArgsForLogging = (args: any[]): string => { */ export const createGenericLoggingMiddleware: () => MethodMiddleware = () => { const middlewareName = 'GenericLoggingMiddleware' - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars return (_: MiddlewareAPI) => (next) => async (ctx, args) => { const methodName = ctx.methodName const logPrefix = `[${middlewareName} (${methodName})]` diff --git a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts index 4c72e877a9..ae346af836 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' -import { flushLinkConverterBuffer, smartLinkConverter } from '@renderer/utils/linkConverter' +import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsContext, CompletionsMiddleware } from '../types' @@ -28,8 +28,6 @@ export const WebSearchMiddleware: CompletionsMiddleware = } // 调用下游中间件 const result = await next(ctx, params) - - const model = params.assistant?.model! let isFirstChunk = true // 响应后处理:记录Web搜索事件 @@ -42,15 +40,9 @@ export const WebSearchMiddleware: CompletionsMiddleware = new TransformStream({ transform(chunk: GenericChunk, controller) { if (chunk.type === ChunkType.TEXT_DELTA) { - const providerType = model.provider || 'openai' // 使用当前可用的Web搜索结果进行链接转换 const text = chunk.text - const result = smartLinkConverter( - text, - providerType, - isFirstChunk, - ctx._internal.webSearchState!.results - ) + const result = convertLinks(text, isFirstChunk) if (isFirstChunk) { isFirstChunk = false } diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 04b5efd1b5..20b89cf2e5 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,3 +1,4 @@ +import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import type { MCPTool, Message, Model, Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' @@ -20,11 +21,14 @@ export interface AiSdkMiddlewareConfig { isSupportedToolUse: boolean // image generation endpoint isImageGenerationEndpoint: boolean + // 是否开启内置搜索 enableWebSearch: boolean enableGenerateImage: boolean enableUrlContext: boolean mcpTools?: MCPTool[] uiMessages?: Message[] + // 内置搜索配置 + webSearchPluginConfig?: WebSearchPluginConfig } /** @@ -136,7 +140,19 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo return builder.build() } -const tagNameArray = ['think', 'thought'] +const tagName = { + reasoning: 'reasoning', + think: 'think', + thought: 'thought', + seedThink: 'seed:think' +} + +function getReasoningTagName(modelId: string | undefined): string { + if (modelId?.includes('gpt-oss')) return tagName.reasoning + if (modelId?.includes('gemini')) return tagName.thought + if (modelId?.includes('seed-oss-36b')) return tagName.seedThink + return tagName.think +} /** * 添加provider特定的中间件 @@ -152,7 +168,7 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: case 'openai': case 'azure-openai': { if (config.enableReasoning) { - const tagName = config.model?.id.includes('gemini') ? tagNameArray[1] : tagNameArray[0] + const tagName = getReasoningTagName(config.model?.id.toLowerCase()) builder.add({ name: 'thinking-tag-extraction', middleware: extractReasoningMiddleware({ tagName }) @@ -163,6 +179,9 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: case 'gemini': // Gemini特定中间件 break + case 'aws-bedrock': { + break + } default: // 其他provider的通用处理 break diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index d94ed4aab9..7767564bd9 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' import { Assistant } from '@renderer/types' import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder' -import reasoningTimePlugin from './reasoningTimePlugin' import { searchOrchestrationPlugin } from './searchOrchestrationPlugin' import { createTelemetryPlugin } from './telemetryPlugin' @@ -30,9 +29,8 @@ export function buildPlugins( } // 1. 模型内置搜索 - if (middlewareConfig.enableWebSearch) { - // 内置了默认搜索参数,如果改的话可以传config进去 - plugins.push(webSearchPlugin()) + if (middlewareConfig.enableWebSearch && middlewareConfig.webSearchPluginConfig) { + plugins.push(webSearchPlugin(middlewareConfig.webSearchPluginConfig)) } // 2. 支持工具调用时添加搜索插件 if (middlewareConfig.isSupportedToolUse || middlewareConfig.isPromptToolUse) { @@ -40,9 +38,9 @@ export function buildPlugins( } // 3. 推理模型时添加推理插件 - if (middlewareConfig.enableReasoning) { - plugins.push(reasoningTimePlugin) - } + // if (middlewareConfig.enableReasoning) { + // plugins.push(reasoningTimePlugin) + // } // 4. 启用Prompt工具调用时添加工具插件 if (middlewareConfig.isPromptToolUse) { diff --git a/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts b/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts index 1fe0a177c3..b76d9ea342 100644 --- a/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts +++ b/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts @@ -7,18 +7,14 @@ export default definePlugin({ transformStream: () => () => { // === 时间跟踪状态 === let thinkingStartTime = 0 - let hasStartedThinking = false let accumulatedThinkingContent = '' - let reasoningBlockId = '' return new TransformStream, TextStreamPart>({ transform(chunk: TextStreamPart, controller: TransformStreamDefaultController>) { // === 处理 reasoning 类型 === if (chunk.type === 'reasoning-start') { controller.enqueue(chunk) - hasStartedThinking = true thinkingStartTime = performance.now() - reasoningBlockId = chunk.id } else if (chunk.type === 'reasoning-delta') { accumulatedThinkingContent += chunk.text controller.enqueue({ @@ -32,21 +28,6 @@ export default definePlugin({ } } }) - } else if (chunk.type === 'reasoning-end' && hasStartedThinking) { - controller.enqueue({ - type: 'reasoning-end', - id: reasoningBlockId, - providerMetadata: { - metadata: { - thinking_millsec: performance.now() - thinkingStartTime, - thinking_content: accumulatedThinkingContent - } - } - }) - accumulatedThinkingContent = '' - hasStartedThinking = false - thinkingStartTime = 0 - reasoningBlockId = '' } else { controller.enqueue(chunk) } diff --git a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts index 6eb66575c5..75bf6e116c 100644 --- a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts +++ b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts @@ -66,6 +66,7 @@ class AdapterTracer { spanName: name, topicId: this.topicId, modelName: this.modelName, + // oxlint-disable-next-line no-undef False alarm. see https://github.com/oxc-project/oxc/issues/4232 argCount: arguments.length }) diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index ff51b07973..1ad04230b5 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -5,6 +5,8 @@ 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 { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas' import { loggerService } from '@logger' import { isGenerateImageModel, @@ -16,19 +18,25 @@ import { isWebSearchModel } from '@renderer/config/models' import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' +import store from '@renderer/store' +import { CherryWebSearchConfig } from '@renderer/store/websearch' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' -import type { ModelMessage } from 'ai' +import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' +import type { ModelMessage, Tool } from 'ai' import { stepCountIs } from 'ai' import { getAiSdkProviderId } from '../provider/factory' import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' import { getAnthropicThinkingBudget } from '../utils/reasoning' +import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' import { getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') +type ProviderDefinedTool = Extract, { type: 'provider-defined' }> + /** * 构建 AI SDK 流式参数 * 这是主要的参数构建函数,整合所有转换逻辑 @@ -40,6 +48,7 @@ export async function buildStreamTextParams( options: { mcpTools?: MCPTool[] webSearchProviderId?: string + webSearchConfig?: CherryWebSearchConfig requestOptions?: { signal?: AbortSignal timeout?: number @@ -55,6 +64,7 @@ export async function buildStreamTextParams( enableGenerateImage: boolean enableUrlContext: boolean } + webSearchPluginConfig?: WebSearchPluginConfig }> { const { mcpTools } = options @@ -91,6 +101,12 @@ export async function buildStreamTextParams( // } // 构建真正的 providerOptions + const webSearchConfig: CherryWebSearchConfig = { + maxResults: store.getState().websearch.maxResults, + excludeDomains: store.getState().websearch.excludeDomains, + searchWithTime: store.getState().websearch.searchWithTime + } + const providerOptions = buildProviderOptions(assistant, model, provider, { enableReasoning, enableWebSearch, @@ -107,15 +123,22 @@ export async function buildStreamTextParams( maxTokens -= getAnthropicThinkingBudget(assistant, model) } - // google-vertex | google-vertex-anthropic + let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined if (enableWebSearch) { + if (isBaseProvider(aiSdkProviderId)) { + webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig) + } if (!tools) { tools = {} } if (aiSdkProviderId === 'google-vertex') { - tools.google_search = vertex.tools.googleSearch({}) + tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool } else if (aiSdkProviderId === 'google-vertex-anthropic') { - tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) + const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains) + tools.web_search = vertexAnthropic.tools.webSearch_20250305({ + maxUses: webSearchConfig.maxResults, + blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined + }) as ProviderDefinedTool } } @@ -124,7 +147,7 @@ export async function buildStreamTextParams( if (!tools) { tools = {} } - tools.url_context = vertex.tools.urlContext({}) + tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool } // 构建基础参数 @@ -149,7 +172,8 @@ export async function buildStreamTextParams( return { params, modelId: model.id, - capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext } + capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext }, + webSearchPluginConfig } } diff --git a/src/renderer/src/aiCore/provider/factory.ts b/src/renderer/src/aiCore/provider/factory.ts index 617758753e..bfcd3da383 100644 --- a/src/renderer/src/aiCore/provider/factory.ts +++ b/src/renderer/src/aiCore/provider/factory.ts @@ -69,6 +69,9 @@ export function getAiSdkProviderId(provider: Provider): ProviderId | 'openai-com return resolvedFromType } } + if (provider.apiHost.includes('api.openai.com')) { + return 'openai-chat' + } // 3. 最后的fallback(通常会成为openai-compatible) return provider.id as ProviderId } diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index ddbb178f3f..4c5c181093 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -6,6 +6,7 @@ import { type ProviderSettingsMap } from '@cherrystudio/ai-core/provider' import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' +import { isNewApiProvider } from '@renderer/config/providers' import { getAwsBedrockAccessKeyId, getAwsBedrockRegion, @@ -15,9 +16,9 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV import { getProviderByModel } from '@renderer/services/AssistantService' import { loggerService } from '@renderer/services/LoggerService' import store from '@renderer/store' -import type { Model, Provider } from '@renderer/types' +import { isSystemProvider, type Model, type Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' -import { cloneDeep, isEmpty } from 'lodash' +import { cloneDeep, trim } from 'lodash' import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config' import { getAiSdkProviderId } from './factory' @@ -61,14 +62,16 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { // return createVertexProvider(provider) // } - if (provider.id === 'aihubmix') { - return aihubmixProviderCreator(model, provider) - } - if (provider.id === 'newapi') { - return newApiResolverCreator(model, provider) - } - if (provider.id === 'vertexai') { - return vertexAnthropicProviderCreator(model, provider) + if (isSystemProvider(provider)) { + if (provider.id === 'aihubmix') { + return aihubmixProviderCreator(model, provider) + } + if (isNewApiProvider(provider)) { + return newApiResolverCreator(model, provider) + } + if (provider.id === 'vertexai') { + return vertexAnthropicProviderCreator(model, provider) + } } return provider } @@ -117,7 +120,7 @@ export function providerToAiSdkConfig( // 构建基础配置 const baseConfig = { - baseURL: actualProvider.apiHost, + baseURL: trim(actualProvider.apiHost), apiKey: getRotatedApiKey(actualProvider) } // 处理OpenAI模式 @@ -192,7 +195,10 @@ export function providerToAiSdkConfig( } else if (baseConfig.baseURL.endsWith('/v1')) { baseConfig.baseURL = baseConfig.baseURL.slice(0, -3) } - baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL + + if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) { + baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google` + } } // 如果AI SDK支持该provider,使用原生配置 @@ -211,7 +217,8 @@ export function providerToAiSdkConfig( options: { ...options, name: actualProvider.id, - ...extraOptions + ...extraOptions, + includeUsage: true } } } @@ -247,10 +254,10 @@ export async function prepareSpecialProviderConfig( config.options.apiKey = token break } - case 'cherryin': { + case 'cherryai': { config.options.fetch = async (url, options) => { // 在这里对最终参数进行签名 - const signature = await window.api.cherryin.generateSignature({ + const signature = await window.api.cherryai.generateSignature({ method: 'POST', path: '/chat/completions', query: '', diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index cf3366d70a..3c188313b9 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -39,6 +39,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [ creatorFunctionName: 'createAmazonBedrock', supportsImageGeneration: true, aliases: ['aws-bedrock'] + }, + { + id: 'perplexity', + name: 'Perplexity', + import: () => import('@ai-sdk/perplexity'), + creatorFunctionName: 'createPerplexity', + supportsImageGeneration: false, + aliases: ['perplexity'] } ] as const diff --git a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts index 7764fd81e7..314eb9ba01 100644 --- a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts +++ b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts @@ -18,12 +18,13 @@ export const knowledgeSearchTool = ( ) => { return tool({ name: 'builtin_knowledge_search', - description: `Search the knowledge base for relevant information using pre-analyzed search intent. + description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored. -Pre-extracted search queries: "${extractedKeywords.question.join(', ')}" -Rewritten query: "${extractedKeywords.rewrite}" +This tool has been configured with search parameters based on the conversation context: +- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')} +- Query rewrite: "${extractedKeywords.rewrite}" -Call this tool to execute the search. You can optionally provide additional context to refine the search.`, +You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`, inputSchema: z.object({ additionalContext: z diff --git a/src/renderer/src/aiCore/tools/WebSearchTool.ts b/src/renderer/src/aiCore/tools/WebSearchTool.ts index 2b0b7fb13b..2d6e318306 100644 --- a/src/renderer/src/aiCore/tools/WebSearchTool.ts +++ b/src/renderer/src/aiCore/tools/WebSearchTool.ts @@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = ( return tool({ name: 'builtin_web_search', - description: `Search the web and return citable sources using pre-analyzed search intent. + description: `Web search tool for finding current information, news, and real-time data from the internet. -Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${ - extractedKeywords.links +This tool has been configured with search parameters based on the conversation context: +- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${ + extractedKeywords.links?.length ? ` -Relevant links: ${extractedKeywords.links.join(', ')}` +- Relevant URLs: ${extractedKeywords.links.join(', ')}` : '' } -Call this tool to execute the search. You can optionally provide additional context to refine the search.`, +You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`, inputSchema: z.object({ additionalContext: z diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts index e7f6b5a393..9606d9ea6e 100644 --- a/src/renderer/src/aiCore/utils/mcp.ts +++ b/src/renderer/src/aiCore/utils/mcp.ts @@ -9,7 +9,7 @@ import { JSONSchema7 } from 'json-schema' const logger = loggerService.withContext('MCP-utils') // Setup tools configuration based on provided parameters -export function setupToolsConfig(mcpTools?: MCPTool[]): Record | undefined { +export function setupToolsConfig(mcpTools?: MCPTool[]): Record> | undefined { let tools: ToolSet = {} if (!mcpTools?.length) { diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index f85c8c7879..d151b57029 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -82,7 +82,9 @@ export function buildProviderOptions( // 应该覆盖所有类型 switch (baseProviderId) { case 'openai': + case 'openai-chat': case 'azure': + case 'azure-responses': providerSpecificOptions = { ...buildOpenAIProviderOptions(assistant, model, capabilities), serviceTier: serviceTierSetting @@ -101,13 +103,15 @@ export function buildProviderOptions( providerSpecificOptions = buildXAIProviderOptions(assistant, model, capabilities) break case 'deepseek': - case 'openai-compatible': + case 'openrouter': + case 'openai-compatible': { // 对于其他 provider,使用通用的构建逻辑 providerSpecificOptions = { ...buildGenericProviderOptions(assistant, model, capabilities), serviceTier: serviceTierSetting } break + } default: throw new Error(`Unsupported base provider ${baseProviderId}`) } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 385d8183c5..9328f7f0ce 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -52,7 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return {} } // Don't disable reasoning for models that require it - if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) { + if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) { return {} } return { reasoning: { enabled: false, exclude: true } } @@ -112,6 +112,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return { enable_thinking: true } + case SystemProviderIds.hunyuan: + case SystemProviderIds['tencent-cloud-ti']: case SystemProviderIds.doubao: return { thinking: { @@ -312,7 +314,7 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number { const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant) - if (maxTokens === undefined || reasoningEffort === undefined) { + if (reasoningEffort === undefined) { return 0 } const effortRatio = EFFORT_RATIO[reasoningEffort] diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index d2d0345826..9e29454b79 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -1,6 +1,13 @@ +import { + AnthropicSearchConfig, + OpenAISearchConfig, + WebSearchPluginConfig +} from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper' +import { BaseProviderId } from '@cherrystudio/ai-core/provider' import { isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models' -import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '@renderer/config/prompts' +import { CherryWebSearchConfig } from '@renderer/store/websearch' import { Model } from '@renderer/types' +import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' export function getWebSearchParams(model: Model): Record { if (model.provider === 'hunyuan') { @@ -21,11 +28,79 @@ export function getWebSearchParams(model: Model): Record { web_search_options: {} } } - - if (model.provider === 'openrouter') { - return { - plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }] - } - } return {} } + +/** + * range in [0, 100] + * @param maxResults + */ +function mapMaxResultToOpenAIContextSize(maxResults: number): OpenAISearchConfig['searchContextSize'] { + if (maxResults <= 33) return 'low' + if (maxResults <= 66) return 'medium' + return 'high' +} + +export function buildProviderBuiltinWebSearchConfig( + providerId: BaseProviderId, + webSearchConfig: CherryWebSearchConfig +): WebSearchPluginConfig | undefined { + switch (providerId) { + case 'openai': { + return { + openai: { + searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults) + } + } + } + case 'openai-chat': { + return { + 'openai-chat': { + searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults) + } + } + } + case 'anthropic': { + const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains) + const anthropicSearchOptions: AnthropicSearchConfig = { + maxUses: webSearchConfig.maxResults, + blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined + } + return { + anthropic: anthropicSearchOptions + } + } + case 'xai': { + return { + xai: { + maxSearchResults: webSearchConfig.maxResults, + returnCitations: true, + sources: [ + { + type: 'web', + excludedWebsites: mapRegexToPatterns(webSearchConfig.excludeDomains) + }, + { type: 'news' }, + { type: 'x' } + ], + mode: 'on' + } + } + } + case 'openrouter': { + return { + openrouter: { + plugins: [ + { + id: 'web', + max_results: webSearchConfig.maxResults + } + ] + } + } + } + default: { + return {} + } + } +} diff --git a/src/renderer/src/assets/images/providers/aiOnly.webp b/src/renderer/src/assets/images/providers/aiOnly.webp new file mode 100644 index 0000000000..f86f74543b Binary files /dev/null and b/src/renderer/src/assets/images/providers/aiOnly.webp differ diff --git a/src/renderer/src/assets/images/providers/longcat.png b/src/renderer/src/assets/images/providers/longcat.png new file mode 100644 index 0000000000..146cf3ea7a Binary files /dev/null and b/src/renderer/src/assets/images/providers/longcat.png differ diff --git a/src/renderer/src/assets/styles/CommandListPopover.css b/src/renderer/src/assets/styles/CommandListPopover.css index a1bb27ec57..fc4fe2b86e 100644 --- a/src/renderer/src/assets/styles/CommandListPopover.css +++ b/src/renderer/src/assets/styles/CommandListPopover.css @@ -1,6 +1,5 @@ .command-list-popover { /* Base styles are handled inline for theme support */ - /* Arrow styles based on placement */ } diff --git a/src/renderer/src/assets/styles/font.css b/src/renderer/src/assets/styles/font.css index be71cef964..8e122fe887 100644 --- a/src/renderer/src/assets/styles/font.css +++ b/src/renderer/src/assets/styles/font.css @@ -1,23 +1,24 @@ :root { --font-family: - Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans', - 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + var(--user-font-family), Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, + Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; --font-family-serif: serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; + --code-font-family: var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; } /* Windows系统专用字体配置 */ body[os='windows'] { --font-family: - 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, - Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; + var(--user-font-family), 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, + Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --code-font-family: - 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI', Courier, monospace; + var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI', + Courier, monospace; } diff --git a/src/renderer/src/assets/styles/markdown.css b/src/renderer/src/assets/styles/markdown.css index 5a1ca236d6..c01d9b63ef 100644 --- a/src/renderer/src/assets/styles/markdown.css +++ b/src/renderer/src/assets/styles/markdown.css @@ -76,6 +76,10 @@ list-style: initial; } +.markdown ol { + list-style: decimal; +} + .markdown ul, .markdown ol { padding-left: 1.5em; @@ -121,6 +125,7 @@ border-radius: 5px; word-break: keep-all; white-space: pre; + text-wrap: wrap; } .markdown code { diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css index 4d9e0d4f00..f05b01b65c 100644 --- a/src/renderer/src/assets/styles/tailwind.css +++ b/src/renderer/src/assets/styles/tailwind.css @@ -53,6 +53,7 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); + --icon: #00000099; } .dark { @@ -87,6 +88,7 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.552 0.016 285.938); + --icon: #ffffff99; } @theme inline { @@ -128,6 +130,7 @@ --color-sidebar-ring: var(--sidebar-ring); --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + --color-icon: var(--icon); @keyframes marquee { from { transform: translateX(0); diff --git a/src/renderer/src/components/Buttons/ActionIconButton.tsx b/src/renderer/src/components/Buttons/ActionIconButton.tsx new file mode 100644 index 0000000000..1448008090 --- /dev/null +++ b/src/renderer/src/components/Buttons/ActionIconButton.tsx @@ -0,0 +1,30 @@ +import { cn } from '@heroui/react' +import { Button, ButtonProps } from 'antd' +import React, { memo } from 'react' + +interface ActionIconButtonProps extends ButtonProps { + children: React.ReactNode + active?: boolean +} + +/** + * A simple action button rendered as an icon + */ +const ActionIconButton: React.FC = ({ children, active = false, className, ...props }) => { + return ( + + ) +} + +export default memo(ActionIconButton) diff --git a/src/renderer/src/components/Buttons/index.ts b/src/renderer/src/components/Buttons/index.ts new file mode 100644 index 0000000000..623c2f21cc --- /dev/null +++ b/src/renderer/src/components/Buttons/index.ts @@ -0,0 +1 @@ +export { default as ActionIconButton } from './ActionIconButton' diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 084a79a439..d322f41616 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -1,4 +1,4 @@ -import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar' +import { ActionIconButton } from '@renderer/components/Buttons' import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' import { Tooltip } from 'antd' import { debounce } from 'lodash' @@ -364,23 +364,23 @@ export const ContentSearch = React.forwardRef( {showUserToggle && ( - + - + )} - + - + - + - + @@ -397,15 +397,15 @@ export const ContentSearch = React.forwardRef( )} - + - - + + - - + + - + diff --git a/src/renderer/src/components/FreeTrialModelTag.tsx b/src/renderer/src/components/FreeTrialModelTag.tsx index 0ce63ade37..b86de99708 100644 --- a/src/renderer/src/components/FreeTrialModelTag.tsx +++ b/src/renderer/src/components/FreeTrialModelTag.tsx @@ -15,7 +15,7 @@ interface Props { } export const FreeTrialModelTag: FC = ({ model, showLabel = true }) => { - if (model.provider !== 'cherryin') { + if (model.provider !== 'cherryai') { return null } diff --git a/src/renderer/src/components/Icons/MinAppIcon.tsx b/src/renderer/src/components/Icons/MinAppIcon.tsx index c9612416bb..98974da745 100644 --- a/src/renderer/src/components/Icons/MinAppIcon.tsx +++ b/src/renderer/src/components/Icons/MinAppIcon.tsx @@ -1,7 +1,6 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { MinAppType } from '@renderer/types' import { FC } from 'react' -import styled from 'styled-components' interface Props { app: MinAppType @@ -11,31 +10,52 @@ interface Props { } const MinAppIcon: FC = ({ app, size = 48, style, sidebar = false }) => { + // First try to find in DEFAULT_MIN_APPS for predefined styling const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id) - if (!_app) { - return null + // If found in DEFAULT_MIN_APPS, use predefined styling + if (_app) { + return ( + {app.name + ) } - return ( - - ) + // If not found in DEFAULT_MIN_APPS but app has logo, use it (for temporary apps) + if (app.logo) { + return ( + {app.name + ) + } + + return null } -const Container = styled.img` - border-radius: 16px; - user-select: none; - -webkit-user-drag: none; -` - export default MinAppIcon diff --git a/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap b/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap index e41515fed6..3395e366f4 100644 --- a/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap +++ b/src/renderer/src/components/Icons/__tests__/__snapshots__/MinAppIcon.test.tsx.snap @@ -1,15 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`MinAppIcon > should render correctly with various props 1`] = ` -.c0 { - border-radius: 16px; - user-select: none; - -webkit-user-drag: none; -} - Test App `; diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx index 853d37d109..5833ed9b1a 100644 --- a/src/renderer/src/components/MinApp/MinApp.tsx +++ b/src/renderer/src/components/MinApp/MinApp.tsx @@ -135,6 +135,7 @@ const Container = styled.div` align-items: center; cursor: pointer; overflow: hidden; + min-height: 85px; ` const IconContainer = styled.div` diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 8416774406..848173e9b1 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -12,7 +12,7 @@ import { } from '@ant-design/icons' import { loggerService } from '@logger' import WindowControls from '@renderer/components/WindowControls' -import { isLinux, isMac, isWin } from '@renderer/config/constant' +import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useBridge } from '@renderer/hooks/useBridge' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' @@ -170,8 +170,6 @@ const MinappPopupContainer: React.FC = () => { const { isLeftNavbar } = useNavbarPosition() - const isInDevelopment = process.env.NODE_ENV === 'development' - const { setTimeoutTimer } = useTimer() useBridge() @@ -477,7 +475,7 @@ const MinappPopupContainer: React.FC = () => { - {isInDevelopment && ( + {isDev && ( handleOpenDevTools(appInfo.id)}> diff --git a/src/renderer/src/components/OAuth/OAuthButton.tsx b/src/renderer/src/components/OAuth/OAuthButton.tsx index fec3aae619..3368f60afe 100644 --- a/src/renderer/src/components/OAuth/OAuthButton.tsx +++ b/src/renderer/src/components/OAuth/OAuthButton.tsx @@ -3,6 +3,7 @@ import { Provider } from '@renderer/types' import { oauthWith302AI, oauthWithAihubmix, + oauthWithAiOnly, oauthWithPPIO, oauthWithSiliconFlow, oauthWithTokenFlux @@ -46,6 +47,10 @@ const OAuthButton: FC = ({ provider, onSuccess, ...buttonProps }) => { if (provider.id === '302ai') { oauthWith302AI(handleSuccess) } + + if (provider.id === 'aionly') { + oauthWithAiOnly(handleSuccess) + } } return ( diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx index 93446b6749..c111aa99fe 100644 --- a/src/renderer/src/components/OGCard.tsx +++ b/src/renderer/src/components/OGCard.tsx @@ -36,7 +36,7 @@ export const OGCard = ({ link, show }: Props) => { const GeneratedGraph = useCallback(() => { return (
-

{metadata['og:title'] || hostname}

+

{metadata['og:title'] || hostname}

) }, [hostname, metadata]) diff --git a/src/renderer/src/components/Popups/PrivacyPopup.tsx b/src/renderer/src/components/Popups/PrivacyPopup.tsx new file mode 100644 index 0000000000..1ec9639bfc --- /dev/null +++ b/src/renderer/src/components/Popups/PrivacyPopup.tsx @@ -0,0 +1,137 @@ +import { TopView } from '@renderer/components/TopView' +import { useTheme } from '@renderer/context/ThemeProvider' +import { ThemeMode } from '@renderer/types' +import { runAsyncFunction } from '@renderer/utils' +import { Button, Modal } from 'antd' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const WebViewContainer = styled.div` + width: 100%; + height: 500px; + overflow: hidden; + + webview { + width: 100%; + height: 100%; + border: none; + background: transparent; + } +` + +interface ShowParams { + title?: string + showDeclineButton?: boolean +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ title, showDeclineButton = true, resolve }) => { + const [open, setOpen] = useState(true) + const [privacyUrl, setPrivacyUrl] = useState('') + const { theme } = useTheme() + const { i18n } = useTranslation() + + const getTitle = () => { + if (title) return title + const isChinese = i18n.language.startsWith('zh') + return isChinese ? '隐私协议' : 'Privacy Policy' + } + + const handleAccept = () => { + setOpen(false) + localStorage.setItem('privacy-popup-accepted', 'true') + resolve({ accepted: true }) + } + + const handleDecline = () => { + setOpen(false) + window.api.quit() + resolve({ accepted: false }) + } + + const onClose = () => { + if (!showDeclineButton) { + handleAccept() + } else { + handleDecline() + } + } + + useEffect(() => { + runAsyncFunction(async () => { + const { appPath } = await window.api.getAppInfo() + const isChinese = i18n.language.startsWith('zh') + const htmlFile = isChinese ? 'privacy-zh.html' : 'privacy-en.html' + const url = `file://${appPath}/resources/cherry-studio/${htmlFile}?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}` + setPrivacyUrl(url) + }) + }, [theme, i18n.language]) + + PrivacyPopup.hide = () => setOpen(false) + + return ( + + {i18n.language.startsWith('zh') ? '拒绝' : 'Decline'} + + ), + + ].filter(Boolean)}> + + {privacyUrl && } + + + ) +} + +const TopViewKey = 'PrivacyPopup' + +export default class PrivacyPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static async show(props?: ShowParams) { + const accepted = localStorage.getItem('privacy-popup-accepted') + + if (accepted) { + return + } + + return new Promise<{ accepted: boolean }>((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index 9a7d7ee93a..cea3aca7cb 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -253,10 +253,39 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { let savedCount = 0 try { + // Validate knowledge base configuration before proceeding + if (!selectedBaseId) { + throw new Error('No knowledge base selected') + } + + const selectedBase = bases.find((base) => base.id === selectedBaseId) + if (!selectedBase) { + throw new Error('Selected knowledge base not found') + } + + if (!selectedBase.version) { + throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.') + } + if (isNoteMode) { const note = source.data as NotesTreeNode - const content = await window.api.file.read(note.id + '.md') - logger.debug('Note content:', content) + if (!note.externalPath) { + throw new Error('Note external path is required for export') + } + + let content = '' + try { + content = await window.api.file.readExternal(note.externalPath) + } catch (error) { + logger.error('Failed to read note file:', error as Error) + throw new Error('Failed to read note content. Please ensure the file exists and is accessible.') + } + + if (!content || content.trim() === '') { + throw new Error('Note content is empty. Cannot export empty notes to knowledge base.') + } + + logger.debug('Note content loaded', { contentLength: content.length }) await addNote(content) savedCount = 1 } else { @@ -281,9 +310,23 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { resolve({ success: true, savedCount }) } catch (error) { logger.error('save failed:', error as Error) - window.toast.error( - t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed') + + // Provide more specific error messages + let errorMessage = t( + isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed' ) + + if (error instanceof Error) { + if (error.message.includes('not properly configured')) { + errorMessage = error.message + } else if (error.message.includes('empty')) { + errorMessage = error.message + } else if (error.message.includes('read note content')) { + errorMessage = error.message + } + } + + window.toast.error(errorMessage) setLoading(false) } } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index 1cd7926145..0e4ca65036 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,5 +1,6 @@ import { PushpinOutlined } from '@ant-design/icons' import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag' +import { HStack } from '@renderer/components/Layout' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' import { TopView } from '@renderer/components/TopView' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' @@ -102,16 +103,18 @@ const PopupContainer: React.FC = ({ model, filter: baseFilter, showTagFil (model: Model, provider: Provider, isPinned: boolean): FlatListModel => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) - const isCherryin = provider.id === 'cherryin' + const isCherryAi = provider.id === 'cherryai' return { key: isPinned ? `${modelId}_pinned` : modelId, type: 'model', name: ( - {model.name} - {isPinned && | {groupName}} - {isCherryin && } + + {model.name} + {isPinned && | {groupName}} + + {isCherryAi && } ), tags: ( @@ -177,7 +180,7 @@ const PopupContainer: React.FC = ({ model, filter: baseFilter, showTagFil key: `provider-${p.id}`, type: 'group', name: getFancyProviderName(p), - actions: ( + actions: p.id !== 'cherryai' && ( = ({ setInputText }) => { const cursorPosition = textArea.selectionStart ?? 0 const textBeforeCursor = textArea.value.slice(0, cursorPosition) - // 查找最后一个 @ 或 / 符号的位置 - const lastAtIndex = textBeforeCursor.lastIndexOf('@') - const lastSlashIndex = textBeforeCursor.lastIndexOf('/') - const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex) + // 查找末尾最近的触发符号(@ 或 /),允许位于文本起始或空格后 + const match = textBeforeCursor.match(/(^| )([@/][^\s]*)$/) + if (!match) return - if (lastSymbolIndex === -1) return + const matchIndex = match.index ?? -1 + if (matchIndex === -1) return + + const boundarySegment = match[1] ?? '' + const symbolSegment = match[2] ?? '' + if (!symbolSegment) return + + const boundaryStart = matchIndex + const symbolStart = boundaryStart + boundarySegment.length // 根据 includeSymbol 决定是否删除符号 - const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1 + const deleteStart = includeSymbol ? boundaryStart : symbolStart + 1 const deleteEnd = cursorPosition if (deleteStart >= deleteEnd) return @@ -203,7 +210,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (textArea) { setInputText(textArea.value) } - } else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) { + } else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) { clearSearchText(true) } }, @@ -341,11 +348,12 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { scrollTriggerRef.current = 'none' }, [index]) - // 处理键盘事件(折叠时不拦截全局键盘) + // 处理键盘事件: + // - 可见且未折叠时:拦截 Enter 及其组合键(纯 Enter 选择项;带修饰键仅拦截不处理)。 + // - 软隐藏/折叠时:不拦截 Enter,允许输入框处理(用于发送消息等)。 + // - 不可见时:不拦截,输入框按常规处理。 useEffect(() => { - const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0 - const isCollapsed = hasSearchTextFlag && list.length === 0 - if (!ctx.isVisible || isCollapsed) return + if (!ctx.isVisible) return const handleKeyDown = (e: KeyboardEvent) => { if (isMac ? e.metaKey : e.ctrlKey) { @@ -438,9 +446,30 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { break case 'Enter': - case 'NumpadEnter': + case 'NumpadEnter': { if (isComposing.current) return + // 折叠/软隐藏时不拦截,让输入框处理(用于发送消息) + const hasSearch = searchText.replace(/^[/@]/, '').length > 0 + const nonPinnedCount = list.filter((i) => !i.alwaysVisible).length + const isCollapsed = hasSearch && nonPinnedCount === 0 + if (isCollapsed) return + + // 面板可见且未折叠时:拦截所有 Enter 变体; + // 纯 Enter 选择项,带修饰键仅拦截不处理 + if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { + // Don't prevent default or stop propagation - let it create a newline + setIsMouseOver(false) + break + } + + if (e.ctrlKey || e.metaKey || e.altKey) { + e.preventDefault() + e.stopPropagation() + setIsMouseOver(false) + break + } + if (list?.[index]) { e.preventDefault() e.stopPropagation() @@ -451,6 +480,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { handleClose('enter_empty') } break + } case 'Escape': e.stopPropagation() handleClose('esc') @@ -516,6 +546,18 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list]) const collapsed = hasSearchText && visibleNonPinnedCount === 0 + useEffect(() => { + if (!ctx.isVisible) return + if (!collapsed) return + if (ctx.triggerInfo?.type !== 'input') return + if (ctx.multiple) return + + const trimmedSearch = searchText.replace(/^[/@]/, '').trim() + if (!trimmedSearch) return + + handleClose('no_result') + }, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText]) + const estimateSize = useCallback(() => ITEM_HEIGHT, []) const rowRenderer = useCallback( diff --git a/src/renderer/src/components/RichEditor/CommandListPopover.tsx b/src/renderer/src/components/RichEditor/CommandListPopover.tsx index 4f8df4d20c..1f2250a437 100644 --- a/src/renderer/src/components/RichEditor/CommandListPopover.tsx +++ b/src/renderer/src/components/RichEditor/CommandListPopover.tsx @@ -87,6 +87,9 @@ const CommandListPopover = ({ return true case 'Enter': + if (event.shiftKey) { + return false + } event.preventDefault() if (items[internalSelectedIndex]) { selectItem(internalSelectedIndex) diff --git a/src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts b/src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts new file mode 100644 index 0000000000..e352e957d0 --- /dev/null +++ b/src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { commandSuggestion } from '../command' + +describe('commandSuggestion render', () => { + it('has render function', () => { + expect(commandSuggestion.render).toBeDefined() + expect(typeof commandSuggestion.render).toBe('function') + }) + + it('render function returns object with onKeyDown', () => { + const renderResult = commandSuggestion.render?.() + expect(renderResult).toBeDefined() + expect(renderResult?.onKeyDown).toBeDefined() + expect(typeof renderResult?.onKeyDown).toBe('function') + }) +}) diff --git a/src/renderer/src/components/RichEditor/command.ts b/src/renderer/src/components/RichEditor/command.ts index a460e210d4..1371b3ebb6 100644 --- a/src/renderer/src/components/RichEditor/command.ts +++ b/src/renderer/src/components/RichEditor/command.ts @@ -628,13 +628,34 @@ export const commandSuggestion: Omit { + // Let CommandListPopover handle events first + const popoverHandled = component.ref?.onKeyDown?.(props.event) + if (popoverHandled) { + return true + } + + // Handle Shift+Enter for newline when popover doesn't handle it + if (props.event.key === 'Enter' && props.event.shiftKey) { + props.event.preventDefault() + // Close the suggestion menu + if (cleanup) cleanup() + component.destroy() + // Use the view from SuggestionKeyDownProps to insert newline + const { view } = props + const { state, dispatch } = view + const { tr } = state + tr.insertText('\n') + dispatch(tr) + return true + } + if (props.event.key === 'Escape') { if (cleanup) cleanup() component.destroy() return true } - return component.ref?.onKeyDown(props.event) + return false }, onExit: () => { diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index efa19565d1..3930819c6e 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -1,4 +1,5 @@ import { PlusOutlined } from '@ant-design/icons' +import { loggerService } from '@logger' import { Sortable, useDndReorder } from '@renderer/components/dnd' import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' import { isMac } from '@renderer/config/constant' @@ -12,9 +13,10 @@ import tabsService from '@renderer/services/TabsService' import { useAppDispatch, useAppSelector } from '@renderer/store' import type { Tab } from '@renderer/store/tabs' import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs' -import { ThemeMode } from '@renderer/types' +import { MinAppType, ThemeMode } from '@renderer/types' import { classNames } from '@renderer/utils' import { Tooltip } from 'antd' +import { LRUCache } from 'lru-cache' import { FileSearch, Folder, @@ -45,14 +47,40 @@ interface TabsContainerProps { children: React.ReactNode } -const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => { +const logger = loggerService.withContext('TabContainer') + +const getTabIcon = ( + tabId: string, + minapps: MinAppType[], + minAppsCache?: LRUCache +): React.ReactNode | undefined => { // Check if it's a minapp tab (format: apps:appId) if (tabId.startsWith('apps:')) { const appId = tabId.replace('apps:', '') - const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + + // If not found in permanent apps, search in temporary apps cache + // The cache stores apps opened via openSmartMinapp() for top navbar mode + // These are temporary MinApps that were opened but not yet saved to user's config + // The cache is LRU (Least Recently Used) with max size from settings + // Cache validity: Apps in cache are currently active/recently used, not outdated + if (!app && minAppsCache) { + app = minAppsCache.get(appId) + + // Defensive programming: If app not found in cache but tab exists, + // the cache entry may have been evicted due to LRU policy + // Log warning for debugging potential sync issues + if (!app) { + logger.warn(`MinApp ${appId} not found in cache, using fallback icon`) + } + } + if (app) { return } + + // Fallback: If no app found (cache evicted), show default icon + return } switch (tabId) { @@ -94,7 +122,7 @@ const TabsContainer: React.FC = ({ children }) => { const activeTabId = useAppSelector((state) => state.tabs.activeTabId) const isFullscreen = useFullscreen() const { settedTheme, toggleTheme } = useTheme() - const { hideMinappPopup } = useMinappPopup() + const { hideMinappPopup, minAppsCache } = useMinappPopup() const { minapps } = useMinapps() const { t } = useTranslation() @@ -112,8 +140,23 @@ const TabsContainer: React.FC = ({ children }) => { // Check if it's a minapp tab if (tabId.startsWith('apps:')) { const appId = tabId.replace('apps:', '') - const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) - return app ? app.name : 'MinApp' + let app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + + // If not found in permanent apps, search in temporary apps cache + // This ensures temporary MinApps display proper titles while being used + // The LRU cache automatically manages app lifecycle and prevents memory leaks + if (!app && minAppsCache) { + app = minAppsCache.get(appId) + + // Defensive programming: If app not found in cache but tab exists, + // the cache entry may have been evicted due to LRU policy + if (!app) { + logger.warn(`MinApp ${appId} not found in cache, using fallback title`) + } + } + + // Return app name if found, otherwise use fallback with appId + return app ? app.name : `MinApp-${appId}` } return getTitleLabel(tabId) } @@ -196,7 +239,7 @@ const TabsContainer: React.FC = ({ children }) => { renderItem={(tab) => ( handleTabClick(tab)}> - {tab.id && {getTabIcon(tab.id, minapps)}} + {tab.id && {getTabIcon(tab.id, minapps, minAppsCache)}} {getTabTitle(tab.id)} {tab.id !== 'home' && ( @@ -259,7 +302,7 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>` flex-direction: row; align-items: center; gap: 5px; - padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-x)' : '15px')}; + padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'calc(env(titlebar-area-x) + 4px)' : '15px')}; padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : '0')}; height: var(--navbar-height); min-height: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? 'env(titlebar-area-height)' : '')}; diff --git a/src/renderer/src/components/Tags/CustomTag.tsx b/src/renderer/src/components/Tags/CustomTag.tsx index 561b3edf41..3ecba381d4 100644 --- a/src/renderer/src/components/Tags/CustomTag.tsx +++ b/src/renderer/src/components/Tags/CustomTag.tsx @@ -43,7 +43,7 @@ const CustomTag: FC = ({ ...(disabled && { cursor: 'not-allowed' }), ...style }}> - {icon && icon} {children} + {icon} {children} {closable && ( ` flex-direction: row; align-items: center; justify-content: space-between; - padding: 0 ${isMac ? '20px' : 0}; + padding-right: ${isMac ? '20px' : 0}; + padding-left: 10px; font-weight: bold; color: var(--color-text-1); padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')}; diff --git a/src/renderer/src/components/dnd/ItemRenderer.tsx b/src/renderer/src/components/dnd/ItemRenderer.tsx index a33301df62..d13e298963 100644 --- a/src/renderer/src/components/dnd/ItemRenderer.tsx +++ b/src/renderer/src/components/dnd/ItemRenderer.tsx @@ -17,6 +17,7 @@ interface ItemRendererProps { transform?: Transform | null transition?: string | null listeners?: DraggableSyntheticListeners + itemStyle?: React.CSSProperties } export function ItemRenderer({ @@ -30,6 +31,7 @@ export function ItemRenderer({ transform, transition, listeners, + itemStyle, ...props }: ItemRendererProps) { useEffect(() => { @@ -44,7 +46,7 @@ export function ItemRenderer({ } }, [dragOverlay]) - const wrapperStyle = { + const style = { transition, transform: CSS.Transform.toString(transform ?? null) } as React.CSSProperties @@ -54,7 +56,7 @@ export function ItemRenderer({ ref={ref} data-index={index} className={classNames({ dragOverlay: dragOverlay })} - style={{ ...wrapperStyle }}> + style={{ ...style, ...itemStyle }}> { className?: string /** Item list style */ listStyle?: React.CSSProperties + /** Item style */ + itemStyle?: React.CSSProperties /** Item gap */ gap?: number | string /** Restrictions, shortcuts for some modifiers */ @@ -87,6 +89,7 @@ function Sortable({ showGhost = false, className, listStyle, + itemStyle, gap, restrictions, modifiers: customModifiers @@ -195,19 +198,19 @@ function Sortable({ renderItem={renderItem} useDragOverlay={useDragOverlay} showGhost={showGhost} + itemStyle={itemStyle} /> ))} - {useDragOverlay - ? createPortal( - - {activeItem ? : null} - , - document.body - ) - : null} + {useDragOverlay && + createPortal( + + {activeItem && } + , + document.body + )} ) } diff --git a/src/renderer/src/components/dnd/SortableItem.tsx b/src/renderer/src/components/dnd/SortableItem.tsx index 60901223a0..ec91f54da8 100644 --- a/src/renderer/src/components/dnd/SortableItem.tsx +++ b/src/renderer/src/components/dnd/SortableItem.tsx @@ -10,6 +10,7 @@ interface SortableItemProps { renderItem: RenderItemType useDragOverlay?: boolean showGhost?: boolean + itemStyle?: React.CSSProperties } export function SortableItem({ @@ -18,7 +19,8 @@ export function SortableItem({ index, renderItem, useDragOverlay = true, - showGhost = true + showGhost = true, + itemStyle }: SortableItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id @@ -36,6 +38,7 @@ export function SortableItem({ transform={transform} transition={transition} listeners={listeners} + itemStyle={itemStyle} {...attributes} /> ) diff --git a/src/renderer/src/config/__test__/models.test.ts b/src/renderer/src/config/__test__/models.test.ts index 547ff42efd..926ac3547d 100644 --- a/src/renderer/src/config/__test__/models.test.ts +++ b/src/renderer/src/config/__test__/models.test.ts @@ -16,7 +16,7 @@ describe('Qwen Model Detection', () => { initialState: {} })) vi.mock('@renderer/services/AssistantService', () => ({ - getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryin' }) + getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' }) })) }) test('isQwenReasoningModel', () => { @@ -52,7 +52,7 @@ describe('Vision Model Detection', () => { initialState: {} })) vi.mock('@renderer/services/AssistantService', () => ({ - getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryin' }) + getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' }) })) }) test('isVisionModel', () => { @@ -81,7 +81,7 @@ describe('Web Search Model Detection', () => { initialState: {} })) vi.mock('@renderer/services/AssistantService', () => ({ - getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryin' }) + getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' }) })) }) test('isWebSearchModel', () => { diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 1b8e9ad1bc..1858675ed8 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -3,14 +3,14 @@ import { Model, SystemProviderId } from '@renderer/types' export const glm45FlashModel: Model = { id: 'glm-4.5-flash', name: 'GLM-4.5-Flash', - provider: 'cherryin', + provider: 'cherryai', group: 'GLM-4.5' } export const qwen38bModel: Model = { id: 'Qwen/Qwen3-8B', name: 'Qwen3-8B', - provider: 'cherryin', + provider: 'cherryai', group: 'Qwen' } @@ -25,20 +25,7 @@ export const SYSTEM_MODELS: Record = // Default quick assistant model glm45FlashModel ], - cherryin: [ - { - id: 'glm-4.5-flash', - name: 'GLM-4.5-Flash', - provider: 'cherryin', - group: 'GLM-4.5' - }, - { - id: 'Qwen/Qwen3-8B', - name: 'Qwen3-8B', - provider: 'cherryin', - group: 'Qwen' - } - ], + // cherryin: [], vertexai: [], '302ai': [ { @@ -1785,5 +1772,51 @@ export const SYSTEM_MODELS: Record = provider: 'poe', group: 'poe' } + ], + aionly: [ + { + id: 'claude-opus-4.1', + name: 'claude-opus-4.1', + provider: 'aionly', + group: 'claude' + }, + { + id: 'claude-sonnet4', + name: 'claude-sonnet4', + provider: 'aionly', + group: 'claude' + }, + { + id: 'claude-3.5-sonnet-v2', + name: 'claude-3.5-sonnet-v2', + provider: 'aionly', + group: 'claude' + }, + { + id: 'gpt-4.1', + name: 'gpt-4.1', + provider: 'aionly', + group: 'gpt' + }, + { + id: 'gemini-2.5-flash', + name: 'gemini-2.5-flash', + provider: 'aionly', + group: 'gemini' + } + ], + longcat: [ + { + id: 'LongCat-Flash-Chat', + name: 'LongCat Flash Chat', + provider: 'longcat', + group: 'LongCat' + }, + { + id: 'LongCat-Flash-Thinking', + name: 'LongCat Flash Thinking', + provider: 'longcat', + group: 'LongCat' + } ] } diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index bbbc690784..f309811a9d 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -93,7 +93,17 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean { // Specifically for DeepSeek V3.1. White list for now if (isDeepSeekHybridInferenceModel(model)) { return ( - ['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[] + [ + 'openrouter', + 'dashscope', + 'modelscope', + 'doubao', + 'silicon', + 'nvidia', + 'ppio', + 'hunyuan', + 'tencent-cloud-ti' + ] satisfies SystemProviderId[] ).some((id) => id === model.provider) } @@ -225,6 +235,8 @@ export function isSupportedThinkingTokenQwenModel(model?: Model): boolean { 'qwen-plus-2025-04-28', 'qwen-plus-0714', 'qwen-plus-2025-07-14', + 'qwen-plus-2025-07-28', + 'qwen-plus-2025-09-11', 'qwen-turbo', 'qwen-turbo-latest', 'qwen-turbo-0428', @@ -323,7 +335,7 @@ export const isDeepSeekHybridInferenceModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) // deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别 // openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险 - return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1') + return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1') } export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel @@ -379,7 +391,8 @@ export function isReasoningModel(model?: Model): boolean { isDeepSeekHybridInferenceModel(model) || modelId.includes('magistral') || modelId.includes('minimax-m1') || - modelId.includes('pangu-pro-moe') + modelId.includes('pangu-pro-moe') || + modelId.includes('seed-oss') ) { return true } @@ -410,13 +423,14 @@ export const THINKING_TOKEN_MAP: Record = 'gemini-.*-pro.*$': { min: 128, max: 32768 }, // Qwen models + // qwen-plus-x 系列自 qwen-plus-2025-07-28 后模型最长思维链变为 81_920, qwen-plus 模型于 2025.9.16 同步变更 'qwen3-235b-a22b-thinking-2507$': { min: 0, max: 81_920 }, 'qwen3-30b-a3b-thinking-2507$': { min: 0, max: 81_920 }, - 'qwen-plus-2025-07-28$': { min: 0, max: 81_920 }, - 'qwen-plus-latest$': { min: 0, max: 81_920 }, + 'qwen-plus-2025-07-14$': { min: 0, max: 38_912 }, + 'qwen-plus-2025-04-28$': { min: 0, max: 38_912 }, 'qwen3-1\\.7b$': { min: 0, max: 30_720 }, 'qwen3-0\\.6b$': { min: 0, max: 30_720 }, - 'qwen-plus.*$': { min: 0, max: 38_912 }, + 'qwen-plus.*$': { min: 0, max: 81_920 }, 'qwen-turbo.*$': { min: 0, max: 38_912 }, 'qwen-flash.*$': { min: 0, max: 81_920 }, 'qwen3-(?!max).*$': { min: 1024, max: 38_912 }, diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 3fd27308f8..39078e2924 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -229,6 +229,11 @@ export const isGPT5SeriesModel = (model: Model) => { return modelId.includes('gpt-5') } +export const isGPT5SeriesReasoningModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-5') && !modelId.includes('chat') +} + export const isGeminiModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) return modelId.includes('gemini') diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index f28a88c7c7..64e78e847a 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -3,6 +3,7 @@ import HunyuanProviderLogo from '@renderer/assets/images/models/hunyuan.png' import AzureProviderLogo from '@renderer/assets/images/models/microsoft.png' import Ai302ProviderLogo from '@renderer/assets/images/providers/302ai.webp' import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp' +import AiOnlyProviderLogo from '@renderer/assets/images/providers/aiOnly.webp' import AlayaNewProviderLogo from '@renderer/assets/images/providers/alayanew.webp' import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.png' import AwsProviderLogo from '@renderer/assets/images/providers/aws-bedrock.webp' @@ -26,6 +27,7 @@ import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png' import JinaProviderLogo from '@renderer/assets/images/providers/jina.png' import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png' import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png' +import LongCatProviderLogo from '@renderer/assets/images/providers/longcat.png' import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png' import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png' import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png' @@ -63,19 +65,30 @@ import { } from '@renderer/types' import { TOKENFLUX_HOST } from './constant' -import { SYSTEM_MODELS } from './models' +import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models' + +export const CHERRYAI_PROVIDER: SystemProvider = { + id: 'cherryai' as SystemProviderId, + name: 'CherryAI', + type: 'openai', + apiKey: '', + apiHost: 'https://api.cherry-ai.com/', + models: [glm45FlashModel, qwen38bModel], + isSystem: true, + enabled: true +} export const SYSTEM_PROVIDERS_CONFIG: Record = { - cherryin: { - id: 'cherryin', - name: 'CherryIN', - type: 'openai', - apiKey: '', - apiHost: 'https://api.cherry-ai.com/', - models: SYSTEM_MODELS.cherryin, - isSystem: true, - enabled: true - }, + // cherryin: { + // id: 'cherryin', + // name: 'CherryIN', + // type: 'openai', + // apiKey: '', + // apiHost: 'https://open.cherryin.ai', + // models: [], + // isSystem: true, + // enabled: true + // }, silicon: { id: 'silicon', name: 'Silicon', @@ -126,16 +139,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = isSystem: true, enabled: false }, - ppio: { - id: 'ppio', - name: 'PPIO', - type: 'openai', - apiKey: '', - apiHost: 'https://api.ppinfra.com/v3/openai/', - models: SYSTEM_MODELS.ppio, - isSystem: true, - enabled: false - }, alayanew: { id: 'alayanew', name: 'AlayaNew', @@ -146,16 +149,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = isSystem: true, enabled: false }, - qiniu: { - id: 'qiniu', - name: 'Qiniu', - type: 'openai', - apiKey: '', - apiHost: 'https://api.qnaigc.com', - models: SYSTEM_MODELS.qiniu, - isSystem: true, - enabled: false - }, dmxapi: { id: 'dmxapi', name: 'DMXAPI', @@ -166,6 +159,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = isSystem: true, enabled: false }, + aionly: { + id: 'aionly', + name: 'AIOnly', + type: 'openai', + apiKey: '', + apiHost: 'https://api.aiionly.com', + models: SYSTEM_MODELS.aionly, + isSystem: true, + enabled: false + }, burncloud: { id: 'burncloud', name: 'BurnCloud', @@ -226,6 +229,26 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = isSystem: true, enabled: false }, + ppio: { + id: 'ppio', + name: 'PPIO', + type: 'openai', + apiKey: '', + apiHost: 'https://api.ppinfra.com/v3/openai/', + models: SYSTEM_MODELS.ppio, + isSystem: true, + enabled: false + }, + qiniu: { + id: 'qiniu', + name: 'Qiniu', + type: 'openai', + apiKey: '', + apiHost: 'https://api.qnaigc.com', + models: SYSTEM_MODELS.qiniu, + isSystem: true, + enabled: false + }, openrouter: { id: 'openrouter', name: 'OpenRouter', @@ -600,6 +623,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = models: SYSTEM_MODELS['poe'], isSystem: true, enabled: false + }, + longcat: { + id: 'longcat', + name: 'LongCat', + type: 'openai', + apiKey: '', + apiHost: 'https://api.longcat.chat/openai', + models: SYSTEM_MODELS.longcat, + isSystem: true, + enabled: false } } as const @@ -661,7 +694,9 @@ export const PROVIDER_LOGO_MAP: AtLeast = { vertexai: VertexAIProviderLogo, 'new-api': NewAPIProviderLogo, 'aws-bedrock': AwsProviderLogo, - poe: 'poe' // use svg icon component + poe: 'poe', // use svg icon component + aionly: AiOnlyProviderLogo, + longcat: LongCatProviderLogo } as const export function getProviderLogo(providerId: string) { @@ -685,16 +720,17 @@ type ProviderUrls = { } export const PROVIDER_URLS: Record = { - cherryin: { - api: { - url: 'https://api.cherry-ai.com' - }, - websites: { - official: 'https://cherry-ai.com', - docs: 'https://docs.cherry-ai.com', - models: 'https://docs.cherry-ai.com/pre-basic/providers/cherryin' - } - }, + // cherryin: { + // api: { + // url: 'https://open.cherryin.ai' + // }, + // websites: { + // official: 'https://open.cherryin.ai', + // apiKey: 'https://open.cherryin.ai/console/token', + // docs: 'https://open.cherryin.ai', + // models: 'https://open.cherryin.ai/pricing' + // } + // }, ph8: { api: { url: 'https://ph8.co' @@ -1020,7 +1056,7 @@ export const PROVIDER_URLS: Record = { }, anthropic: { api: { - url: 'https://api.anthropic.com/' + url: 'https://api.anthropic.com' }, websites: { official: 'https://anthropic.com/', @@ -1255,6 +1291,28 @@ export const PROVIDER_URLS: Record = { docs: 'https://creator.poe.com/docs/external-applications/openai-compatible-api', models: 'https://poe.com/' } + }, + aionly: { + api: { + url: 'https://api.aiionly.com' + }, + websites: { + official: 'https://www.aiionly.com', + apiKey: 'https://www.aiionly.com/keyApi', + docs: 'https://www.aiionly.com/document', + models: 'https://www.aiionly.com' + } + }, + longcat: { + api: { + url: 'https://api.longcat.chat/openai' + }, + websites: { + official: 'https://longcat.chat', + apiKey: 'https://longcat.chat/platform/api_keys', + docs: 'https://longcat.chat/platform/docs/zh/', + models: 'https://longcat.chat/platform/docs/zh/APIDocs.html' + } } } @@ -1340,3 +1398,7 @@ const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as con export const isGeminiWebSearchProvider = (provider: Provider) => { return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id) } + +export const isNewApiProvider = (provider: Provider) => { + return ['new-api', 'cherryin'].includes(provider.id) +} diff --git a/src/renderer/src/context/HeroUIProvider.tsx b/src/renderer/src/context/HeroUIProvider.tsx new file mode 100644 index 0000000000..1a0f31a89b --- /dev/null +++ b/src/renderer/src/context/HeroUIProvider.tsx @@ -0,0 +1,13 @@ +import { HeroUIProvider } from '@heroui/react' +import { useSettings } from '@renderer/hooks/useSettings' + +const AppHeroUIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { language } = useSettings() + return ( + + {children} + + ) +} + +export { AppHeroUIProvider as HeroUIProvider } diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index 5fe61108eb..226caefca1 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -31,7 +31,7 @@ const tailwindThemeChange = (theme: ThemeMode) => { export const ThemeProvider: React.FC = ({ children }) => { // 用户设置的主题 - const { theme: settedTheme, setTheme: setSettedTheme } = useSettings() + const { theme: settedTheme, setTheme: setSettedTheme, language } = useSettings() const [actualTheme, setActualTheme] = useState( window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light ) @@ -59,6 +59,7 @@ export const ThemeProvider: React.FC = ({ children }) => { document.body.classList.add('light') } document.body.setAttribute('navbar-position', navbarPosition) + document.documentElement.lang = language // if theme is old auto, then set theme to system // we can delete this after next big release @@ -73,7 +74,7 @@ export const ThemeProvider: React.FC = ({ children }) => { document.body.setAttribute('theme-mode', actualTheme) setActualTheme(actualTheme) }) - }, [actualTheme, initUserTheme, navbarPosition, setSettedTheme, settedTheme]) + }, [actualTheme, initUserTheme, language, navbarPosition, setSettedTheme, settedTheme]) useEffect(() => { tailwindThemeChange(actualTheme) diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 05bda8661d..83ad6b663d 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -7,7 +7,6 @@ import { } from '@renderer/types' // Import necessary types for blocks and new message structure import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' import { Dexie, type EntityTable } from 'dexie' import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades' @@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', { quick_phrases: EntityTable message_blocks: EntityTable // Correct type for message_blocks translate_languages: EntityTable - notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'> } db.version(1).stores({ @@ -118,8 +116,7 @@ db.version(10).stores({ translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt', translate_languages: '&id, langCode', quick_phrases: 'id', - message_blocks: 'id, messageId, file.id', - notes_tree: '&id' + message_blocks: 'id, messageId, file.id' }) export default db diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 571e5877a8..e9e3b55706 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -38,7 +38,7 @@ export function useAppInit() { customCss, enableDataCollection } = useSettings() - const { isTopNavbar } = useNavbarPosition() + const { isLeftNavbar } = useNavbarPosition() const { minappShow } = useRuntime() const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) @@ -102,16 +102,15 @@ export function useAppInit() { }, [language]) useEffect(() => { - const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow + const isMacTransparentWindow = windowStyle === 'transparent' && isMac - if (minappShow && isTopNavbar) { - window.root.style.background = - windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)' + if (minappShow && isLeftNavbar) { + window.root.style.background = isMacTransparentWindow ? 'var(--color-background)' : 'var(--navbar-background)' return } - window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)' - }, [windowStyle, minappShow, theme, isTopNavbar]) + window.root.style.background = isMacTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)' + }, [windowStyle, minappShow, theme, isLeftNavbar]) useEffect(() => { if (isLocalAi) { diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index 258048f0b1..8a038bec31 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -172,7 +172,10 @@ export function useAssistant(id: string) { (model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })), [assistant, dispatch] ), - updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)), + updateAssistant: useCallback( + (update: Partial>) => dispatch(updateAssistant({ id, ...update })), + [dispatch, id] + ), updateAssistantSettings } } diff --git a/src/renderer/src/hooks/useCodeTools.ts b/src/renderer/src/hooks/useCodeTools.ts index db8b21bbeb..4d1527ed98 100644 --- a/src/renderer/src/hooks/useCodeTools.ts +++ b/src/renderer/src/hooks/useCodeTools.ts @@ -8,7 +8,8 @@ import { setCurrentDirectory, setEnvironmentVariables, setSelectedCliTool, - setSelectedModel + setSelectedModel, + setSelectedTerminal } from '@renderer/store/codeTools' import { Model } from '@renderer/types' import { codeTools } from '@shared/config/constant' @@ -35,6 +36,14 @@ export const useCodeTools = () => { [dispatch] ) + // 设置选择的终端 + const setTerminal = useCallback( + (terminal: string) => { + dispatch(setSelectedTerminal(terminal)) + }, + [dispatch] + ) + // 设置环境变量 const setEnvVars = useCallback( (envVars: string) => { @@ -105,6 +114,7 @@ export const useCodeTools = () => { // 状态 selectedCliTool: codeToolsState.selectedCliTool, selectedModel: selectedModel, + selectedTerminal: codeToolsState.selectedTerminal, environmentVariables: environmentVariables, directories: codeToolsState.directories, currentDirectory: codeToolsState.currentDirectory, @@ -113,6 +123,7 @@ export const useCodeTools = () => { // 操作函数 setCliTool, setModel, + setTerminal, setEnvVars, addDir, removeDir, diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 733726eb6c..3bc0c085c3 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -24,7 +24,6 @@ import { KnowledgeBase, KnowledgeItem, KnowledgeNoteItem, - MigrationModeEnum, ProcessingStatus } from '@renderer/types' import { runAsyncFunction, uuid } from '@renderer/utils' @@ -68,7 +67,8 @@ export const useKnowledge = (baseId: string) => { // 添加笔记 const addNote = async (content: string) => { await dispatch(addNoteThunk(baseId, content)) - checkAllBases() + // 确保数据库写入完成后再触发队列检查 + setTimeout(() => KnowledgeQueue.checkAllBases(), 100) } // 添加URL @@ -231,7 +231,7 @@ export const useKnowledge = (baseId: string) => { } // 迁移知识库(保留原知识库) - const migrateBase = async (newBase: KnowledgeBase, mode: MigrationModeEnum) => { + const migrateBase = async (newBase: KnowledgeBase) => { if (!base) return const timestamp = dayjs().format('YYMMDDHHmmss') @@ -244,14 +244,9 @@ export const useKnowledge = (baseId: string) => { name: newName, created_at: Date.now(), updated_at: Date.now(), - items: [], - framework: mode === MigrationModeEnum.MigrationToLangChain ? 'langchain' : base.framework + items: [] } satisfies KnowledgeBase - if (mode === MigrationModeEnum.MigrationToLangChain) { - await window.api.knowledgeBase.create(getKnowledgeBaseParams(migratedBase)) - } - dispatch(addBase(migratedBase)) const files: FileMetadata[] = [] diff --git a/src/renderer/src/hooks/useKnowledgeBaseForm.ts b/src/renderer/src/hooks/useKnowledgeBaseForm.ts index f641a4fa5f..cecc0ee03d 100644 --- a/src/renderer/src/hooks/useKnowledgeBaseForm.ts +++ b/src/renderer/src/hooks/useKnowledgeBaseForm.ts @@ -14,11 +14,7 @@ const createInitialKnowledgeBase = (): KnowledgeBase => ({ items: [], created_at: Date.now(), updated_at: Date.now(), - version: 1, - framework: 'langchain', - retriever: { - mode: 'hybrid' - } + version: 1 }) /** diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts index e8765267a6..99b49e43e8 100644 --- a/src/renderer/src/hooks/useMinappPopup.ts +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -1,6 +1,7 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值 +import NavigationService from '@renderer/services/NavigationService' import TabsService from '@renderer/services/TabsService' import { useAppDispatch } from '@renderer/store' import { @@ -14,6 +15,8 @@ import { clearWebviewState } from '@renderer/utils/webviewStateManager' import { LRUCache } from 'lru-cache' import { useCallback } from 'react' +import { useNavbarPosition } from './useSettings' + let minAppsCache: LRUCache /** @@ -34,6 +37,7 @@ export const useMinappPopup = () => { const dispatch = useAppDispatch() const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime() const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值 + const { isTopNavbar } = useNavbarPosition() const createLRUCache = useCallback(() => { return new LRUCache({ @@ -165,6 +169,33 @@ export const useMinappPopup = () => { dispatch(setMinappShow(false)) }, [dispatch, minappShow, openedOneOffMinapp]) + /** Smart open minapp that adapts to navbar position */ + const openSmartMinapp = useCallback( + (config: MinAppType, keepAlive: boolean = false) => { + if (isTopNavbar) { + // For top navbar mode, need to add to cache first for temporary apps + const cacheApp = minAppsCache.get(config.id) + if (!cacheApp) { + // Add temporary app to cache so MinAppPage can find it + minAppsCache.set(config.id, config) + } + + // Set current minapp and show state + dispatch(setCurrentMinappId(config.id)) + dispatch(setMinappShow(true)) + + // Then navigate to the app tab using NavigationService + if (NavigationService.navigate) { + NavigationService.navigate(`/apps/${config.id}`) + } + } else { + // For side navbar, use the traditional popup system + openMinapp(config, keepAlive) + } + }, + [isTopNavbar, openMinapp, dispatch] + ) + return { openMinapp, openMinappKeepAlive, @@ -172,6 +203,7 @@ export const useMinappPopup = () => { closeMinapp, hideMinappPopup, closeAllMinapps, + openSmartMinapp, // Expose cache instance for TabsService integration minAppsCache } diff --git a/src/renderer/src/hooks/useModel.ts b/src/renderer/src/hooks/useModel.ts index 27962e7cef..75119d0f89 100644 --- a/src/renderer/src/hooks/useModel.ts +++ b/src/renderer/src/hooks/useModel.ts @@ -1,6 +1,5 @@ -import store from '@renderer/store' - import { useProviders } from './useProvider' +import { getStoreProviders } from './useStore' export function useModel(id?: string, providerId?: string) { const { providers } = useProviders() @@ -15,7 +14,7 @@ export function useModel(id?: string, providerId?: string) { } export function getModel(id?: string, providerId?: string) { - const providers = store.getState().llm.providers + const providers = getStoreProviders() const allModels = providers.map((p) => p.models).flat() return allModels.find((m) => { if (providerId) { diff --git a/src/renderer/src/hooks/useProvider.ts b/src/renderer/src/hooks/useProvider.ts index 9182db3776..96120d4dc8 100644 --- a/src/renderer/src/hooks/useProvider.ts +++ b/src/renderer/src/hooks/useProvider.ts @@ -1,4 +1,5 @@ import { createSelector } from '@reduxjs/toolkit' +import { CHERRYAI_PROVIDER } from '@renderer/config/providers' import { getDefaultProvider } from '@renderer/services/AssistantService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { @@ -16,7 +17,7 @@ import { useDefaultModel } from './useAssistant' const selectEnabledProviders = createSelector( (state) => state.llm.providers, - (providers) => providers.filter((p) => p.enabled) + (providers) => providers.filter((p) => p.enabled).concat(CHERRYAI_PROVIDER) ) export function useProviders() { @@ -24,7 +25,7 @@ export function useProviders() { const dispatch = useAppDispatch() return { - providers: providers || {}, + providers: providers || [], addProvider: (provider: Provider) => dispatch(addProvider(provider)), removeProvider: (provider: Provider) => dispatch(removeProvider(provider)), updateProvider: (updates: Partial & { id: string }) => dispatch(updateProvider(updates)), @@ -45,7 +46,9 @@ export function useAllProviders() { } export function useProvider(id: string) { - const provider = useAppSelector((state) => state.llm.providers.find((p) => p.id === id)) || getDefaultProvider() + const provider = + useAppSelector((state) => state.llm.providers.concat([CHERRYAI_PROVIDER]).find((p) => p.id === id)) || + getDefaultProvider() const dispatch = useAppDispatch() return { diff --git a/src/renderer/src/hooks/useStore.ts b/src/renderer/src/hooks/useStore.ts index 1b731e74c7..53e4646450 100644 --- a/src/renderer/src/hooks/useStore.ts +++ b/src/renderer/src/hooks/useStore.ts @@ -1,4 +1,5 @@ -import { useAppDispatch, useAppSelector } from '@renderer/store' +import { CHERRYAI_PROVIDER } from '@renderer/config/providers' +import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { setAssistantsTabSortType, setShowAssistants, @@ -39,3 +40,7 @@ export function useAssistantsTabSortType() { setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType)) } } + +export function getStoreProviders() { + return store.getState().llm.providers.concat([CHERRYAI_PROVIDER]) +} diff --git a/src/renderer/src/hooks/useUserTheme.ts b/src/renderer/src/hooks/useUserTheme.ts index 0b1bc5fb24..03191f5984 100644 --- a/src/renderer/src/hooks/useUserTheme.ts +++ b/src/renderer/src/hooks/useUserTheme.ts @@ -15,6 +15,10 @@ export default function useUserTheme() { document.body.style.setProperty('--primary', colorPrimary.toString()) document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString()) document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString()) + + // Set font family CSS variables + document.documentElement.style.setProperty('--user-font-family', `'${theme.userFontFamily}'`) + document.documentElement.style.setProperty('--user-code-font-family', `'${theme.userCodeFontFamily}'`) } return { diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 8d621f6517..a07daa975f 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -80,7 +80,8 @@ const providerKeyMap = { yi: 'provider.yi', zhinao: 'provider.zhinao', zhipu: 'provider.zhipu', - poe: 'provider.poe' + poe: 'provider.poe', + aionly: 'provider.aionly' } as const /** diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 29d6d4ae2a..38d596a493 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -332,7 +332,8 @@ }, "new_topic": "New Topic {{Command}}", "pause": "Pause", - "placeholder": "Type your message here, press {{key}} to send...", + "placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools", + "placeholder_without_triggers": "Type your message here, press {{key}} to send", "send": "Send", "settings": "Settings", "thinking": { @@ -362,8 +363,9 @@ "translate": "Translate to {{target_language}}", "translating": "Translating...", "upload": { + "attachment": "Upload attachment", "document": "Upload document file (model does not support images)", - "label": "Upload image or document file", + "image_or_document": "Upload image or document file", "upload_from_local": "Upload local file..." }, "url_context": "URL Context", @@ -659,13 +661,22 @@ "title": "Topics", "unpin": "Unpin Topic" }, - "translate": "Translate" + "translate": "Translate", + "web_search": { + "warning": { + "openai": "The GPT-5 model's minimal reasoning effort does not support web search." + } + } }, "code": { "auto_update_to_latest": "Automatically update to latest version", "bun_required_message": "Bun environment is required to run CLI tools", "cli_tool": "CLI Tool", "cli_tool_placeholder": "Select the CLI tool to use", + "custom_path": "Custom path", + "custom_path_error": "Failed to set custom terminal path", + "custom_path_required": "Custom path required for this terminal", + "custom_path_set": "Custom terminal path set successfully", "description": "Quickly launch multiple code CLI tools to improve development efficiency", "env_vars_help": "Enter custom environment variables (one per line, format: KEY=value)", "environment_variables": "Environment Variables", @@ -684,7 +695,10 @@ "model_placeholder": "Select the model to use", "model_required": "Please select a model", "select_folder": "Select Folder", + "set_custom_path": "Set custom terminal path", "supported_providers": "Supported Providers", + "terminal": "Terminal", + "terminal_placeholder": "Select terminal application", "title": "Code Tools", "update_options": "Update Options", "working_directory": "Working Directory" @@ -1063,10 +1077,6 @@ "error": { "failed": "Migration failed" }, - "migrate_to_langchain": { - "content": "The knowledge base migration does not delete the old knowledge base but creates a copy and reprocesses all entries, which may consume a significant number of tokens. Please proceed with caution.", - "info": "The knowledge base architecture has been updated. Click to migrate to the new architecture." - }, "source_dimensions": "Source Dimensions", "source_model": "Source Model", "target_dimensions": "Target Dimensions", @@ -1085,20 +1095,6 @@ "quota": "{{name}} Left Quota: {{quota}}", "quota_infinity": "{{name}} Quota: Unlimited", "rename": "Rename", - "retriever": "Retrieve mode", - "retriever_bm25": "full-text search", - "retriever_bm25_desc": "Search for documents based on keyword relevance and frequency.", - "retriever_hybrid": "Hybrid Search (Recommended)", - "retriever_hybrid_desc": "Combine keyword search and semantic search to achieve optimal retrieval accuracy.", - "retriever_hybrid_weight": { - "bm25": "full text", - "recommended": "recommend", - "title": "Hybrid Search Weight Adjustment (Full-text/Vector)", - "vector": "vector" - }, - "retriever_tooltip": "Using different retrieval methods to search the knowledge base", - "retriever_vector": "vector search", - "retriever_vector_desc": "Retrieve documents based on semantic similarity and meaning.", "search": "Search knowledge base", "search_placeholder": "Enter text to search", "settings": { @@ -2029,6 +2025,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3123,6 +3120,13 @@ "placeholder": "/* Put custom CSS here */" } }, + "font": { + "code": "Code Font", + "default": "Default", + "global": "Global Font", + "select": "Select Font", + "title": "Font Settings" + }, "navbar": { "position": { "label": "Navbar Position", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 862839bcef..a88a826529 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -132,7 +132,6 @@ }, "title": "API 服务器" }, - "assistants": { "abbr": "助手", "clear": { @@ -333,7 +332,8 @@ }, "new_topic": "新话题 {{Command}}", "pause": "暂停", - "placeholder": "在这里输入消息,按 {{key}} 发送...", + "placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具", + "placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送", "send": "发送", "settings": "设置", "thinking": { @@ -363,8 +363,9 @@ "translate": "翻译成 {{target_language}}", "translating": "翻译中...", "upload": { + "attachment": "上传附件", "document": "上传文档(模型不支持图片)", - "label": "上传图片或文档", + "image_or_document": "上传图片或文档", "upload_from_local": "上传本地文件..." }, "url_context": "网页上下文", @@ -660,13 +661,22 @@ "title": "话题", "unpin": "取消固定" }, - "translate": "翻译" + "translate": "翻译", + "web_search": { + "warning": { + "openai": "GPT5 模型 minimal 思考强度不支持网络搜索" + } + } }, "code": { "auto_update_to_latest": "检查更新并安装最新版本", "bun_required_message": "运行 CLI 工具需要安装 Bun 环境", "cli_tool": "CLI 工具", "cli_tool_placeholder": "选择要使用的 CLI 工具", + "custom_path": "自定义路径", + "custom_path_error": "设置自定义终端路径失败", + "custom_path_required": "此终端需要设置自定义路径", + "custom_path_set": "自定义终端路径设置成功", "description": "快速启动多个代码 CLI 工具,提高开发效率", "env_vars_help": "输入自定义环境变量(每行一个,格式:KEY=value)", "environment_variables": "环境变量", @@ -685,7 +695,10 @@ "model_placeholder": "选择要使用的模型", "model_required": "请选择模型", "select_folder": "选择文件夹", + "set_custom_path": "设置自定义终端路径", "supported_providers": "支持的服务商", + "terminal": "终端", + "terminal_placeholder": "选择终端应用", "title": "代码工具", "update_options": "更新选项", "working_directory": "工作目录" @@ -1064,10 +1077,6 @@ "error": { "failed": "迁移失败" }, - "migrate_to_langchain": { - "content": "知识库迁移不会删除旧知识库,而是创建一个副本之后重新处理所有知识库条目,可能消耗大量 tokens,请谨慎操作。", - "info": "知识库架构已更新,点击迁移到新架构" - }, "source_dimensions": "源维度", "source_model": "源模型", "target_dimensions": "目标维度", @@ -1086,20 +1095,6 @@ "quota": "{{name}} 剩余额度:{{quota}}", "quota_infinity": "{{name}} 剩余额度:无限制", "rename": "重命名", - "retriever": "检索模式", - "retriever_bm25": "全文搜索", - "retriever_bm25_desc": "根据关键字的相关性和频率查找文档。", - "retriever_hybrid": "混合搜索 (推荐)", - "retriever_hybrid_desc": "结合关键词搜索和语义搜索,以实现最佳检索准确性。", - "retriever_hybrid_weight": { - "bm25": "全文", - "recommended": "推荐", - "title": "混合搜索权重调整 (全文/向量)", - "vector": "向量" - }, - "retriever_tooltip": "使用不同的检索方式检索知识库", - "retriever_vector": "向量搜索", - "retriever_vector_desc": "根据语义相似性和含义查找文档。", "search": "搜索知识库", "search_placeholder": "输入查询内容", "settings": { @@ -2030,6 +2025,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "唯一AI (AiOnly)", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3124,6 +3120,13 @@ "placeholder": "/* 这里写自定义 CSS */" } }, + "font": { + "code": "代码字体", + "default": "默认", + "global": "全局字体", + "select": "选择字体", + "title": "字体设置" + }, "navbar": { "position": { "label": "导航栏位置", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6df4691ba0..056c733ae8 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -332,7 +332,8 @@ }, "new_topic": "新話題 {{Command}}", "pause": "暫停", - "placeholder": "在此輸入您的訊息,按 {{key}} 傳送...", + "placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具", + "placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送", "send": "傳送", "settings": "設定", "thinking": { @@ -362,8 +363,9 @@ "translate": "翻譯成 {{target_language}}", "translating": "翻譯中...", "upload": { + "attachment": "上傳附件", "document": "上傳文件(模型不支援圖片)", - "label": "上傳圖片或文件", + "image_or_document": "上傳圖片或文件", "upload_from_local": "上傳本地文件..." }, "url_context": "網頁上下文", @@ -659,13 +661,22 @@ "title": "話題", "unpin": "取消固定" }, - "translate": "翻譯" + "translate": "翻譯", + "web_search": { + "warning": { + "openai": "GPT-5 模型的最小推理力度不支援網路搜尋。" + } + } }, "code": { "auto_update_to_latest": "檢查更新並安裝最新版本", "bun_required_message": "運行 CLI 工具需要安裝 Bun 環境", "cli_tool": "CLI 工具", "cli_tool_placeholder": "選擇要使用的 CLI 工具", + "custom_path": "自訂路徑", + "custom_path_error": "設定自訂終端機路徑失敗", + "custom_path_required": "此終端機需要設定自訂路徑", + "custom_path_set": "自訂終端機路徑設定成功", "description": "快速啟動多個程式碼 CLI 工具,提高開發效率", "env_vars_help": "輸入自定義環境變數(每行一個,格式:KEY=value)", "environment_variables": "環境變數", @@ -684,7 +695,10 @@ "model_placeholder": "選擇要使用的模型", "model_required": "請選擇模型", "select_folder": "選擇資料夾", + "set_custom_path": "設定自訂終端機路徑", "supported_providers": "支援的供應商", + "terminal": "終端機", + "terminal_placeholder": "選擇終端機應用程式", "title": "程式碼工具", "update_options": "更新選項", "working_directory": "工作目錄" @@ -1063,10 +1077,6 @@ "error": { "failed": "遷移失敗" }, - "migrate_to_langchain": { - "content": "知識庫遷移不會刪除舊知識庫,而是建立一個副本後重新處理所有知識庫條目,可能消耗大量 tokens,請謹慎操作。", - "info": "知識庫架構已更新,點擊遷移到新架構" - }, "source_dimensions": "源維度", "source_model": "源模型", "target_dimensions": "目標維度", @@ -1085,20 +1095,6 @@ "quota": "{{name}} 剩餘配額:{{quota}}", "quota_infinity": "{{name}} 配額:無限制", "rename": "重新命名", - "retriever": "搜尋模式", - "retriever_bm25": "全文搜尋", - "retriever_bm25_desc": "根據關鍵字的相關性和頻率查找文件。", - "retriever_hybrid": "混合搜尋(推薦)", - "retriever_hybrid_desc": "結合關鍵字搜索和語義搜索,以實現最佳檢索準確性。", - "retriever_hybrid_weight": { - "bm25": "全文", - "recommended": "推薦", - "title": "混合搜尋權重調整 (全文/向量)", - "vector": "向量" - }, - "retriever_tooltip": "使用不同的檢索方式檢索知識庫", - "retriever_vector": "向量搜尋", - "retriever_vector_desc": "根據語意相似性和含義查找文件。", "search": "搜尋知識庫", "search_placeholder": "輸入查詢內容", "settings": { @@ -2029,6 +2025,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "唯一AI (AiOnly)", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3123,6 +3120,13 @@ "placeholder": "/* 這裡寫自訂 CSS */" } }, + "font": { + "code": "程式碼字型", + "default": "預設", + "global": "全域字型", + "select": "選擇字體", + "title": "字型設定" + }, "navbar": { "position": { "label": "導航欄位置", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 8f1c235ece..71aca53b82 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -362,8 +362,9 @@ "translate": "Μετάφραση στο {{target_language}}", "translating": "Μετάφραση...", "upload": { + "attachment": "Μεταφόρτωση συνημμένου", "document": "Φόρτωση έγγραφου (το μοντέλο δεν υποστηρίζει εικόνες)", - "label": "Φόρτωση εικόνας ή έγγραφου", + "image_or_document": "Μεταφόρτωση εικόνας ή εγγράφου", "upload_from_local": "Μεταφόρτωση αρχείου από τον υπολογιστή..." }, "url_context": "Περιεχόμενο ιστοσελίδας", @@ -659,13 +660,22 @@ "title": "Θέματα", "unpin": "Ξεκαρφίτσωμα" }, - "translate": "Μετάφραση" + "translate": "Μετάφραση", + "web_search": { + "warning": { + "openai": "Το μοντέλο GPT5 με ελάχιστη ένταση σκέψης δεν υποστηρίζει αναζήτηση στο διαδίκτυο" + } + } }, "code": { "auto_update_to_latest": "Έλεγχος για ενημερώσεις και εγκατάσταση της τελευταίας έκδοσης", "bun_required_message": "Για τη λειτουργία του εργαλείου CLI πρέπει να εγκαταστήσετε το περιβάλλον Bun", "cli_tool": "Εργαλείο CLI", "cli_tool_placeholder": "Επιλέξτε το CLI εργαλείο που θέλετε να χρησιμοποιήσετε", + "custom_path": "Προσαρμοσμένη διαδρομή", + "custom_path_error": "Η ρύθμιση της προσαρμοσμένης διαδρομής τερματικού απέτυχε", + "custom_path_required": "Αυτό το τερματικό απαιτεί τη ρύθμιση προσαρμοσμένης διαδρομής", + "custom_path_set": "Η προσαρμοσμένη διαδρομή τερματικού ορίστηκε με επιτυχία", "description": "Εκκίνηση γρήγορα πολλών εργαλείων CLI κώδικα, για αύξηση της αποδοτικότητας ανάπτυξης", "env_vars_help": "Εισαγάγετε προσαρμοσμένες μεταβλητές περιβάλλοντος (μία ανά γραμμή, με τη μορφή: KEY=value)", "environment_variables": "Μεταβλητές περιβάλλοντος", @@ -684,7 +694,10 @@ "model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε", "model_required": "Επιλέξτε μοντέλο", "select_folder": "Επιλογή φακέλου", + "set_custom_path": "Ρύθμιση προσαρμοσμένης διαδρομής τερματικού", "supported_providers": "υποστηριζόμενοι πάροχοι", + "terminal": "τερματικό", + "terminal_placeholder": "Επιλέξτε εφαρμογή τερματικού", "title": "Εργαλεία κώδικα", "update_options": "Ενημέρωση επιλογών", "working_directory": "κατάλογος εργασίας" @@ -1063,10 +1076,6 @@ "error": { "failed": "Αποτυχία μεταφοράς" }, - "migrate_to_langchain": { - "content": "Η μετανάστευση της βάσης γνώσεων δεν διαγράφει την παλιά βάση γνώσεων, αλλά δημιουργεί ένα αντίγραφο και στη συνέχεια επεξεργάζεται ξανά όλες τις εγγραφές της βάσης γνώσεων, κάτι που μπορεί να καταναλώσει μεγάλο αριθμό tokens, οπότε ενεργήστε με προσοχή.", - "info": "Η δομή της βάσης γνώσεων έχει ενημερωθεί, κάντε κλικ για μετεγκατάσταση στη νέα δομή" - }, "source_dimensions": "Πηγαίες διαστάσεις", "source_model": "Πηγαίο μοντέλο", "target_dimensions": "Προορισμένες διαστάσεις", @@ -1085,20 +1094,6 @@ "quota": "Διαθέσιμο όριο για {{name}}: {{quota}}", "quota_infinity": "Διαθέσιμο όριο για {{name}}: Απεριόριστο", "rename": "Μετονομασία", - "retriever": "Λειτουργία αναζήτησης", - "retriever_bm25": "Πλήρης αναζήτηση κειμένου", - "retriever_bm25_desc": "Αναζήτηση εγγράφων με βάση τη σχετικότητα και τη συχνότητα των λέξεων-κλειδιών.", - "retriever_hybrid": "Μικτή αναζήτηση (συνιστάται)", - "retriever_hybrid_desc": "Συνδυάστε την αναζήτηση με λέξεις-κλειδιά και την σημασιολογική αναζήτηση για την επίτευξη της βέλτιστης ακρίβειας ανάκτησης.", - "retriever_hybrid_weight": { - "bm25": "ολόκληρο το κείμενο", - "recommended": "Προτείνω", - "title": "Προσαρμογή βάρους μικτής αναζήτησης (πλήρες κείμενο/διανυσματικό)", - "vector": "διάνυσμα" - }, - "retriever_tooltip": "Χρησιμοποιώντας διαφορετικές μεθόδους αναζήτησης για αναζήτηση στη βάση γνώσης", - "retriever_vector": "Αναζήτηση διανυσμάτων", - "retriever_vector_desc": "Βρείτε έγγραφα βάση της σημασιολογικής ομοιότητας και της έννοιας.", "search": "Αναζήτηση βάσης γνώσεων", "search_placeholder": "Εισάγετε την αναζήτηση", "settings": { @@ -2022,6 +2017,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3116,6 +3112,13 @@ "placeholder": "/* Γράψτε εδώ την προσαρμοστική CSS */" } }, + "font": { + "code": "γραμματοσειρά κώδικα", + "default": "προεπιλογή", + "global": "Γενική γραμματοσειρά", + "select": "Επιλέξτε γραμματοσειρά", + "title": "Ρύθμιση γραμματοσειράς" + }, "navbar": { "position": { "label": "Θέση Γραμμής Πλοήγησης", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 905e720b8e..de3b5cb17f 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -362,8 +362,9 @@ "translate": "Traducir a {{target_language}}", "translating": "Traduciendo...", "upload": { + "attachment": "Subir archivo adjunto", "document": "Subir documento (el modelo no admite imágenes)", - "label": "Subir imagen o documento", + "image_or_document": "Subir imagen o documento", "upload_from_local": "Subir archivo local..." }, "url_context": "Contexto de la página web", @@ -659,13 +660,22 @@ "title": "Tema", "unpin": "Quitar fijación" }, - "translate": "Traducir" + "translate": "Traducir", + "web_search": { + "warning": { + "openai": "El modelo GPT5 con intensidad de pensamiento mínima no admite búsqueda en la web." + } + } }, "code": { "auto_update_to_latest": "Comprobar actualizaciones e instalar la versión más reciente", "bun_required_message": "Se requiere instalar el entorno Bun para ejecutar la herramienta de línea de comandos", "cli_tool": "Herramienta de línea de comandos", "cli_tool_placeholder": "Seleccione la herramienta de línea de comandos que desea utilizar", + "custom_path": "Ruta personalizada", + "custom_path_error": "Error al establecer la ruta de terminal personalizada", + "custom_path_required": "此终端需要设置自定义路径", + "custom_path_set": "Configuración de ruta de terminal personalizada exitosa", "description": "Inicia rápidamente múltiples herramientas de línea de comandos para código, aumentando la eficiencia del desarrollo", "env_vars_help": "Introduzca variables de entorno personalizadas (una por línea, formato: CLAVE=valor)", "environment_variables": "Variables de entorno", @@ -684,7 +694,10 @@ "model_placeholder": "Seleccionar el modelo que se va a utilizar", "model_required": "Seleccione el modelo", "select_folder": "Seleccionar carpeta", + "set_custom_path": "Establecer ruta de terminal personalizada", "supported_providers": "Proveedores de servicios compatibles", + "terminal": "terminal", + "terminal_placeholder": "Seleccionar aplicación de terminal", "title": "Herramientas de código", "update_options": "Opciones de actualización", "working_directory": "directorio de trabajo" @@ -1063,10 +1076,6 @@ "error": { "failed": "Error en la migración" }, - "migrate_to_langchain": { - "content": "La migración de la base de conocimiento no elimina la base antigua, sino que crea una copia y luego reprocesa todas las entradas, lo que puede consumir una gran cantidad de tokens. Proceda con precaución.", - "info": "La estructura de la base de conocimiento ha sido actualizada. Haz clic para migrar a la nueva estructura." - }, "source_dimensions": "Dimensiones de origen", "source_model": "Modelo de origen", "target_dimensions": "Dimensiones de destino", @@ -1085,20 +1094,6 @@ "quota": "Cupo restante de {{name}}: {{quota}}", "quota_infinity": "Cupo restante de {{name}}: ilimitado", "rename": "Renombrar", - "retriever": "modo de recuperación", - "retriever_bm25": "búsqueda de texto completo", - "retriever_bm25_desc": "Encontrar documentos basados en la relevancia y frecuencia de las palabras clave.", - "retriever_hybrid": "Búsqueda híbrida (recomendada)", - "retriever_hybrid_desc": "Combinar la búsqueda por palabras clave con la búsqueda semántica para lograr la máxima precisión en la recuperación.", - "retriever_hybrid_weight": { - "bm25": "texto completo", - "recommended": "Recomendado", - "title": "Ajuste de ponderación en búsqueda híbrida (texto completo/vectorial)", - "vector": "vector" - }, - "retriever_tooltip": "Usar diferentes métodos de búsqueda para consultar la base de conocimiento", - "retriever_vector": "búsqueda vectorial", - "retriever_vector_desc": "Buscar documentos según similitud semántica y significado.", "search": "Buscar en la base de conocimientos", "search_placeholder": "Ingrese el contenido de la consulta", "settings": { @@ -2022,6 +2017,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Antropológico", "aws-bedrock": "AWS Bedrock", @@ -3116,6 +3112,13 @@ "placeholder": "/* Escribe tu CSS personalizado aquí */" } }, + "font": { + "code": "fuente de código", + "default": "predeterminado", + "global": "Fuente global", + "select": "Seleccionar fuente", + "title": "Configuración de fuente" + }, "navbar": { "position": { "label": "Posición de la barra de navegación", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 5c2a0d2a5d..66a3a5b4ae 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -362,8 +362,9 @@ "translate": "Traduire en {{target_language}}", "translating": "Traduction en cours...", "upload": { + "attachment": "Télécharger la pièce jointe", "document": "Télécharger un document (le modèle ne prend pas en charge les images)", - "label": "Télécharger une image ou un document", + "image_or_document": "Télécharger une image ou un document", "upload_from_local": "Télécharger un fichier local..." }, "url_context": "Contexte de la page web", @@ -659,13 +660,22 @@ "title": "Sujet", "unpin": "Annuler le fixage" }, - "translate": "Traduire" + "translate": "Traduire", + "web_search": { + "warning": { + "openai": "Le modèle GPT5 avec une intensité de réflexion minimale ne prend pas en charge la recherche sur Internet." + } + } }, "code": { "auto_update_to_latest": "Vérifier les mises à jour et installer la dernière version", "bun_required_message": "L'exécution de l'outil en ligne de commande nécessite l'installation de l'environnement Bun", "cli_tool": "Outil CLI", "cli_tool_placeholder": "Sélectionnez l'outil CLI à utiliser", + "custom_path": "Chemin personnalisé", + "custom_path_error": "Échec de la définition du chemin de terminal personnalisé", + "custom_path_required": "Ce terminal nécessite la configuration d’un chemin personnalisé", + "custom_path_set": "Paramétrage personnalisé du chemin du terminal réussi", "description": "Lancer rapidement plusieurs outils CLI de code pour améliorer l'efficacité du développement", "env_vars_help": "Saisissez les variables d'environnement personnalisées (une par ligne, format : KEY=value)", "environment_variables": "variables d'environnement", @@ -684,7 +694,10 @@ "model_placeholder": "Sélectionnez le modèle à utiliser", "model_required": "Veuillez sélectionner le modèle", "select_folder": "Sélectionner le dossier", + "set_custom_path": "Définir un chemin de terminal personnalisé", "supported_providers": "fournisseurs pris en charge", + "terminal": "Terminal", + "terminal_placeholder": "Choisir une application de terminal", "title": "Outils de code", "update_options": "Options de mise à jour", "working_directory": "répertoire de travail" @@ -1063,10 +1076,6 @@ "error": { "failed": "Erreur lors de la migration" }, - "migrate_to_langchain": { - "content": "La migration de la base de connaissances ne supprime pas l'ancienne base, mais crée une copie avant de retraiter tous les éléments, ce qui peut consommer un grand nombre de tokens. Veuillez agir avec prudence.", - "info": "L'architecture de la base de connaissances a été mise à jour, cliquez pour migrer vers la nouvelle architecture." - }, "source_dimensions": "Dimensions source", "source_model": "Modèle source", "target_dimensions": "Dimensions cible", @@ -1085,20 +1094,6 @@ "quota": "Quota restant pour {{name}} : {{quota}}", "quota_infinity": "Quota restant pour {{name}} : illimité", "rename": "Renommer", - "retriever": "Mode de recherche", - "retriever_bm25": "Recherche plein texte", - "retriever_bm25_desc": "Rechercher des documents en fonction de la pertinence et de la fréquence des mots-clés.", - "retriever_hybrid": "Recherche hybride (recommandé)", - "retriever_hybrid_desc": "Associez la recherche par mots-clés et la recherche sémantique pour une précision de recherche optimale.", - "retriever_hybrid_weight": { - "bm25": "texte intégral", - "recommended": "Recommandé", - "title": "Ajustement des pondérations de recherche hybride (texte intégral/vecteur)", - "vector": "vecteur" - }, - "retriever_tooltip": "Utiliser différentes méthodes de recherche pour interroger la base de connaissances", - "retriever_vector": "Recherche vectorielle", - "retriever_vector_desc": "Rechercher des documents selon la similarité sémantique et le sens.", "search": "Rechercher dans la base de connaissances", "search_placeholder": "Entrez votre requête", "settings": { @@ -2022,6 +2017,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3116,6 +3112,13 @@ "placeholder": "/* Écrire votre CSS personnalisé ici */" } }, + "font": { + "code": "police de code", + "default": "Par défaut", + "global": "Police de caractère globale", + "select": "Sélectionner la police", + "title": "Paramètres de police" + }, "navbar": { "position": { "label": "Position de la barre de navigation", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 6c511623b3..ca1e1a2078 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -362,8 +362,9 @@ "translate": "{{target_language}}に翻訳", "translating": "翻訳中...", "upload": { + "attachment": "添付ファイルをアップロード", "document": "ドキュメントをアップロード(モデルは画像をサポートしません)", - "label": "画像またはドキュメントをアップロード", + "image_or_document": "画像またはドキュメントをアップロード", "upload_from_local": "ローカルファイルをアップロード..." }, "url_context": "URLコンテキスト", @@ -659,13 +660,22 @@ "title": "トピック", "unpin": "固定解除" }, - "translate": "翻訳" + "translate": "翻訳", + "web_search": { + "warning": { + "openai": "GPT5モデルの最小思考強度ではネット検索はサポートされません" + } + } }, "code": { "auto_update_to_latest": "最新バージョンを自動的に更新する", "bun_required_message": "CLI ツールを実行するには Bun 環境が必要です", "cli_tool": "CLI ツール", "cli_tool_placeholder": "使用する CLI ツールを選択してください", + "custom_path": "カスタムパス", + "custom_path_error": "カスタムターミナルパスの設定に失敗しました", + "custom_path_required": "この端末にはカスタムパスを設定する必要があります", + "custom_path_set": "カスタムターミナルパスの設定が成功しました", "description": "開発効率を向上させるために、複数のコード CLI ツールを迅速に起動します", "env_vars_help": "環境変数を設定して、CLI ツールの実行時に使用します。各変数は 1 行ごとに設定してください。", "environment_variables": "環境変数", @@ -684,7 +694,10 @@ "model_placeholder": "使用するモデルを選択してください", "model_required": "モデルを選択してください", "select_folder": "フォルダを選択", + "set_custom_path": "カスタムターミナルパスを設定", "supported_providers": "サポートされているプロバイダー", + "terminal": "端末", + "terminal_placeholder": "ターミナルアプリを選択", "title": "コードツール", "update_options": "更新オプション", "working_directory": "作業ディレクトリ" @@ -1063,10 +1076,6 @@ "error": { "failed": "移行が失敗しました" }, - "migrate_to_langchain": { - "content": "ナレッジベースの移行は旧ナレッジベースを削除せず、すべてのエントリーを再処理したコピーを作成します。大量のトークンを消費する可能性があるため、操作には十分注意してください。", - "info": "ナレッジベースのアーキテクチャが更新されました、新しいアーキテクチャに移行するにはクリックしてください" - }, "source_dimensions": "ソース次元", "source_model": "ソースモデル", "target_dimensions": "ターゲット次元", @@ -1085,20 +1094,6 @@ "quota": "{{name}} 残りクォータ: {{quota}}", "quota_infinity": "{{name}} クォータ: 無制限", "rename": "名前を変更", - "retriever": "検索モード", - "retriever_bm25": "全文検索", - "retriever_bm25_desc": "キーワードの関連性と頻度に基づいてドキュメントを検索します。", - "retriever_hybrid": "ハイブリッド検索(おすすめ)", - "retriever_hybrid_desc": "キーワード検索と意味検索を組み合わせて、最高の検索精度を実現します。", - "retriever_hybrid_weight": { - "bm25": "全文(ぜんぶん)", - "recommended": "おすすめ", - "title": "ハイブリッド検索の重み付け調整 (全文/ベクトル)", - "vector": "ベクトル" - }, - "retriever_tooltip": "異なる検索方法を使用してナレッジベースを検索する", - "retriever_vector": "ベクトル検索", - "retriever_vector_desc": "意味的な類似性と意味に基づいて文書を検索します。", "search": "ナレッジベースを検索", "search_placeholder": "検索するテキストを入力", "settings": { @@ -2022,6 +2017,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3116,6 +3112,13 @@ "placeholder": "/* ここにカスタムCSSを入力 */" } }, + "font": { + "code": "コードフォント", + "default": "デフォルト", + "global": "グローバルフォント", + "select": "フォントを選択", + "title": "フォント設定" + }, "navbar": { "position": { "label": "ナビゲーションバー位置", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 4d1edba05d..5c9bbf566e 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -362,8 +362,9 @@ "translate": "Traduzir para {{target_language}}", "translating": "Traduzindo...", "upload": { + "attachment": "Carregar anexo", "document": "Carregar documento (o modelo não suporta imagens)", - "label": "Carregar imagem ou documento", + "image_or_document": "Carregar imagem ou documento", "upload_from_local": "Fazer upload de arquivo local..." }, "url_context": "Contexto da Página da Web", @@ -659,13 +660,22 @@ "title": "Tópicos", "unpin": "Desfixar" }, - "translate": "Traduzir" + "translate": "Traduzir", + "web_search": { + "warning": { + "openai": "O modelo GPT5 com intensidade mínima de pensamento não suporta pesquisa na web" + } + } }, "code": { "auto_update_to_latest": "Verificar atualizações e instalar a versão mais recente", "bun_required_message": "Executar a ferramenta CLI requer a instalação do ambiente Bun", "cli_tool": "Ferramenta de linha de comando", "cli_tool_placeholder": "Selecione a ferramenta de linha de comando a ser utilizada", + "custom_path": "Caminho personalizado", + "custom_path_error": "Falha ao definir caminho de terminal personalizado", + "custom_path_required": "Este terminal requer a definição de um caminho personalizado", + "custom_path_set": "Configuração personalizada do caminho do terminal bem-sucedida", "description": "Inicie rapidamente várias ferramentas de linha de comando de código, aumentando a eficiência do desenvolvimento", "env_vars_help": "Insira variáveis de ambiente personalizadas (uma por linha, formato: CHAVE=valor)", "environment_variables": "variáveis de ambiente", @@ -684,7 +694,10 @@ "model_placeholder": "Selecione o modelo a ser utilizado", "model_required": "Selecione o modelo", "select_folder": "Selecionar pasta", + "set_custom_path": "Definir caminho personalizado do terminal", "supported_providers": "Provedores de serviço suportados", + "terminal": "terminal", + "terminal_placeholder": "Selecionar aplicativo de terminal", "title": "Ferramenta de código", "update_options": "Opções de atualização", "working_directory": "diretório de trabalho" @@ -1063,10 +1076,6 @@ "error": { "failed": "Falha na migração" }, - "migrate_to_langchain": { - "content": "A migração da base de conhecimento não elimina a base antiga, mas sim cria uma cópia e reprocessa todas as entradas, o que pode consumir muitos tokens. Por favor, proceda com cautela.", - "info": "A arquitetura da base de conhecimento foi atualizada, clique para migrar para a nova arquitetura." - }, "source_dimensions": "Dimensões de origem", "source_model": "Modelo de origem", "target_dimensions": "Dimensões de destino", @@ -1085,20 +1094,6 @@ "quota": "Cota restante de {{name}}: {{quota}}", "quota_infinity": "Cota restante de {{name}}: ilimitada", "rename": "Renomear", - "retriever": "Modo de pesquisa", - "retriever_bm25": "pesquisa de texto completo", - "retriever_bm25_desc": "Pesquisar documentos com base na relevância e frequência das palavras-chave.", - "retriever_hybrid": "Pesquisa híbrida (recomendada)", - "retriever_hybrid_desc": "Combine a pesquisa por palavras-chave com a pesquisa semântica para alcançar a melhor precisão de recuperação.", - "retriever_hybrid_weight": { - "bm25": "texto integral", - "recommended": "Recomendar", - "title": "Ajuste de ponderação de pesquisa híbrida (texto completo/vetorial)", - "vector": "vetor" - }, - "retriever_tooltip": "Utilize diferentes métodos de pesquisa para consultar a base de conhecimento.", - "retriever_vector": "pesquisa vetorial", - "retriever_vector_desc": "Encontrar documentos com base na similaridade semântica e significado.", "search": "Pesquisar repositório de conhecimento", "search_placeholder": "Digite o conteúdo da consulta", "settings": { @@ -2022,6 +2017,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Antropológico", "aws-bedrock": "AWS Bedrock", @@ -3116,6 +3112,13 @@ "placeholder": "/* Escreva seu CSS personalizado aqui */" } }, + "font": { + "code": "fonte de código", + "default": "padrão", + "global": "Fonte global", + "select": "Selecionar fonte", + "title": "Configuração de fonte" + }, "navbar": { "position": { "label": "Posição da Barra de Navegação", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 493a565508..ec50afccd0 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -362,8 +362,9 @@ "translate": "Перевести на {{target_language}}", "translating": "Перевод...", "upload": { + "attachment": "Загрузить вложение", "document": "Загрузить документ (модель не поддерживает изображения)", - "label": "Загрузить изображение или документ", + "image_or_document": "Загрузить изображение или документ", "upload_from_local": "Загрузить локальный файл..." }, "url_context": "Контекст страницы", @@ -659,13 +660,22 @@ "title": "Топики", "unpin": "Открепленные темы" }, - "translate": "Перевести" + "translate": "Перевести", + "web_search": { + "warning": { + "openai": "Модель GPT5 с минимальной интенсивностью мышления не поддерживает поиск в интернете" + } + } }, "code": { "auto_update_to_latest": "Автоматически обновлять до последней версии", "bun_required_message": "Запуск CLI-инструментов требует установки среды Bun", "cli_tool": "Инструмент", "cli_tool_placeholder": "Выберите CLI-инструмент для использования", + "custom_path": "Пользовательский путь", + "custom_path_error": "Не удалось задать пользовательский путь терминала", + "custom_path_required": "Этот терминал требует установки пользовательского пути", + "custom_path_set": "Пользовательский путь терминала успешно установлен", "description": "Быстро запускает несколько CLI-инструментов для кода, повышая эффективность разработки", "env_vars_help": "Установите переменные окружения для использования при запуске CLI-инструментов. Каждая переменная должна быть на отдельной строке в формате KEY=value", "environment_variables": "Переменные окружения", @@ -684,7 +694,10 @@ "model_placeholder": "Выберите модель для использования", "model_required": "Пожалуйста, выберите модель", "select_folder": "Выберите папку", + "set_custom_path": "Настройка пользовательского пути терминала", "supported_providers": "Поддерживаемые поставщики", + "terminal": "терминал", + "terminal_placeholder": "Выбор приложения терминала", "title": "Инструменты кода", "update_options": "Параметры обновления", "working_directory": "Рабочая директория" @@ -1063,10 +1076,6 @@ "error": { "failed": "Миграция завершена с ошибками" }, - "migrate_to_langchain": { - "content": "Миграция базы знаний не удаляет старую базу, а создает ее копию с последующей повторной обработкой всех записей, что может потребовать значительного количества токенов. Пожалуйста, действуйте осторожно.", - "info": "Архитектура базы знаний обновлена, нажмите, чтобы перейти на новую архитектуру" - }, "source_dimensions": "Исходная размерность", "source_model": "Исходная модель", "target_dimensions": "Целевая размерность", @@ -1085,20 +1094,6 @@ "quota": "{{name}} Остаток квоты: {{quota}}", "quota_infinity": "{{name}} Квота: Не ограничена", "rename": "Переименовать", - "retriever": "Режим поиска", - "retriever_bm25": "полнотекстовый поиск", - "retriever_bm25_desc": "Поиск документов на основе релевантности и частоты ключевых слов.", - "retriever_hybrid": "Гибридный поиск (рекомендуется)", - "retriever_hybrid_desc": "Сочетание поиска по ключевым словам и семантического поиска для достижения оптимальной точности поиска.", - "retriever_hybrid_weight": { - "bm25": "Полный текст", - "recommended": "рекомендовать", - "title": "Регулировка весов гибридного поиска (полнотекстовый/векторный)", - "vector": "вектор" - }, - "retriever_tooltip": "Использование различных методов поиска в базе знаний", - "retriever_vector": "векторный поиск", - "retriever_vector_desc": "Поиск документов по семантическому сходству и смыслу.", "search": "Поиск в базе знаний", "search_placeholder": "Введите текст для поиска", "settings": { @@ -2022,6 +2017,7 @@ "provider": { "302ai": "302.AI", "aihubmix": "AiHubMix", + "aionly": "AiOnly", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -3116,6 +3112,13 @@ "placeholder": "/* Здесь введите пользовательский CSS */" } }, + "font": { + "code": "шрифт кода", + "default": "по умолчанию", + "global": "Глобальный шрифт", + "select": "Выбрать шрифт", + "title": "Настройки шрифта" + }, "navbar": { "position": { "label": "Положение навигации", diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index bee260efa5..b64833f6d6 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -1,6 +1,7 @@ import AiProvider from '@renderer/aiCore' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import ModelSelector from '@renderer/components/ModelSelector' +import { isMac, isWin } from '@renderer/config/constant' import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' import { getProviderLogo } from '@renderer/config/providers' import { useCodeTools } from '@renderer/hooks/useCodeTools' @@ -12,9 +13,10 @@ import { loggerService } from '@renderer/services/LoggerService' import { getModelUniqId } from '@renderer/services/ModelService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled } from '@renderer/store/mcp' -import { Model } from '@renderer/types' -import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space } from 'antd' -import { ArrowUpRight, Download, HelpCircle, Terminal, X } from 'lucide-react' +import { EndpointType, Model } from '@renderer/types' +import { codeTools, terminalApps, TerminalConfig } from '@shared/config/constant' +import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd' +import { ArrowUpRight, Download, FolderOpen, HelpCircle, Terminal, X } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' @@ -26,6 +28,7 @@ import { CLI_TOOLS, generateToolEnvironment, getClaudeSupportedProviders, + OPENAI_CODEX_SUPPORTED_PROVIDERS, parseEnvironmentVariables } from '.' @@ -40,12 +43,14 @@ const CodeToolsPage: FC = () => { const { selectedCliTool, selectedModel, + selectedTerminal, environmentVariables, directories, currentDirectory, canLaunch, setCliTool, setModel, + setTerminal, setEnvVars, setCurrentDir, removeDir, @@ -56,21 +61,52 @@ const CodeToolsPage: FC = () => { const [isLaunching, setIsLaunching] = useState(false) const [isInstallingBun, setIsInstallingBun] = useState(false) const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false) + const [availableTerminals, setAvailableTerminals] = useState([]) + const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + const [terminalCustomPaths, setTerminalCustomPaths] = useState>({}) const modelPredicate = useCallback( (m: Model) => { if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) { return false } - if (m.provider === 'cherryin') { + + if (m.provider === 'cherryai') { return false } - if (selectedCliTool === 'claude-code') { + + if (selectedCliTool === codeTools.claudeCode) { + if (m.supported_endpoint_types) { + return m.supported_endpoint_types.includes('anthropic') + } return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider) } - if (selectedCliTool === 'gemini-cli') { + + if (selectedCliTool === codeTools.geminiCli) { + if (m.supported_endpoint_types) { + return m.supported_endpoint_types.includes('gemini') + } return m.id.includes('gemini') } + + if (selectedCliTool === codeTools.openaiCodex) { + if (m.supported_endpoint_types) { + return ['openai', 'openai-response'].some((type) => + m.supported_endpoint_types?.includes(type as EndpointType) + ) + } + return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider) + } + + if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) { + if (m.supported_endpoint_types) { + return ['openai', 'openai-response'].some((type) => + m.supported_endpoint_types?.includes(type as EndpointType) + ) + } + return true + } + return true }, [selectedCliTool] @@ -114,6 +150,26 @@ const CodeToolsPage: FC = () => { } }, [dispatch]) + // 获取可用终端 + const loadAvailableTerminals = useCallback(async () => { + if (!isMac && !isWin) return // 仅 macOS 和 Windows 支持 + + try { + setIsLoadingTerminals(true) + const terminals = await window.api.codeTools.getAvailableTerminals() + setAvailableTerminals(terminals) + logger.info( + `Found ${terminals.length} available terminals:`, + terminals.map((t) => t.name) + ) + } catch (error) { + logger.error('Failed to load available terminals:', error as Error) + setAvailableTerminals([]) + } finally { + setIsLoadingTerminals(false) + } + }, []) + // 安装 bun const handleInstallBun = async () => { try { @@ -153,8 +209,8 @@ const CodeToolsPage: FC = () => { const modelProvider = getProviderByModel(selectedModel) const aiProvider = new AiProvider(modelProvider) - const baseUrl = await aiProvider.getBaseURL() - const apiKey = await aiProvider.getApiKey() + const baseUrl = aiProvider.getBaseURL() + const apiKey = aiProvider.getApiKey() // 生成工具特定的环境变量 const toolEnv = generateToolEnvironment({ @@ -173,10 +229,38 @@ const CodeToolsPage: FC = () => { // 执行启动操作 const executeLaunch = async (env: Record) => { - window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { autoUpdateToLatest }) + window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { + autoUpdateToLatest, + terminal: selectedTerminal + }) window.toast.success(t('code.launch.success')) } + // 设置终端自定义路径 + const handleSetCustomPath = async (terminalId: string) => { + try { + const result = await window.api.file.select({ + properties: ['openFile'], + filters: [ + { name: 'Executable', extensions: ['exe'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + + if (result && result.length > 0) { + const path = result[0].path + await window.api.codeTools.setCustomTerminalPath(terminalId, path) + setTerminalCustomPaths((prev) => ({ ...prev, [terminalId]: path })) + window.toast.success(t('code.custom_path_set')) + // Reload terminals to reflect changes + loadAvailableTerminals() + } + } catch (error) { + logger.error('Failed to set custom terminal path:', error as Error) + window.toast.error(t('code.custom_path_error')) + } + } + // 处理启动 const handleLaunch = async () => { const validation = validateLaunch() @@ -197,7 +281,7 @@ const CodeToolsPage: FC = () => { await executeLaunch(env) } catch (error) { - logger.error('启动失败:', error as Error) + logger.error('start code tools failed:', error as Error) window.toast.error(t('code.launch.error')) } finally { setIsLaunching(false) @@ -209,6 +293,11 @@ const CodeToolsPage: FC = () => { checkBunInstallation() }, [checkBunInstallation]) + // 页面加载时获取可用终端 + useEffect(() => { + loadAvailableTerminals() + }, [loadAvailableTerminals]) + return ( @@ -343,6 +432,47 @@ const CodeToolsPage: FC = () => {
{t('code.env_vars_help')}
+ {/* 终端选择 (macOS 和 Windows) */} + {(isMac || isWin) && availableTerminals.length > 0 && ( + +
{t('code.terminal')}
+ + ), - Segmented: ({ value, onChange, options, style }: any) => ( -
- {options?.map((option: any) => ( - - ))} -
- ), Slider: ({ value, onChange, min, max, step, marks, style }: any) => { // Determine test ID based on slider characteristics const isWeightSlider = min === 0 && max === 1 && step === 0.1 @@ -193,14 +175,10 @@ function createKnowledgeBase(overrides: Partial = {}): KnowledgeB id: 'test-base-id', name: 'Test Knowledge Base', model: defaultModel, - retriever: { - mode: 'hybrid' - }, items: [], created_at: Date.now(), updated_at: Date.now(), version: 1, - framework: 'langchain', ...overrides } } @@ -319,42 +297,6 @@ describe('GeneralSettingsPanel', () => { expect(mockSetNewBase).toHaveBeenCalledWith(expect.any(Function)) }) - it('should handle hybrid weight change', async () => { - renderComponent() - - const weightSlider = screen.getByTestId('weight-slider') - fireEvent.change(weightSlider, { target: { value: '0.7' } }) - - expect(mockSetNewBase).toHaveBeenCalledWith({ - ...mockBase, - retriever: { - ...mockBase.retriever, - mode: 'hybrid', - weight: 0.7 - } - }) - }) - - it('should handle retriever selection change', async () => { - renderComponent() - - // Test clicking on hybrid retriever option - const hybridOption = screen.getByTestId('segmented-option-hybrid') - await user.click(hybridOption) - - expect(mockSetNewBase).toHaveBeenCalledWith({ - ...mockBase, - retriever: { mode: 'hybrid' } - }) - }) - - it('should not render retriever segmented when framework is embedjs', () => { - const baseWithEmbedjs = createKnowledgeBase({ framework: 'embedjs' }) - renderComponent({ newBase: baseWithEmbedjs }) - - expect(screen.queryByTestId('retriever-segmented')).not.toBeInTheDocument() - }) - it('should disable dimension input when no model is selected', () => { const baseWithoutModel = createKnowledgeBase({ model: undefined as any }) renderComponent({ newBase: baseWithoutModel }) diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap index dddbeebbbe..ad9c3da240 100644 --- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap @@ -170,69 +170,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = ` -
-
- knowledge.retriever - - ℹ️ - -
-
- - - -
-
-
-
- knowledge.retriever_hybrid_weight.title -
- -
diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap index e7aeaffd38..3e6ec8336a 100644 --- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap @@ -65,7 +65,7 @@ exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] = data-title="Knowledge Base Settings" styles="[object Object]" transitionname="animation-move-down" - width="min(900px, 75vw)" + width="min(900px, 65vw)" >
= ({ title, resolve }) => { const _newBase: KnowledgeBase = { ...newBase, created_at: Date.now(), - updated_at: Date.now(), - framework: 'langchain' + updated_at: Date.now() } await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase)) diff --git a/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx b/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx index 0f1490238c..82dfc02a9c 100644 --- a/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx @@ -4,7 +4,7 @@ import { TopView } from '@renderer/components/TopView' import { useKnowledge } from '@renderer/hooks/useKnowledge' import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm' import { getModelUniqId } from '@renderer/services/ModelService' -import { KnowledgeBase, MigrationModeEnum } from '@renderer/types' +import { KnowledgeBase } from '@renderer/types' import { formatErrorMessage } from '@renderer/utils/error' import { Flex } from 'antd' import { useCallback, useMemo, useState } from 'react' @@ -48,7 +48,7 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) const handleEmbeddingModelChangeMigration = useCallback(async () => { const migratedBase = { ...newBase, id: nanoid() } try { - await migrateBase(migratedBase, MigrationModeEnum.EmbeddingModelChange) + await migrateBase(migratedBase) setOpen(false) resolve(migratedBase) } catch (error) { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx index 5b01da00ed..c5125c4aff 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx @@ -6,7 +6,7 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { KnowledgeBase, PreprocessProvider } from '@renderer/types' -import { Input, Segmented, Select, SelectProps, Slider } from 'antd' +import { Input, Select, SelectProps, Slider } from 'antd' import { useTranslation } from 'react-i18next' import { SettingsItem, SettingsPanel } from './styles' @@ -106,55 +106,6 @@ const GeneralSettingsPanel: React.FC = ({ /> - {newBase.framework !== 'embedjs' && ( - <> - -
- {t('knowledge.retriever')} - -
- - setNewBase({ ...newBase, retriever: { mode: value as 'vector' | 'bm25' | 'hybrid' } }) - } - options={[ - { label: t('knowledge.retriever_hybrid'), value: 'hybrid' }, - { label: t('knowledge.retriever_vector'), value: 'vector' }, - { label: t('knowledge.retriever_bm25'), value: 'bm25' } - ]} - /> -
- {newBase.retriever?.mode === 'hybrid' && ( - -
{t('knowledge.retriever_hybrid_weight.title')}
- - setNewBase({ - ...newBase, - retriever: { - ...newBase.retriever, - mode: 'hybrid', - weight: value - } - }) - } - /> -
- )} - - )} -
{t('knowledge.document_count')} diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx index 73c55fb06b..25ee7cbe8c 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx @@ -25,9 +25,9 @@ const KnowledgeBaseFormModal: React.FC = ({ panels, maskClosable={false} centered transitionName="animation-move-down" - width="min(900px, 75vw)" + width="min(900px, 65vw)" styles={{ - body: { padding: 0, height: 700 }, + body: { padding: 0, height: 550 }, header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', diff --git a/src/renderer/src/pages/knowledge/components/MigrationInfoTag.tsx b/src/renderer/src/pages/knowledge/components/MigrationInfoTag.tsx deleted file mode 100644 index 7af67f2f30..0000000000 --- a/src/renderer/src/pages/knowledge/components/MigrationInfoTag.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { loggerService } from '@logger' -import { nanoid } from '@reduxjs/toolkit' -import { useKnowledge } from '@renderer/hooks/useKnowledge' -import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm' -import { KnowledgeBase, MigrationModeEnum } from '@renderer/types' -import { formatErrorMessage } from '@renderer/utils/error' -import { Flex, Tag } from 'antd' -import { FC, useCallback } from 'react' -import { useTranslation } from 'react-i18next' - -const logger = loggerService.withContext('MigrationInfoTag') - -const MigrationInfoTag: FC<{ base: KnowledgeBase }> = ({ base: _base }) => { - const { t } = useTranslation() - const { migrateBase } = useKnowledge(_base.id) - const { newBase } = useKnowledgeBaseForm(_base) - - // 处理嵌入模型更改迁移 - const handleMigration = useCallback(async () => { - const migratedBase = { ...newBase, id: nanoid() } - try { - await migrateBase(migratedBase, MigrationModeEnum.MigrationToLangChain) - } catch (error) { - logger.error('KnowledgeBase migration failed:', error as Error) - window.toast.error(t('knowledge.migrate.error.failed') + ': ' + formatErrorMessage(error)) - } - }, [newBase, migrateBase, t]) - - const onClick = async () => { - window.modal.confirm({ - title: t('knowledge.migrate.confirm.title'), - content: ( - - {t('knowledge.migrate.migrate_to_langchain.content')} - - ), - okText: t('knowledge.migrate.confirm.ok'), - centered: true, - onOk: handleMigration - }) - } - - return ( - - {t('knowledge.migrate.migrate_to_langchain.info')} - - ) -} - -export default MigrationInfoTag diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index 66bcfb0412..b6f29ce5d1 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -31,18 +31,10 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quot const userId = getStoreSetting('userId') const baseParams = getKnowledgeBaseParams(base) try { - let response: number - if (base.framework === 'langchain') { - response = await window.api.knowledgeBase.checkQuota({ - base: baseParams, - userId: userId as string - }) - } else { - response = await window.api.knowledgeBase.checkQuota({ - base: baseParams, - userId: userId as string - }) - } + const response = await window.api.knowledgeBase.checkQuota({ + base: baseParams, + userId: userId as string + }) setQuota(response) } catch (error) { logger.error('[KnowledgeContent] Error checking quota:', error as Error) diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx index 9629b3c56d..c85afab22c 100644 --- a/src/renderer/src/pages/minapps/MinAppPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -44,11 +44,20 @@ const MinAppPage: FC = () => { } }, [isTopNavbar]) - // Find the app from all available apps + // Find the app from all available apps (including cached ones) const app = useMemo(() => { if (!appId) return null - return [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) - }, [appId, minapps]) + + // First try to find in default and custom mini-apps + let foundApp = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId) + + // If not found and we have cache, try to find in cache (for temporary apps) + if (!foundApp && minAppsCache) { + foundApp = minAppsCache.get(appId) + } + + return foundApp + }, [appId, minapps, minAppsCache]) useEffect(() => { // If app not found, redirect to apps list diff --git a/src/renderer/src/pages/minapps/MinAppsPage.tsx b/src/renderer/src/pages/minapps/MinAppsPage.tsx index db82bf43a3..5aadc22020 100644 --- a/src/renderer/src/pages/minapps/MinAppsPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppsPage.tsx @@ -46,10 +46,7 @@ const AppsPage: FC = () => { style={{ width: '30%', height: 28, - borderRadius: 15, - position: 'absolute', - left: '50vw', - transform: 'translateX(-50%)' + borderRadius: 15 }} size="small" variant="filled" @@ -107,6 +104,7 @@ const Container = styled.div` flex: 1; flex-direction: column; height: 100%; + overflow: hidden; ` const ContentContainer = styled.div` @@ -132,11 +130,13 @@ const MainContainer = styled.div` flex: 1; flex-direction: row; height: calc(100vh - var(--navbar-height)); + width: 100%; ` const RightContainer = styled(Scrollbar)` display: flex; - flex: 1; + flex: 1 1 0%; + min-width: 0; flex-direction: column; height: 100%; align-items: center; @@ -150,6 +150,7 @@ const AppsContainerWrapper = styled(Scrollbar)` justify-content: center; padding: 50px 0; width: 100%; + margin-bottom: 20px; [navbar-position='top'] & { padding: 20px 0; } @@ -159,6 +160,7 @@ const AppsContainer = styled.div` display: grid; min-width: 0; max-width: 930px; + margin: 0 20px; width: 100%; grid-template-columns: repeat(auto-fill, 90px); gap: 25px; diff --git a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx index 60a43b8684..dcbe7adeff 100644 --- a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx +++ b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx @@ -8,6 +8,7 @@ import { PushpinOutlined, ReloadOutlined } from '@ant-design/icons' +import { isDev } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' import { useSettings } from '@renderer/hooks/useSettings' @@ -37,8 +38,6 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp const navigate = useNavigate() const [canGoBack, setCanGoBack] = useState(false) const [canGoForward, setCanGoForward] = useState(false) - - const isInDevelopment = process.env.NODE_ENV === 'development' const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id) const isPinned = pinned.some((item) => item.id === app.id) const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') @@ -139,7 +138,7 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp - {isInDevelopment && ( + {isDev && ( diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index 81ed5eca80..8288ed71db 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -5,12 +5,11 @@ import { HStack } from '@renderer/components/Layout' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' -import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService' -import { NotesTreeNode } from '@types' -import { Dropdown, Tooltip } from 'antd' +import { findNode } from '@renderer/services/NotesTreeService' +import { Dropdown, Input, Tooltip } from 'antd' import { t } from 'i18next' import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' import { menuItems } from './MenuConfig' @@ -18,12 +17,22 @@ import { ExportContext, handleExportPDF } from './utils/exportUtils' const logger = loggerService.withContext('HeaderNavbar') -const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRef, currentContent }) => { +const HeaderNavbar = ({ + notesTree, + editorRef, + currentContent, + getCurrentNoteContent, + onToggleStar, + onExpandPath, + onRenameNode, +}) => { const { showWorkspace, toggleShowWorkspace } = useShowWorkspace() const { activeNode } = useActiveNode(notesTree) const [breadcrumbItems, setBreadcrumbItems] = useState< Array<{ key: string; title: string; treePath: string; isFolder: boolean }> >([]) + const [titleValue, setTitleValue] = useState('') + const titleInputRef = useRef(null) const { settings, updateSettings } = useNotesSettings() const canShowStarButton = activeNode?.type === 'file' && onToggleStar @@ -53,37 +62,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRe }, [getCurrentNoteContent]) const handleBreadcrumbClick = useCallback( - async (item: { treePath: string; isFolder: boolean }) => { - if (item.isFolder && notesTree) { - try { - // 获取从根目录到点击目录的所有路径片段 - const pathParts = item.treePath.split('/').filter(Boolean) - const expandPromises: Promise[] = [] - - // 逐级展开从根到目标路径的所有文件夹 - for (let i = 0; i < pathParts.length; i++) { - const currentPath = '/' + pathParts.slice(0, i + 1).join('/') - const folderNode = findNodeByPath(notesTree, currentPath) - - if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) { - expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true })) - } - } - - // 并行执行所有展开操作 - if (expandPromises.length > 0) { - await Promise.all(expandPromises) - logger.info('Expanded folder path from breadcrumb:', { - targetPath: item.treePath, - expandedCount: expandPromises.length - }) - } - } catch (error) { - logger.error('Failed to expand folder path from breadcrumb:', error as Error) - } + (item: { treePath: string; isFolder: boolean }) => { + if (item.isFolder && onExpandPath) { + onExpandPath(item.treePath) } }, - [notesTree] + [onExpandPath] + ) + + const handleTitleChange = useCallback((e: React.ChangeEvent) => { + setTitleValue(e.target.value) + }, []) + + const handleTitleBlur = useCallback(() => { + if (activeNode && titleValue.trim() && titleValue.trim() !== activeNode.name.replace('.md', '')) { + onRenameNode?.(activeNode.id, titleValue.trim()) + } else if (activeNode) { + // 如果没有更改或为空,恢复原始值 + setTitleValue(activeNode.name.replace('.md', '')) + } + }, [activeNode, titleValue, onRenameNode]) + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + titleInputRef.current?.blur() + } else if (e.key === 'Escape') { + e.preventDefault() + if (activeNode) { + setTitleValue(activeNode.name.replace('.md', '')) + } + titleInputRef.current?.blur() + } + }, + [activeNode] ) const handleExportPDFAction = useCallback(async () => { @@ -145,13 +158,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRe } } + // 同步标题值 + useEffect(() => { + if (activeNode?.type === 'file') { + setTitleValue(activeNode.name.replace('.md', '')) + } + }, [activeNode]) + // 构建面包屑路径 useEffect(() => { if (!activeNode || !notesTree) { setBreadcrumbItems([]) return } - const node = findNodeInTree(notesTree, activeNode.id) + const node = findNode(notesTree, activeNode.id) if (!node) return const pathParts = node.treePath.split('/').filter(Boolean) @@ -191,16 +211,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, editorRe - - {breadcrumbItems.map((item, index) => ( - - handleBreadcrumbClick(item)} - $clickable={item.isFolder && index < breadcrumbItems.length - 1}> - {item.title} - - - ))} + + {breadcrumbItems.map((item, index) => { + const isLastItem = index === breadcrumbItems.length - 1 + const isCurrentNote = isLastItem && !item.isFolder + + return ( + + {isCurrentNote ? ( + + + + ) : ( + handleBreadcrumbClick(item)} + $clickable={item.isFolder && !isLastItem}> + {item.title} + + )} + + ) + })} @@ -315,6 +360,30 @@ export const BreadcrumbsContainer = styled.div` align-items: center; } + /* 最后一个面包屑项(当前笔记)可以扩展 */ + & li:last-child { + flex: 1 !important; + min-width: 0 !important; + max-width: none !important; + } + + /* 覆盖 HeroUI BreadcrumbItem 的样式 */ + & li:last-child [data-slot="item"] { + flex: 1 !important; + width: 100% !important; + max-width: none !important; + } + + /* 更强的样式覆盖 */ + & li:last-child * { + max-width: none !important; + } + + & li:last-child > * { + flex: 1 !important; + width: 100% !important; + } + /* 确保分隔符不会与标题重叠 */ & li:not(:last-child)::after { flex-shrink: 0; @@ -342,4 +411,64 @@ export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>` `} ` +export const TitleInputWrapper = styled.div` + width: 100%; + flex: 1; + min-width: 0; + max-width: none; + display: flex; + align-items: center; +` + +export const TitleInput = styled(Input)` + &&& { + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + font-family: inherit !important; + padding: 0 !important; + height: auto !important; + line-height: inherit !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + flex: 1 !important; + + &:focus, + &:hover { + border: none !important; + box-shadow: none !important; + background: transparent !important; + } + + &::placeholder { + color: var(--color-text-3) !important; + } + + input { + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + font-family: inherit !important; + padding: 0 !important; + height: auto !important; + line-height: inherit !important; + width: 100% !important; + + &:focus, + &:hover { + border: none !important; + box-shadow: none !important; + background: transparent !important; + } + } + } +` + export default HeaderNavbar diff --git a/src/renderer/src/pages/notes/NotesEditor.tsx b/src/renderer/src/pages/notes/NotesEditor.tsx index a9ed8f592f..8bdd44d12c 100644 --- a/src/renderer/src/pages/notes/NotesEditor.tsx +++ b/src/renderer/src/pages/notes/NotesEditor.tsx @@ -5,7 +5,7 @@ import { RichEditorRef } from '@renderer/components/RichEditor/types' import Selector from '@renderer/components/Selector' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { EditorView } from '@renderer/types' -import { Empty, Spin } from 'antd' +import { Empty } from 'antd' import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -14,13 +14,12 @@ interface NotesEditorProps { activeNodeId?: string currentContent: string tokenCount: number - isLoading: boolean editorRef: RefObject onMarkdownChange: (content: string) => void } const NotesEditor: FC = memo( - ({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => { + ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => { const { t } = useTranslation() const { settings } = useNotesSettings() const currentViewMode = useMemo(() => { @@ -47,14 +46,6 @@ const NotesEditor: FC = memo( ) } - if (isLoading) { - return ( - - - - ) - } - return ( <> @@ -122,14 +113,6 @@ const NotesEditor: FC = memo( NotesEditor.displayName = 'NotesEditor' -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; -` - const EmptyContainer = styled.div` display: flex; justify-content: center; diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 548ff2b51f..3e004ed759 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' import { - createFolder, - createNote, - deleteNode, - initWorkSpace, - moveNode, - renameNode, - sortAllLevels, - uploadFiles + addDir, + addNote, + delNode, + loadTree, + renameNode as renameEntry, + sortTree, + uploadNotes } from '@renderer/services/NotesService' -import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note' +import { + addUniquePath, + findNode, + findNodeByPath, + findParent, + normalizePathValue, + removePathEntries, + reorderTreeNodes, + replacePathEntries, + updateTreeNode +} from '@renderer/services/NotesTreeService' +import { useAppDispatch, useAppSelector, useAppStore } from '@renderer/store' +import { + selectActiveFilePath, + selectExpandedPaths, + selectSortType, + selectStarredPaths, + setActiveFilePath, + setExpandedPaths, + setSortType, + setStarredPaths +} from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { FileChangeEvent } from '@shared/config/types' -import { useLiveQuery } from 'dexie-react-hooks' import { debounce } from 'lodash' import { AnimatePresence, motion } from 'motion/react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -38,27 +55,98 @@ const NotesPage: FC = () => { const { t } = useTranslation() const { showWorkspace } = useShowWorkspace() const dispatch = useAppDispatch() + const store = useAppStore() const activeFilePath = useAppSelector(selectActiveFilePath) const sortType = useAppSelector(selectSortType) + const starredPaths = useAppSelector(selectStarredPaths) + const expandedPaths = useAppSelector(selectExpandedPaths) const { settings, notesPath, updateNotesPath } = useNotesSettings() // 混合策略:useLiveQuery用于笔记树,React Query用于文件内容 - const notesTreeQuery = useLiveQuery(() => getNotesTree(), []) - const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery]) + const [notesTree, setNotesTree] = useState([]) + const starredSet = useMemo(() => new Set(starredPaths), [starredPaths]) + const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths]) const { activeNode } = useActiveNode(notesTree) const { invalidateFileContent } = useFileContentSync() - const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath) + const { data: currentContent = '' } = useFileContent(activeFilePath) const [tokenCount, setTokenCount] = useState(0) const [selectedFolderId, setSelectedFolderId] = useState(null) const watcherRef = useRef<(() => void) | null>(null) - const isSyncingTreeRef = useRef(false) const lastContentRef = useRef('') const lastFilePathRef = useRef(undefined) - const isInitialSortApplied = useRef(false) const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) + const activeFilePathRef = useRef(activeFilePath) + const currentContentRef = useRef(currentContent) + + const updateStarredPaths = useCallback( + (updater: (paths: string[]) => string[]) => { + const current = store.getState().note.starredPaths + const safeCurrent = Array.isArray(current) ? current : [] + const next = updater(safeCurrent) ?? [] + if (!Array.isArray(next)) { + return + } + if (next !== safeCurrent) { + dispatch(setStarredPaths(next)) + } + }, + [dispatch, store] + ) + + const updateExpandedPaths = useCallback( + (updater: (paths: string[]) => string[]) => { + const current = store.getState().note.expandedPaths + const safeCurrent = Array.isArray(current) ? current : [] + const next = updater(safeCurrent) ?? [] + if (!Array.isArray(next)) { + return + } + if (next !== safeCurrent) { + dispatch(setExpandedPaths(next)) + } + }, + [dispatch, store] + ) + + const mergeTreeState = useCallback( + (nodes: NotesTreeNode[]): NotesTreeNode[] => { + return nodes.map((node) => { + const normalizedPath = normalizePathValue(node.externalPath) + const merged: NotesTreeNode = { + ...node, + externalPath: normalizedPath, + isStarred: starredSet.has(normalizedPath) + } + + if (node.type === 'folder') { + merged.expanded = expandedSet.has(normalizedPath) + merged.children = node.children ? mergeTreeState(node.children) : [] + } + + return merged + }) + }, + [starredSet, expandedSet] + ) + + const refreshTree = useCallback(async () => { + if (!notesPath) { + setNotesTree([]) + return + } + + try { + const rawTree = await loadTree(notesPath) + const sortedTree = sortTree(rawTree, sortType) + setNotesTree(mergeTreeState(sortedTree)) + } catch (error) { + logger.error('Failed to refresh notes tree:', error as Error) + } + }, [mergeTreeState, notesPath, sortType]) + useEffect(() => { const updateCharCount = () => { const textContent = editorRef.current?.getContent() || currentContent @@ -68,19 +156,16 @@ const NotesPage: FC = () => { updateCharCount() }, [currentContent]) - // 查找树节点 by ID - const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => { - for (const node of tree) { - if (node.id === nodeId) { - return node - } - if (node.children) { - const found = findNodeById(node.children, nodeId) - if (found) return found - } + useEffect(() => { + refreshTree() + }, [refreshTree]) + + // Re-merge tree state when starred or expanded paths change + useEffect(() => { + if (notesTree.length > 0) { + setNotesTree((prev) => mergeTreeState(prev)) } - return null - }, []) + }, [starredPaths, expandedPaths, mergeTreeState, notesTree.length]) // 保存当前笔记内容 const saveCurrentNote = useCallback( @@ -108,6 +193,11 @@ const NotesPage: FC = () => { [saveCurrentNote] ) + const saveCurrentNoteRef = useRef(saveCurrentNote) + const debouncedSaveRef = useRef(debouncedSave) + const invalidateFileContentRef = useRef(invalidateFileContent) + const refreshTreeRef = useRef(refreshTree) + const handleMarkdownChange = useCallback( (newMarkdown: string) => { // 记录最新内容和文件路径,用于兜底保存 @@ -119,6 +209,30 @@ const NotesPage: FC = () => { [debouncedSave, activeFilePath] ) + useEffect(() => { + activeFilePathRef.current = activeFilePath + }, [activeFilePath]) + + useEffect(() => { + currentContentRef.current = currentContent + }, [currentContent]) + + useEffect(() => { + saveCurrentNoteRef.current = saveCurrentNote + }, [saveCurrentNote]) + + useEffect(() => { + debouncedSaveRef.current = debouncedSave + }, [debouncedSave]) + + useEffect(() => { + invalidateFileContentRef.current = invalidateFileContent + }, [invalidateFileContent]) + + useEffect(() => { + refreshTreeRef.current = refreshTree + }, [refreshTree]) + useEffect(() => { async function initialize() { if (!notesPath) { @@ -134,29 +248,12 @@ const NotesPage: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [notesPath]) - // 应用初始排序 - useEffect(() => { - async function applyInitialSort() { - if (notesTree.length > 0 && !isInitialSortApplied.current) { - try { - await sortAllLevels(sortType) - isInitialSortApplied.current = true - } catch (error) { - logger.error('Failed to apply initial sorting:', error as Error) - } - } - } - - applyInitialSort() - }, [notesTree.length, sortType]) - // 处理树同步时的状态管理 useEffect(() => { if (notesTree.length === 0) return // 如果有activeFilePath但找不到对应节点,清空选择 // 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空 - const shouldClearPath = - activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current + const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current if (shouldClearPath) { logger.warn('Clearing activeFilePath - node not found in tree', { @@ -168,7 +265,7 @@ const NotesPage: FC = () => { }, [notesTree, activeFilePath, activeNode, dispatch]) useEffect(() => { - if (!notesPath || notesTree.length === 0) return + if (!notesPath) return async function startFileWatcher() { // 清理之前的监控 @@ -182,31 +279,14 @@ const NotesPage: FC = () => { try { if (!notesPath) return const { eventType, filePath } = data + const normalizedEventPath = normalizePathValue(filePath) switch (eventType) { case 'change': { // 处理文件内容变化 - 只有内容真正改变时才触发更新 - if (activeFilePath === filePath) { - try { - // 读取文件最新内容 - // const newFileContent = await window.api.file.readExternal(filePath) - // // 获取当前编辑器/缓存中的内容 - // const currentEditorContent = editorRef.current?.getMarkdown() - // // 如果编辑器还未初始化完成,忽略FileWatcher事件 - // if (!isEditorInitialized.current) { - // return - // } - // // 比较内容是否真正发生变化 - // if (newFileContent.trim() !== currentEditorContent?.trim()) { - // invalidateFileContent(filePath) - // } - } catch (error) { - logger.error('Failed to read file for content comparison:', error as Error) - // 读取失败时,还是执行原来的逻辑 - invalidateFileContent(filePath) - } - } else { - await initWorkSpace(notesPath, sortType) + const activePath = activeFilePathRef.current + if (activePath && normalizePathValue(activePath) === normalizedEventPath) { + invalidateFileContentRef.current?.(normalizedEventPath) } break } @@ -216,20 +296,18 @@ const NotesPage: FC = () => { case 'unlink': case 'unlinkDir': { // 如果删除的是当前活动文件,清空选择 - if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) { + if ( + (eventType === 'unlink' || eventType === 'unlinkDir') && + activeFilePathRef.current && + normalizePathValue(activeFilePathRef.current) === normalizedEventPath + ) { dispatch(setActiveFilePath(undefined)) + editorRef.current?.clear() } - // 设置同步标志,避免竞态条件 - isSyncingTreeRef.current = true - - // 重新同步数据库,useLiveQuery会自动响应数据库变化 - try { - await initWorkSpace(notesPath, sortType) - } catch (error) { - logger.error('Failed to sync database:', error as Error) - } finally { - isSyncingTreeRef.current = false + const refresh = refreshTreeRef.current + if (refresh) { + await refresh() } break } @@ -262,26 +340,19 @@ const NotesPage: FC = () => { }) // 如果有未保存的内容,立即保存 - if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) { - saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => { - logger.error('Emergency save failed:', error as Error) - }) + if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) { + const saveFn = saveCurrentNoteRef.current + if (saveFn) { + saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => { + logger.error('Emergency save failed:', error as Error) + }) + } } // 清理防抖函数 - debouncedSave.cancel() + debouncedSaveRef.current?.cancel() } - }, [ - notesPath, - notesTree.length, - activeFilePath, - invalidateFileContent, - dispatch, - currentContent, - debouncedSave, - saveCurrentNote, - sortType - ]) + }, [dispatch, notesPath]) useEffect(() => { const editor = editorRef.current @@ -317,13 +388,13 @@ const NotesPage: FC = () => { // 获取目标文件夹路径(选中文件夹或根目录) const getTargetFolderPath = useCallback(() => { if (selectedFolderId) { - const selectedNode = findNodeById(notesTree, selectedFolderId) + const selectedNode = findNode(notesTree, selectedFolderId) if (selectedNode && selectedNode.type === 'folder') { return selectedNode.externalPath } } return notesPath // 默认返回根目录 - }, [selectedFolderId, notesTree, notesPath, findNodeById]) + }, [selectedFolderId, notesTree, notesPath]) // 创建文件夹 const handleCreateFolder = useCallback( @@ -333,12 +404,14 @@ const NotesPage: FC = () => { if (!targetPath) { throw new Error('No folder path selected') } - await createFolder(name, targetPath) + await addDir(name, targetPath) + updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetPath))) + await refreshTree() } catch (error) { logger.error('Failed to create folder:', error as Error) } }, - [getTargetFolderPath] + [getTargetFolderPath, refreshTree, updateExpandedPaths] ) // 创建笔记 @@ -351,11 +424,13 @@ const NotesPage: FC = () => { if (!targetPath) { throw new Error('No folder path selected') } - const newNote = await createNote(name, '', targetPath) - dispatch(setActiveFilePath(newNote.externalPath)) + const { path: notePath } = await addNote(name, '', targetPath) + const normalizedParent = normalizePathValue(targetPath) + updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent)) + dispatch(setActiveFilePath(notePath)) setSelectedFolderId(null) - await sortAllLevels(sortType) + await refreshTree() } catch (error) { logger.error('Failed to create note:', error as Error) } finally { @@ -365,73 +440,41 @@ const NotesPage: FC = () => { }, 500) } }, - [dispatch, getTargetFolderPath, sortType] - ) - - // 切换展开状态 - const toggleNodeExpanded = useCallback( - async (nodeId: string) => { - try { - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.type === 'folder') { - await updateNodeInTree(tree, nodeId, { - expanded: !node.expanded - }) - } - - return tree - } catch (error) { - logger.error('Failed to toggle expanded:', error as Error) - throw error - } - }, - [findNodeById] + [dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths] ) const handleToggleExpanded = useCallback( - async (nodeId: string) => { - try { - await toggleNodeExpanded(nodeId) - } catch (error) { - logger.error('Failed to toggle expanded:', error as Error) + (nodeId: string) => { + const targetNode = findNode(notesTree, nodeId) + if (!targetNode || targetNode.type !== 'folder') { + return } + + const nextExpanded = !targetNode.expanded + // Update Redux state first, then let mergeTreeState handle the UI update + updateExpandedPaths((prev) => + nextExpanded + ? addUniquePath(prev, targetNode.externalPath) + : removePathEntries(prev, targetNode.externalPath, false) + ) }, - [toggleNodeExpanded] - ) - - // 切换收藏状态 - const toggleStarred = useCallback( - async (nodeId: string) => { - try { - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.type === 'file') { - await updateNodeInTree(tree, nodeId, { - isStarred: !node.isStarred - }) - } - - return tree - } catch (error) { - logger.error('Failed to toggle star:', error as Error) - throw error - } - }, - [findNodeById] + [notesTree, updateExpandedPaths] ) const handleToggleStar = useCallback( - async (nodeId: string) => { - try { - await toggleStarred(nodeId) - } catch (error) { - logger.error('Failed to toggle star:', error as Error) + (nodeId: string) => { + const node = findNode(notesTree, nodeId) + if (!node) { + return } + + const nextStarred = !node.isStarred + // Update Redux state first, then let mergeTreeState handle the UI update + updateStarredPaths((prev) => + nextStarred ? addUniquePath(prev, node.externalPath) : removePathEntries(prev, node.externalPath, false) + ) }, - [toggleStarred] + [notesTree, updateStarredPaths] ) // 选择节点 @@ -448,7 +491,7 @@ const NotesPage: FC = () => { } } else if (node.type === 'folder') { setSelectedFolderId(node.id) - await handleToggleExpanded(node.id) + handleToggleExpanded(node.id) } }, [dispatch, handleToggleExpanded, invalidateFileContent] @@ -458,28 +501,35 @@ const NotesPage: FC = () => { const handleDeleteNode = useCallback( async (nodeId: string) => { try { - const nodeToDelete = findNodeById(notesTree, nodeId) + const nodeToDelete = findNode(notesTree, nodeId) if (!nodeToDelete) return - const isActiveNodeOrParent = - activeFilePath && - (nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || '')) + await delNode(nodeToDelete) - await deleteNode(nodeId) - await sortAllLevels(sortType) + updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')) + updateExpandedPaths((prev) => + removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder') + ) - // 如果删除的是当前活动节点或其父节点,清空编辑器 - if (isActiveNodeOrParent) { + const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined + const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath) + const isActiveNode = normalizedActivePath === normalizedDeletePath + const isActiveDescendant = + nodeToDelete.type === 'folder' && + normalizedActivePath && + normalizedActivePath.startsWith(`${normalizedDeletePath}/`) + + if (isActiveNode || isActiveDescendant) { dispatch(setActiveFilePath(undefined)) - if (editorRef.current) { - editorRef.current.clear() - } + editorRef.current?.clear() } + + await refreshTree() } catch (error) { logger.error('Failed to delete node:', error as Error) } }, - [findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch] + [notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 重命名节点 @@ -488,29 +538,30 @@ const NotesPage: FC = () => { try { isRenamingRef.current = true - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.name !== newName) { - const oldExternalPath = node.externalPath - const renamedNode = await renameNode(nodeId, newName) - - if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) { - dispatch(setActiveFilePath(renamedNode.externalPath)) - } else if ( - renamedNode.type === 'folder' && - activeFilePath && - activeFilePath.startsWith(oldExternalPath + '/') - ) { - const relativePath = activeFilePath.substring(oldExternalPath.length) - const newFilePath = renamedNode.externalPath + relativePath - dispatch(setActiveFilePath(newFilePath)) - } - await sortAllLevels(sortType) - if (renamedNode.name !== newName) { - window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name })) - } + const node = findNode(notesTree, nodeId) + if (!node || node.name === newName) { + return } + + const oldPath = node.externalPath + const renamed = await renameEntry(node, newName) + + if (node.type === 'file' && activeFilePath === oldPath) { + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = renamed.path + dispatch(setActiveFilePath(renamed.path)) + } else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) { + const suffix = activeFilePath.slice(oldPath.length) + const nextActivePath = `${renamed.path}${suffix}` + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = nextActivePath + dispatch(setActiveFilePath(nextActivePath)) + } + + updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')) + updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')) + + await refreshTree() } catch (error) { logger.error('Failed to rename node:', error as Error) } finally { @@ -519,7 +570,7 @@ const NotesPage: FC = () => { }, 500) } }, - [activeFilePath, dispatch, findNodeById, sortType, t] + [activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 处理文件上传 @@ -536,7 +587,7 @@ const NotesPage: FC = () => { throw new Error('No folder path selected') } - const result = await uploadFiles(files, targetFolderPath) + const result = await uploadNotes(files, targetFolderPath) // 检查上传结果 if (result.fileCount === 0) { @@ -545,7 +596,8 @@ const NotesPage: FC = () => { } // 排序并显示成功信息 - await sortAllLevels(sortType) + updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath))) + await refreshTree() const successMessage = t('notes.upload_success') @@ -555,37 +607,141 @@ const NotesPage: FC = () => { window.toast.error(t('notes.upload_failed')) } }, - [getTargetFolderPath, sortType, t] + [getTargetFolderPath, refreshTree, t, updateExpandedPaths] ) // 处理节点移动 const handleMoveNode = useCallback( async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => { + if (!notesPath) { + return + } + try { - const result = await moveNode(sourceNodeId, targetNodeId, position) - if (result.success && result.type !== 'manual_reorder') { - await sortAllLevels(sortType) + const sourceNode = findNode(notesTree, sourceNodeId) + const targetNode = findNode(notesTree, targetNodeId) + + if (!sourceNode || !targetNode) { + return } + + if (position === 'inside' && targetNode.type !== 'folder') { + return + } + + const rootPath = normalizePathValue(notesPath) + const sourceParentNode = findParent(notesTree, sourceNodeId) + const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId) + + const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath + const targetParentPath = + position === 'inside' ? targetNode.externalPath : targetParentNode ? targetParentNode.externalPath : rootPath + + const normalizedSourceParent = normalizePathValue(sourceParentPath) + const normalizedTargetParent = normalizePathValue(targetParentPath) + + const isManualReorder = position !== 'inside' && normalizedSourceParent === normalizedTargetParent + + if (isManualReorder) { + // For manual reordering within the same parent, we can optimize by only updating the affected parent + setNotesTree((prev) => + reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after') + ) + return + } + + const { safeName } = await window.api.file.checkFileName( + normalizedTargetParent, + sourceNode.name, + sourceNode.type === 'file' + ) + + const destinationPath = + sourceNode.type === 'file' + ? `${normalizedTargetParent}/${safeName}.md` + : `${normalizedTargetParent}/${safeName}` + + if (destinationPath === sourceNode.externalPath) { + return + } + + if (sourceNode.type === 'file') { + await window.api.file.move(sourceNode.externalPath, destinationPath) + } else { + await window.api.file.moveDir(sourceNode.externalPath, destinationPath) + } + + updateStarredPaths((prev) => + replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder') + ) + updateExpandedPaths((prev) => { + let next = replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder') + next = addUniquePath(next, normalizedTargetParent) + return next + }) + + const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined + if (normalizedActivePath) { + if (normalizedActivePath === sourceNode.externalPath) { + dispatch(setActiveFilePath(destinationPath)) + } else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) { + const suffix = normalizedActivePath.slice(sourceNode.externalPath.length) + dispatch(setActiveFilePath(`${destinationPath}${suffix}`)) + } + } + + await refreshTree() } catch (error) { logger.error('Failed to move nodes:', error as Error) } }, - [sortType] + [activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 处理节点排序 const handleSortNodes = useCallback( async (newSortType: NotesSortType) => { - try { - // 更新Redux中的排序类型 - dispatch(setSortType(newSortType)) - await sortAllLevels(newSortType) - } catch (error) { - logger.error('Failed to sort notes:', error as Error) - throw error + dispatch(setSortType(newSortType)) + setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType))) + }, + [dispatch, mergeTreeState] + ) + + const handleExpandPath = useCallback( + (treePath: string) => { + if (!treePath) { + return + } + + const segments = treePath.split('/').filter(Boolean) + if (segments.length === 0) { + return + } + + let nextTree = notesTree + const pathsToAdd: string[] = [] + + segments.forEach((_, index) => { + const currentPath = '/' + segments.slice(0, index + 1).join('/') + const node = findNodeByPath(nextTree, currentPath) + if (node && node.type === 'folder' && !node.expanded) { + pathsToAdd.push(node.externalPath) + nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true })) + } + }) + + if (pathsToAdd.length > 0) { + setNotesTree(nextTree) + updateExpandedPaths((prev) => { + let updated = prev + pathsToAdd.forEach((path) => { + updated = addUniquePath(updated, path) + }) + return updated + }) } }, - [dispatch] + [notesTree, updateExpandedPaths] ) const getCurrentNoteContent = useCallback(() => { @@ -675,16 +831,17 @@ const NotesPage: FC = () => { diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 2ccf075f28..ff132ace7d 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -9,6 +9,7 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' import { useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' +import { useVirtualizer } from '@tanstack/react-virtual' import { Dropdown, Input, InputRef, MenuProps } from 'antd' import { ChevronDown, @@ -23,7 +24,7 @@ import { Star, StarOff } from 'lucide-react' -import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -45,6 +46,157 @@ interface NotesSidebarProps { const logger = loggerService.withContext('NotesSidebar') +interface TreeNodeProps { + node: NotesTreeNode + depth: number + selectedFolderId?: string | null + activeNodeId?: string + editingNodeId: string | null + draggedNodeId: string | null + dragOverNodeId: string | null + dragPosition: 'before' | 'inside' | 'after' + inPlaceEdit: any + getMenuItems: (node: NotesTreeNode) => any[] + onSelectNode: (node: NotesTreeNode) => void + onToggleExpanded: (nodeId: string) => void + onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void + onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent, node: NotesTreeNode) => void + onDragEnd: () => void + renderChildren?: boolean // 控制是否渲染子节点 +} + +const TreeNode = memo( + ({ + node, + depth, + selectedFolderId, + activeNodeId, + editingNodeId, + draggedNodeId, + dragOverNodeId, + dragPosition, + inPlaceEdit, + getMenuItems, + onSelectNode, + onToggleExpanded, + onDragStart, + onDragOver, + onDragLeave, + onDrop, + onDragEnd, + renderChildren = true + }) => { + const { t } = useTranslation() + + const isActive = selectedFolderId + ? node.type === 'folder' && node.id === selectedFolderId + : node.id === activeNodeId + const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing + const hasChildren = node.children && node.children.length > 0 + const isDragging = draggedNodeId === node.id + const isDragOver = dragOverNodeId === node.id + const isDragBefore = isDragOver && dragPosition === 'before' + const isDragInside = isDragOver && dragPosition === 'inside' + const isDragAfter = isDragOver && dragPosition === 'after' + + return ( +
+ +
+ onDragStart(e, node)} + onDragOver={(e) => onDragOver(e, node)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node)} + onDragEnd={onDragEnd}> + onSelectNode(node)}> + + + {node.type === 'folder' && ( + { + e.stopPropagation() + onToggleExpanded(node.id) + }} + title={node.expanded ? t('notes.collapse') : t('notes.expand')}> + {node.expanded ? : } + + )} + + + {node.type === 'folder' ? ( + node.expanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {isEditing ? ( + } + value={inPlaceEdit.editValue} + onChange={inPlaceEdit.handleInputChange} + onBlur={inPlaceEdit.saveEdit} + onKeyDown={inPlaceEdit.handleKeyDown} + onClick={(e) => e.stopPropagation()} + autoFocus + size="small" + /> + ) : ( + {node.name} + )} + + +
+
+ + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ) + } +) + const NotesSidebar: FC = ({ onCreateFolder, onCreateNote, @@ -114,7 +266,7 @@ const NotesSidebar: FC = ({ const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2 scrollContainer.scrollTo({ top: Math.max(0, targetScrollTop), - behavior: 'smooth' + behavior: 'instant' }) } } @@ -280,9 +432,26 @@ const NotesSidebar: FC = ({ setIsShowSearch(!isShowSearch) }, [isShowSearch]) - const filteredTree = useMemo(() => { - if (!isShowStarred && !isShowSearch) return notesTree - const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => { + // Flatten tree nodes for virtualization and filtering + const flattenedNodes = useMemo(() => { + const flattenForVirtualization = ( + nodes: NotesTreeNode[], + depth: number = 0 + ): Array<{ node: NotesTreeNode; depth: number }> => { + let result: Array<{ node: NotesTreeNode; depth: number }> = [] + + for (const node of nodes) { + result.push({ node, depth }) + + // Include children only if the folder is expanded + if (node.type === 'folder' && node.expanded && node.children && node.children.length > 0) { + result = [...result, ...flattenForVirtualization(node.children, depth + 1)] + } + } + return result + } + + const flattenForFiltering = (nodes: NotesTreeNode[]): NotesTreeNode[] => { let result: NotesTreeNode[] = [] for (const node of nodes) { @@ -296,15 +465,41 @@ const NotesSidebar: FC = ({ } } if (node.children && node.children.length > 0) { - result = [...result, ...flattenNodes(node.children)] + result = [...result, ...flattenForFiltering(node.children)] } } return result } - return flattenNodes(notesTree) + if (isShowStarred || isShowSearch) { + // For filtered views, return flat list without virtualization for simplicity + const filteredNodes = flattenForFiltering(notesTree) + return filteredNodes.map((node) => ({ node, depth: 0 })) + } + + // For normal tree view, use hierarchical flattening for virtualization + return flattenForVirtualization(notesTree) }, [notesTree, isShowStarred, isShowSearch, searchKeyword]) + // Use virtualization only for normal tree view with many items + const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 + + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: flattenedNodes.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, // Estimated height of each tree item + overscan: 10 + }) + + const filteredTree = useMemo(() => { + if (isShowStarred || isShowSearch) { + return flattenedNodes.map(({ node }) => node) + } + return notesTree + }, [flattenedNodes, isShowStarred, isShowSearch, notesTree]) + const getMenuItems = useCallback( (node: NotesTreeNode) => { const baseMenuItems: MenuProps['items'] = [ @@ -371,115 +566,6 @@ const NotesSidebar: FC = ({ [t, handleStartEdit, onToggleStar, handleExportKnowledge, handleExportToPDF, handleDeleteNode] ) - const renderTreeNode = useCallback( - (node: NotesTreeNode, depth: number = 0) => { - const isActive = selectedFolderId - ? node.type === 'folder' && node.id === selectedFolderId - : node.id === activeNode?.id - const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing - const hasChildren = node.children && node.children.length > 0 - const isDragging = draggedNodeId === node.id - const isDragOver = dragOverNodeId === node.id - const isDragBefore = isDragOver && dragPosition === 'before' - const isDragInside = isDragOver && dragPosition === 'inside' - const isDragAfter = isDragOver && dragPosition === 'after' - - return ( -
- -
- handleDragStart(e, node)} - onDragOver={(e) => handleDragOver(e, node)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, node)} - onDragEnd={handleDragEnd}> - onSelectNode(node)}> - - - {node.type === 'folder' && ( - { - e.stopPropagation() - onToggleExpanded(node.id) - }} - title={node.expanded ? t('notes.collapse') : t('notes.expand')}> - {node.expanded ? : } - - )} - - - {node.type === 'folder' ? ( - node.expanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {isEditing ? ( - } - value={inPlaceEdit.editValue} - onChange={inPlaceEdit.handleInputChange} - onPressEnter={inPlaceEdit.saveEdit} - onBlur={inPlaceEdit.saveEdit} - onKeyDown={inPlaceEdit.handleKeyDown} - onClick={(e) => e.stopPropagation()} - autoFocus - size="small" - /> - ) : ( - {node.name} - )} - - -
-
- - {node.type === 'folder' && node.expanded && hasChildren && ( -
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
- )} -
- ) - }, - [ - selectedFolderId, - activeNode?.id, - editingNodeId, - inPlaceEdit.isEditing, - inPlaceEdit.inputRef, - inPlaceEdit.editValue, - inPlaceEdit.handleInputChange, - inPlaceEdit.saveEdit, - inPlaceEdit.handleKeyDown, - draggedNodeId, - dragOverNodeId, - dragPosition, - getMenuItems, - handleDragLeave, - handleDragEnd, - t, - handleDragStart, - handleDragOver, - handleDrop, - onSelectNode, - onToggleExpanded - ] - ) - const handleDropFiles = useCallback( async (e: React.DragEvent) => { e.preventDefault() @@ -585,9 +671,54 @@ const NotesSidebar: FC = ({ /> - - - {filteredTree.map((node) => renderTreeNode(node))} + {shouldUseVirtualization ? ( + +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const { node, depth } = flattenedNodes[virtualItem.index] + return ( +
+
+ +
+
+ ) + })} +
{!isShowStarred && !isShowSearch && ( @@ -600,8 +731,70 @@ const NotesSidebar: FC = ({ )} -
-
+ + ) : ( + + + {isShowStarred || isShowSearch + ? filteredTree.map((node) => ( + + )) + : notesTree.map((node) => ( + + ))} + {!isShowStarred && !isShowSearch && ( + + + + + + + {t('notes.drop_markdown_hint')} + + + + )} + + + )}
{isDragOverSidebar && } @@ -612,7 +805,7 @@ const NotesSidebar: FC = ({ const SidebarContainer = styled.div` width: 250px; min-width: 250px; - height: 100vh; + height: calc(100vh - var(--navbar-height)); background-color: var(--color-background); border-right: 0.5px solid var(--color-border); border-top-left-radius: 10px; @@ -626,7 +819,15 @@ const NotesTreeContainer = styled.div` overflow: hidden; display: flex; flex-direction: column; - height: calc(100vh - 45px); + height: calc(100vh - var(--navbar-height) - 45px); +` + +const VirtualizedTreeContainer = styled.div` + flex: 1; + height: 100%; + overflow: auto; + position: relative; + padding-top: 10px; ` const StyledScrollbar = styled(Scrollbar)` @@ -772,7 +973,8 @@ const DragOverIndicator = styled.div` ` const DropHintNode = styled.div` - margin-top: 8px; + margin: 8px; + margin-bottom: 20px; ${TreeNodeContainer} { background-color: transparent; @@ -793,4 +995,4 @@ const DropHintText = styled.div` font-style: italic; ` -export default NotesSidebar +export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/paintings/SiliconPage.tsx b/src/renderer/src/pages/paintings/SiliconPage.tsx index eebd3ccb95..691aaa0d01 100644 --- a/src/renderer/src/pages/paintings/SiliconPage.tsx +++ b/src/renderer/src/pages/paintings/SiliconPage.tsx @@ -47,6 +47,12 @@ export const TEXT_TO_IMAGES_MODELS = [ provider: 'silicon', name: 'Kolors', group: 'Kwai-Kolors' + }, + { + id: 'Qwen/Qwen-Image', + provider: 'silicon', + name: 'Qwen-Image', + group: 'qwen' } ] diff --git a/src/renderer/src/pages/paintings/TokenFluxPage.tsx b/src/renderer/src/pages/paintings/TokenFluxPage.tsx index 0860b62ee9..ea5e4ed47f 100644 --- a/src/renderer/src/pages/paintings/TokenFluxPage.tsx +++ b/src/renderer/src/pages/paintings/TokenFluxPage.tsx @@ -298,7 +298,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { // Set form data from painting's input params if (newPainting.inputParams) { // Filter out the prompt from inputParams since it's handled separately - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { prompt, ...formInputParams } = newPainting.inputParams setFormData(formInputParams) } else { diff --git a/src/renderer/src/pages/paintings/ZhipuPage.tsx b/src/renderer/src/pages/paintings/ZhipuPage.tsx index ea8d7cfcea..9ebfe51a7d 100644 --- a/src/renderer/src/pages/paintings/ZhipuPage.tsx +++ b/src/renderer/src/pages/paintings/ZhipuPage.tsx @@ -307,7 +307,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { } } - const createNewPainting = () => { + const handleAddPainting = () => { if (generating) return const newPainting = getNewPainting() const addedPainting = addPainting('zhipu_paintings', newPainting) @@ -342,12 +342,12 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { return ( - - {t('title.paintings')} - + {t('paintings.title')} {isMac && ( - - )} @@ -482,7 +482,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { selectedPainting={painting} onSelectPainting={onSelectPainting} onDeletePainting={onDeletePainting} - onNewPainting={createNewPainting} + onNewPainting={handleAddPainting} /> @@ -556,12 +556,6 @@ const ToolbarMenu = styled.div` gap: 8px; ` -const Title = styled.h1` - margin: 0; - font-size: 18px; - font-weight: 600; -` - const ProviderTitleContainer = styled.div` display: flex; justify-content: space-between; diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index b79f1cb099..d611ed458e 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -14,7 +14,7 @@ import { runAsyncFunction } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd' import { debounce } from 'lodash' -import { Bug, FileCheck, Github, Globe, Mail, Rss } from 'lucide-react' +import { Bug, FileCheck, Globe, Mail, Rss } from 'lucide-react' import { BadgeQuestionMark } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -32,7 +32,7 @@ const AboutSettings: FC = () => { const { theme } = useTheme() const dispatch = useAppDispatch() const { update } = useRuntime() - const { openMinapp } = useMinappPopup() + const { openSmartMinapp } = useMinappPopup() const onCheckUpdate = debounce( async () => { @@ -79,7 +79,7 @@ const AboutSettings: FC = () => { const showLicense = async () => { const { appPath } = await window.api.getAppInfo() - openMinapp({ + openSmartMinapp({ id: 'cherrystudio-license', name: t('settings.about.license.title'), url: `file://${appPath}/resources/cherry-studio/license.html`, @@ -89,7 +89,7 @@ const AboutSettings: FC = () => { const showReleases = async () => { const { appPath } = await window.api.getAppInfo() - openMinapp({ + openSmartMinapp({ id: 'cherrystudio-releases', name: t('settings.about.releases.title'), url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`, @@ -273,7 +273,7 @@ const AboutSettings: FC = () => { - + {typeof update.info.releaseNotes === 'string' ? update.info.releaseNotes.replace(/\n/g, '\n\n') @@ -309,7 +309,7 @@ const AboutSettings: FC = () => { - + {t('settings.about.feedback.title')} - - - )} + + + + ) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx index 4e81b39aa2..8812a5926d 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx @@ -1,6 +1,7 @@ import { TopView } from '@renderer/components/TopView' import { endpointTypeOptions } from '@renderer/config/endpointTypes' import { isNotSupportedTextDelta } from '@renderer/config/models' +import { isNewApiProvider } from '@renderer/config/providers' import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth' import { useProvider } from '@renderer/hooks/useProvider' import { EndpointType, Model, Provider } from '@renderer/types' @@ -60,7 +61,7 @@ const PopupContainer: React.FC = ({ title, provider, resolve, model, endp provider: provider.id, name: values.name ? values.name : id.toUpperCase(), group: values.group ?? getDefaultGroupName(id), - endpoint_type: provider.id === 'new-api' ? values.endpointType : undefined + endpoint_type: isNewApiProvider(provider) ? values.endpointType : undefined } addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) }) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx index 5315110dc2..2d8b38be81 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx @@ -1,5 +1,6 @@ import AI302ProviderLogo from '@renderer/assets/images/providers/302ai.webp' import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp' +import AiOnlyProviderLogo from '@renderer/assets/images/providers/aiOnly.webp' import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png' @@ -25,7 +26,8 @@ const PROVIDER_LOGO_MAP = { silicon: SiliconFlowProviderLogo, aihubmix: AiHubMixProviderLogo, ppio: PPIOProviderLogo, - tokenflux: TokenFluxProviderLogo + tokenflux: TokenFluxProviderLogo, + aionly: AiOnlyProviderLogo } const ProviderOAuth: FC = ({ providerId }) => { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 0bd7b152f4..ea40f6d9ac 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -16,7 +16,13 @@ import { useAppDispatch } from '@renderer/store' import { updateWebSearchProvider } from '@renderer/store/websearch' import { isSystemProvider } from '@renderer/types' import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck' -import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils' +import { + formatApiHost, + formatApiKeys, + getFancyProviderName, + isAnthropicProvider, + isOpenAIProvider +} from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' @@ -62,7 +68,7 @@ const ProviderSetting: FC = ({ providerId }) => { const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai' const isDmxapi = provider.id === 'dmxapi' - const hideApiInput = ['vertexai', 'aws-bedrock', 'cherryin'].includes(provider.id) + const hideApiInput = ['vertexai', 'aws-bedrock'].includes(provider.id) const providerConfig = PROVIDER_URLS[provider.id] const officialWebsite = providerConfig?.websites?.official @@ -212,6 +218,10 @@ const ProviderSetting: FC = ({ providerId }) => { if (provider.type === 'azure-openai') { return formatApiHost(apiHost) + 'openai/v1' } + + if (provider.type === 'anthropic') { + return formatApiHost(apiHost) + 'messages' + } return formatApiHost(apiHost) + 'responses' } @@ -361,7 +371,7 @@ const ProviderSetting: FC = ({ providerId }) => { )} - {isOpenAIProvider(provider) && ( + {(isOpenAIProvider(provider) || isAnthropicProvider(provider)) && ( diff --git a/src/renderer/src/pages/settings/QuickAssistantSettings.tsx b/src/renderer/src/pages/settings/QuickAssistantSettings.tsx index b7279fce6f..a547a5450b 100644 --- a/src/renderer/src/pages/settings/QuickAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/QuickAssistantSettings.tsx @@ -11,9 +11,10 @@ import { setEnableQuickAssistant, setReadClipboardAtStartup } from '@renderer/store/settings' +import { matchKeywordsInString } from '@renderer/utils' import HomeWindow from '@renderer/windows/mini/home/HomeWindow' import { Button, Select, Switch, Tooltip } from 'antd' -import { FC } from 'react' +import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -26,9 +27,15 @@ const QuickAssistantSettings: FC = () => { const dispatch = useAppDispatch() const { assistants } = useAssistants() const { quickAssistantId } = useAppSelector((state) => state.llm) - const { defaultAssistant } = useDefaultAssistant() + const { defaultAssistant: _defaultAssistant } = useDefaultAssistant() const { defaultModel } = useDefaultModel() + // Take the "default assistant" from the assistant list first. + const defaultAssistant = useMemo( + () => assistants.find((a) => a.id === _defaultAssistant.id) || _defaultAssistant, + [assistants, _defaultAssistant] + ) + const handleEnableQuickAssistant = async (enable: boolean) => { dispatch(setEnableQuickAssistant(enable)) await window.api.config.set('enableQuickAssistant', enable, true) @@ -110,27 +117,39 @@ const QuickAssistantSettings: FC = () => { value={quickAssistantId || defaultAssistant.id} style={{ width: 300, height: 34 }} onChange={(value) => dispatch(setQuickAssistantId(value))} - placeholder={t('settings.models.quick_assistant_selection')}> - - - - {defaultAssistant.name} - - {t('settings.models.quick_assistant_default_tag')} - - - {assistants - .filter((a) => a.id !== defaultAssistant.id) - .map((a) => ( - + placeholder={t('settings.models.quick_assistant_selection')} + showSearch + options={[ + { + key: defaultAssistant.id, + value: defaultAssistant.id, + title: defaultAssistant.name, + label: ( - - {a.name} + + {defaultAssistant.name} + {t('settings.models.quick_assistant_default_tag')} - - ))} - + ) + }, + ...assistants + .filter((a) => a.id !== defaultAssistant.id) + .map((a) => ({ + key: a.id, + value: a.id, + title: a.name, + label: ( + + + {a.name} + + + ) + })) + ]} + filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')} + /> )} diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 91171fd441..e883955eb1 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -846,7 +846,8 @@ const ContentContainer = styled.div<{ $historyDrawerVisible: boolean }>` ` const AreaContainer = styled.div` - display: flex; + display: grid; + grid-template-columns: 1fr 1fr; flex: 1; gap: 8px; ` @@ -917,6 +918,11 @@ const OutputContainer = styled.div` border-radius: 10px; padding: 10px 5px; height: calc(100vh - var(--navbar-height) - 70px); + overflow: hidden; + + & > div > .markdown > pre { + background-color: var(--color-background-mute) !important; + } &:hover .copy-button { opacity: 1; diff --git a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts index 7911646630..2a17dc3a26 100644 --- a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts @@ -95,7 +95,7 @@ export default class LocalSearchProvider extends BaseWebSearchProvider { return query } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars protected parseValidUrls(_htmlContent: string): SearchItem[] { throw new Error('Not implemented') } diff --git a/src/renderer/src/queue/NotificationQueue.ts b/src/renderer/src/queue/NotificationQueue.ts index f256212837..9f8c9cb799 100644 --- a/src/renderer/src/queue/NotificationQueue.ts +++ b/src/renderer/src/queue/NotificationQueue.ts @@ -8,7 +8,7 @@ export class NotificationQueue { private queue = new PQueue({ concurrency: 1 }) private listeners: NotificationListener[] = [] - // eslint-disable-next-line @typescript-eslint/no-empty-function + // oxlint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} public static getInstance(): NotificationQueue { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 01807027ba..64e2c1ae31 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -117,7 +117,8 @@ export async function fetchChatCompletion({ const { params: aiSdkParams, modelId, - capabilities + capabilities, + webSearchPluginConfig } = await buildStreamTextParams(messages, assistant, provider, { mcpTools: mcpTools, webSearchProviderId: assistant.webSearchProviderId, @@ -132,6 +133,7 @@ export async function fetchChatCompletion({ isPromptToolUse: isPromptToolUse(assistant), isSupportedToolUse: isSupportedToolUse(assistant), isImageGenerationEndpoint: isDedicatedImageGenerationModel(assistant.model || getDefaultModel()), + webSearchPluginConfig: webSearchPluginConfig, enableWebSearch: capabilities.enableWebSearch, enableGenerateImage: capabilities.enableGenerateImage, enableUrlContext: capabilities.enableUrlContext, @@ -335,7 +337,7 @@ export async function fetchGenerate({ export function hasApiKey(provider: Provider) { if (!provider) return false - if (['ollama', 'lmstudio', 'vertexai', 'cherryin'].includes(provider.id)) return true + if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true return !isEmpty(provider.apiKey) } diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 734866f65f..ea2dd3ef36 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -7,7 +7,9 @@ import { UNLIMITED_CONTEXT_COUNT } from '@renderer/config/constant' import { isQwenMTModel } from '@renderer/config/models' +import { CHERRYAI_PROVIDER } from '@renderer/config/providers' import { UNKNOWN } from '@renderer/config/translate' +import { getStoreProviders } from '@renderer/hooks/useStore' import i18n from '@renderer/i18n' import store from '@renderer/store' import { addAssistant } from '@renderer/store/assistants' @@ -70,24 +72,23 @@ export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage, temperature: 0.7 } - let prompt: string - let content: string - if (isQwenMTModel(model)) { - content = text - prompt = '' - } else { - content = 'follow system instruction' - prompt = store + const getTranslateContent = (model: Model, text: string, targetLanguage: TranslateLanguage): string => { + if (isQwenMTModel(model)) { + return text // QwenMT models handle raw text directly + } + + return store .getState() .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage.value) .replaceAll('{{text}}', text) } + const content = getTranslateContent(model, text, targetLanguage) const translateAssistant = { ...assistant, model, settings, - prompt, + prompt: '', targetLanguage, content } satisfies TranslateAssistant @@ -127,26 +128,25 @@ export function getTranslateModel() { } export function getAssistantProvider(assistant: Assistant): Provider { - const providers = store.getState().llm.providers + const providers = getStoreProviders() const provider = providers.find((p) => p.id === assistant.model?.provider) return provider || getDefaultProvider() } export function getProviderByModel(model?: Model): Provider { - const providers = store.getState().llm.providers + const providers = getStoreProviders() const provider = providers.find((p) => p.id === model?.provider) if (!provider) { const defaultProvider = providers.find((p) => p.id === getDefaultModel()?.provider) - const cherryinProvider = providers.find((p) => p.id === 'cherryin') - return defaultProvider || cherryinProvider || providers[0] + return defaultProvider || CHERRYAI_PROVIDER || providers[0] } return provider } export function getProviderByModelId(modelId?: string) { - const providers = store.getState().llm.providers + const providers = getStoreProviders() const _modelId = modelId || getDefaultModel().id return providers.find((p) => p.models.find((m) => m.id === _modelId)) as Provider } diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 6523608767..985247e43f 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -141,7 +141,11 @@ export async function backupToWebdav({ showMessage = false, customFileName = '', autoBackupProcess = false -}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { +}: { + showMessage?: boolean + customFileName?: string + autoBackupProcess?: boolean +} = {}) { const notificationService = NotificationService.getInstance() if (isManualBackupRunning) { logger.verbose('Manual backup already in progress') @@ -319,7 +323,11 @@ export async function backupToS3({ showMessage = false, customFileName = '', autoBackupProcess = false -}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { +}: { + showMessage?: boolean + customFileName?: string + autoBackupProcess?: boolean +} = {}) { const notificationService = NotificationService.getInstance() if (isManualBackupRunning) { logger.verbose('Manual backup already in progress') diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index a36a63ac9f..bc64a92015 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -75,9 +75,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams baseURL: rerankHost }, documentCount: base.documentCount, - preprocessProvider: updatedPreprocessProvider, - framework: base.framework, - retriever: base.retriever || { mode: 'hybrid' } + preprocessProvider: updatedPreprocessProvider } } diff --git a/src/renderer/src/services/ModelService.ts b/src/renderer/src/services/ModelService.ts index 52049f2293..9cb1c5cc4c 100644 --- a/src/renderer/src/services/ModelService.ts +++ b/src/renderer/src/services/ModelService.ts @@ -1,4 +1,4 @@ -import store from '@renderer/store' +import { getStoreProviders } from '@renderer/hooks/useStore' import { Model } from '@renderer/types' import { pick } from 'lodash' @@ -9,9 +9,8 @@ export const getModelUniqId = (m?: Model) => { } export const hasModel = (m?: Model) => { - const allModels = store - .getState() - .llm.providers.filter((p) => p.enabled) + const allModels = getStoreProviders() + .filter((p) => p.enabled) .map((p) => p.models) .flat() @@ -19,7 +18,7 @@ export const hasModel = (m?: Model) => { } export function getModelName(model?: Model) { - const provider = store.getState().llm.providers.find((p) => p.id === model?.provider) + const provider = getStoreProviders().find((p) => p.id === model?.provider) const modelName = model?.name || model?.id || '' if (provider) { diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 45383344d4..a76df9d845 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -1,100 +1,10 @@ import { loggerService } from '@logger' -import db from '@renderer/databases' -import { - findNodeInTree, - findParentNode, - getNotesTree, - insertNodeIntoTree, - isParentNode, - moveNodeInTree, - removeNodeFromTree, - renameNodeFromTree -} from '@renderer/services/NotesTreeService' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { getFileDirectory } from '@renderer/utils' -import { v4 as uuidv4 } from 'uuid' - -const MARKDOWN_EXT = '.md' -const NOTES_TREE_ID = 'notes-tree-structure' const logger = loggerService.withContext('NotesService') -export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' } - -/** - * 初始化/同步笔记树结构 - */ -export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise { - const tree = await window.api.file.getDirectoryStructure(folderPath) - await sortAllLevels(sortType, tree) -} - -/** - * 创建新文件夹 - */ -export async function createFolder(name: string, folderPath: string): Promise { - const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false) - if (exists) { - logger.warn(`Folder already exists: ${safeName}`) - } - - const tree = await getNotesTree() - const folderId = uuidv4() - - const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`) - - // 查找父节点ID - const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) - - const folder: NotesTreeNode = { - id: folderId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: targetPath, - type: 'folder', - children: [], - expanded: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - insertNodeIntoTree(tree, folder, parentNode?.id) - - return folder -} - -/** - * 创建新笔记文件 - */ -export async function createNote(name: string, content: string = '', folderPath: string): Promise { - const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true) - if (exists) { - logger.warn(`Note already exists: ${safeName}`) - } - - const tree = await getNotesTree() - const noteId = uuidv4() - const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` - - await window.api.file.write(notePath, content) - - // 查找父节点ID - const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) - - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - insertNodeIntoTree(tree, note, parentNode?.id) - - return note -} +const MARKDOWN_EXT = '.md' export interface UploadResult { uploadedNodes: NotesTreeNode[] @@ -104,641 +14,195 @@ export interface UploadResult { folderCount: number } -/** - * 上传文件或文件夹,支持单个或批量上传,保持文件夹结构 - */ -export async function uploadFiles(files: File[], targetFolderPath: string): Promise { - const tree = await getNotesTree() - const uploadedNodes: NotesTreeNode[] = [] - let skippedFiles = 0 - - const markdownFiles = filterMarkdownFiles(files) - skippedFiles = files.length - markdownFiles.length - - if (markdownFiles.length === 0) { - return createEmptyUploadResult(files.length, skippedFiles) - } - - // 处理重复的根文件夹名称 - const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath) - - const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath) - - const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes) - - await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes) - - const fileCount = uploadedNodes.filter((node) => node.type === 'file').length - const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length - - return { - uploadedNodes, - totalFiles: files.length, - skippedFiles, - fileCount, - folderCount - } +export async function loadTree(rootPath: string): Promise { + return window.api.file.getDirectoryStructure(normalizePath(rootPath)) } -/** - * 删除笔记或文件夹 - */ -export async function deleteNode(nodeId: string): Promise { - const tree = await getNotesTree() - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } +export function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] { + const cloned = nodes.map((node) => ({ + ...node, + children: node.children ? sortTree(node.children, sortType) : undefined + })) + + const sorter = getSorter(sortType) + + cloned.sort((a, b) => { + if (a.type === b.type) { + return sorter(a, b) + } + return a.type === 'folder' ? -1 : 1 + }) + + return cloned +} + +export async function addDir(name: string, parentPath: string): Promise<{ path: string; name: string }> { + const basePath = normalizePath(parentPath) + const { safeName } = await window.api.file.checkFileName(basePath, name, false) + const fullPath = `${basePath}/${safeName}` + await window.api.file.mkdir(fullPath) + return { path: fullPath, name: safeName } +} + +export async function addNote( + name: string, + content: string = '', + parentPath: string +): Promise<{ path: string; name: string }> { + const basePath = normalizePath(parentPath) + const { safeName } = await window.api.file.checkFileName(basePath, name, true) + const notePath = `${basePath}/${safeName}${MARKDOWN_EXT}` + await window.api.file.write(notePath, content) + return { path: notePath, name: safeName } +} + +export async function delNode(node: NotesTreeNode): Promise { if (node.type === 'folder') { await window.api.file.deleteExternalDir(node.externalPath) - } else if (node.type === 'file') { + } else { await window.api.file.deleteExternalFile(node.externalPath) } - - await removeNodeFromTree(tree, nodeId) } -/** - * 重命名笔记或文件夹 - */ -export async function renameNode(nodeId: string, newName: string): Promise { - const tree = await getNotesTree() - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } - - const dirPath = getFileDirectory(node.externalPath) - const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file') +export async function renameNode(node: NotesTreeNode, newName: string): Promise<{ path: string; name: string }> { + const isFile = node.type === 'file' + const parentDir = normalizePath(getFileDirectory(node.externalPath)) + const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile) if (exists) { - logger.warn(`Target name already exists: ${safeName}`) throw new Error(`Target name already exists: ${safeName}`) } - if (node.type === 'file') { + if (isFile) { await window.api.file.rename(node.externalPath, safeName) - } else if (node.type === 'folder') { - await window.api.file.renameDir(node.externalPath, safeName) + return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName } } - return renameNodeFromTree(tree, nodeId, safeName) + + await window.api.file.renameDir(node.externalPath, safeName) + return { path: `${parentDir}/${safeName}`, name: safeName } } -/** - * 移动节点 - */ -export async function moveNode( - sourceNodeId: string, - targetNodeId: string, - position: 'before' | 'after' | 'inside' -): Promise { - try { - const tree = await getNotesTree() +export async function uploadNotes(files: File[], targetPath: string): Promise { + const basePath = normalizePath(targetPath) + const markdownFiles = filterMarkdown(files) + const skippedFiles = files.length - markdownFiles.length - // 找到源节点和目标节点 - const sourceNode = findNodeInTree(tree, sourceNodeId) - const targetNode = findNodeInTree(tree, targetNodeId) - - if (!sourceNode || !targetNode) { - logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) - return { success: false } + if (markdownFiles.length === 0) { + return { + uploadedNodes: [], + totalFiles: files.length, + skippedFiles, + fileCount: 0, + folderCount: 0 } + } - // 不允许文件夹被放入文件中 - if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') { - logger.error('Move nodes failed: cannot move a folder inside a file') - return { success: false } + const folders = collectFolders(markdownFiles, basePath) + await createFolders(folders) + + let fileCount = 0 + + for (const file of markdownFiles) { + const { dir, name } = resolveFileTarget(file, basePath) + const { safeName } = await window.api.file.checkFileName(dir, name, true) + const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}` + + try { + const content = await file.text() + await window.api.file.write(finalPath, content) + fileCount += 1 + } catch (error) { + logger.error('Failed to write uploaded file:', error as Error) } + } - // 不允许将节点移动到自身内部 - if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) { - logger.error('Move nodes failed: cannot move a node inside itself or its descendants') - return { success: false } - } - - let targetPath: string = '' - - if (position === 'inside') { - // 目标是文件夹内部 - if (targetNode.type === 'folder') { - targetPath = targetNode.externalPath - } else { - logger.error('Cannot move node inside a file node') - return { success: false } - } - } else { - const targetParent = findParentNode(tree, targetNodeId) - if (targetParent) { - targetPath = targetParent.externalPath - } else { - targetPath = getFileDirectory(targetNode.externalPath!) - } - } - - // 检查是否为同级拖动排序 - const sourceParent = findParentNode(tree, sourceNodeId) - const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!) - - const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath - - if (isSameLevelReorder) { - // 同级拖动排序:跳过文件系统操作,只更新树结构 - logger.debug(`Same level reorder detected, skipping file system operations`) - const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) - // 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序 - return success ? { success: true, type: 'manual_reorder' } : { success: false } - } - - // 构建新的文件路径 - const sourceName = sourceNode.externalPath!.split('/').pop()! - const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '') - - const { safeName } = await window.api.file.checkFileName( - targetPath, - sourceNameWithoutExt, - sourceNode.type === 'file' - ) - - const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '') - const newPath = `${targetPath}/${baseName}` - - if (sourceNode.externalPath !== newPath) { - try { - if (sourceNode.type === 'folder') { - await window.api.file.moveDir(sourceNode.externalPath, newPath) - } else { - await window.api.file.move(sourceNode.externalPath, newPath) - } - sourceNode.externalPath = newPath - logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`) - } catch (error) { - logger.error(`Failed to move external ${sourceNode.type}:`, error as Error) - return { success: false } - } - } - - const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) - return success ? { success: true, type: 'file_system_move' } : { success: false } - } catch (error) { - logger.error('Move nodes failed:', error as Error) - return { success: false } + return { + uploadedNodes: [], + totalFiles: files.length, + skippedFiles, + fileCount, + folderCount: folders.size } } -/** - * 对节点数组进行排序 - */ -function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void { - // 首先分离文件夹和文件 - const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder') - const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file') - - // 根据排序类型对文件夹和文件分别进行排序 - const sortFunction = getSortFunction(sortType) - folders.sort(sortFunction) - files.sort(sortFunction) - - // 清空原数组并重新填入排序后的节点 - nodes.length = 0 - nodes.push(...folders, ...files) -} - -/** - * 根据排序类型获取相应的排序函数 - */ -function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { +function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { switch (sortType) { case 'sort_a2z': return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' }) - case 'sort_z2a': return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' }) - case 'sort_updated_desc': - return (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 - return timeB - timeA - } - + return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt) case 'sort_updated_asc': - return (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 - return timeA - timeB - } - + return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt) case 'sort_created_desc': - return (a, b) => { - const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return timeB - timeA - } - + return (a, b) => getTime(b.createdAt) - getTime(a.createdAt) case 'sort_created_asc': - return (a, b) => { - const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return timeA - timeB - } - + return (a, b) => getTime(a.createdAt) - getTime(b.createdAt) default: return (a, b) => a.name.localeCompare(b.name) } } -/** - * 递归排序笔记树中的所有层级 - */ -export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise { - try { - if (!tree) { - tree = await getNotesTree() - } - sortNodesArray(tree, sortType) - recursiveSortNodes(tree, sortType) - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - logger.info(`Sorted all levels of notes successfully: ${sortType}`) - } catch (error) { - logger.error('Failed to sort all levels of notes:', error as Error) - throw error - } +function getTime(value?: string): number { + return value ? new Date(value).getTime() : 0 } -/** - * 递归对节点中的子节点进行排序 - */ -function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void { - for (const node of nodes) { - if (node.type === 'folder' && node.children && node.children.length > 0) { - sortNodesArray(node.children, sortType) - recursiveSortNodes(node.children, sortType) - } - } +function normalizePath(value: string): string { + return value.replace(/\\/g, '/') } -/** - * 根据外部路径查找节点(递归查找) - */ -function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null { - for (const node of nodes) { - if (node.externalPath === externalPath) { - return node - } - if (node.children && node.children.length > 0) { - const found = findNodeByExternalPath(node.children, externalPath) - if (found) { - return found - } - } - } - return null +function filterMarkdown(files: File[]): File[] { + return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT)) } -/** - * 过滤出 Markdown 文件 - */ -function filterMarkdownFiles(files: File[]): File[] { - return Array.from(files).filter((file) => { - if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) { - return true +function collectFolders(files: File[], basePath: string): Set { + const folders = new Set() + + files.forEach((file) => { + const relativePath = file.webkitRelativePath || '' + if (!relativePath.includes('/')) { + return + } + + const parts = relativePath.split('/') + parts.pop() + + let current = basePath + for (const part of parts) { + current = `${current}/${part}` + folders.add(current) } - logger.warn(`Skipping non-markdown file: ${file.name}`) - return false }) + + return folders } -/** - * 创建空的上传结果 - */ -function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult { - return { - uploadedNodes: [], - totalFiles, - skippedFiles, - fileCount: 0, - folderCount: 0 - } -} - -/** - * 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath - */ -async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise { - // 按根文件夹名称分组文件 - const filesByRootFolder = new Map() - const processedFiles: File[] = [] - - for (const file of markdownFiles) { - const filePath = file.webkitRelativePath || file.name - - if (filePath.includes('/')) { - const rootFolderName = filePath.substring(0, filePath.indexOf('/')) - if (!filesByRootFolder.has(rootFolderName)) { - filesByRootFolder.set(rootFolderName, []) - } - filesByRootFolder.get(rootFolderName)!.push(file) - } else { - // 单个文件,直接添加 - processedFiles.push(file) - } - } - - // 为每个根文件夹组生成唯一的文件夹名称 - for (const [rootFolderName, files] of filesByRootFolder.entries()) { - const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false) - - for (const file of files) { - // 创建一个新的 File 对象,并修改 webkitRelativePath - const originalPath = file.webkitRelativePath || file.name - const relativePath = originalPath.substring(originalPath.indexOf('/') + 1) - const newPath = `${safeName}/${relativePath}` - - const newFile = new File([file], file.name, { - type: file.type, - lastModified: file.lastModified - }) - - Object.defineProperty(newFile, 'webkitRelativePath', { - value: newPath, - writable: false - }) - - processedFiles.push(newFile) - } - } - - return processedFiles -} - -/** - * 按路径分组文件并收集需要创建的文件夹 - */ -function groupFilesByPath( - markdownFiles: File[], - targetFolderPath: string -): { filesByPath: Map; foldersToCreate: Set } { - const filesByPath = new Map() - const foldersToCreate = new Set() - - for (const file of markdownFiles) { - const filePath = file.webkitRelativePath || file.name - const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : '' - const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath - - if (relativeDirPath) { - const pathParts = relativeDirPath.split('/') - - let currentPath = targetFolderPath - for (const part of pathParts) { - currentPath = `${currentPath}/${part}` - foldersToCreate.add(currentPath) - } - } - - if (!filesByPath.has(fullDirPath)) { - filesByPath.set(fullDirPath, []) - } - filesByPath.get(fullDirPath)!.push(file) - } - - return { filesByPath, foldersToCreate } -} - -/** - * 顺序创建文件夹(避免竞争条件) - */ -async function createFoldersSequentially( - foldersToCreate: Set, - targetFolderPath: string, - tree: NotesTreeNode[], - uploadedNodes: NotesTreeNode[] -): Promise> { - const createdFolders = new Map() - const sortedFolders = Array.from(foldersToCreate).sort() - const folderCreationLock = new Set() - - for (const folderPath of sortedFolders) { - if (folderCreationLock.has(folderPath)) { - continue - } - folderCreationLock.add(folderPath) +async function createFolders(folders: Set): Promise { + const ordered = Array.from(folders).sort((a, b) => a.length - b.length) + for (const folder of ordered) { try { - const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders) - if (result) { - createdFolders.set(folderPath, result) - if (result.externalPath !== folderPath) { - createdFolders.set(result.externalPath, result) - } - uploadedNodes.push(result) - logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`) - } + await window.api.file.mkdir(folder) } catch (error) { - logger.error(`Failed to create folder ${folderPath}:`, error as Error) - } finally { - folderCreationLock.delete(folderPath) + logger.debug('Skip existing folder while uploading notes', { + folder, + error: (error as Error).message + }) } } - - return createdFolders -} - -/** - * 创建单个文件夹 - */ -async function createSingleFolder( - folderPath: string, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map -): Promise { - const existingNode = findNodeByExternalPath(tree, folderPath) - if (existingNode) { - return existingNode - } - - const relativePath = folderPath.replace(targetFolderPath + '/', '') - const originalFolderName = relativePath.split('/').pop()! - const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/')) - - const { safeName: safeFolderName, exists } = await window.api.file.checkFileName( - parentFolderPath, - originalFolderName, - false - ) - - const actualFolderPath = `${parentFolderPath}/${safeFolderName}` - - if (exists) { - logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`) - } - - try { - await window.api.file.mkdir(actualFolderPath) - } catch (error) { - logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error) - } - - let parentNode: NotesTreeNode | null - if (parentFolderPath === targetFolderPath) { - parentNode = - tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) - } else { - parentNode = createdFolders.get(parentFolderPath) || null - if (!parentNode) { - parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null - if (!parentNode) { - parentNode = findNodeByExternalPath(tree, parentFolderPath) - } - } - } - - const folderId = uuidv4() - const folder: NotesTreeNode = { - id: folderId, - name: safeFolderName, - treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`, - externalPath: actualFolderPath, - type: 'folder', - children: [], - expanded: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - await insertNodeIntoTree(tree, folder, parentNode?.id) - return folder -} - -/** - * 读取文件内容(支持大文件处理) - */ -async function readFileContent(file: File): Promise { - const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB - - if (file.size > MAX_FILE_SIZE) { - logger.warn( - `Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.` - ) - } - - try { - return await file.text() - } catch (error) { - logger.error(`Failed to read file content for ${file.name}:`, error as Error) - throw new Error(`Failed to read file content: ${file.name}`) - } } -/** - * 上传所有文件 - */ -async function uploadAllFiles( - filesByPath: Map, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map, - uploadedNodes: NotesTreeNode[] -): Promise { - const uploadPromises: Promise[] = [] - - for (const [dirPath, dirFiles] of filesByPath.entries()) { - for (const file of dirFiles) { - const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders) - .then((result) => { - if (result) { - logger.debug(`Uploaded file: ${result.externalPath}`) - } - return result - }) - .catch((error) => { - logger.error(`Failed to upload file ${file.name}:`, error as Error) - return null - }) - - uploadPromises.push(uploadPromise) - } +function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } { + if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) { + const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT) ? file.name.slice(0, -MARKDOWN_EXT.length) : file.name + return { dir: basePath, name: nameWithoutExt } } - const results = await Promise.all(uploadPromises) + const parts = file.webkitRelativePath.split('/') + const fileName = parts.pop() || file.name + const dirPath = `${basePath}/${parts.join('/')}` + const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName - results.forEach((result) => { - if (result) { - uploadedNodes.push(result) - } - }) -} - -/** - * 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点 - */ -async function uploadSingleFile( - file: File, - originalDirPath: string, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map -): Promise { - const fileName = (file.webkitRelativePath || file.name).split('/').pop()! - const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') - - let actualDirPath = originalDirPath - let parentNode: NotesTreeNode | null = null - - if (originalDirPath === targetFolderPath) { - parentNode = - tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) - - if (!parentNode) { - logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`) - } - } else { - parentNode = createdFolders.get(originalDirPath) || null - if (!parentNode) { - parentNode = tree.find((node) => node.externalPath === originalDirPath) || null - if (!parentNode) { - parentNode = findNodeByExternalPath(tree, originalDirPath) - } - } - - if (!parentNode) { - for (const [originalPath, createdNode] of createdFolders.entries()) { - if (originalPath === originalDirPath) { - parentNode = createdNode - actualDirPath = createdNode.externalPath - break - } - } - } - - if (!parentNode) { - logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`) - return null - } - } - - const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true) - if (exists) { - logger.warn(`Note already exists, will be overwritten: ${safeName}`) - } - - const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}` - - const noteId = uuidv4() - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - const content = await readFileContent(file) - await window.api.file.write(notePath, content) - await insertNodeIntoTree(tree, note, parentNode?.id) - - return note + return { dir: dirPath, name: nameWithoutExt } } diff --git a/src/renderer/src/services/NotesTreeService.ts b/src/renderer/src/services/NotesTreeService.ts index 4159948323..676b4996aa 100644 --- a/src/renderer/src/services/NotesTreeService.ts +++ b/src/renderer/src/services/NotesTreeService.ts @@ -1,217 +1,47 @@ -import { loggerService } from '@logger' -import db from '@renderer/databases' import { NotesTreeNode } from '@renderer/types/note' -const MARKDOWN_EXT = '.md' -const NOTES_TREE_ID = 'notes-tree-structure' - -const logger = loggerService.withContext('NotesTreeService') - -/** - * 获取树结构 - */ -export const getNotesTree = async (): Promise => { - const record = await db.notes_tree.get(NOTES_TREE_ID) - return record?.tree || [] +export function normalizePathValue(path: string): string { + return path.replace(/\\/g, '/') } -/** - * 在树中插入节点 - */ -export async function insertNodeIntoTree( - tree: NotesTreeNode[], - node: NotesTreeNode, - parentId?: string -): Promise { - try { - if (!parentId) { - tree.push(node) - } else { - const parent = findNodeInTree(tree, parentId) - if (parent && parent.type === 'folder') { - if (!parent.children) { - parent.children = [] - } - parent.children.push(node) - } - } - - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - return tree - } catch (error) { - logger.error('Failed to insert node into tree:', error as Error) - throw error - } +export function addUniquePath(list: string[], path: string): string[] { + const normalized = normalizePathValue(path) + return list.includes(normalized) ? list : [...list, normalized] } -/** - * 从树中删除节点 - */ -export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise { - const removed = removeNodeFromTreeInMemory(tree, nodeId) - if (removed) { - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - } - return removed -} - -/** - * 从树中删除节点(仅在内存中操作,不保存数据库) - */ -function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean { - for (let i = 0; i < tree.length; i++) { - if (tree[i].id === nodeId) { - tree.splice(i, 1) - return true - } - if (tree[i].children) { - const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId) - if (removed) { - return true - } - } - } - return false -} - -export async function moveNodeInTree( - tree: NotesTreeNode[], - sourceNodeId: string, - targetNodeId: string, - position: 'before' | 'after' | 'inside' -): Promise { - try { - const sourceNode = findNodeInTree(tree, sourceNodeId) - const targetNode = findNodeInTree(tree, targetNodeId) - - if (!sourceNode || !targetNode) { - logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) +export function removePathEntries(list: string[], path: string, deep: boolean): string[] { + const normalized = normalizePathValue(path) + const prefix = `${normalized}/` + return list.filter((item) => { + if (item === normalized) { return false } + return !(deep && item.startsWith(prefix)) + }) +} - // 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序 - const sourceParent = findParentNode(tree, sourceNodeId) - const targetParent = findParentNode(tree, targetNodeId) - - // 从原位置移除节点(不保存数据库,只在内存中操作) - const removed = removeNodeFromTreeInMemory(tree, sourceNodeId) - if (!removed) { - logger.error('Move nodes in tree failed: could not remove source node') - return false +export function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] { + const oldNormalized = normalizePathValue(oldPath) + const newNormalized = normalizePathValue(newPath) + const prefix = `${oldNormalized}/` + return list.map((item) => { + if (item === oldNormalized) { + return newNormalized } - - try { - // 根据位置进行放置 - if (position === 'inside' && targetNode.type === 'folder') { - if (!targetNode.children) { - targetNode.children = [] - } - targetNode.children.push(sourceNode) - targetNode.expanded = true - - sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}` - } else { - const targetList = targetParent ? targetParent.children! : tree - const targetIndex = targetList.findIndex((node) => node.id === targetNodeId) - - if (targetIndex === -1) { - logger.error('Move nodes in tree failed: target position not found') - return false - } - - // 根据position确定插入位置 - const insertIndex = position === 'before' ? targetIndex : targetIndex + 1 - targetList.splice(insertIndex, 0, sourceNode) - - // 检查是否为同级排序,如果是则保持原有的 treePath - const isSameLevelReorder = sourceParent === targetParent - - // 只有在跨级移动时才更新节点路径 - if (!isSameLevelReorder) { - if (targetParent) { - sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}` - } else { - sourceNode.treePath = `/${sourceNode.name}` - } - } - } - - // 更新修改时间 - sourceNode.updatedAt = new Date().toISOString() - - // 只有在所有操作成功后才保存到数据库 - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - - return true - } catch (error) { - logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error) - // 如果放置失败,尝试恢复原始节点到原位置 - // 这里需要重新实现恢复逻辑,暂时返回false - return false + if (deep && item.startsWith(prefix)) { + return `${newNormalized}${item.slice(oldNormalized.length)}` } - } catch (error) { - logger.error('Move nodes in tree failed:', error as Error) - return false - } + return item + }) } -/** - * 重命名节点 - */ -export async function renameNodeFromTree( - tree: NotesTreeNode[], - nodeId: string, - newName: string -): Promise { - const node = findNodeInTree(tree, nodeId) - - if (!node) { - throw new Error('Node not found') - } - - node.name = newName - - const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1) - node.treePath = dirPath + newName - - const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1) - node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName - - node.updatedAt = new Date().toISOString() - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - return node -} - -/** - * 修改节点键值 - */ -export async function updateNodeInTree( - tree: NotesTreeNode[], - nodeId: string, - updates: Partial -): Promise { - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } - - Object.assign(node, updates) - node.updatedAt = new Date().toISOString() - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - - return node -} - -/** - * 在树中查找节点 - */ -export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { +export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { for (const node of tree) { if (node.id === nodeId) { return node } if (node.children) { - const found = findNodeInTree(node.children, nodeId) + const found = findNode(node.children, nodeId) if (found) { return found } @@ -220,16 +50,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree return null } -/** - * 根据路径查找节点 - */ -export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null { +export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null { for (const node of tree) { - if (node.treePath === path) { + if (node.treePath === targetPath || node.externalPath === targetPath) { return node } if (node.children) { - const found = findNodeByPath(node.children, path) + const found = findNodeByPath(node.children, targetPath) if (found) { return found } @@ -238,53 +65,113 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo return null } -// --- -// 辅助函数 -// --- +export function updateTreeNode( + nodes: NotesTreeNode[], + nodeId: string, + updater: (node: NotesTreeNode) => NotesTreeNode +): NotesTreeNode[] { + let changed = false -/** - * 查找节点的父节点 - */ -export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null { + const nextNodes = nodes.map((node) => { + if (node.id === nodeId) { + changed = true + const updated = updater(node) + if (updated.type === 'folder' && !updated.children) { + return { ...updated, children: [] } + } + return updated + } + + if (node.children && node.children.length > 0) { + const updatedChildren = updateTreeNode(node.children, nodeId, updater) + if (updatedChildren !== node.children) { + changed = true + return { ...node, children: updatedChildren } + } + } + + return node + }) + + return changed ? nextNodes : nodes +} + +export function findParent(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { for (const node of tree) { - if (node.children) { - const isDirectChild = node.children.some((child) => child.id === targetNodeId) - if (isDirectChild) { - return node - } - - const parent = findParentNode(node.children, targetNodeId) - if (parent) { - return parent - } + if (!node.children) { + continue + } + if (node.children.some((child) => child.id === nodeId)) { + return node + } + const found = findParent(node.children, nodeId) + if (found) { + return found } } return null } -/** - * 判断节点是否为另一个节点的父节点 - */ -export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean { - const childNode = findNodeInTree(tree, childId) - if (!childNode) { - return false +export function reorderTreeNodes( + nodes: NotesTreeNode[], + sourceId: string, + targetId: string, + position: 'before' | 'after' +): NotesTreeNode[] { + const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position) + if (moved) { + return updatedNodes } - const parentNode = findNodeInTree(tree, parentId) - if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) { - return false - } - - if (parentNode.children.some((child) => child.id === childId)) { - return true - } - - for (const child of parentNode.children) { - if (isParentNode(tree, child.id, childId)) { - return true + let changed = false + const nextNodes = nodes.map((node) => { + if (!node.children || node.children.length === 0) { + return node } + + const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position) + if (reorderedChildren !== node.children) { + changed = true + return { ...node, children: reorderedChildren } + } + + return node + }) + + return changed ? nextNodes : nodes +} + +function reorderSiblings( + nodes: NotesTreeNode[], + sourceId: string, + targetId: string, + position: 'before' | 'after' +): [NotesTreeNode[], boolean] { + const sourceIndex = nodes.findIndex((node) => node.id === sourceId) + const targetIndex = nodes.findIndex((node) => node.id === targetId) + + if (sourceIndex === -1 || targetIndex === -1) { + return [nodes, false] } - return false + const updated = [...nodes] + const [sourceNode] = updated.splice(sourceIndex, 1) + + let insertIndex = targetIndex + if (sourceIndex < targetIndex) { + insertIndex -= 1 + } + if (position === 'after') { + insertIndex += 1 + } + + if (insertIndex < 0) { + insertIndex = 0 + } + if (insertIndex > updated.length) { + insertIndex = updated.length + } + + updated.splice(insertIndex, 0, sourceNode) + return [updated, true] } diff --git a/src/renderer/src/services/ProviderService.ts b/src/renderer/src/services/ProviderService.ts index 7a6409c903..e0c6b38858 100644 --- a/src/renderer/src/services/ProviderService.ts +++ b/src/renderer/src/services/ProviderService.ts @@ -1,4 +1,4 @@ -import store from '@renderer/store' +import { getStoreProviders } from '@renderer/hooks/useStore' import { Model, Provider } from '@renderer/types' import { getFancyProviderName } from '@renderer/utils' @@ -14,9 +14,9 @@ export function getProviderName(model?: Model) { export function getProviderByModel(model?: Model) { const id = model?.provider - const provider = store.getState().llm.providers.find((p) => p.id === id) + const provider = getStoreProviders().find((p) => p.id === id) - if (provider?.id === 'cherryin') { + if (provider?.id === 'cherryai') { const map = { 'glm-4.5-flash': 'zhipu', 'Qwen/Qwen3-8B': 'silicon' @@ -33,7 +33,7 @@ export function getProviderByModel(model?: Model) { } export function isProviderSupportAuth(provider: Provider) { - const supportProviders = ['302ai', 'silicon', 'aihubmix', 'ppio', 'tokenflux'] + const supportProviders = ['302ai', 'silicon', 'aihubmix', 'ppio', 'tokenflux', 'aionly'] return supportProviders.includes(provider.id) } @@ -43,5 +43,5 @@ export function isProviderSupportCharge(provider: Provider) { } export function getProviderById(id: string) { - return store.getState().llm.providers.find((p) => p.id === id) + return getStoreProviders().find((p) => p.id === id) } diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index a7feca7267..d7d30c1dbc 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -244,8 +244,7 @@ class WebSearchService { items: [], created_at: Date.now(), updated_at: Date.now(), - version: 1, - framework: 'langchain' + version: 1 } // 更新LRU cache diff --git a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts index 80c63858c7..605259b646 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts @@ -15,11 +15,12 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) => // 内部维护的状态 let thinkingBlockId: string | null = null + let thinking_millsec_now: number = 0 return { onThinkingStart: async () => { if (blockManager.hasInitialPlaceholder) { - const changes = { + const changes: Partial = { type: MessageBlockType.THINKING, content: '', status: MessageBlockStatus.STREAMING, @@ -35,29 +36,31 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) => thinkingBlockId = newBlock.id await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING) } + thinking_millsec_now = performance.now() }, - onThinkingChunk: async (text: string, thinking_millsec?: number) => { + onThinkingChunk: async (text: string) => { if (thinkingBlockId) { const blockChanges: Partial = { content: text, - status: MessageBlockStatus.STREAMING, - thinking_millsec: thinking_millsec || 0 + status: MessageBlockStatus.STREAMING + // thinking_millsec: performance.now() - thinking_millsec_now } blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING) } }, - onThinkingComplete: (finalText: string, final_thinking_millsec?: number) => { + onThinkingComplete: (finalText: string) => { if (thinkingBlockId) { - const changes = { - type: MessageBlockType.THINKING, + const now = performance.now() + const changes: Partial = { content: finalText, status: MessageBlockStatus.SUCCESS, - thinking_millsec: final_thinking_millsec || 0 + thinking_millsec: now - thinking_millsec_now } blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true) thinkingBlockId = null + thinking_millsec_now = 0 } else { logger.warn( `[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${blockManager.lastBlockType}) or lastBlockId is null.` diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 15a735bc7f..5462f4bb3e 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -46,8 +46,9 @@ const assistantsSlice = createSlice({ removeAssistant: (state, action: PayloadAction<{ id: string }>) => { state.assistants = state.assistants.filter((c) => c.id !== action.payload.id) }, - updateAssistant: (state, action: PayloadAction) => { - state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? action.payload : c)) + updateAssistant: (state, action: PayloadAction & { id: string }>) => { + const { id, ...update } = action.payload + state.assistants = state.assistants.map((c) => (c.id === id ? { ...c, ...update } : c)) }, updateAssistantSettings: ( state, diff --git a/src/renderer/src/store/codeTools.ts b/src/renderer/src/store/codeTools.ts index 689335b43f..471a31113e 100644 --- a/src/renderer/src/store/codeTools.ts +++ b/src/renderer/src/store/codeTools.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { Model } from '@renderer/types' -import { codeTools } from '@shared/config/constant' +import { codeTools, terminalApps } from '@shared/config/constant' // 常量定义 const MAX_DIRECTORIES = 10 // 最多保存10个目录 @@ -16,6 +16,8 @@ export interface CodeToolsState { directories: string[] // 当前选择的目录 currentDirectory: string + // 选择的终端 ( macOS 和 Windows) + selectedTerminal: string } export const initialState: CodeToolsState = { @@ -32,7 +34,8 @@ export const initialState: CodeToolsState = { 'gemini-cli': '' }, directories: [], - currentDirectory: '' + currentDirectory: '', + selectedTerminal: terminalApps.systemDefault } const codeToolsSlice = createSlice({ @@ -44,6 +47,11 @@ const codeToolsSlice = createSlice({ state.selectedCliTool = action.payload }, + // 设置选择的终端 + setSelectedTerminal: (state, action: PayloadAction) => { + state.selectedTerminal = action.payload + }, + // 设置选择的模型(为当前 CLI 工具设置) setSelectedModel: (state, action: PayloadAction) => { state.selectedModels[state.selectedCliTool] = action.payload @@ -113,12 +121,14 @@ const codeToolsSlice = createSlice({ state.environmentVariables = initialState.environmentVariables state.directories = initialState.directories state.currentDirectory = initialState.currentDirectory + state.selectedTerminal = initialState.selectedTerminal } } }) export const { setSelectedCliTool, + setSelectedTerminal, setSelectedModel, setEnvironmentVariables, addDirectory, diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index baf85581ec..4b74ba91a2 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 153, + version: 158, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index cd77c05430..524889bb32 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -1,16 +1,19 @@ import { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type { GroundingMetadata } from '@google/genai' import { createEntityAdapter, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types' +import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types' import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' import type OpenAI from 'openai' import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出 +// Create a simplified type for the entity adapter to avoid circular type issues +type MessageBlockEntity = MessageBlock + // 1. 创建实体适配器 (Entity Adapter) // 我们使用块的 `id` 作为唯一标识符。 -const messageBlocksAdapter = createEntityAdapter() +const messageBlocksAdapter = createEntityAdapter() // 2. 使用适配器定义初始状态 (Initial State) // 如果需要,可以在规范化实体的旁边添加其他状态属性。 @@ -20,6 +23,7 @@ const initialState = messageBlocksAdapter.getInitialState({ }) // 3. 创建 Slice +// @ts-ignore ignore export const messageBlocksSlice = createSlice({ name: 'messageBlocks', initialState, @@ -76,8 +80,13 @@ export const messageBlocksSelectors = messageBlocksAdapter.getSelectors - blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined // Use adapter selector +const selectBlockEntityById = (state: RootState, blockId: string | undefined): MessageBlock | undefined => { + const entity = blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined + if (!entity) return undefined + + // Convert back to full MessageBlock type + return entity +} // --- Centralized Citation Formatting Logic --- export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => { @@ -173,13 +182,16 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined case WebSearchSource.GROK: case WebSearchSource.OPENROUTER: formattedCitations = - (block.response.results as any[])?.map((url, index) => { + (block.response.results as AISDKWebSearchResult[])?.map((result, index) => { + const url = result.url try { - const hostname = new URL(url).hostname + const hostname = new URL(result.url).hostname + const content = result.providerMetadata && result.providerMetadata['openrouter']?.content return { number: index + 1, url, - hostname, + title: result.title || hostname, + content: content as string, showFavicon: true, type: 'websearch' } @@ -218,10 +230,12 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined break case WebSearchSource.AISDK: formattedCitations = - (block.response.results as any[])?.map((result, index) => ({ + (block.response?.results as AISDKWebSearchResult[])?.map((result, index) => ({ number: index + 1, url: result.url, - title: result.title, + title: result.title || new URL(result.url).hostname, + showFavicon: true, + type: 'websearch', providerMetadata: result?.providerMetadata })) || [] break diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index e879b49d3e..1c954ba27e 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1394,7 +1394,7 @@ const migrateConfig = { if (state.websearch?.providers) { state.websearch.providers = state.websearch.providers.map((provider) => { if (provider.id === 'exa' || provider.id === 'tavily') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { basicAuthUsername, basicAuthPassword, ...rest } = provider return rest } @@ -2376,8 +2376,8 @@ const migrateConfig = { '147': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { - if (!base.framework) { - base.framework = 'embedjs' + if ((base as any).framework) { + delete (base as any).framework } }) return state @@ -2398,8 +2398,8 @@ const migrateConfig = { '149': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { - if (!base.framework) { - base.framework = 'embedjs' + if ((base as any).framework) { + delete (base as any).framework } }) return state @@ -2451,6 +2451,104 @@ const migrateConfig = { logger.error('migrate 153 error', error as Error) return state } + }, + '154': (state: RootState) => { + try { + if (state.settings.userTheme) { + state.settings.userTheme.userFontFamily = settingsInitialState.userTheme.userFontFamily + state.settings.userTheme.userCodeFontFamily = settingsInitialState.userTheme.userCodeFontFamily + } + return state + } catch (error) { + logger.error('migrate 154 error', error as Error) + return state + } + }, + '155': (state: RootState) => { + try { + state.knowledge.bases.forEach((base) => { + if ((base as any).framework) { + delete (base as any).framework + } + }) + return state + } catch (error) { + logger.error('migrate 155 error', error as Error) + return state + } + }, + '156': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.anthropic) { + if (provider.apiHost.endsWith('/')) { + provider.apiHost = provider.apiHost.slice(0, -1) + } + } + }) + return state + } catch (error) { + logger.error('migrate 156 error', error as Error) + return state + } + }, + '157': (state: RootState) => { + try { + addProvider(state, 'aionly') + state.llm.providers = moveProvider(state.llm.providers, 'aionly', 10) + + const cherryinProvider = state.llm.providers.find((provider) => provider.id === 'cherryin') + + if (cherryinProvider) { + updateProvider(state, 'cherryin', { apiHost: 'https://open.cherryin.ai', models: [] }) + } + + if (state.llm.defaultModel?.provider === 'cherryin') { + state.llm.defaultModel.provider = 'cherryai' + } + + if (state.llm.quickModel?.provider === 'cherryin') { + state.llm.quickModel.provider = 'cherryai' + } + + if (state.llm.translateModel?.provider === 'cherryin') { + state.llm.translateModel.provider = 'cherryai' + } + + state.assistants.assistants.forEach((assistant) => { + if (assistant.model?.provider === 'cherryin') { + assistant.model.provider = 'cherryai' + } + if (assistant.defaultModel?.provider === 'cherryin') { + assistant.defaultModel.provider = 'cherryai' + } + }) + + state.agents.agents.forEach((agent) => { + // @ts-ignore model is not defined in Agent + if (agent.model?.provider === 'cherryin') { + // @ts-ignore model is not defined in Agent + agent.model.provider = 'cherryai' + } + if (agent.defaultModel?.provider === 'cherryin') { + agent.defaultModel.provider = 'cherryai' + } + }) + return state + } catch (error) { + logger.error('migrate 157 error', error as Error) + return state + } + }, + '158': (state: RootState) => { + try { + state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin') + addProvider(state, 'longcat') + return state + } catch (error) { + logger.error('migrate 158 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/note.ts b/src/renderer/src/store/note.ts index df4478c07a..38f331c76e 100644 --- a/src/renderer/src/store/note.ts +++ b/src/renderer/src/store/note.ts @@ -20,6 +20,8 @@ export interface NoteState { settings: NotesSettings notesPath: string sortType: NotesSortType + starredPaths: string[] + expandedPaths: string[] } export const initialState: NoteState = { @@ -36,7 +38,9 @@ export const initialState: NoteState = { showWorkspace: true }, notesPath: '', - sortType: 'sort_a2z' + sortType: 'sort_a2z', + starredPaths: [], + expandedPaths: [] } const noteSlice = createSlice({ @@ -57,16 +61,32 @@ const noteSlice = createSlice({ }, setSortType: (state, action: PayloadAction) => { state.sortType = action.payload + }, + setStarredPaths: (state, action: PayloadAction) => { + state.starredPaths = action.payload ?? [] + }, + setExpandedPaths: (state, action: PayloadAction) => { + state.expandedPaths = action.payload ?? [] } } }) -export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath, setSortType } = noteSlice.actions +export const { + setActiveNodeId, + setActiveFilePath, + updateNotesSettings, + setNotesPath, + setSortType, + setStarredPaths, + setExpandedPaths +} = noteSlice.actions export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath export const selectNotesSettings = (state: RootState) => state.note.settings export const selectNotesPath = (state: RootState) => state.note.notesPath export const selectSortType = (state: RootState) => state.note.sortType +export const selectStarredPaths = (state: RootState) => state.note.starredPaths ?? [] +export const selectExpandedPaths = (state: RootState) => state.note.expandedPaths ?? [] export default noteSlice.reducer diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 79f2797abd..887be370d2 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -33,6 +33,8 @@ export type AssistantIconType = 'model' | 'emoji' | 'none' export type UserTheme = { colorPrimary: string + userFontFamily: string + userCodeFontFamily: string } export interface SettingsState { @@ -242,7 +244,9 @@ export const initialState: SettingsState = { tray: true, theme: ThemeMode.system, userTheme: { - colorPrimary: '#00b96b' + colorPrimary: '#00b96b', + userFontFamily: '', + userCodeFontFamily: '' }, windowStyle: isMac ? 'transparent' : 'opaque', fontSize: 14, diff --git a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts index 96aff69f7b..49c71aea56 100644 --- a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts +++ b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts @@ -410,7 +410,8 @@ describe('streamCallback Integration Tests', () => { { type: ChunkType.THINKING_START }, { type: ChunkType.THINKING_DELTA, text: 'Let me think...', thinking_millsec: 1000 }, { type: ChunkType.THINKING_DELTA, text: 'I need to consider...', thinking_millsec: 2000 }, - { type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts', thinking_millsec: 3000 }, + { type: ChunkType.THINKING_DELTA, text: 'Final thoughts', thinking_millsec: 3000 }, + { type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts' }, { type: ChunkType.BLOCK_COMPLETE } ] @@ -424,7 +425,10 @@ describe('streamCallback Integration Tests', () => { expect(thinkingBlock).toBeDefined() expect(thinkingBlock?.content).toBe('Final thoughts') expect(thinkingBlock?.status).toBe(MessageBlockStatus.SUCCESS) - expect((thinkingBlock as any)?.thinking_millsec).toBe(3000) + // thinking_millsec 现在是本地计算的,只验证它存在且是一个合理的数字 + expect((thinkingBlock as any)?.thinking_millsec).toBeDefined() + expect(typeof (thinkingBlock as any)?.thinking_millsec).toBe('number') + expect((thinkingBlock as any)?.thinking_millsec).toBeGreaterThanOrEqual(0) }) it('should handle tool call flow', async () => { diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 45143b3ccc..8d7004ecc7 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -19,6 +19,7 @@ import { resetAssistantMessage } from '@renderer/utils/messageUtils/create' import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue' +import { defaultAppHeaders } from '@shared/utils' import { t } from 'i18next' import { isEmpty, throttle } from 'lodash' import { LRUCache } from 'lru-cache' @@ -369,7 +370,8 @@ const fetchAndProcessAssistantResponseImpl = async ( topicId, options: { signal: abortController.signal, - timeout: 30000 + timeout: 30000, + headers: defaultAppHeaders() } }, streamProcessorCallbacks @@ -1073,7 +1075,7 @@ export const cloneMessagesToNewTopicThunk = const oldBlock = state.messageBlocks.entities[oldBlockId] if (oldBlock) { const newBlockId = uuid() - const newBlock: MessageBlock = { + const newBlock = { ...oldBlock, id: newBlockId, messageId: newMsgId // Link block to the NEW message ID @@ -1169,7 +1171,7 @@ export const updateMessageAndBlocksThunk = try { // 1. 更新 Redux Store if (messageUpdates && messageId) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { id: msgId, ...actualMessageChanges } = messageUpdates // Separate ID from actual changes // Only dispatch message update if there are actual changes beyond the ID diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 1e3fe2a25b..16029ccf47 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -41,6 +41,8 @@ export interface WebSearchState { providerConfig: Record } +export type CherryWebSearchConfig = Pick + export const initialState: WebSearchState = { defaultProvider: 'local-bing', providers: WEB_SEARCH_PROVIDERS, diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index a5fba1040b..2b9271d548 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -1,3 +1,4 @@ +import type { LanguageModelV2Source } from '@ai-sdk/provider' import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type { GenerateImagesConfig, GroundingMetadata, PersonGeneration } from '@google/genai' import type OpenAI from 'openai' @@ -16,6 +17,7 @@ import type { BaseTool, MCPTool } from './tool' export * from './knowledge' export * from './mcp' +export * from './notification' export * from './ocr' export type Assistant = { @@ -267,7 +269,7 @@ export type Provider = { } export const SystemProviderIds = { - cherryin: 'cherryin', + // cherryin: 'cherryin', silicon: 'silicon', aihubmix: 'aihubmix', ocoolai: 'ocoolai', @@ -319,7 +321,9 @@ export const SystemProviderIds = { gpustack: 'gpustack', voyageai: 'voyageai', 'aws-bedrock': 'aws-bedrock', - poe: 'poe' + poe: 'poe', + aionly: 'aionly', + longcat: 'longcat' } as const export type SystemProviderId = keyof typeof SystemProviderIds @@ -726,12 +730,15 @@ export type WebSearchProviderResponse = { results: WebSearchProviderResult[] } +export type AISDKWebSearchResult = Omit, 'sourceType'> + export type WebSearchResults = | WebSearchProviderResponse | GroundingMetadata | OpenAI.Chat.Completions.ChatCompletionMessage.Annotation.URLCitation[] | OpenAI.Responses.ResponseOutputText.URLCitation[] | WebSearchResultBlock[] + | AISDKWebSearchResult[] | any[] export enum WebSearchSource { diff --git a/src/renderer/src/types/knowledge.ts b/src/renderer/src/types/knowledge.ts index 720e60d4d1..c32e6dd003 100644 --- a/src/renderer/src/types/knowledge.ts +++ b/src/renderer/src/types/knowledge.ts @@ -100,12 +100,6 @@ export interface KnowledgeBase { type: 'preprocess' provider: PreprocessProvider } - framework: 'embedjs' | 'langchain' - // default is hybrid - retriever?: { - mode: 'vector' | 'bm25' | 'hybrid' - weight?: number - } } export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed' @@ -145,11 +139,6 @@ export type KnowledgeBaseParams = { type: 'preprocess' provider: PreprocessProvider } - framework: 'embedjs' | 'langchain' - retriever?: { - mode: 'vector' | 'bm25' | 'hybrid' - weight?: number - } } export type KnowledgeReference = { @@ -166,8 +155,3 @@ export interface KnowledgeSearchResult { score: number metadata: Record } - -export enum MigrationModeEnum { - EmbeddingModelChange = 'EmbeddingModelChange', - MigrationToLangChain = 'MigrationToLangChain' -} diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index 74e2b8266a..7ac6ab5bcb 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -10,6 +10,7 @@ import type { MemoryItem, Metrics, Model, + NormalToolResponse, Topic, Usage, WebSearchResponse, @@ -113,7 +114,7 @@ export interface ToolMessageBlock extends BaseMessageBlock { arguments?: Record content?: string | object metadata?: BaseMessageBlock['metadata'] & { - rawMcpToolResponse?: MCPToolResponse + rawMcpToolResponse?: MCPToolResponse | NormalToolResponse } } diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index ee91e2ad2b..f25ac2e68f 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -25,7 +25,7 @@ describe('api', () => { }) it('should handle empty string gracefully', () => { - expect(formatApiHost('')).toBe('/v1/') + expect(formatApiHost('')).toBe('') }) }) diff --git a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts index 4656e30562..a5c0983479 100644 --- a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts +++ b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts @@ -26,7 +26,7 @@ import { describe, expect, it } from 'vitest' -import { MatchPatternMap } from '../blacklistMatchPattern' +import { mapRegexToPatterns, MatchPatternMap } from '../blacklistMatchPattern' function get(map: MatchPatternMap, url: string) { return map.get(url).sort() @@ -161,3 +161,28 @@ describe('blacklistMatchPattern', () => { expect(get(map, 'https://b.mozilla.org/path/')).toEqual([0, 1, 2, 6]) }) }) + +describe('mapRegexToPatterns', () => { + it('extracts domains from regex patterns', () => { + const result = mapRegexToPatterns([ + '/example\\.com/', + '/(?:www\\.)?sub\\.example\\.co\\.uk/', + '/api\\.service\\.io/', + 'https://baidu.com' + ]) + + expect(result).toEqual(['example.com', 'sub.example.co.uk', 'api.service.io', 'baidu.com']) + }) + + it('deduplicates domains across multiple patterns', () => { + const result = mapRegexToPatterns(['/example\\.com/', '/(example\\.com|test\\.org)/']) + + expect(result).toEqual(['example.com', 'test.org']) + }) + + it('ignores patterns without domain matches', () => { + const result = mapRegexToPatterns(['', 'plain-domain.com', '/^https?:\\/\\/[^/]+$/']) + + expect(result).toEqual(['plain-domain.com']) + }) +}) diff --git a/src/renderer/src/utils/__tests__/linkConverter.test.ts b/src/renderer/src/utils/__tests__/linkConverter.test.ts index 16d2e8aefb..1b813f1a54 100644 --- a/src/renderer/src/utils/__tests__/linkConverter.test.ts +++ b/src/renderer/src/utils/__tests__/linkConverter.test.ts @@ -3,91 +3,12 @@ import { describe, expect, it } from 'vitest' import { cleanLinkCommas, completeLinks, - completionPerplexityLinks, convertLinks, - convertLinksToHunyuan, - convertLinksToOpenRouter, - convertLinksToZhipu, extractUrlsFromMarkdown, flushLinkConverterBuffer } from '../linkConverter' describe('linkConverter', () => { - describe('convertLinksToZhipu', () => { - it('should correctly convert complete [ref_N] format', () => { - const input = '这里有一个参考文献 [ref_1] 和另一个 [ref_2]' - const result = convertLinksToZhipu(input, true) - expect(result).toBe('这里有一个参考文献 [1]() 和另一个 [2]()') - }) - - it('should handle chunked input and preserve incomplete link patterns', () => { - // 第一个块包含未完成的模式 - const chunk1 = '这是第一部分 [ref' - const result1 = convertLinksToZhipu(chunk1, true) - expect(result1).toBe('这是第一部分 ') - - // 第二个块完成该模式 - const chunk2 = '_1] 这是剩下的部分' - const result2 = convertLinksToZhipu(chunk2, false) - expect(result2).toBe('[1]() 这是剩下的部分') - }) - - it('should clear buffer when resetting counter', () => { - // 先进行一次转换不重置 - const input1 = '第一次输入 [ref_1]' - convertLinksToZhipu(input1, false) - - // 然后重置并进行新的转换 - const input2 = '新的输入 [ref_2]' - const result = convertLinksToZhipu(input2, true) - expect(result).toBe('新的输入 [2]()') - }) - }) - - describe('convertLinksToHunyuan', () => { - it('should correctly convert [N](@ref) format to links with URLs', () => { - const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }] - const input = '这里有单个引用 [1](@ref) 和多个引用 [2](@ref)' - const result = convertLinksToHunyuan(input, webSearch, true) - expect(result).toBe( - '这里有单个引用 [1](https://example.com/1) 和多个引用 [2](https://example.com/2)' - ) - }) - - it('should correctly handle comma-separated multiple references', () => { - const webSearch = [ - { url: 'https://example.com/1' }, - { url: 'https://example.com/2' }, - { url: 'https://example.com/3' } - ] - const input = '这里有多个引用 [1, 2, 3](@ref)' - const result = convertLinksToHunyuan(input, webSearch, true) - expect(result).toBe( - '这里有多个引用 [1](https://example.com/1)[2](https://example.com/2)[3](https://example.com/3)' - ) - }) - - it('should handle non-existent reference indices', () => { - const webSearch = [{ url: 'https://example.com/1' }] - const input = '这里有一个超出范围的引用 [2](@ref)' - const result = convertLinksToHunyuan(input, webSearch, true) - expect(result).toBe('这里有一个超出范围的引用 [2](@ref)') - }) - - it('should handle incomplete reference formats in chunked input', () => { - const webSearch = [{ url: 'https://example.com/1' }] - // 第一个块包含未完成的模式 - const chunk1 = '这是第一部分 [' - const result1 = convertLinksToHunyuan(chunk1, webSearch, true) - expect(result1).toBe('这是第一部分 ') - - // 第二个块完成该模式 - const chunk2 = '1](@ref) 这是剩下的部分' - const result2 = convertLinksToHunyuan(chunk2, webSearch, false) - expect(result2).toBe('[1](https://example.com/1) 这是剩下的部分') - }) - }) - describe('convertLinks', () => { it('should convert number links to numbered links', () => { const input = '参考 [1](https://example.com/1) 和 [2](https://example.com/2)' @@ -226,8 +147,10 @@ describe('linkConverter', () => { it('should handle real links split across small chunks with proper buffering', () => { // 模拟真实链接被分割成小chunks的情况 - 更现实的分割方式 const chunks = [ - 'Please visit [example.com](', // 不完整链接 - 'https://example.com) for details' // 完成链接 + 'Please visit [example.', + 'com](', // 不完整链接' + 'https://exa', + 'mple.com) for details' // 完成链接' ] let accumulatedText = '' @@ -235,14 +158,24 @@ describe('linkConverter', () => { // 第一个chunk:包含不完整链接 [text]( const result1 = convertLinks(chunks[0], true) expect(result1.text).toBe('Please visit ') // 只返回安全部分 - expect(result1.hasBufferedContent).toBe(true) // [example.com]( 被缓冲 + expect(result1.hasBufferedContent).toBe(true) // accumulatedText += result1.text - // 第二个chunk:完成链接 + // 第二个chunk const result2 = convertLinks(chunks[1], false) - expect(result2.text).toBe('[1](https://example.com) for details') // 完整链接 + 剩余文本 - expect(result2.hasBufferedContent).toBe(false) - accumulatedText += result2.text + expect(result2.text).toBe('') + expect(result2.hasBufferedContent).toBe(true) + // 第三个chunk + const result3 = convertLinks(chunks[2], false) + expect(result3.text).toBe('') + expect(result3.hasBufferedContent).toBe(true) + accumulatedText += result3.text + + // 第四个chunk + const result4 = convertLinks(chunks[3], false) + expect(result4.text).toBe('[1](https://example.com) for details') + expect(result4.hasBufferedContent).toBe(false) + accumulatedText += result4.text // 验证最终结果 expect(accumulatedText).toBe('Please visit [1](https://example.com) for details') @@ -293,32 +226,6 @@ describe('linkConverter', () => { }) }) - describe('convertLinksToOpenRouter', () => { - it('should only convert links with domain-like text', () => { - const input = '网站 [example.com](https://example.com) 和 [点击这里](https://other.com)' - const result = convertLinksToOpenRouter(input, true) - expect(result).toBe('网站 [1](https://example.com) 和 [点击这里](https://other.com)') - }) - - it('should use the same counter for duplicate URLs', () => { - const input = '两个相同的链接 [example.com](https://example.com) 和 [example.org](https://example.com)' - const result = convertLinksToOpenRouter(input, true) - expect(result).toBe('两个相同的链接 [1](https://example.com) 和 [1](https://example.com)') - }) - - it('should handle incomplete links in chunked input', () => { - // 第一个块包含未完成的链接 - const chunk1 = '这是域名链接 [' - const result1 = convertLinksToOpenRouter(chunk1, true) - expect(result1).toBe('这是域名链接 ') - - // 第二个块完成链接 - const chunk2 = 'example.com](https://example.com)' - const result2 = convertLinksToOpenRouter(chunk2, false) - expect(result2).toBe('[1](https://example.com)') - }) - }) - describe('completeLinks', () => { it('should complete empty links with webSearch data', () => { const webSearch = [{ link: 'https://example.com/1' }, { link: 'https://example.com/2' }] @@ -383,13 +290,4 @@ describe('linkConverter', () => { expect(result).toBe('[链接1](https://example.com)[链接2](https://other.com)') }) }) - - describe('completionPerplexityLinks', () => { - it('should complete links with webSearch data', () => { - const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }] - const input = '参考 [1] 和 [2]' - const result = completionPerplexityLinks(input, webSearch) - expect(result).toBe('参考 [1](https://example.com/1) 和 [2](https://example.com/2)') - }) - }) }) diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 5e9b8f91a6..62d0db5623 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -20,6 +20,10 @@ export function formatApiKeys(value: string): string { * @returns {string} 格式化后的 API 主机地址。 */ export function formatApiHost(host: string, apiVersion: string = 'v1'): string { + if (!host) { + return '' + } + const forceUseOriginalHost = () => { if (host.endsWith('/')) { return true diff --git a/src/renderer/src/utils/blacklistMatchPattern.ts b/src/renderer/src/utils/blacklistMatchPattern.ts index 9e78ab58fb..b00e07a785 100644 --- a/src/renderer/src/utils/blacklistMatchPattern.ts +++ b/src/renderer/src/utils/blacklistMatchPattern.ts @@ -202,6 +202,43 @@ export async function parseSubscribeContent(url: string): Promise { throw error } } + +export function mapRegexToPatterns(patterns: string[]): string[] { + const patternSet = new Set() + const domainMatcher = /[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g + + patterns.forEach((pattern) => { + if (!pattern) { + return + } + + // Handle regex patterns (wrapped in /) + if (pattern.startsWith('/') && pattern.endsWith('/')) { + const rawPattern = pattern.slice(1, -1) + const normalizedPattern = rawPattern.replace(/\\\./g, '.').replace(/\\\//g, '/') + const matches = normalizedPattern.match(domainMatcher) + + if (matches) { + matches.forEach((match) => { + patternSet.add(match.replace(/http(s)?:\/\//g, '').toLowerCase()) + }) + } + } else if (pattern.includes('://')) { + // Handle URLs with protocol (e.g., https://baidu.com) + const matches = pattern.match(domainMatcher) + if (matches) { + matches.forEach((match) => { + patternSet.add(match.replace(/http(s)?:\/\//g, '').toLowerCase()) + }) + } + } else { + patternSet.add(pattern.toLowerCase()) + } + }) + + return Array.from(patternSet) +} + export async function filterResultWithBlacklist( response: WebSearchProviderResponse, websearch: WebSearchState diff --git a/src/renderer/src/utils/dataLimit.ts b/src/renderer/src/utils/dataLimit.ts index 9cbc378a53..7678099be2 100644 --- a/src/renderer/src/utils/dataLimit.ts +++ b/src/renderer/src/utils/dataLimit.ts @@ -40,7 +40,7 @@ async function checkAppDataDiskQuota(appDataPath: string) { return false } const { free } = diskInfo - logger.info(`App data disk quota: Free ${free} GB`) + logger.info(`App data disk quota: Free ${(free / GB).toFixed(2)} GB`) // if free is less than 1GB, return true return free < 1 * GB } catch (error) { diff --git a/src/renderer/src/utils/error.ts b/src/renderer/src/utils/error.ts index 024a6713b0..b69073e406 100644 --- a/src/renderer/src/utils/error.ts +++ b/src/renderer/src/utils/error.ts @@ -11,6 +11,7 @@ import { InvalidToolInputError, NoSuchToolError } from 'ai' import { t } from 'i18next' import { z } from 'zod' +import { parseJSON } from './json' import { safeSerialize } from './serialize' // const logger = loggerService.withContext('Utils:error') @@ -126,7 +127,23 @@ export const serializeError = (error: AiSdkErrorUnion): SerializedError => { if ('url' in error) serializedError.url = error.url if ('requestBodyValues' in error) serializedError.requestBodyValues = safeSerialize(error.requestBodyValues) if ('statusCode' in error) serializedError.statusCode = error.statusCode ?? null - if ('responseBody' in error) serializedError.responseBody = error.responseBody ?? null + if ('responseBody' in error && error.responseBody) { + const body = parseJSON(error.responseBody) + if (body) { + // try to parse internal msg + const message = body.message || body.msg + if (message) { + if (serializedError.message === null) { + serializedError.message = message + } else { + serializedError.message += ' ' + message + } + } + serializedError.responseBody = JSON.stringify(body, null, 2) + } else { + serializedError.responseBody = error.responseBody + } + } if ('isRetryable' in error) serializedError.isRetryable = error.isRetryable if ('data' in error) serializedError.data = safeSerialize(error.data) if ('responseHeaders' in error) serializedError.responseHeaders = error.responseHeaders ?? null diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index d4fed4d029..f3ce321d63 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -3,12 +3,11 @@ import { Client } from '@notionhq/client' import i18n from '@renderer/i18n' import { getProviderLabel } from '@renderer/i18n/label' import { getMessageTitle } from '@renderer/services/MessagesService' -import { createNote } from '@renderer/services/NotesService' +import { addNote } from '@renderer/services/NotesService' import store from '@renderer/store' import { setExportState } from '@renderer/store/runtime' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' import { removeSpecialCharactersForFileName } from '@renderer/utils/file' import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown' import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find' @@ -1052,18 +1051,12 @@ async function createSiyuanDoc( * @param content * @param folderPath */ -export const exportMessageToNotes = async ( - title: string, - content: string, - folderPath: string -): Promise => { +export const exportMessageToNotes = async (title: string, content: string, folderPath: string): Promise => { try { const cleanedContent = content.replace(/^## 🤖 Assistant(\n|$)/m, '') - const note = await createNote(title, cleanedContent, folderPath) + await addNote(title, cleanedContent, folderPath) window.toast.success(i18n.t('message.success.notes.export')) - - return note } catch (error) { logger.error('导出到笔记失败:', error as Error) window.toast.error(i18n.t('message.error.notes.export')) @@ -1077,14 +1070,12 @@ export const exportMessageToNotes = async ( * @param folderPath * @returns 创建的笔记节点 */ -export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { +export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { try { const content = await topicToMarkdown(topic) - const note = await createNote(topic.name, content, folderPath) + await addNote(topic.name, content, folderPath) window.toast.success(i18n.t('message.success.notes.export')) - - return note } catch (error) { logger.error('导出到笔记失败:', error as Error) window.toast.error(i18n.t('message.error.notes.export')) diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 828d288ba8..64f943946a 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -205,6 +205,10 @@ export function isOpenAIProvider(provider: Provider): boolean { return !['anthropic', 'gemini', 'vertexai'].includes(provider.type) } +export function isAnthropicProvider(provider: Provider): boolean { + return provider.type === 'anthropic' +} + /** * 判断模型是否为用户手动选择 * @param {Model} model 模型对象 diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index 220333f027..ccb6ec5004 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -1,5 +1,3 @@ -import { WebSearchResponse, WebSearchSource } from '@renderer/types' - // Counter for numbering links let linkCounter = 1 // Buffer to hold incomplete link fragments across chunks @@ -17,109 +15,6 @@ function isHost(text: string): boolean { return /^(https?:\/\/)?[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(text) || /^[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(text) } -/** - * Converts Markdown links in the text to numbered links based on the rules:s - * [ref_N] -> [N] - * @param {string} text The current chunk of text to process - * @param {boolean} resetCounter Whether to reset the counter and buffer - * @returns {string} Processed text with complete links converted - */ -export function convertLinksToZhipu(text: string, resetCounter: boolean = false): string { - if (resetCounter) { - linkCounter = 1 - buffer = '' - } - - // Append the new text to the buffer - buffer += text - let safePoint = buffer.length - - // Check from the end for potentially incomplete [ref_N] patterns - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - // Check if it's a complete [ref_N] pattern - const match = /^\[ref_\d+\]/.exec(substring) - - if (!match) { - // Potentially incomplete [ref_N] pattern - safePoint = i - break - } - } - } - - // Process the safe part of the buffer - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Replace all complete [ref_N] patterns - return safeBuffer.replace(/\[ref_(\d+)\]/g, (_, num) => { - return `[${num}]()` - }) -} - -/** - * Converts Markdown links in the text to numbered links based on the rules: - * [N](@ref) -> [N]() - * [N,M,...](@ref) -> [N]() [M]() ... - * @param {string} text The current chunk of text to process - * @param {any[]} webSearch webSearch results - * @param {boolean} resetCounter Whether to reset the counter and buffer - * @returns {string} Processed text with complete links converted - */ -export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter: boolean = false): string { - if (resetCounter) { - linkCounter = 1 - buffer = '' - } - - buffer += text - let safePoint = buffer.length - - // Check from the end for potentially incomplete patterns - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - // Check if it's a complete pattern - handles both [N](@ref) and [N,M,...](@ref) - const match = /^\[[\d,\s]+\]\(@ref\)/.exec(substring) - - if (!match) { - // Potentially incomplete pattern - safePoint = i - break - } - } - } - - // Process the safe part of the buffer - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Replace all complete patterns - return safeBuffer.replace(/\[([\d,\s]+)\]\(@ref\)/g, (_, numbers) => { - // Split the numbers string into individual numbers - const numArray = numbers - .split(',') - .map((num) => parseInt(num.trim())) - .filter((num) => !isNaN(num)) - - // Generate separate superscript links for each number - const links = numArray.map((num) => { - const index = num - 1 - // Check if the index is valid in webSearch array - if (index >= 0 && index < webSearch.length && webSearch[index]?.url) { - return `[${num}](${webSearch[index].url})` - } - // If no matching URL found, keep the original reference format for this number - return `[${num}](@ref)` - }) - - // Join the separate links with spaces - return links.join('') - }) -} - /** * Converts Markdown links in the text to numbered links based on the rules: * 1. ([host](url)) -> [cnt](url) @@ -171,13 +66,21 @@ export function convertLinks( break } - // 检查是否是完整的链接但需要验证 + // 检查是否是完整的链接 const completeLink = /^\[([^\]]+)\]\(([^)]+)\)/.test(substring) if (completeLink) { // 如果是完整链接,继续处理,不设置safePoint continue } + // 检查是否是不完整的 [ 开始但还没有闭合的 ] + // 例如 [example. 这种情况 + const incompleteBracket = /^\[[^\]]*$/.test(substring) + if (incompleteBracket) { + safePoint = i + break + } + // 如果不是潜在的链接格式,继续检查 } } @@ -263,65 +166,6 @@ export function convertLinks( } } -/** - * Converts Markdown links in the text to numbered links based on the rules: - * 1. [host](url) -> [cnt](url) - * - * @param {string} text The current chunk of text to process - * @param {boolean} resetCounter Whether to reset the counter and buffer - * @returns {string} Processed text with complete links converted - */ -export function convertLinksToOpenRouter(text: string, resetCounter = false): string { - if (resetCounter) { - linkCounter = 1 - buffer = '' - urlToCounterMap = new Map() - } - - // Append the new text to the buffer - buffer += text - - // Find a safe point to process - let safePoint = buffer.length - - // Check for potentially incomplete link patterns from the end - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(substring) - - if (!match) { - safePoint = i - break - } - } - } - - // Extract the part of the buffer that we can safely process - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Process the safe buffer to handle complete links - const result = safeBuffer.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { - // Only convert link if the text looks like a host/URL - if (isHost(text)) { - // Check if this URL has been seen before - let counter: number - if (urlToCounterMap.has(url)) { - counter = urlToCounterMap.get(url)! - } else { - counter = linkCounter++ - urlToCounterMap.set(url, counter) - } - return `[${counter}](${url})` - } - // Keep original link format if the text doesn't look like a host - return match - }) - - return result -} - /** * 根据webSearch结果补全链接,将[num]()转换为[num](webSearch[num-1].url) * @param {string} text 原始文本 @@ -341,25 +185,6 @@ export function completeLinks(text: string, webSearch: any[]): string { }) } -/** - * 根据webSearch结果补全链接,将[num]转换为[num](webSearch[num-1].url) - * @param {string} text 原始文本 - * @param {any[]} webSearch webSearch结果 - * @returns {string} 补全后的文本 - */ -export function completionPerplexityLinks(text: string, webSearch: any[]): string { - return text.replace(/\[(\d+)\]/g, (match, numStr) => { - const num = parseInt(numStr) - const index = num - 1 - // 检查 webSearch 数组中是否存在对应的 URL - if (index >= 0 && index < webSearch.length && webSearch[index].url) { - return `[${num}](${webSearch[index].url})` - } - // 如果没有找到对应的 URL,保持原样 - return match - }) -} - /** * 从Markdown文本中提取所有URL * 支持以下格式: @@ -412,118 +237,6 @@ export function cleanLinkCommas(text: string): string { return text.replace(/\]\(([^)]+)\)\s*,\s*\[/g, ']($1)[') } -/** - * 从文本中识别各种格式的Web搜索引用占位符 - * 支持的格式包括:[1], [ref_1], [1](@ref), [1,2,3](@ref) 等 - * @param {string} text 要分析的文本 - * @returns {Array} 识别到的引用信息数组 - */ -export function extractWebSearchReferences(text: string): Array<{ - match: string - placeholder: string - numbers: number[] - startIndex: number - endIndex: number -}> { - const references: Array<{ - match: string - placeholder: string - numbers: number[] - startIndex: number - endIndex: number - }> = [] - - // 匹配各种引用格式的正则表达式 - const patterns = [ - // [1], [2], [3] - 简单数字引用 - { regex: /\[(\d+)\]/g, type: 'simple' }, - // [ref_1], [ref_2] - Zhipu格式 - { regex: /\[ref_(\d+)\]/g, type: 'zhipu' }, - // [1](@ref), [2](@ref) - Hunyuan单个引用格式 - { regex: /\[(\d+)\]\(@ref\)/g, type: 'hunyuan_single' }, - // [1,2,3](@ref) - Hunyuan多个引用格式 - { regex: /\[([\d,\s]+)\]\(@ref\)/g, type: 'hunyuan_multiple' } - ] - - patterns.forEach(({ regex, type }) => { - let match - while ((match = regex.exec(text)) !== null) { - let numbers: number[] = [] - - if (type === 'hunyuan_multiple') { - // 解析逗号分隔的数字 - numbers = match[1] - .split(',') - .map((num) => parseInt(num.trim())) - .filter((num) => !isNaN(num)) - } else { - // 单个数字 - numbers = [parseInt(match[1])] - } - - references.push({ - match: match[0], - placeholder: match[0], - numbers: numbers, - startIndex: match.index!, - endIndex: match.index! + match[0].length - }) - } - }) - - // 按位置排序 - return references.sort((a, b) => a.startIndex - b.startIndex) -} - -/** - * 智能链接转换器 - 根据文本中的引用模式和Web搜索结果自动选择合适的转换策略 - * @param {string} text 当前文本块 - * @param {any[]} webSearchResults Web搜索结果数组 - * @param {string} providerType Provider类型 ('openai', 'zhipu', 'hunyuan', 'openrouter', etc.) - * @param {boolean} resetCounter 是否重置计数器 - * @returns {{text: string, hasBufferedContent: boolean}} 转换后的文本和是否有内容被缓冲 - */ -export function smartLinkConverter( - text: string, - providerType: string = 'openai', - resetCounter: boolean = false, - webSearchResults?: WebSearchResponse -): { text: string; hasBufferedContent: boolean } { - if (webSearchResults) { - const webSearch = webSearchResults.results - switch (webSearchResults.source) { - case WebSearchSource.PERPLEXITY: { - text = completionPerplexityLinks(text, webSearch as any[]) - break - } - } - } - // 检测文本中的引用模式 - const references = extractWebSearchReferences(text) - - if (references.length === 0) { - // 如果没有特定的引用模式,使用通用转换 - return convertLinks(text, resetCounter) - } - - // 根据检测到的引用模式选择合适的转换器 - const hasZhipuPattern = references.some((ref) => ref.placeholder.includes('ref_')) - - if (hasZhipuPattern) { - return { - text: convertLinksToZhipu(text, resetCounter), - hasBufferedContent: false - } - } else if (providerType === 'openrouter') { - return { - text: convertLinksToOpenRouter(text, resetCounter), - hasBufferedContent: false - } - } else { - return convertLinks(text, resetCounter) - } -} - /** * 强制返回buffer中的所有内容,用于流结束时清空缓冲区 * @returns {string} buffer中剩余的所有内容 diff --git a/src/renderer/src/utils/model.ts b/src/renderer/src/utils/model.ts index 9795b72d26..e918c84572 100644 --- a/src/renderer/src/utils/model.ts +++ b/src/renderer/src/utils/model.ts @@ -64,7 +64,7 @@ export const getModelTags = (models: Model[]): Record => { } export function isFreeModel(model: Model) { - if (model.provider === 'cherryin') { + if (model.provider === 'cherryai') { return true } diff --git a/src/renderer/src/utils/oauth.ts b/src/renderer/src/utils/oauth.ts index 9fbb632a07..5d57547f69 100644 --- a/src/renderer/src/utils/oauth.ts +++ b/src/renderer/src/utils/oauth.ts @@ -172,6 +172,27 @@ export const oauthWith302AI = async (setKey) => { window.addEventListener('message', messageHandler) } +export const oauthWithAiOnly = async (setKey) => { + const authUrl = `https://www.aiionly.com/login?inviteCode=1755481173663DrZBBOC0&cherryCode=01` + + const popup = window.open( + authUrl, + 'login', + 'width=720,height=720,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes' + ) + + const messageHandler = (event) => { + if (event.data.length > 0 && event.data[0]['secretKey'] !== undefined) { + setKey(event.data[0]['secretKey']) + popup?.close() + window.removeEventListener('message', messageHandler) + } + } + + window.removeEventListener('message', messageHandler) + window.addEventListener('message', messageHandler) +} + export const providerCharge = async (provider: string) => { const chargeUrlMap = { silicon: { @@ -198,6 +219,11 @@ export const providerCharge = async (provider: string) => { url: 'https://dash.302.ai/charge', width: 900, height: 700 + }, + aionly: { + url: `https://www.aiionly.com/recharge`, + width: 900, + height: 700 } } @@ -236,6 +262,11 @@ export const providerBills = async (provider: string) => { url: 'https://dash.302.ai/charge', width: 900, height: 700 + }, + aionly: { + url: `https://www.aiionly.com/billManagement`, + width: 900, + height: 700 } } diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index 89d6726c18..f717ee015a 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { isQwenMTModel } from '@renderer/config/models' import { LANG_DETECT_PROMPT } from '@renderer/config/prompts' -import { builtinLanguages as builtinLanguages, LanguagesEnum, UNKNOWN } from '@renderer/config/translate' +import { builtinLanguages, LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import db from '@renderer/databases' import i18n from '@renderer/i18n' import { fetchChatCompletion } from '@renderer/services/ApiService' diff --git a/src/renderer/src/windows/mini/MiniWindowApp.tsx b/src/renderer/src/windows/mini/MiniWindowApp.tsx index b459a3e027..0f6d8bc60d 100644 --- a/src/renderer/src/windows/mini/MiniWindowApp.tsx +++ b/src/renderer/src/windows/mini/MiniWindowApp.tsx @@ -1,9 +1,9 @@ import '@renderer/databases' -import { HeroUIProvider } from '@heroui/react' import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { ToastPortal } from '@renderer/components/ToastPortal' import { getToastUtilities } from '@renderer/components/TopView/toast' +import { HeroUIProvider } from '@renderer/context/HeroUIProvider' import { useSettings } from '@renderer/hooks/useSettings' import store, { persistor } from '@renderer/store' import { useEffect } from 'react' diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index 9dd9cbeb40..0d29687787 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -138,8 +138,9 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { } } - assistantRef.current = getDefaultTranslateAssistant(translateLang, action.selectedText) - processMessages(assistantRef.current, topicRef.current, action.selectedText, setAskId, onStream, onFinish, onError) + const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText) + assistantRef.current = assistant + processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError) }, [action, targetLanguage, alterLanguage, scrollToBottom]) useEffect(() => { diff --git a/src/renderer/src/windows/selection/action/entryPoint.tsx b/src/renderer/src/windows/selection/action/entryPoint.tsx index 44f24ccb97..5aa8d3f745 100644 --- a/src/renderer/src/windows/selection/action/entryPoint.tsx +++ b/src/renderer/src/windows/selection/action/entryPoint.tsx @@ -2,13 +2,13 @@ import '@renderer/assets/styles/index.css' import '@renderer/assets/styles/tailwind.css' import '@ant-design/v5-patch-for-react-19' -import { HeroUIProvider } from '@heroui/react' import KeyvStorage from '@kangfenmao/keyv-storage' import { loggerService } from '@logger' import { ToastPortal } from '@renderer/components/ToastPortal' import { getToastUtilities } from '@renderer/components/TopView/toast' import AntdProvider from '@renderer/context/AntdProvider' import { CodeStyleProvider } from '@renderer/context/CodeStyleProvider' +import { HeroUIProvider } from '@renderer/context/HeroUIProvider' import { ThemeProvider } from '@renderer/context/ThemeProvider' import storeSyncService from '@renderer/services/StoreSyncService' import store, { persistor } from '@renderer/store' diff --git a/tests/__mocks__/MainLoggerService.ts b/tests/__mocks__/MainLoggerService.ts index 43b1f33e4a..e7c8a6f198 100644 --- a/tests/__mocks__/MainLoggerService.ts +++ b/tests/__mocks__/MainLoggerService.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +/* oslint-disable @typescript-eslint/no-empty-function */ // Simple mock LoggerService class for main process export class MockMainLoggerService { diff --git a/tests/__mocks__/RendererLoggerService.ts b/tests/__mocks__/RendererLoggerService.ts index 40f77635eb..90769dc5b8 100644 --- a/tests/__mocks__/RendererLoggerService.ts +++ b/tests/__mocks__/RendererLoggerService.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +/* oxlint-disable @typescript-eslint/no-empty-function */ // Simple mock LoggerService class for renderer process export class MockRendererLoggerService { diff --git a/tsconfig.node.json b/tsconfig.node.json index 0e9a295f97..b6f9061cdf 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,14 +19,13 @@ "electron-vite/node", "vitest/globals" ], - "baseUrl": ".", "paths": { - "@logger": ["src/main/services/LoggerService"], - "@main/*": ["src/main/*"], - "@types": ["src/renderer/src/types/index.ts"], - "@shared/*": ["packages/shared/*"], - "@mcp-trace/*": ["packages/mcp-trace/*"], - "@modelcontextprotocol/sdk/*": ["node_modules/@modelcontextprotocol/sdk/dist/esm/*"] + "@logger": ["./src/main/services/LoggerService"], + "@main/*": ["./src/main/*"], + "@types": ["./src/renderer/src/types/index.ts"], + "@shared/*": ["./packages/shared/*"], + "@mcp-trace/*": ["./packages/mcp-trace/*"], + "@modelcontextprotocol/sdk/*": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/*"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 5936bfaa03..1204192253 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -8,7 +8,7 @@ "tests/__mocks__/**/*", "packages/mcp-trace/**/*", "packages/aiCore/src/**/*", - "src/main/integration/cherryin/index.js", + "src/main/integration/cherryai/index.js", "packages/extension-table-plus/**/*" ], "compilerOptions": { @@ -16,19 +16,18 @@ "incremental": true, "tsBuildInfoFile": ".tsbuildinfo/tsconfig.web.tsbuildinfo", "jsx": "react-jsx", - "baseUrl": ".", "moduleResolution": "bundler", "paths": { - "@logger": ["src/renderer/src/services/LoggerService"], - "@renderer/*": ["src/renderer/src/*"], - "@shared/*": ["packages/shared/*"], - "@types": ["src/renderer/src/types/index.ts"], - "@mcp-trace/*": ["packages/mcp-trace/*"], - "@cherrystudio/ai-core/provider": ["packages/aiCore/src/core/providers/index.ts"], - "@cherrystudio/ai-core/built-in/plugins": ["packages/aiCore/src/core/plugins/built-in/index.ts"], - "@cherrystudio/ai-core/*": ["packages/aiCore/src/*"], - "@cherrystudio/ai-core": ["packages/aiCore/src/index.ts"], - "@cherrystudio/extension-table-plus": ["packages/extension-table-plus/src/index.ts"] + "@logger": ["./src/renderer/src/services/LoggerService"], + "@renderer/*": ["./src/renderer/src/*"], + "@shared/*": ["./packages/shared/*"], + "@types": ["./src/renderer/src/types/index.ts"], + "@mcp-trace/*": ["./packages/mcp-trace/*"], + "@cherrystudio/ai-core/provider": ["./packages/aiCore/src/core/providers/index.ts"], + "@cherrystudio/ai-core/built-in/plugins": ["./packages/aiCore/src/core/plugins/built-in/index.ts"], + "@cherrystudio/ai-core/*": ["./packages/aiCore/src/*"], + "@cherrystudio/ai-core": ["./packages/aiCore/src/index.ts"], + "@cherrystudio/extension-table-plus": ["./packages/extension-table-plus/src/index.ts"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/yarn.lock b/yarn.lock index eb0cde8a17..9252c911de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -74,209 +74,169 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/amazon-bedrock@npm:^3.0.0": - version: 3.0.8 - resolution: "@ai-sdk/amazon-bedrock@npm:3.0.8" +"@ai-sdk/amazon-bedrock@npm:^3.0.21": + version: 3.0.21 + resolution: "@ai-sdk/amazon-bedrock@npm:3.0.21" dependencies: - "@ai-sdk/anthropic": "npm:2.0.4" + "@ai-sdk/anthropic": "npm:2.0.17" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.3" + "@ai-sdk/provider-utils": "npm:3.0.9" "@smithy/eventstream-codec": "npm:^4.0.1" "@smithy/util-utf8": "npm:^4.0.0" aws4fetch: "npm:^1.0.20" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/d7b303b8581e9d28e9ac375b3718ef3f7fff3353d18185870f0b90fd542eb9398d029768502981e9e45a6b64137a7029f591993afd0b18e9ef74525f625524f7 + checksum: 10c0/2d15baaad53e389666cede9673e2b43f5299e2cedb70f5b7afc656b7616e73775a9108c2cc1beee4644ff4c66ad41c8dd0b412373dd05caa4fc3d477c4343ea8 languageName: node linkType: hard -"@ai-sdk/anthropic@npm:2.0.15": - version: 2.0.15 - resolution: "@ai-sdk/anthropic@npm:2.0.15" +"@ai-sdk/anthropic@npm:2.0.17, @ai-sdk/anthropic@npm:^2.0.17": + version: 2.0.17 + resolution: "@ai-sdk/anthropic@npm:2.0.17" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/9597b32be8b83dab67b23f162ca66cde385213fb1665f54091d59430789becf73e2b4fcd2be66ceb13020409f59cd8f9da7dae23adf183bc9eb7ce94f55bde96 + checksum: 10c0/783b6a953f3854c4303ad7c30dd56d4706486c7d1151adb17071d87933418c59c26bce53d5c26d34c4d4728eaac4a856ce49a336caed26a7216f982fea562814 languageName: node linkType: hard -"@ai-sdk/anthropic@npm:2.0.4": - version: 2.0.4 - resolution: "@ai-sdk/anthropic@npm:2.0.4" +"@ai-sdk/azure@npm:^2.0.30": + version: 2.0.30 + resolution: "@ai-sdk/azure@npm:2.0.30" dependencies: + "@ai-sdk/openai": "npm:2.0.30" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.3" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/2e5a997b6e2d9a2964c4681418643fd2f347df78ac1f9677a0cc6a3a3454920d05c663e35521d8922f0a382ec77a25e4b92204b3760a1da05876bf00d41adc39 + checksum: 10c0/22af450e28026547badc891a627bcb3cfa2d030864089947172506810f06cfa4c74c453aabd6a0d5c05ede5ffdee381b9278772ce781eca0c7c826c7d7ae3dc3 languageName: node linkType: hard -"@ai-sdk/anthropic@npm:^2.0.5": - version: 2.0.5 - resolution: "@ai-sdk/anthropic@npm:2.0.5" +"@ai-sdk/deepseek@npm:^1.0.17": + version: 1.0.17 + resolution: "@ai-sdk/deepseek@npm:1.0.17" dependencies: + "@ai-sdk/openai-compatible": "npm:1.0.17" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/aaca0d4b2e00715c513a7c688d6b6116eaf29d1d37f005c150f1229200713fb1c393c81a8b01ac29af954fb1ee213f3a537861227051865abe51aa547dca364e + checksum: 10c0/c408701343bb28ed0b3e034b8789e6de1dfd6cfc6a9b53feb68f155889e29a9fbbcf05bd99e63f60809cf05ee4b158abaccdf1cbcd9df92c0987094220a61d08 languageName: node linkType: hard -"@ai-sdk/azure@npm:^2.0.16": - version: 2.0.16 - resolution: "@ai-sdk/azure@npm:2.0.16" +"@ai-sdk/gateway@npm:1.0.23": + version: 1.0.23 + resolution: "@ai-sdk/gateway@npm:1.0.23" dependencies: - "@ai-sdk/openai": "npm:2.0.16" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/49bd9d27cba3104ba5d8a82c70a16dd475572585c5187e5bc29c9d46a30a373338181b29f37dfe9f61f50b5b82e86808139c93da225eb1721cb15e1a8b97cceb + checksum: 10c0/b1e1a6ab63b9191075eed92c586cd927696f8997ad24f056585aee3f5fffd283d981aa6b071a2560ecda4295445b80a4cfd321fa63c06e7ac54a06bc4c84887f languageName: node linkType: hard -"@ai-sdk/deepseek@npm:^1.0.9": - version: 1.0.9 - resolution: "@ai-sdk/deepseek@npm:1.0.9" +"@ai-sdk/google-vertex@npm:^3.0.27": + version: 3.0.27 + resolution: "@ai-sdk/google-vertex@npm:3.0.27" dependencies: - "@ai-sdk/openai-compatible": "npm:1.0.9" + "@ai-sdk/anthropic": "npm:2.0.17" + "@ai-sdk/google": "npm:2.0.14" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/b02a000a98a6df9808d472bf63640ee96297f9acce7422de0d198ffda40edcbcadc0946ae383464b80a92ac033a3a61cf71fa1bc640c08cac589bebc8d5623b9 - languageName: node - linkType: hard - -"@ai-sdk/gateway@npm:1.0.20": - version: 1.0.20 - resolution: "@ai-sdk/gateway@npm:1.0.20" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/c25e98aab2513f783b2b552245b027e5a73b209d974e25bbfae0e69b67fd3468bba0bf57085ca3d7259b4dc8881e7f40fca769f698f0b1eb028a849f587ad09c - languageName: node - linkType: hard - -"@ai-sdk/google-vertex@npm:^3.0.25": - version: 3.0.25 - resolution: "@ai-sdk/google-vertex@npm:3.0.25" - dependencies: - "@ai-sdk/anthropic": "npm:2.0.15" - "@ai-sdk/google": "npm:2.0.13" - "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" google-auth-library: "npm:^9.15.0" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/ed67a439fc4a446aa7353d258c61497198aecdf0de55500d2abbea86109bbf1ff4570fffdfcf58508db1c887a2095a71322777634f76326a45e259d28ef0b801 + checksum: 10c0/7017838aef9c04c18ce9acec52eb602ee0a38d68a7496977a3898411f1ac235b2d7776011fa686084b90b0881e65c69596014e5465b8ed0d0e313b5db1f967a7 languageName: node linkType: hard -"@ai-sdk/google@npm:2.0.13, @ai-sdk/google@npm:^2.0.13": - version: 2.0.13 - resolution: "@ai-sdk/google@npm:2.0.13" +"@ai-sdk/google@npm:2.0.14": + version: 2.0.14 + resolution: "@ai-sdk/google@npm:2.0.14" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/a05210de11d7ab41d49bcd0330c37f4116441b149d8ccc9b6bc5eaa12ea42bae82364dc2cd09502734b15115071f07395525806ea4998930b285b1ce74102186 + checksum: 10c0/2c04839cf58c33514a54c9de8190c363b5cacfbfc8404fea5d2ec36ad0af5ced4fc571f978e7aa35876bd9afae138f4c700d2bc1f64a78a37d0401f6797bf8f3 languageName: node linkType: hard -"@ai-sdk/mistral@npm:^2.0.0": - version: 2.0.4 - resolution: "@ai-sdk/mistral@npm:2.0.4" +"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch": + version: 2.0.14 + resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=351f1a" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.3" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/cca88cba855d4952551ca0be748e21f0d1b54537d0c7e08f30facdfbdbac7e6894ff4a1ceb53657aaf6e4380bbaa39d3cc37d1f734d777cdc1caba004c87221f + checksum: 10c0/1ed5a0732a82b981d51f63c6241ed8ee94d5c29a842764db770305cfc2f49ab6e528cac438b5357fc7b02194104c7b76d4390a1dc1d019ace9c174b0849e0da6 languageName: node linkType: hard -"@ai-sdk/openai-compatible@npm:1.0.9, @ai-sdk/openai-compatible@npm:^1.0.9": - version: 1.0.9 - resolution: "@ai-sdk/openai-compatible@npm:1.0.9" +"@ai-sdk/mistral@npm:^2.0.14": + version: 2.0.14 + resolution: "@ai-sdk/mistral@npm:2.0.14" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/a98505438f7a4c0d5c1aee9fb03aae00ff726c1c5ba0eff45d00ddc30ab9f25de634fcfd111a634bd654042150b9f16a131ce3f45887f9661c0241e3807d6ad4 + checksum: 10c0/420be3a039095830aaf59b6f82c1f986ff4800ba5b9438e1dd85530026a42c9454a6e632b6a1a1839816609f4752d0a19140d8943ad78bb976fb5d6a37714e16 languageName: node linkType: hard -"@ai-sdk/openai@npm:2.0.16": - version: 2.0.16 - resolution: "@ai-sdk/openai@npm:2.0.16" +"@ai-sdk/openai-compatible@npm:1.0.17, @ai-sdk/openai-compatible@npm:^1.0.17": + version: 1.0.17 + resolution: "@ai-sdk/openai-compatible@npm:1.0.17" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/1ea694bd096175a67a383e73fd1f4434eeaa7ddc6c378e44f295333d9a7b4153251d405dac2d8da330f95e4d5ef58641cc8533a3e63ff4d250b3cbc66f9abfea + checksum: 10c0/53ab6111e0f44437a2e268a51fb747600844d85b0cd0d170fb87a7b68af3eb21d7728d7bbf14d71c9fcf36e7a0f94ad75f0ad6b1070e473c867ab08ef84f6564 languageName: node linkType: hard -"@ai-sdk/openai@npm:^2.0.26": - version: 2.0.26 - resolution: "@ai-sdk/openai@npm:2.0.26" +"@ai-sdk/openai@npm:2.0.30, @ai-sdk/openai@npm:^2.0.30": + version: 2.0.30 + resolution: "@ai-sdk/openai@npm:2.0.30" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/b8cb01c0c38525c38901f41f1693cd15589932a2aceddea14bed30f44719532a5e74615fb0e974eff1a0513048ac204c27456ff8829a9c811d1461cc635c9cc5 + checksum: 10c0/90a57c1b10dac46c0bbe7e16cf9202557fb250d9f0e94a2a5fb7d95b5ea77815a56add78b00238d3823f0313c9b2c42abe865478d28a6196f72b341d32dd40af languageName: node linkType: hard -"@ai-sdk/provider-utils@npm:3.0.3": - version: 3.0.3 - resolution: "@ai-sdk/provider-utils@npm:3.0.3" +"@ai-sdk/perplexity@npm:^2.0.9": + version: 2.0.9 + resolution: "@ai-sdk/perplexity@npm:2.0.9" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@standard-schema/spec": "npm:^1.0.0" - eventsource-parser: "npm:^3.0.3" - zod-to-json-schema: "npm:^3.24.1" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/f02e26a6b85ef728862505b150475ef2e52d60130ca64b23316ff7b952f1817b01f959b9e48819dad64d82a96ba4ad538610d69dbbfe5be4b4b38469c16a6ccf + checksum: 10c0/2023aadc26c41430571c4897df79074e7a95a12f2238ad57081355484066bcf9e8dfde1da60fa6af12fc9fb2a195899326f753c69f4913dc005a33367f150349 languageName: node linkType: hard -"@ai-sdk/provider-utils@npm:3.0.4, @ai-sdk/provider-utils@npm:^3.0.4": - version: 3.0.4 - resolution: "@ai-sdk/provider-utils@npm:3.0.4" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@standard-schema/spec": "npm:^1.0.0" - eventsource-parser: "npm:^3.0.3" - zod-to-json-schema: "npm:^3.24.1" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/6732b99310561d72262cdeef40cc58190afa55248dca0eb3a378ef87fede12086e534c68687e0fe5ef5b092da41f3e745857ce3f9b248a272a78c0dc268dffd4 - languageName: node - linkType: hard - -"@ai-sdk/provider-utils@npm:3.0.8": - version: 3.0.8 - resolution: "@ai-sdk/provider-utils@npm:3.0.8" +"@ai-sdk/provider-utils@npm:3.0.9, @ai-sdk/provider-utils@npm:^3.0.9": + version: 3.0.9 + resolution: "@ai-sdk/provider-utils@npm:3.0.9" dependencies: "@ai-sdk/provider": "npm:2.0.0" "@standard-schema/spec": "npm:^1.0.0" eventsource-parser: "npm:^3.0.5" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/f466657c886cbb9f7ecbcd2dd1abc51a88af9d3f1cff030f7e97e70a4790a99f3338ad886e9c0dccf04dacdcc84522c7d57119b9a4e8e1d84f2dae9c893c397e + checksum: 10c0/f8b659343d7e22ae099f7b6fc514591c0408012eb0aa00f7a912798b6d7d7305cafa8f18a07c7adec0bb5d39d9b6256b76d65c5393c3fc843d1361c52f1f8080 languageName: node linkType: hard @@ -289,16 +249,16 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/xai@npm:^2.0.9": - version: 2.0.9 - resolution: "@ai-sdk/xai@npm:2.0.9" +"@ai-sdk/xai@npm:^2.0.18": + version: 2.0.18 + resolution: "@ai-sdk/xai@npm:2.0.18" dependencies: - "@ai-sdk/openai-compatible": "npm:1.0.9" + "@ai-sdk/openai-compatible": "npm:1.0.17" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/15a3ace8e06b42ee148d8d100cdf946919e0763c45fb1b85454e313d4de43426c6d162c333d07ad338a9de415dc9e68c50411a6ec0305dbc5edb7d623c2023da + checksum: 10c0/7134501a2d315ec13605558aa24d7f5662885fe8b0491a634abefeb0c5c88517149677d1beff0c8abeec78a6dcd14573a2f57d96fa54a1d63d03820ac7ff827a languageName: node linkType: hard @@ -2237,6 +2197,97 @@ __metadata: languageName: node linkType: hard +"@biomejs/biome@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/biome@npm:2.2.4" + dependencies: + "@biomejs/cli-darwin-arm64": "npm:2.2.4" + "@biomejs/cli-darwin-x64": "npm:2.2.4" + "@biomejs/cli-linux-arm64": "npm:2.2.4" + "@biomejs/cli-linux-arm64-musl": "npm:2.2.4" + "@biomejs/cli-linux-x64": "npm:2.2.4" + "@biomejs/cli-linux-x64-musl": "npm:2.2.4" + "@biomejs/cli-win32-arm64": "npm:2.2.4" + "@biomejs/cli-win32-x64": "npm:2.2.4" + dependenciesMeta: + "@biomejs/cli-darwin-arm64": + optional: true + "@biomejs/cli-darwin-x64": + optional: true + "@biomejs/cli-linux-arm64": + optional: true + "@biomejs/cli-linux-arm64-musl": + optional: true + "@biomejs/cli-linux-x64": + optional: true + "@biomejs/cli-linux-x64-musl": + optional: true + "@biomejs/cli-win32-arm64": + optional: true + "@biomejs/cli-win32-x64": + optional: true + bin: + biome: bin/biome + checksum: 10c0/7229fcc743db48f3cfd7417fb3f33d1cd9e7dfe42a12ed3c1046cd3110718237bb71ea3fe5c8b0de9bd3df2b918d0be58027602aa3720b64904b63d9cedf53e3 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-arm64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-darwin-arm64@npm:2.2.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-x64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-darwin-x64@npm:2.2.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64-musl@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-arm64-musl@npm:2.2.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-arm64@npm:2.2.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64-musl@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-x64-musl@npm:2.2.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-x64@npm:2.2.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-win32-arm64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-win32-arm64@npm:2.2.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-win32-x64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-win32-x64@npm:2.2.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@braintree/sanitize-url@npm:^7.0.4": version: 7.1.1 resolution: "@braintree/sanitize-url@npm:7.1.1" @@ -2270,19 +2321,18 @@ __metadata: languageName: node linkType: hard -"@cherrystudio/ai-core@workspace:*, @cherrystudio/ai-core@workspace:packages/aiCore": +"@cherrystudio/ai-core@workspace:^1.0.0-alpha.18, @cherrystudio/ai-core@workspace:packages/aiCore": version: 0.0.0-use.local resolution: "@cherrystudio/ai-core@workspace:packages/aiCore" dependencies: - "@ai-sdk/anthropic": "npm:^2.0.5" - "@ai-sdk/azure": "npm:^2.0.16" - "@ai-sdk/deepseek": "npm:^1.0.9" - "@ai-sdk/google": "npm:^2.0.13" - "@ai-sdk/openai": "npm:^2.0.26" - "@ai-sdk/openai-compatible": "npm:^1.0.9" + "@ai-sdk/anthropic": "npm:^2.0.17" + "@ai-sdk/azure": "npm:^2.0.30" + "@ai-sdk/deepseek": "npm:^1.0.17" + "@ai-sdk/openai": "npm:^2.0.30" + "@ai-sdk/openai-compatible": "npm:^1.0.17" "@ai-sdk/provider": "npm:^2.0.0" - "@ai-sdk/provider-utils": "npm:^3.0.4" - "@ai-sdk/xai": "npm:^2.0.9" + "@ai-sdk/provider-utils": "npm:^3.0.9" + "@ai-sdk/xai": "npm:^2.0.18" tsdown: "npm:^0.12.9" typescript: "npm:^5.0.0" vitest: "npm:^3.2.4" @@ -2499,13 +2549,13 @@ __metadata: version: 0.0.0-use.local resolution: "@cherrystudio/extension-table-plus@workspace:packages/extension-table-plus" dependencies: + "@biomejs/biome": "npm:2.2.4" "@tiptap/core": "npm:^3.2.0" "@tiptap/pm": "npm:^3.2.0" eslint: "npm:^9.22.0" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" - prettier: "npm:^3.5.3" tsdown: "npm:^0.13.3" peerDependencies: "@tiptap/core": ^3.0.9 @@ -3087,19 +3137,6 @@ __metadata: languageName: node linkType: hard -"@electron-toolkit/eslint-config-prettier@npm:^3.0.0": - version: 3.0.0 - resolution: "@electron-toolkit/eslint-config-prettier@npm:3.0.0" - dependencies: - eslint-config-prettier: "npm:^10.0.1" - eslint-plugin-prettier: "npm:^5.2.3" - peerDependencies: - eslint: ">= 9.0.0" - prettier: ">= 3.0.0" - checksum: 10c0/c17e272c83b9bcc0ce87b7d116b0eb2606920202c342f65332e0d792f89322de5382b8a345a79d291e45a21d5159eb82fc4e8ec8efbff2f535e336502bb8a998 - languageName: node - linkType: hard - "@electron-toolkit/eslint-config-ts@npm:^3.0.0": version: 3.1.0 resolution: "@electron-toolkit/eslint-config-ts@npm:3.1.0" @@ -6174,26 +6211,6 @@ __metadata: languageName: node linkType: hard -"@langchain/core@npm:^0.3.68": - version: 0.3.73 - resolution: "@langchain/core@npm:0.3.73" - dependencies: - "@cfworker/json-schema": "npm:^4.0.2" - ansi-styles: "npm:^5.0.0" - camelcase: "npm:6" - decamelize: "npm:1.2.0" - js-tiktoken: "npm:^1.0.12" - langsmith: "npm:^0.3.46" - mustache: "npm:^4.2.0" - p-queue: "npm:^6.6.2" - p-retry: "npm:4" - uuid: "npm:^10.0.0" - zod: "npm:^3.25.32" - zod-to-json-schema: "npm:^3.22.3" - checksum: 10c0/c10be034bd6698b276f040a749c9792008b43cfb3d41e9c58d6a11ff09fa1b8cf68ddc1836ca858a4b53a45163b57ff22be033cf415bc976f761b9e81c453c2b - languageName: node - linkType: hard - "@langchain/core@patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch": version: 0.3.44 resolution: "@langchain/core@patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch::version=0.3.44&hash=41dd7b" @@ -6228,20 +6245,6 @@ __metadata: languageName: node linkType: hard -"@langchain/ollama@npm:^0.2.1": - version: 0.2.1 - resolution: "@langchain/ollama@npm:0.2.1" - dependencies: - ollama: "npm:^0.5.12" - uuid: "npm:^10.0.0" - zod: "npm:^3.24.1" - zod-to-json-schema: "npm:^3.24.1" - peerDependencies: - "@langchain/core": ">=0.2.21 <0.4.0" - checksum: 10c0/be3083a15e879f2c19d0a51aafb88a3ba4ea69f9fdd90e6069b84edd92649c00a29a34cb885746e099f5cab0791b55df3776cd582cba7de299b8bd574d32b8c1 - languageName: node - linkType: hard - "@langchain/openai@npm:0.3.16": version: 0.3.16 resolution: "@langchain/openai@npm:0.3.16" @@ -6270,7 +6273,7 @@ __metadata: languageName: node linkType: hard -"@langchain/openai@npm:>=0.2.0 <0.7.0, @langchain/openai@npm:^0.6.7": +"@langchain/openai@npm:>=0.2.0 <0.7.0": version: 0.6.11 resolution: "@langchain/openai@npm:0.6.11" dependencies: @@ -7169,6 +7172,104 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/darwin-arm64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.2.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/darwin-x64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.2.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-arm64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.2.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-x64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.2.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-arm64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.2.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-x64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.2.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/darwin-arm64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/darwin-arm64@npm:1.15.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/darwin-x64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/darwin-x64@npm:1.15.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/linux-arm64-gnu@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-arm64-gnu@npm:1.15.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-arm64-musl@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-arm64-musl@npm:1.15.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/linux-x64-gnu@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-x64-gnu@npm:1.15.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-x64-musl@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-x64-musl@npm:1.15.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/win32-arm64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/win32-arm64@npm:1.15.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/win32-x64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/win32-x64@npm:1.15.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@pdf-lib/standard-fonts@npm:^1.0.0": version: 1.0.0 resolution: "@pdf-lib/standard-fonts@npm:1.0.0" @@ -7194,13 +7295,6 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.2.3": - version: 0.2.4 - resolution: "@pkgr/core@npm:0.2.4" - checksum: 10c0/2528a443bbbef5d4686614e1d73f834f19ccbc975f62b2a64974a6b97bcdf677b9c5e8948e04808ac4f0d853e2f422adfaae2a06e9e9f4f5cf8af76f1adf8dc1 - languageName: node - linkType: hard - "@playwright/test@npm:^1.52.0": version: 1.52.0 resolution: "@playwright/test@npm:1.52.0" @@ -12267,6 +12361,87 @@ __metadata: languageName: node linkType: hard +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20250915.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20250915.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20250915.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20250915.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20250915.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20250915.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20250915.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview@npm:latest": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20250915.1" + dependencies: + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20250915.1" + dependenciesMeta: + "@typescript/native-preview-darwin-arm64": + optional: true + "@typescript/native-preview-darwin-x64": + optional: true + "@typescript/native-preview-linux-arm": + optional: true + "@typescript/native-preview-linux-arm64": + optional: true + "@typescript/native-preview-linux-x64": + optional: true + "@typescript/native-preview-win32-arm64": + optional: true + "@typescript/native-preview-win32-x64": + optional: true + bin: + tsgo: bin/tsgo.js + checksum: 10c0/88c8c4d497e610b05ef3a429959364fff7f0fc2b77f191909c15f886b21b06ceabdd9f89d9e5f903ee87076cfeca4d61ee609d2df897326ed115e23e01650fec + languageName: node + linkType: hard + "@uiw/codemirror-extensions-basic-setup@npm:4.25.1": version: 4.25.1 resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.25.1" @@ -13020,16 +13195,18 @@ __metadata: "@agentic/exa": "npm:^7.3.3" "@agentic/searxng": "npm:^7.3.3" "@agentic/tavily": "npm:^7.3.3" - "@ai-sdk/amazon-bedrock": "npm:^3.0.0" - "@ai-sdk/google-vertex": "npm:^3.0.25" - "@ai-sdk/mistral": "npm:^2.0.0" + "@ai-sdk/amazon-bedrock": "npm:^3.0.21" + "@ai-sdk/google-vertex": "npm:^3.0.27" + "@ai-sdk/mistral": "npm:^2.0.14" + "@ai-sdk/perplexity": "npm:^2.0.9" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" "@anthropic-ai/sdk": "npm:^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": "npm:^3.840.0" "@aws-sdk/client-bedrock-runtime": "npm:^3.840.0" "@aws-sdk/client-s3": "npm:^3.840.0" - "@cherrystudio/ai-core": "workspace:*" + "@biomejs/biome": "npm:2.2.4" + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31" @@ -13047,7 +13224,6 @@ __metadata: "@dnd-kit/modifiers": "npm:^9.0.0" "@dnd-kit/sortable": "npm:^10.0.0" "@dnd-kit/utilities": "npm:^3.2.2" - "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" "@electron-toolkit/tsconfig": "npm:^1.0.1" @@ -13061,9 +13237,6 @@ __metadata: "@heroui/react": "npm:^2.8.3" "@kangfenmao/keyv-storage": "npm:^0.1.0" "@langchain/community": "npm:^0.3.50" - "@langchain/core": "npm:^0.3.68" - "@langchain/ollama": "npm:^0.2.1" - "@langchain/openai": "npm:^0.6.7" "@libsql/client": "npm:0.14.0" "@libsql/win32-x64-msvc": "npm:^0.4.7" "@mistralai/mistralai": "npm:^1.7.5" @@ -13132,6 +13305,7 @@ __metadata: "@types/tinycolor2": "npm:^1" "@types/turndown": "npm:^5.0.5" "@types/word-extractor": "npm:^1" + "@typescript/native-preview": "npm:latest" "@uiw/codemirror-extensions-langs": "npm:^4.25.1" "@uiw/codemirror-themes-all": "npm:^4.25.1" "@uiw/react-codemirror": "npm:^4.25.1" @@ -13143,7 +13317,7 @@ __metadata: "@viz-js/lang-dot": "npm:^1.0.5" "@viz-js/viz": "npm:^3.14.0" "@xyflow/react": "npm:^12.4.4" - ai: "npm:^5.0.38" + ai: "npm:^5.0.44" antd: "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch" archiver: "npm:^7.0.1" async-mutex: "npm:^0.5.0" @@ -13177,14 +13351,15 @@ __metadata: emoji-picker-element: "npm:^1.22.1" epub: "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch" eslint: "npm:^9.22.0" + eslint-plugin-oxlint: "npm:^1.15.0" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" express: "npm:^5.1.0" - faiss-node: "npm:^0.5.1" fast-diff: "npm:^1.3.0" fast-xml-parser: "npm:^5.2.0" fetch-socks: "npm:1.3.2" + font-list: "npm:^2.0.0" framer-motion: "npm:^12.23.12" franc-min: "npm:^6.2.0" fs-extra: "npm:^11.2.0" @@ -13219,13 +13394,12 @@ __metadata: officeparser: "npm:^4.2.0" openai: "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch" os-proxy-config: "npm:^1.1.2" + oxlint: "npm:^1.15.0" + oxlint-tsgolint: "npm:^0.2.0" p-queue: "npm:^8.1.0" pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" playwright: "npm:^1.52.0" - prettier: "npm:^3.5.3" - prettier-plugin-sort-json: "npm:^4.1.1" - prettier-plugin-tailwindcss: "npm:^0.6.14" proxy-agent: "npm:^6.5.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" @@ -13273,7 +13447,7 @@ __metadata: turndown: "npm:7.2.0" turndown-plugin-gfm: "npm:^1.0.2" tw-animate-css: "npm:^1.3.8" - typescript: "npm:^5.6.2" + typescript: "npm:~5.8.2" undici: "npm:6.21.2" unified: "npm:^11.0.5" uuid: "npm:^10.0.0" @@ -13401,17 +13575,17 @@ __metadata: languageName: node linkType: hard -"ai@npm:^5.0.38": - version: 5.0.38 - resolution: "ai@npm:5.0.38" +"ai@npm:^5.0.44": + version: 5.0.44 + resolution: "ai@npm:5.0.44" dependencies: - "@ai-sdk/gateway": "npm:1.0.20" + "@ai-sdk/gateway": "npm:1.0.23" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" "@opentelemetry/api": "npm:1.9.0" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/9ea7a76ae5609574e9edb2f9541e2fe9cf0e7296547c5e9ae30ec000206c967b4c07fbb03b85f9027493f6877e15f6bfbe454faa793fca860826acf306982fc5 + checksum: 10c0/528c7e165f75715194204051ce0aa341d8dca7d5536c2abcf3df83ccda7399ed5d91deaa45a81340f93d2461b1c2fc5f740f7804dfd396927c71b0667403569b languageName: node linkType: hard @@ -14048,15 +14222,6 @@ __metadata: languageName: node linkType: hard -"bindings@npm:^1.5.0": - version: 1.5.0 - resolution: "bindings@npm:1.5.0" - dependencies: - file-uri-to-path: "npm:1.0.0" - checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba - languageName: node - linkType: hard - "birecord@npm:^0.1.1": version: 0.1.1 resolution: "birecord@npm:0.1.1" @@ -16225,13 +16390,6 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4": - version: 2.0.4 - resolution: "detect-libc@npm:2.0.4" - checksum: 10c0/c15541f836eba4b1f521e4eecc28eefefdbc10a94d3b8cb4c507689f332cc111babb95deda66f2de050b22122113189986d5190be97d51b5a2b23b938415e67c - languageName: node - linkType: hard - "detect-libc@npm:^2.0.1": version: 2.0.3 resolution: "detect-libc@npm:2.0.3" @@ -16239,6 +16397,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4": + version: 2.0.4 + resolution: "detect-libc@npm:2.0.4" + checksum: 10c0/c15541f836eba4b1f521e4eecc28eefefdbc10a94d3b8cb4c507689f332cc111babb95deda66f2de050b22122113189986d5190be97d51b5a2b23b938415e67c + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -17011,34 +17176,12 @@ __metadata: languageName: node linkType: hard -"eslint-config-prettier@npm:^10.0.1": - version: 10.1.2 - resolution: "eslint-config-prettier@npm:10.1.2" - peerDependencies: - eslint: ">=7.0.0" - bin: - eslint-config-prettier: bin/cli.js - checksum: 10c0/c22c8e29193cc8fd70becf1c2dd072513f2b3004a175c2a49404c79d1745ba4dc0edc2afd00d16b0e26d24f95813a0469e7445a25104aec218f6d84cdb1697e9 - languageName: node - linkType: hard - -"eslint-plugin-prettier@npm:^5.2.3": - version: 5.2.6 - resolution: "eslint-plugin-prettier@npm:5.2.6" +"eslint-plugin-oxlint@npm:^1.15.0": + version: 1.15.0 + resolution: "eslint-plugin-oxlint@npm:1.15.0" dependencies: - prettier-linter-helpers: "npm:^1.0.0" - synckit: "npm:^0.11.0" - peerDependencies: - "@types/eslint": ">=8.0.0" - eslint: ">=8.0.0" - eslint-config-prettier: ">= 7.0.0 <10.0.0 || >=10.1.0" - prettier: ">=3.0.0" - peerDependenciesMeta: - "@types/eslint": - optional: true - eslint-config-prettier: - optional: true - checksum: 10c0/9911740a5edac7933d92671381908671c61ffa32a3cee7aed667ebab89831ee2c0b69eb9530f68dbe172ca9d4b3fa3d47350762dc1eb096a3ce125fa31c0e616 + jsonc-parser: "npm:^3.3.1" + checksum: 10c0/1c16e4a70fe3bc7ca4cda28393bb00f20d2b009121d5804dc3e9a247adb2773236e542078d6a0f4d4168e4ba68cb88fe5ad8efaee52bec24f72c16b1dad2990c languageName: node linkType: hard @@ -17447,7 +17590,7 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.3": +"eventsource-parser@npm:^3.0.0": version: 3.0.3 resolution: "eventsource-parser@npm:3.0.3" checksum: 10c0/2594011630efba56cafafc8ed6bd9a50db8f6d5dd62089b0950346e7961828c16efe07a588bdea3ba79e568fd9246c8163824a2ffaade767e1fdb2270c1fae0b @@ -17611,18 +17754,6 @@ __metadata: languageName: node linkType: hard -"faiss-node@npm:^0.5.1": - version: 0.5.1 - resolution: "faiss-node@npm:0.5.1" - dependencies: - bindings: "npm:^1.5.0" - node-addon-api: "npm:^6.0.0" - node-gyp: "npm:latest" - prebuild-install: "npm:^7.1.1" - checksum: 10c0/2045c4db87b39637d56c8228b26a30f4bcf49f5e0f99a7717b89030683aa8ed4d37c2f58d3b7b630fe56128fa7b8b55a0ad375ea651008bff17cb3618abc30be - languageName: node - linkType: hard - "fast-deep-equal@npm:3.1.3, fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -17630,7 +17761,7 @@ __metadata: languageName: node linkType: hard -"fast-diff@npm:^1.1.2, fast-diff@npm:^1.3.0": +"fast-diff@npm:^1.3.0": version: 1.3.0 resolution: "fast-diff@npm:1.3.0" checksum: 10c0/5c19af237edb5d5effda008c891a18a585f74bf12953be57923f17a3a4d0979565fc64dbc73b9e20926b9d895f5b690c618cbb969af0cf022e3222471220ad29 @@ -17917,13 +18048,6 @@ __metadata: languageName: node linkType: hard -"file-uri-to-path@npm:1.0.0": - version: 1.0.0 - resolution: "file-uri-to-path@npm:1.0.0" - checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 - languageName: node - linkType: hard - "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -18030,6 +18154,13 @@ __metadata: languageName: node linkType: hard +"font-list@npm:^2.0.0": + version: 2.0.0 + resolution: "font-list@npm:2.0.0" + checksum: 10c0/9fc8600fa40a5d079982505ea101e49b21260a36f33167ac993fd7b26cec8372a16017c00d6fb404e259600ce8d588830167c9141c2df7dedb0fedd5953905f6 + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" @@ -19930,6 +20061,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:^3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -22410,13 +22548,6 @@ __metadata: languageName: node linkType: hard -"napi-build-utils@npm:^2.0.0": - version: 2.0.0 - resolution: "napi-build-utils@npm:2.0.0" - checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db - languageName: node - linkType: hard - "native-promise-only@npm:0.8.1": version: 0.8.1 resolution: "native-promise-only@npm:0.8.1" @@ -22516,15 +22647,6 @@ __metadata: languageName: node linkType: hard -"node-addon-api@npm:^6.0.0": - version: 6.1.0 - resolution: "node-addon-api@npm:6.1.0" - dependencies: - node-gyp: "npm:latest" - checksum: 10c0/d2699c4ad15740fd31482a3b6fca789af7723ab9d393adc6ac45250faaee72edad8f0b10b2b9d087df0de93f1bdc16d97afdd179b26b9ebc9ed68b569faa4bac - languageName: node - linkType: hard - "node-addon-api@npm:^8.4.0": version: 8.4.0 resolution: "node-addon-api@npm:8.4.0" @@ -23006,6 +23128,76 @@ __metadata: languageName: node linkType: hard +"oxlint-tsgolint@npm:^0.2.0": + version: 0.2.0 + resolution: "oxlint-tsgolint@npm:0.2.0" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.2.0" + "@oxlint-tsgolint/darwin-x64": "npm:0.2.0" + "@oxlint-tsgolint/linux-arm64": "npm:0.2.0" + "@oxlint-tsgolint/linux-x64": "npm:0.2.0" + "@oxlint-tsgolint/win32-arm64": "npm:0.2.0" + "@oxlint-tsgolint/win32-x64": "npm:0.2.0" + dependenciesMeta: + "@oxlint-tsgolint/darwin-arm64": + optional: true + "@oxlint-tsgolint/darwin-x64": + optional: true + "@oxlint-tsgolint/linux-arm64": + optional: true + "@oxlint-tsgolint/linux-x64": + optional: true + "@oxlint-tsgolint/win32-arm64": + optional: true + "@oxlint-tsgolint/win32-x64": + optional: true + bin: + tsgolint: bin/tsgolint.js + checksum: 10c0/b2117a0d07c5c876a6608d710838c934ef456cf7cff668fba9455d380eb8e3a7d9841c8f3a03d59bbc77b0f1342d5dca0e69557cac361a1afa8b8eb3d1b114c6 + languageName: node + linkType: hard + +"oxlint@npm:^1.15.0": + version: 1.15.0 + resolution: "oxlint@npm:1.15.0" + dependencies: + "@oxlint/darwin-arm64": "npm:1.15.0" + "@oxlint/darwin-x64": "npm:1.15.0" + "@oxlint/linux-arm64-gnu": "npm:1.15.0" + "@oxlint/linux-arm64-musl": "npm:1.15.0" + "@oxlint/linux-x64-gnu": "npm:1.15.0" + "@oxlint/linux-x64-musl": "npm:1.15.0" + "@oxlint/win32-arm64": "npm:1.15.0" + "@oxlint/win32-x64": "npm:1.15.0" + peerDependencies: + oxlint-tsgolint: ">=0.2.0" + dependenciesMeta: + "@oxlint/darwin-arm64": + optional: true + "@oxlint/darwin-x64": + optional: true + "@oxlint/linux-arm64-gnu": + optional: true + "@oxlint/linux-arm64-musl": + optional: true + "@oxlint/linux-x64-gnu": + optional: true + "@oxlint/linux-x64-musl": + optional: true + "@oxlint/win32-arm64": + optional: true + "@oxlint/win32-x64": + optional: true + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + bin: + oxc_language_server: bin/oxc_language_server + oxlint: bin/oxlint + checksum: 10c0/3eb2a27b972f2a02200b068345ab6a3a17f7bc29c4546c6b3478727388d8d59b94a554f9b6bb1320b71a75cc598b728de0ffee5e4e70ac27457104b8efebb257 + languageName: node + linkType: hard + "p-cancelable@npm:^2.0.0": version: 2.1.1 resolution: "p-cancelable@npm:2.1.1" @@ -23662,28 +23854,6 @@ __metadata: languageName: node linkType: hard -"prebuild-install@npm:^7.1.1": - version: 7.1.3 - resolution: "prebuild-install@npm:7.1.3" - dependencies: - detect-libc: "npm:^2.0.0" - expand-template: "npm:^2.0.3" - github-from-package: "npm:0.0.0" - minimist: "npm:^1.2.3" - mkdirp-classic: "npm:^0.5.3" - napi-build-utils: "npm:^2.0.0" - node-abi: "npm:^3.3.0" - pump: "npm:^3.0.0" - rc: "npm:^1.2.7" - simple-get: "npm:^4.0.0" - tar-fs: "npm:^2.0.0" - tunnel-agent: "npm:^0.6.0" - bin: - prebuild-install: bin.js - checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff - languageName: node - linkType: hard - "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -23691,97 +23861,6 @@ __metadata: languageName: node linkType: hard -"prettier-linter-helpers@npm:^1.0.0": - version: 1.0.0 - resolution: "prettier-linter-helpers@npm:1.0.0" - dependencies: - fast-diff: "npm:^1.1.2" - checksum: 10c0/81e0027d731b7b3697ccd2129470ed9913ecb111e4ec175a12f0fcfab0096516373bf0af2fef132af50cafb0a905b74ff57996d615f59512bb9ac7378fcc64ab - languageName: node - linkType: hard - -"prettier-plugin-sort-json@npm:^4.1.1": - version: 4.1.1 - resolution: "prettier-plugin-sort-json@npm:4.1.1" - peerDependencies: - prettier: ^3.0.0 - checksum: 10c0/b3b86ea679e95d0329c367aa860af4033d8a3b83a0656d7393f2005b2df78e20d19fbe2c40fe7b531262210e40060318a4287459c2a4f61c6a6bd1c2757e2b7d - languageName: node - linkType: hard - -"prettier-plugin-tailwindcss@npm:^0.6.14": - version: 0.6.14 - resolution: "prettier-plugin-tailwindcss@npm:0.6.14" - peerDependencies: - "@ianvs/prettier-plugin-sort-imports": "*" - "@prettier/plugin-hermes": "*" - "@prettier/plugin-oxc": "*" - "@prettier/plugin-pug": "*" - "@shopify/prettier-plugin-liquid": "*" - "@trivago/prettier-plugin-sort-imports": "*" - "@zackad/prettier-plugin-twig": "*" - prettier: ^3.0 - prettier-plugin-astro: "*" - prettier-plugin-css-order: "*" - prettier-plugin-import-sort: "*" - prettier-plugin-jsdoc: "*" - prettier-plugin-marko: "*" - prettier-plugin-multiline-arrays: "*" - prettier-plugin-organize-attributes: "*" - prettier-plugin-organize-imports: "*" - prettier-plugin-sort-imports: "*" - prettier-plugin-style-order: "*" - prettier-plugin-svelte: "*" - peerDependenciesMeta: - "@ianvs/prettier-plugin-sort-imports": - optional: true - "@prettier/plugin-hermes": - optional: true - "@prettier/plugin-oxc": - optional: true - "@prettier/plugin-pug": - optional: true - "@shopify/prettier-plugin-liquid": - optional: true - "@trivago/prettier-plugin-sort-imports": - optional: true - "@zackad/prettier-plugin-twig": - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-import-sort: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-style-order: - optional: true - prettier-plugin-svelte: - optional: true - checksum: 10c0/1bf635be28b30b3f171a184497eecf512601d19328e402dd2eb1ede52aa57b4f5b605eae2929f4916de9ba22526f3d730d9afa90334db09d17c59f463f7b26d8 - languageName: node - linkType: hard - -"prettier@npm:^3.5.3": - version: 3.5.3 - resolution: "prettier@npm:3.5.3" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/3880cb90b9dc0635819ab52ff571518c35bd7f15a6e80a2054c05dbc8a3aa6e74f135519e91197de63705bcb38388ded7e7230e2178432a1468005406238b877 - languageName: node - linkType: hard - "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -26295,17 +26374,6 @@ __metadata: languageName: node linkType: hard -"simple-get@npm:^4.0.0": - version: 4.0.1 - resolution: "simple-get@npm:4.0.1" - dependencies: - decompress-response: "npm:^6.0.0" - once: "npm:^1.3.1" - simple-concat: "npm:^1.0.0" - checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0 - languageName: node - linkType: hard - "simple-swizzle@npm:^0.2.2": version: 0.2.2 resolution: "simple-swizzle@npm:0.2.2" @@ -26951,16 +27019,6 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.11.0": - version: 0.11.4 - resolution: "synckit@npm:0.11.4" - dependencies: - "@pkgr/core": "npm:^0.2.3" - tslib: "npm:^2.8.1" - checksum: 10c0/dd2965a37c93c0b652bf07b1fd8d1639a803b65cf34c0cb1b827b8403044fc3b09ec87f681d922a324825127ee95b2e0394e7caccb502f407892d63e903c5276 - languageName: node - linkType: hard - "tailwind-merge@npm:3.3.1": version: 3.3.1 resolution: "tailwind-merge@npm:3.3.1" @@ -27603,7 +27661,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -27730,7 +27788,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.3, typescript@npm:^5.6.2": +"typescript@npm:^5.4.3, typescript@npm:~5.8.2": version: 5.8.3 resolution: "typescript@npm:5.8.3" bin: @@ -27750,7 +27808,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.6.2#optional!builtin": +"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A~5.8.2#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: