diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4596fc41d6..4216055bc0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,3 +3,12 @@ /src/main/services/ConfigManager.ts @0xfullex /packages/shared/IpcChannel.ts @0xfullex /src/main/ipc.ts @0xfullex + +/migrations/ @0xfullex +/packages/shared/data/ @0xfullex +/src/main/data/ @0xfullex +/src/renderer/src/data/ @0xfullex + +/packages/ui/ @MyPrototypeWhat + +/app-upgrade-config.json @kangfenmao diff --git a/.github/ISSUE_TEMPLATE/0_bug_report.yml b/.github/ISSUE_TEMPLATE/0_bug_report.yml index c50cdef530..9baf846a6d 100644 --- a/.github/ISSUE_TEMPLATE/0_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/0_bug_report.yml @@ -1,4 +1,4 @@ -name: 🐛 Bug Report (English) +name: 🐛 Bug Report description: Create a report to help us improve title: '[Bug]: ' labels: ['BUG'] diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml index 0822742704..d980cb8367 100644 --- a/.github/ISSUE_TEMPLATE/1_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -1,4 +1,4 @@ -name: 💡 Feature Request (English) +name: 💡 Feature Request description: Suggest an idea for this project title: '[Feature]: ' labels: ['feature'] diff --git a/.github/ISSUE_TEMPLATE/3_others.yml b/.github/ISSUE_TEMPLATE/3_others.yml index 4d8a383080..2784c66d59 100644 --- a/.github/ISSUE_TEMPLATE/3_others.yml +++ b/.github/ISSUE_TEMPLATE/3_others.yml @@ -1,4 +1,4 @@ -name: 🤔 Other Questions (English) +name: 🤔 Other Questions description: Submit questions that don't fit into bug reports or feature requests title: '[Other]: ' body: diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index a6c1e3791a..1584ab48db 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -1,4 +1,4 @@ -name: Auto I18N +name: Auto I18N Weekly env: TRANSLATION_API_KEY: ${{ secrets.TRANSLATE_API_KEY }} @@ -7,14 +7,15 @@ env: TRANSLATION_BASE_LOCALE: ${{ vars.AUTO_I18N_BASE_LOCALE || 'en-us'}} on: - pull_request: - types: [opened, synchronize, reopened] + schedule: + # Runs at 00:00 UTC every Sunday. + # This corresponds to 08:00 AM UTC+8 (Beijing time) every Sunday. + - cron: "0 0 * * 0" workflow_dispatch: jobs: auto-i18n: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == 'CherryHQ/cherry-studio' name: Auto I18N permissions: contents: write @@ -24,45 +25,69 @@ jobs: - name: 🐈‍⬛ Checkout uses: actions/checkout@v5 with: - ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 - name: 📦 Setting Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 - package-manager-cache: false + node-version: 22 - - name: 📦 Install dependencies in isolated directory + - name: 📦 Install corepack + run: corepack enable && corepack prepare yarn@4.9.1 --activate + + - name: 📂 Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: 💾 Cache yarn dependencies + uses: actions/cache@v4 + with: + path: | + ${{ steps.yarn-cache-dir-path.outputs.dir }} + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies run: | - # 在临时目录安装依赖 - mkdir -p /tmp/translation-deps - cd /tmp/translation-deps - echo '{"dependencies": {"@cherrystudio/openai": "^6.5.0", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json - npm install --no-package-lock - - # 设置 NODE_PATH 让项目能找到这些依赖 - echo "NODE_PATH=/tmp/translation-deps/node_modules" >> $GITHUB_ENV + yarn install - name: 🏃‍♀️ Translate - run: npx tsx scripts/sync-i18n.ts && npx tsx scripts/auto-translate-i18n.ts + run: yarn sync:i18n && yarn auto:i18n - name: 🔍 Format - run: cd /tmp/translation-deps && npx biome format --config-path /home/runner/work/cherry-studio/cherry-studio/biome.jsonc --write /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/ + run: yarn format - - name: 🔄 Commit changes + - name: 🔍 Check for changes + id: git_status run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add . + # Check if there are any uncommitted changes git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改 - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "fix(i18n): Auto update translations for PR #${{ github.event.pull_request.number }}" - fi + git diff --exit-code --quiet || echo "::set-output name=has_changes::true" + git status --porcelain - - name: 🚀 Push changes - uses: ad-m/github-push-action@master + - name: 📅 Set current date for PR title + id: set_date + run: echo "CURRENT_DATE=$(date +'%b %d, %Y')" >> $GITHUB_ENV # e.g., "Jun 06, 2024" + + - name: 🚀 Create Pull Request if changes exist + if: steps.git_status.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions + commit-message: "feat(bot): Weekly automated script run" + title: "🤖 Weekly Automated Update: ${{ env.CURRENT_DATE }}" + body: | + This PR includes changes generated by the weekly auto i18n. + Review the changes before merging. + + --- + _Generated by the automated weekly workflow_ + branch: "auto-i18n-weekly-${{ github.run_id }}" # Unique branch name + base: "main" # Or 'develop', set your base branch + delete-branch: true # Delete the branch after merging or closing the PR + + - name: 📢 Notify if no changes + if: steps.git_status.outputs.has_changes != 'true' + run: echo "Bot script ran, but no changes were detected. No PR created." diff --git a/.github/workflows/github-issue-tracker.yml b/.github/workflows/github-issue-tracker.yml index 7cc1ad4762..32bd393145 100644 --- a/.github/workflows/github-issue-tracker.yml +++ b/.github/workflows/github-issue-tracker.yml @@ -5,7 +5,7 @@ on: types: [opened] schedule: # Run every day at 8:30 Beijing Time (00:30 UTC) - - cron: '30 0 * * *' + - cron: "30 0 * * *" workflow_dispatch: jobs: @@ -54,9 +54,9 @@ jobs: - name: Setup Node.js if: steps.check_time.outputs.should_delay == 'false' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 22 - name: Process issue with Claude if: steps.check_time.outputs.should_delay == 'false' @@ -121,9 +121,9 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 22 - name: Process pending issues with Claude uses: anthropics/claude-code-action@main diff --git a/.github/workflows/issue-management.yml b/.github/workflows/issue-management.yml index 89ccc1fa8d..c9ff497386 100644 --- a/.github/workflows/issue-management.yml +++ b/.github/workflows/issue-management.yml @@ -21,7 +21,7 @@ jobs: contents: none steps: - name: Close needs-more-info issues - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: 'needs-more-info' @@ -29,8 +29,10 @@ jobs: days-before-close: 0 # Close immediately after stale stale-issue-label: 'inactive' close-issue-label: 'closed:no-response' + exempt-all-milestones: true + exempt-all-assignees: true stale-issue-message: | - This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days. + This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days. It will be closed now due to lack of additional information. 该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。 @@ -40,12 +42,14 @@ jobs: days-before-pr-close: -1 - name: Close inactive issues - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: ${{ env.daysBeforeStale }} days-before-close: ${{ env.daysBeforeClose }} stale-issue-label: 'inactive' + exempt-all-milestones: true + exempt-all-assignees: true stale-issue-message: | This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days. 该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。 diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 9e1608b13e..523a670064 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -3,7 +3,7 @@ name: Nightly Build on: workflow_dispatch: schedule: - - cron: '0 17 * * *' # 1:00 BJ Time + - cron: "0 17 * * *" # 1:00 BJ Time permissions: contents: write @@ -56,9 +56,9 @@ jobs: ref: main - name: Install Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: macos-latest dependencies fix if: matrix.os == 'macos-latest' @@ -66,7 +66,7 @@ jobs: brew install python-setuptools - name: Install corepack - run: corepack enable && corepack prepare yarn@4.6.0 --activate + run: corepack enable && corepack prepare yarn@4.9.1 --activate - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -208,7 +208,7 @@ jobs: echo "总计: $(find renamed-artifacts -type f | wc -l) 个文件" - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: cherry-studio-nightly-${{ steps.date.outputs.date }}-${{ matrix.os }} path: renamed-artifacts/* diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 9108d71fc1..ebd871b3f3 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -17,19 +17,19 @@ jobs: runs-on: ubuntu-latest env: PRCI: true - if: github.event.pull_request.draft == false + if: github.event.pull_request.draft == false || github.head_ref == 'v2' steps: - name: Check out Git repository uses: actions/checkout@v5 - name: Install Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: Install corepack - run: corepack enable && corepack prepare yarn@4.6.0 --activate + run: corepack enable && corepack prepare yarn@4.9.1 --activate - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c54504de07..8bbb46ee67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag (e.g. v1.0.0)' + description: "Release tag (e.g. v1.0.0)" required: true - default: 'v1.0.0' + default: "v1.0.0" push: tags: - v*.*.* @@ -47,9 +47,9 @@ jobs: npm version "$VERSION" --no-git-tag-version --allow-same-version - name: Install Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: macos-latest dependencies fix if: matrix.os == 'macos-latest' @@ -57,7 +57,7 @@ jobs: brew install python-setuptools - name: Install corepack - run: corepack enable && corepack prepare yarn@4.6.0 --activate + run: corepack enable && corepack prepare yarn@4.9.1 --activate - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -127,5 +127,5 @@ jobs: allowUpdates: true makeLatest: false tag: ${{ steps.get-tag.outputs.tag }} - artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/beta*.yml,dist/*.blockmap' + artifacts: "dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/beta*.yml,dist/*.blockmap" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/update-app-upgrade-config.yml b/.github/workflows/update-app-upgrade-config.yml new file mode 100644 index 0000000000..7470bb0b6c --- /dev/null +++ b/.github/workflows/update-app-upgrade-config.yml @@ -0,0 +1,212 @@ +name: Update App Upgrade Config + +on: + release: + types: + - released + - prereleased + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g., v1.2.3)" + required: true + type: string + is_prerelease: + description: "Mark the tag as a prerelease when running manually" + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + propose-update: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false) + + steps: + - name: Check if should proceed + id: check + run: | + EVENT="${{ github.event_name }}" + + if [ "$EVENT" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${{ github.event.release.tag_name }}" + fi + + latest_tag=$( + curl -L \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/${{ github.repository }}/releases/latest \ + | jq -r '.tag_name' + ) + + if [ "$EVENT" = "workflow_dispatch" ]; then + MANUAL_IS_PRERELEASE="${{ github.event.inputs.is_prerelease }}" + if [ -z "$MANUAL_IS_PRERELEASE" ]; then + MANUAL_IS_PRERELEASE="false" + fi + if [ "$MANUAL_IS_PRERELEASE" = "true" ]; then + if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then + echo "Manual prerelease flag set but tag $TAG lacks beta/rc suffix. Skipping." >&2 + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$MANUAL_IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + exit 0 + fi + + IS_PRERELEASE="${{ github.event.release.prerelease }}" + + if [ "$IS_PRERELEASE" = "true" ]; then + if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then + echo "Release marked as prerelease but tag $TAG lacks beta/rc suffix. Skipping." >&2 + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + echo "Release is prerelease, proceeding" + exit 0 + fi + + if [[ "${latest_tag}" == "$TAG" ]]; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + echo "Release is latest, proceeding" + else + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + echo "Release is neither prerelease nor latest, skipping" + fi + + - name: Prepare metadata + id: meta + if: steps.check.outputs.should_run == 'true' + run: | + EVENT="${{ github.event_name }}" + LATEST_TAG="${{ steps.check.outputs.latest_tag }}" + if [ "$EVENT" = "release" ]; then + TAG="${{ github.event.release.tag_name }}" + PRE="${{ github.event.release.prerelease }}" + + if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ]; then + LATEST="true" + else + LATEST="false" + fi + TRIGGER="release" + else + TAG="${{ github.event.inputs.tag }}" + PRE="${{ github.event.inputs.is_prerelease }}" + if [ -z "$PRE" ]; then + PRE="false" + fi + if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ] && [ "$PRE" != "true" ]; then + LATEST="true" + else + LATEST="false" + fi + TRIGGER="manual" + fi + + SAFE_TAG=$(echo "$TAG" | sed 's/[^A-Za-z0-9._-]/-/g') + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "safe_tag=$SAFE_TAG" >> "$GITHUB_OUTPUT" + echo "prerelease=$PRE" >> "$GITHUB_OUTPUT" + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + echo "trigger=$TRIGGER" >> "$GITHUB_OUTPUT" + + - name: Checkout default branch + if: steps.check.outputs.should_run == 'true' + uses: actions/checkout@v5 + with: + ref: ${{ github.event.repository.default_branch }} + path: main + fetch-depth: 0 + + - name: Checkout x-files/app-upgrade-config branch + if: steps.check.outputs.should_run == 'true' + uses: actions/checkout@v5 + with: + ref: x-files/app-upgrade-config + path: cs + fetch-depth: 0 + + - name: Setup Node.js + if: steps.check.outputs.should_run == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable Corepack + if: steps.check.outputs.should_run == 'true' + run: corepack enable && corepack prepare yarn@4.9.1 --activate + + - name: Install dependencies + if: steps.check.outputs.should_run == 'true' + working-directory: main + run: yarn install --immutable + + - name: Update upgrade config + if: steps.check.outputs.should_run == 'true' + working-directory: main + env: + RELEASE_TAG: ${{ steps.meta.outputs.tag }} + IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }} + run: | + yarn tsx scripts/update-app-upgrade-config.ts \ + --tag "$RELEASE_TAG" \ + --config ../cs/app-upgrade-config.json \ + --is-prerelease "$IS_PRERELEASE" + + - name: Detect changes + if: steps.check.outputs.should_run == 'true' + id: diff + working-directory: cs + run: | + if git diff --quiet -- app-upgrade-config.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create pull request + if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + path: cs + base: x-files/app-upgrade-config + branch: chore/update-app-upgrade-config/${{ steps.meta.outputs.safe_tag }} + commit-message: "🤖 chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}" + title: "chore: update app-upgrade-config for ${{ steps.meta.outputs.tag }}" + body: | + Automated update triggered by `${{ steps.meta.outputs.trigger }}`. + + - Source tag: `${{ steps.meta.outputs.tag }}` + - Pre-release: `${{ steps.meta.outputs.prerelease }}` + - Latest: `${{ steps.meta.outputs.latest }}` + - Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + labels: | + automation + app-upgrade + + - name: No changes detected + if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true' + run: echo "No updates required for x-files/app-upgrade-config/app-upgrade-config.json" diff --git a/.oxlintrc.json b/.oxlintrc.json index 5bd988159a..54725dbca9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,12 +22,11 @@ "eslint.config.mjs" ], "overrides": [ - // set different env { "env": { "node": true }, - "files": ["src/main/**", "resources/scripts/**", "scripts/**", "playwright.config.ts", "electron.vite.config.ts"] + "files": ["src/main/**", "resources/scripts/**", "scripts/**", "playwright.config.ts", "electron.vite.config.ts", "packages/ui/scripts/**"] }, { "env": { @@ -37,7 +36,7 @@ "src/renderer/**/*.{ts,tsx}", "packages/aiCore/**", "packages/extension-table-plus/**", - "resources/js/**" + "packages/ui/**" ] }, { @@ -53,76 +52,24 @@ "node": true }, "files": ["src/preload/**"] + }, + { + "files": ["packages/ai-sdk-provider/**"], + "globals": { + "fetch": "readonly" + } } ], - // We don't use the React plugin here because its behavior differs slightly from that of ESLint's React plugin. "plugins": ["unicorn", "typescript", "oxc", "import"], "rules": { - "constructor-super": "error", - "for-direction": "error", - "getter-return": "error", "no-array-constructor": "off", - // "import/no-cycle": "error", // tons of error, bro - "no-async-promise-executor": "error", "no-caller": "warn", - "no-case-declarations": "error", - "no-class-assign": "error", - "no-compare-neg-zero": "error", - "no-cond-assign": "error", - "no-const-assign": "error", - "no-constant-binary-expression": "error", - "no-constant-condition": "error", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-dupe-args": "error", - "no-dupe-class-members": "error", - "no-dupe-else-if": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-pattern": "error", - "no-empty-static-block": "error", "no-eval": "warn", - "no-ex-assign": "error", - "no-extra-boolean-cast": "error", "no-fallthrough": "warn", - "no-func-assign": "error", - "no-global-assign": "error", - "no-import-assign": "error", - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-loss-of-precision": "error", - "no-misleading-character-class": "error", - "no-new-native-nonconstructor": "error", - "no-nonoctal-decimal-escape": "error", - "no-obj-calls": "error", - "no-octal": "error", - "no-prototype-builtins": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-self-assign": "error", - "no-setter-return": "error", - "no-shadow-restricted-names": "error", - "no-sparse-arrays": "error", - "no-this-before-super": "error", "no-unassigned-vars": "warn", - "no-undef": "error", - "no-unexpected-multiline": "error", - "no-unreachable": "error", - "no-unsafe-finally": "error", - "no-unsafe-negation": "error", - "no-unsafe-optional-chaining": "error", - "no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()` - "no-unused-labels": "error", - "no-unused-private-class-members": "error", + "no-unused-expressions": "off", "no-unused-vars": ["warn", { "caughtErrors": "none" }], - "no-useless-backreference": "error", - "no-useless-catch": "error", - "no-useless-escape": "error", "no-useless-rename": "warn", - "no-with": "error", "oxc/bad-array-method-on-arguments": "warn", "oxc/bad-char-at-comparison": "warn", "oxc/bad-comparison-sequence": "warn", @@ -134,19 +81,17 @@ "oxc/erasing-op": "warn", "oxc/missing-throw": "warn", "oxc/number-arg-out-of-range": "warn", - "oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future + "oxc/only-used-in-recursion": "off", "oxc/uninvoked-array-callback": "warn", - "require-yield": "error", "typescript/await-thenable": "warn", - // "typescript/ban-ts-comment": "error", + "typescript/consistent-type-imports": "error", "typescript/no-array-constructor": "error", - // "typescript/consistent-type-imports": "error", "typescript/no-array-delete": "warn", "typescript/no-base-to-string": "warn", "typescript/no-duplicate-enum-values": "error", "typescript/no-duplicate-type-constituents": "warn", "typescript/no-empty-object-type": "off", - "typescript/no-explicit-any": "off", // not safe but too many errors + "typescript/no-explicit-any": "off", "typescript/no-extra-non-null-assertion": "error", "typescript/no-floating-promises": "warn", "typescript/no-for-in-array": "warn", @@ -155,7 +100,7 @@ "typescript/no-misused-new": "error", "typescript/no-misused-spread": "warn", "typescript/no-namespace": "error", - "typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on. + "typescript/no-non-null-asserted-optional-chain": "off", "typescript/no-redundant-type-constituents": "warn", "typescript/no-require-imports": "off", "typescript/no-this-alias": "error", @@ -173,20 +118,18 @@ "typescript/triple-slash-reference": "error", "typescript/unbound-method": "warn", "unicorn/no-await-in-promise-methods": "warn", - "unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-empty-file": "off", "unicorn/no-invalid-fetch-options": "warn", "unicorn/no-invalid-remove-event-listener": "warn", - "unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-new-array": "off", "unicorn/no-single-promise-in-promise-methods": "warn", - "unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-thenable": "off", "unicorn/no-unnecessary-await": "warn", "unicorn/no-useless-fallback-in-spread": "warn", "unicorn/no-useless-length-check": "warn", - "unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-useless-spread": "off", "unicorn/prefer-set-size": "warn", - "unicorn/prefer-string-starts-ends-with": "warn", - "use-isnan": "error", - "valid-typeof": "error" + "unicorn/prefer-string-starts-ends-with": "warn" }, "settings": { "jsdoc": { diff --git a/.vscode/settings.json b/.vscode/settings.json index ab4a2fa759..09d356c41b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,7 +31,8 @@ }, "editor.formatOnSave": true, "files.associations": { - "*.css": "tailwindcss" + "*.css": "tailwindcss", + ".oxlintrc.json": "jsonc" }, "files.eol": "\n", // "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言 @@ -50,6 +51,9 @@ }, "tailwindCSS.classAttributes": [ "className", - "classNames", + "classNames" + ], + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^;]*)[\\);]", "[`'\"`]([^'\"`;]*)[`'\"`]"] ] } diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch deleted file mode 100644 index 34babfe803..0000000000 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/dist/index.mjs b/dist/index.mjs -index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { - - // src/get-model-path.ts - function getModelPath(modelId) { -- return modelId.includes("/") ? modelId : `models/${modelId}`; -+ return modelId?.includes("models/") ? modelId : `models/${modelId}`; - } - - // src/google-generative-ai-options.ts diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch new file mode 100644 index 0000000000..75c418e591 --- /dev/null +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch @@ -0,0 +1,26 @@ +diff --git a/dist/index.js b/dist/index.js +index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { + + // src/get-model-path.ts + function getModelPath(modelId) { +- return modelId.includes("/") ? modelId : `models/${modelId}`; ++ return modelId.includes("models/") ? modelId : `models/${modelId}`; + } + + // src/google-generative-ai-options.ts +diff --git a/dist/index.mjs b/dist/index.mjs +index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { + + // src/get-model-path.ts + function getModelPath(modelId) { +- return modelId.includes("/") ? modelId : `models/${modelId}`; ++ return modelId.includes("models/") ? modelId : `models/${modelId}`; + } + + // src/google-generative-ai-options.ts diff --git a/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch b/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch similarity index 100% rename from .yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch rename to .yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch diff --git a/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch b/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch new file mode 100644 index 0000000000..22b5cf6ea8 --- /dev/null +++ b/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch @@ -0,0 +1,74 @@ +diff --git a/dist/index.js b/dist/index.js +index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa96b52ac0d 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)( + message: import_v42.z.object({ + role: import_v42.z.literal("assistant").nullish(), + content: import_v42.z.string().nullish(), ++ reasoning_content: import_v42.z.string().nullish(), + tool_calls: import_v42.z.array( + import_v42.z.object({ + id: import_v42.z.string().nullish(), +@@ -340,6 +341,7 @@ var openaiChatChunkSchema = (0, import_provider_utils3.lazyValidator)( + delta: import_v42.z.object({ + role: import_v42.z.enum(["assistant"]).nullish(), + content: import_v42.z.string().nullish(), ++ reasoning_content: import_v42.z.string().nullish(), + tool_calls: import_v42.z.array( + import_v42.z.object({ + index: import_v42.z.number(), +@@ -785,6 +787,13 @@ var OpenAIChatLanguageModel = class { + if (text != null && text.length > 0) { + content.push({ type: "text", text }); + } ++ const reasoning = choice.message.reasoning_content; ++ if (reasoning != null && reasoning.length > 0) { ++ content.push({ ++ type: 'reasoning', ++ text: reasoning ++ }); ++ } + for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) { + content.push({ + type: "tool-call", +@@ -866,6 +875,7 @@ var OpenAIChatLanguageModel = class { + }; + let metadataExtracted = false; + let isActiveText = false; ++ let isActiveReasoning = false; + const providerMetadata = { openai: {} }; + return { + stream: response.pipeThrough( +@@ -923,6 +933,21 @@ var OpenAIChatLanguageModel = class { + return; + } + const delta = choice.delta; ++ const reasoningContent = delta.reasoning_content; ++ if (reasoningContent) { ++ if (!isActiveReasoning) { ++ controller.enqueue({ ++ type: 'reasoning-start', ++ id: 'reasoning-0', ++ }); ++ isActiveReasoning = true; ++ } ++ controller.enqueue({ ++ type: 'reasoning-delta', ++ id: 'reasoning-0', ++ delta: reasoningContent, ++ }); ++ } + if (delta.content != null) { + if (!isActiveText) { + controller.enqueue({ type: "text-start", id: "0" }); +@@ -1035,6 +1060,9 @@ var OpenAIChatLanguageModel = class { + } + }, + flush(controller) { ++ if (isActiveReasoning) { ++ controller.enqueue({ type: 'reasoning-end', id: 'reasoning-0' }); ++ } + if (isActiveText) { + controller.enqueue({ type: "text-end", id: "0" }); + } diff --git a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch similarity index 75% rename from .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch rename to .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch index 5e37489f25..896b2d4cbf 100644 --- a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch +++ b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch @@ -1,24 +1,24 @@ diff --git a/sdk.mjs b/sdk.mjs -index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644 +index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755 --- a/sdk.mjs +++ b/sdk.mjs -@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { +@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { } - + // ../src/transport/ProcessTransport.ts -import { spawn } from "child_process"; +import { fork } from "child_process"; import { createInterface } from "readline"; - + // ../src/utils/fsOperations.ts -@@ -6473,14 +6473,11 @@ class ProcessTransport { +@@ -6505,14 +6505,11 @@ class ProcessTransport { const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`; throw new ReferenceError(errorMessage); } - const isNative = isNativeBinary(pathToClaudeCodeExecutable); - const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable; -- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args]; -- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`); +- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args]; +- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`); + this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`); const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore"; - this.child = spawn(spawnCommand, spawnArgs, { diff --git a/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch b/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch deleted file mode 100644 index e1258fcb35..0000000000 --- a/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch +++ /dev/null @@ -1,71 +0,0 @@ -diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs -index 973b0d0e75aeaf8de579419af31b879b32975413..f23c7caa8b9dc8bd404132725346a4786f6b278b 100644 ---- a/dist/utils/tiktoken.cjs -+++ b/dist/utils/tiktoken.cjs -@@ -1,25 +1,14 @@ - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.encodingForModel = exports.getEncoding = void 0; --const lite_1 = require("js-tiktoken/lite"); - const async_caller_js_1 = require("./async_caller.cjs"); - const cache = {}; - const caller = /* #__PURE__ */ new async_caller_js_1.AsyncCaller({}); - async function getEncoding(encoding) { -- if (!(encoding in cache)) { -- cache[encoding] = caller -- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`) -- .then((res) => res.json()) -- .then((data) => new lite_1.Tiktoken(data)) -- .catch((e) => { -- delete cache[encoding]; -- throw e; -- }); -- } -- return await cache[encoding]; -+ throw new Error("TikToken Not implemented"); - } - exports.getEncoding = getEncoding; - async function encodingForModel(model) { -- return getEncoding((0, lite_1.getEncodingNameForModel)(model)); -+ throw new Error("TikToken Not implemented"); - } - exports.encodingForModel = encodingForModel; -diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js -index 8e41ee6f00f2f9c7fa2c59fa2b2f4297634b97aa..aa5f314a6349ad0d1c5aea8631a56aad099176e0 100644 ---- a/dist/utils/tiktoken.js -+++ b/dist/utils/tiktoken.js -@@ -1,20 +1,9 @@ --import { Tiktoken, getEncodingNameForModel, } from "js-tiktoken/lite"; - import { AsyncCaller } from "./async_caller.js"; - const cache = {}; - const caller = /* #__PURE__ */ new AsyncCaller({}); - export async function getEncoding(encoding) { -- if (!(encoding in cache)) { -- cache[encoding] = caller -- .fetch(`https://tiktoken.pages.dev/js/${encoding}.json`) -- .then((res) => res.json()) -- .then((data) => new Tiktoken(data)) -- .catch((e) => { -- delete cache[encoding]; -- throw e; -- }); -- } -- return await cache[encoding]; -+ throw new Error("TikToken Not implemented"); - } - export async function encodingForModel(model) { -- return getEncoding(getEncodingNameForModel(model)); -+ throw new Error("TikToken Not implemented"); - } -diff --git a/package.json b/package.json -index 36072aecf700fca1bc49832a19be832eca726103..90b8922fba1c3d1b26f78477c891b07816d6238a 100644 ---- a/package.json -+++ b/package.json -@@ -37,7 +37,6 @@ - "ansi-styles": "^5.0.0", - "camelcase": "6", - "decamelize": "1.2.0", -- "js-tiktoken": "^1.0.12", - "langsmith": ">=0.2.8 <0.4.0", - "mustache": "^4.2.0", - "p-queue": "^6.6.2", diff --git a/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch b/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch new file mode 100644 index 0000000000..6c8ad0b426 --- /dev/null +++ b/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch @@ -0,0 +1,68 @@ +diff --git a/dist/utils/tiktoken.cjs b/dist/utils/tiktoken.cjs +index c5b41f121d2e3d24c3a4969e31fa1acffdcad3b9..ec724489dcae79ee6c61acf2d4d84bd19daef036 100644 +--- a/dist/utils/tiktoken.cjs ++++ b/dist/utils/tiktoken.cjs +@@ -1,6 +1,5 @@ + const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs'); + const require_utils_async_caller = require('./async_caller.cjs'); +-const js_tiktoken_lite = require_rolldown_runtime.__toESM(require("js-tiktoken/lite")); + + //#region src/utils/tiktoken.ts + var tiktoken_exports = {}; +@@ -11,14 +10,10 @@ require_rolldown_runtime.__export(tiktoken_exports, { + const cache = {}; + const caller = /* @__PURE__ */ new require_utils_async_caller.AsyncCaller({}); + async function getEncoding(encoding) { +- if (!(encoding in cache)) cache[encoding] = caller.fetch(`https://tiktoken.pages.dev/js/${encoding}.json`).then((res) => res.json()).then((data) => new js_tiktoken_lite.Tiktoken(data)).catch((e) => { +- delete cache[encoding]; +- throw e; +- }); +- return await cache[encoding]; ++ throw new Error("TikToken Not implemented"); + } + async function encodingForModel(model) { +- return getEncoding((0, js_tiktoken_lite.getEncodingNameForModel)(model)); ++ throw new Error("TikToken Not implemented"); + } + + //#endregion +diff --git a/dist/utils/tiktoken.js b/dist/utils/tiktoken.js +index 641acca03cb92f04a6fa5c9c31f1880ce635572e..707389970ad957aa0ff20ef37fa8dd2875be737c 100644 +--- a/dist/utils/tiktoken.js ++++ b/dist/utils/tiktoken.js +@@ -1,6 +1,5 @@ + import { __export } from "../_virtual/rolldown_runtime.js"; + import { AsyncCaller } from "./async_caller.js"; +-import { Tiktoken, getEncodingNameForModel } from "js-tiktoken/lite"; + + //#region src/utils/tiktoken.ts + var tiktoken_exports = {}; +@@ -11,14 +10,10 @@ __export(tiktoken_exports, { + const cache = {}; + const caller = /* @__PURE__ */ new AsyncCaller({}); + async function getEncoding(encoding) { +- if (!(encoding in cache)) cache[encoding] = caller.fetch(`https://tiktoken.pages.dev/js/${encoding}.json`).then((res) => res.json()).then((data) => new Tiktoken(data)).catch((e) => { +- delete cache[encoding]; +- throw e; +- }); +- return await cache[encoding]; ++ throw new Error("TikToken Not implemented"); + } + async function encodingForModel(model) { +- return getEncoding(getEncodingNameForModel(model)); ++ throw new Error("TikToken Not implemented"); + } + + //#endregion +diff --git a/package.json b/package.json +index a24f8fc61de58526051999260f2ebee5f136354b..e885359e8966e7730c51772533ce37e01edb3046 100644 +--- a/package.json ++++ b/package.json +@@ -20,7 +20,6 @@ + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", +- "js-tiktoken": "^1.0.12", + "langsmith": "^0.3.64", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", diff --git a/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch b/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch deleted file mode 100644 index 3f37fb3ed8..0000000000 --- a/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch +++ /dev/null @@ -1,19 +0,0 @@ -diff --git a/dist/embeddings.js b/dist/embeddings.js -index 1f8154be3e9c22442a915eb4b85fa6d2a21b0d0c..dc13ef4a30e6c282824a5357bcee9bd0ae222aab 100644 ---- a/dist/embeddings.js -+++ b/dist/embeddings.js -@@ -214,10 +214,12 @@ export class OpenAIEmbeddings extends Embeddings { - * @returns Promise that resolves to an embedding for the document. - */ - async embedQuery(text) { -+ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com') -+ const input = this.stripNewLines ? text.replace(/\n/g, ' ') : text - const params = { - model: this.model, -- input: this.stripNewLines ? text.replace(/\n/g, " ") : text, -- }; -+ input: isBaiduCloud ? [input] : input -+ } - if (this.dimensions) { - params.dimensions = this.dimensions; - } diff --git a/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch b/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch new file mode 100644 index 0000000000..bb4fa01264 --- /dev/null +++ b/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch @@ -0,0 +1,17 @@ +diff --git a/dist/embeddings.js b/dist/embeddings.js +index 6f4b928d3e4717309382e1b5c2e31ab5bc6c5af0..bc79429c88a6d27d4997a2740c4d8ae0707f5991 100644 +--- a/dist/embeddings.js ++++ b/dist/embeddings.js +@@ -94,9 +94,11 @@ var OpenAIEmbeddings = class extends Embeddings { + * @returns Promise that resolves to an embedding for the document. + */ + async embedQuery(text) { ++ const isBaiduCloud = this.clientConfig.baseURL.includes('baidubce.com'); ++ const input = this.stripNewLines ? text.replace(/\n/g, " ") : text + const params = { + model: this.model, +- input: this.stripNewLines ? text.replace(/\n/g, " ") : text ++ input: isBaiduCloud ? [input] : input + }; + if (this.dimensions) params.dimensions = this.dimensions; + if (this.encodingFormat) params.encoding_format = this.encodingFormat; diff --git a/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch b/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch deleted file mode 100644 index e9ca84e6cd..0000000000 --- a/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch +++ /dev/null @@ -1,276 +0,0 @@ -diff --git a/out/macPackager.js b/out/macPackager.js -index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644 ---- a/out/macPackager.js -+++ b/out/macPackager.js -@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager { - } - appPlist.CFBundleName = appInfo.productName; - appPlist.CFBundleDisplayName = appInfo.productName; -- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion; -+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion; - if (minimumSystemVersion != null) { - appPlist.LSMinimumSystemVersion = minimumSystemVersion; - } -diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js -index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644 ---- a/out/publish/updateInfoBuilder.js -+++ b/out/publish/updateInfoBuilder.js -@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) { - const customUpdateInfo = event.updateInfo; - const url = path.basename(event.file); - const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file)); -+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion; - const files = [{ url, sha512 }]; - const result = { - // @ts-ignore -@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) { - path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */, - // @ts-ignore - sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */, -+ minimumSystemVersion, - ...releaseInfo, - }; - if (customUpdateInfo != null) { -+ if (customUpdateInfo.minimumSystemVersion) { -+ delete customUpdateInfo.minimumSystemVersion; -+ } - // file info or nsis web installer packages info - Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo); - } -diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js -index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644 ---- a/out/targets/ArchiveTarget.js -+++ b/out/targets/ArchiveTarget.js -@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target { - } - } - } -+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) { -+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion; -+ } - await packager.info.emitArtifactBuildCompleted({ - updateInfo, - file: artifactPath, -diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js -index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644 ---- a/out/targets/nsis/NsisTarget.js -+++ b/out/targets/nsis/NsisTarget.js -@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target { - if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) { - updateInfo.isAdminRightsRequired = true; - } -+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) { -+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion; -+ } - await packager.info.emitArtifactBuildCompleted({ - file: installerPath, - updateInfo, -diff --git a/out/util/yarn.js b/out/util/yarn.js -index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644 ---- a/out/util/yarn.js -+++ b/out/util/yarn.js -@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) { - arch, - platform, - buildFromSource, -+ ignoreModules: config.excludeReBuildModules || undefined, - projectRootPath: projectDir, - mode: config.nativeRebuilder || "sequential", - disablePreGypCopy: true, -diff --git a/scheme.json b/scheme.json -index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a74dda74c9 100644 ---- a/scheme.json -+++ b/scheme.json -@@ -1825,6 +1825,20 @@ - "string" - ] - }, -+ "excludeReBuildModules": { -+ "anyOf": [ -+ { -+ "items": { -+ "type": "string" -+ }, -+ "type": "array" -+ }, -+ { -+ "type": "null" -+ } -+ ], -+ "description": "The modules to exclude from the rebuild." -+ }, - "executableArgs": { - "anyOf": [ - { -@@ -1975,6 +1989,13 @@ - ], - "description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing." - }, -+ "minimumSystemVersion": { -+ "description": "The minimum os kernel version required to install the application.", -+ "type": [ -+ "null", -+ "string" -+ ] -+ }, - "packageCategory": { - "description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place", - "type": [ -@@ -2327,6 +2348,13 @@ - "MacConfiguration": { - "additionalProperties": false, - "properties": { -+ "LSMinimumSystemVersion": { -+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", -+ "type": [ -+ "null", -+ "string" -+ ] -+ }, - "additionalArguments": { - "anyOf": [ - { -@@ -2527,6 +2555,20 @@ - "string" - ] - }, -+ "excludeReBuildModules": { -+ "anyOf": [ -+ { -+ "items": { -+ "type": "string" -+ }, -+ "type": "array" -+ }, -+ { -+ "type": "null" -+ } -+ ], -+ "description": "The modules to exclude from the rebuild." -+ }, - "executableName": { - "description": "The executable name. Defaults to `productName`.", - "type": [ -@@ -2737,7 +2779,7 @@ - "type": "boolean" - }, - "minimumSystemVersion": { -- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", -+ "description": "The minimum os kernel version required to install the application.", - "type": [ - "null", - "string" -@@ -2959,6 +3001,13 @@ - "MasConfiguration": { - "additionalProperties": false, - "properties": { -+ "LSMinimumSystemVersion": { -+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", -+ "type": [ -+ "null", -+ "string" -+ ] -+ }, - "additionalArguments": { - "anyOf": [ - { -@@ -3159,6 +3208,20 @@ - "string" - ] - }, -+ "excludeReBuildModules": { -+ "anyOf": [ -+ { -+ "items": { -+ "type": "string" -+ }, -+ "type": "array" -+ }, -+ { -+ "type": "null" -+ } -+ ], -+ "description": "The modules to exclude from the rebuild." -+ }, - "executableName": { - "description": "The executable name. Defaults to `productName`.", - "type": [ -@@ -3369,7 +3432,7 @@ - "type": "boolean" - }, - "minimumSystemVersion": { -- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.", -+ "description": "The minimum os kernel version required to install the application.", - "type": [ - "null", - "string" -@@ -6381,6 +6444,20 @@ - "string" - ] - }, -+ "excludeReBuildModules": { -+ "anyOf": [ -+ { -+ "items": { -+ "type": "string" -+ }, -+ "type": "array" -+ }, -+ { -+ "type": "null" -+ } -+ ], -+ "description": "The modules to exclude from the rebuild." -+ }, - "executableName": { - "description": "The executable name. Defaults to `productName`.", - "type": [ -@@ -6507,6 +6584,13 @@ - "string" - ] - }, -+ "minimumSystemVersion": { -+ "description": "The minimum os kernel version required to install the application.", -+ "type": [ -+ "null", -+ "string" -+ ] -+ }, - "protocols": { - "anyOf": [ - { -@@ -7153,6 +7237,20 @@ - "string" - ] - }, -+ "excludeReBuildModules": { -+ "anyOf": [ -+ { -+ "items": { -+ "type": "string" -+ }, -+ "type": "array" -+ }, -+ { -+ "type": "null" -+ } -+ ], -+ "description": "The modules to exclude from the rebuild." -+ }, - "executableName": { - "description": "The executable name. Defaults to `productName`.", - "type": [ -@@ -7376,6 +7474,13 @@ - ], - "description": "MAS (Mac Application Store) development options (`mas-dev` target)." - }, -+ "minimumSystemVersion": { -+ "description": "The minimum os kernel version required to install the application.", -+ "type": [ -+ "null", -+ "string" -+ ] -+ }, - "msi": { - "anyOf": [ - { diff --git a/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch b/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch new file mode 100644 index 0000000000..f9e54ac947 --- /dev/null +++ b/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch @@ -0,0 +1,14 @@ +diff --git a/out/util.js b/out/util.js +index 9294ffd6ca8f02c2e0f90c663e7e9cdc02c1ac37..f52107493e2995320ee4efd0eb2a8c9bf03291a2 100644 +--- a/out/util.js ++++ b/out/util.js +@@ -23,7 +23,8 @@ function newUrlFromBase(pathname, baseUrl, addRandomQueryToAvoidCaching = false) + result.search = search; + } + else if (addRandomQueryToAvoidCaching) { +- result.search = `noCache=${Date.now().toString(32)}`; ++ // use no cache header instead ++ // result.search = `noCache=${Date.now().toString(32)}`; + } + return result; + } diff --git a/CLAUDE.md b/CLAUDE.md index 748c48f608..1ff53e2fe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,8 @@ This file provides guidance to AI coding assistants when working with code in th - **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`. - **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`. - **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references. -- **Seek review**: Ask a human developer to review substantial changes before merging. -- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged. +- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications. +- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`). ## Development Commands @@ -35,14 +35,113 @@ This file provides guidance to AI coding assistants when working with code in th - **Renderer Process** (`src/renderer/`): React UI with Redux state management - **Preload Scripts** (`src/preload/`): Secure IPC bridge -### Key Components -- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers. -- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc. -- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces. -- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state. -- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements. +### Key Architectural Components + +#### Main Process Services (`src/main/services/`) + +- **MCPService**: Model Context Protocol server management +- **KnowledgeService**: Document processing and knowledge base management +- **FileStorage/S3Storage/WebDav**: Multiple storage backends +- **WindowService**: Multi-window management (main, mini, selection windows) +- **ProxyManager**: Network proxy handling +- **SearchService**: Full-text search capabilities + +#### AI Core (`src/renderer/src/aiCore/`) + +- **Middleware System**: Composable pipeline for AI request processing +- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.) +- **Stream Processing**: Real-time response handling + +#### Data Management + +- **Cache System**: Three-layer caching (memory/shared/persist) with React hooks integration +- **Preferences**: Type-safe configuration management with multi-window synchronization +- **User Data**: SQLite-based storage with Drizzle ORM for business data + +#### Knowledge Management + +- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.) +- **OCR**: Document text extraction (system OCR, Doc2x, Mineru) +- **Preprocessing**: Document preparation pipeline +- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.) + +### Build System + +- **Electron-Vite**: Development and build tooling (v4.0.0) +- **Rolldown-Vite**: Using experimental rolldown-vite instead of standard vite +- **Workspaces**: Monorepo structure with `packages/` directory +- **Multiple Entry Points**: Main app, mini window, selection toolbar +- **Styled Components**: CSS-in-JS styling with SWC optimization + +### Testing Strategy + +- **Vitest**: Unit and integration testing +- **Playwright**: End-to-end testing +- **Component Testing**: React Testing Library +- **Coverage**: Available via `yarn test:coverage` + +### Key Patterns + +- **IPC Communication**: Secure main-renderer communication via preload scripts +- **Service Layer**: Clear separation between UI and business logic +- **Plugin Architecture**: Extensible via MCP servers and middleware +- **Multi-language Support**: i18n with dynamic loading +- **Theme System**: Light/dark themes with custom CSS variables + +### UI Design + +The project is in the process of migrating from antd & styled-components to HeroUI. Please use HeroUI to build UI components. The use of antd and styled-components is prohibited. + +HeroUI Docs: https://www.heroui.com/docs/guide/introduction + +### Database Architecture + +- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver +- **ORM**: Drizzle ORM with comprehensive migration system +- **Schemas**: Located in `src/main/data/db/schemas/` directory + +#### Database Standards + +- **Table Naming**: Use singular form with snake_case (e.g., `topic`, `message`, `app_state`) +- **Schema Exports**: Export using `xxxTable` pattern (e.g., `topicTable`, `appStateTable`) +- **Field Definition**: Drizzle auto-infers field names, no need to add default field names +- **JSON Fields**: For JSON support, add `{ mode: 'json' }`, refer to `preference.ts` table definition +- **JSON Serialization**: For JSON fields, no need to manually serialize/deserialize when reading/writing to database, Drizzle handles this automatically +- **Timestamps**: Use existing `crudTimestamps` utility +- **Migrations**: Generate via `yarn run migrations:generate` + +## Data Access Patterns + +The application uses three distinct data management systems. Choose the appropriate system based on data characteristics: + +### Cache System +- **Purpose**: Temporary data that can be regenerated +- **Lifecycle**: Component-level (memory), window-level (shared), or persistent (survives restart) +- **Use Cases**: API response caching, computed results, temporary UI state +- **APIs**: `useCache`, `useSharedCache`, `usePersistCache` hooks, or `cacheService` + +### Preference System +- **Purpose**: User configuration and application settings +- **Lifecycle**: Permanent until user changes +- **Use Cases**: Theme, language, editor settings, user preferences +- **APIs**: `usePreference`, `usePreferences` hooks, or `preferenceService` + +### User Data API +- **Purpose**: Core business data (conversations, files, notes, etc.) +- **Lifecycle**: Permanent business records +- **Use Cases**: Topics, messages, files, knowledge base, user-generated content +- **APIs**: `useDataApi` hook or `dataApiService` for direct calls + +### Selection Guidelines + +- **Use Cache** for data that can be lost without impact (computed values, API responses) +- **Use Preferences** for user settings that affect app behavior (UI configuration, feature flags) +- **Use User Data API** for irreplaceable business data (conversations, documents, user content) + +## Logging Standards + +### Usage -### Logging ```typescript import { loggerService } from '@logger' const logger = loggerService.withContext('moduleName') diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88f034976f..545c34dc12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ Please review the following critical information before submitting your Pull Req Our core team is currently focused on significant architectural updates that involve these data structures. To ensure stability and focus during this period, contributions of this nature will be temporarily managed internally. * **PRs that require changes to Redux state shape or IndexedDB schemas will be closed.** -* **This restriction is temporary and will be lifted with the release of `v2.0.0`.** You can track the progress of `v2.0.0` and its related discussions on issue [#10162](https://github.com/YOUR_ORG/YOUR_REPO/issues/10162) (please replace with your actual repo link). +* **This restriction is temporary and will be lifted with the release of `v2.0.0`.** You can track the progress of `v2.0.0` and its related discussions on issue [#10162](https://github.com/CherryHQ/cherry-studio/pull/10162). We highly encourage contributions for: * Bug fixes 🐞 diff --git a/README.md b/README.md index c3d3f915a1..1223f73ed0 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Cherry Studio is a desktop client that supports multiple LLM providers, availabl 1. **Diverse LLM Provider Support**: - ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more -- 🔗 AI Web Service Integration: Claude, Perplexity, Poe, and others +- 🔗 AI Web Service Integration: Claude, Perplexity, [Poe](https://poe.com/), and others - 💻 Local Model Support with Ollama, LM Studio 2. **AI Assistants & Conversations**: @@ -238,10 +238,6 @@ The Enterprise Edition addresses core challenges in team collaboration by centra ## ✨ Online Demo -> 🚧 **Public Beta Notice** -> -> The Enterprise Edition is currently in its early public beta stage, and we are actively iterating and optimizing its features. We are aware that it may not be perfectly stable yet. If you encounter any issues or have valuable suggestions during your trial, we would be very grateful if you could contact us via email to provide feedback. - **🔗 [Cherry Studio Enterprise](https://www.cherry-ai.com/enterprise)** ## Version Comparison @@ -249,7 +245,7 @@ The Enterprise Edition addresses core challenges in team collaboration by centra | Feature | Community Edition | Enterprise Edition | | :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | | **Open Source** | ✅ Yes | ⭕️ Partially released to customers | -| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee | +| **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee | | **Admin Backend** | — | ● Centralized **Model** Access
● **Employee** Management
● Shared **Knowledge Base**
● **Access** Control
● **Data** Backup | | **Server** | — | ✅ Dedicated Private Deployment | @@ -262,8 +258,12 @@ We believe the Enterprise Edition will become your team's AI productivity engine # 🔗 Related Projects +- [new-api](https://github.com/QuantumNous/new-api): The next-generation LLM gateway and AI asset management system supports multiple languages. + - [one-api](https://github.com/songquanpeng/one-api): LLM API management and distribution system supporting mainstream models like OpenAI, Azure, and Anthropic. Features a unified API interface, suitable for key management and secondary distribution. +- [Poe](https://poe.com/): Poe gives you access to the best AI, all in one place. Explore GPT-5, Claude Opus 4.1, DeepSeek-R1, Veo 3, ElevenLabs, and millions of others. + - [ublacklist](https://github.com/iorate/ublacklist): Blocks specific sites from appearing in Google search results # 🚀 Contributors diff --git a/app-upgrade-config.json b/app-upgrade-config.json new file mode 100644 index 0000000000..84e381c86a --- /dev/null +++ b/app-upgrade-config.json @@ -0,0 +1,49 @@ +{ + "lastUpdated": "2025-11-10T08:14:28Z", + "versions": { + "1.6.7": { + "metadata": { + "segmentId": "legacy-v1", + "segmentType": "legacy" + }, + "minCompatibleVersion": "1.0.0", + "description": "Last stable v1.7.x release - required intermediate version for users below v1.7", + "channels": { + "latest": { + "version": "1.6.7", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7", + "gitcode": "https://releases.cherry-ai.com" + } + }, + "rc": { + "version": "1.6.0-rc.5", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5" + } + }, + "beta": { + "version": "1.7.0-beta.3", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3" + } + } + } + }, + "2.0.0": { + "metadata": { + "segmentId": "gateway-v2", + "segmentType": "breaking" + }, + "minCompatibleVersion": "1.7.0", + "description": "Major release v2.0 - required intermediate version for v2.x upgrades", + "channels": { + "latest": null, + "rc": null, + "beta": null + } + } + } +} diff --git a/biome.jsonc b/biome.jsonc index b86350d70d..191f2518cd 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -21,7 +21,11 @@ "quoteStyle": "single" } }, - "files": { "ignoreUnknown": false }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/.claude/**"], + "maxSize": 2097152 + }, "formatter": { "attributePosition": "auto", "bracketSameLine": false, @@ -38,6 +42,7 @@ "!.github/**", "!.husky/**", "!.vscode/**", + "!.claude/**", "!*.yaml", "!*.yml", "!*.mjs", diff --git a/config/app-upgrade-segments.json b/config/app-upgrade-segments.json new file mode 100644 index 0000000000..70c8ac25f0 --- /dev/null +++ b/config/app-upgrade-segments.json @@ -0,0 +1,81 @@ +{ + "segments": [ + { + "id": "legacy-v1", + "type": "legacy", + "match": { + "range": ">=1.0.0 <2.0.0" + }, + "minCompatibleVersion": "1.0.0", + "description": "Last stable v1.7.x release - required intermediate version for users below v1.7", + "channelTemplates": { + "latest": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://releases.cherry-ai.com" + } + }, + "rc": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}" + } + }, + "beta": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}" + } + } + } + }, + { + "id": "gateway-v2", + "type": "breaking", + "match": { + "exact": ["2.0.0"] + }, + "lockedVersion": "2.0.0", + "minCompatibleVersion": "1.7.0", + "description": "Major release v2.0 - required intermediate version for v2.x upgrades", + "channelTemplates": { + "latest": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}" + } + } + } + }, + { + "id": "current-v2", + "type": "latest", + "match": { + "range": ">=2.0.0 <3.0.0", + "excludeExact": ["2.0.0"] + }, + "minCompatibleVersion": "2.0.0", + "description": "Current latest v2.x release", + "channelTemplates": { + "latest": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}" + } + }, + "rc": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}" + } + }, + "beta": { + "feedTemplates": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}" + } + } + } + } + ] +} diff --git a/docs/CONTRIBUTING.zh.md b/docs/CONTRIBUTING.zh.md index 67193ed098..98efcc286e 100644 --- a/docs/CONTRIBUTING.zh.md +++ b/docs/CONTRIBUTING.zh.md @@ -81,7 +81,7 @@ git commit --signoff -m "Your commit message" 我们的核心团队目前正专注于涉及这些数据结构的关键架构更新和基础工作。为确保在此期间的稳定性与专注,此类贡献将暂时由内部进行管理。 * **需要更改 Redux 状态结构或 IndexedDB schema 的 PR 将会被关闭。** -* **此限制是临时性的,并将在 `v2.0.0` 版本发布后解除。** 您可以通过 Issue [#10162](https://github.com/YOUR_ORG/YOUR_REPO/issues/10162) (请替换为您的实际仓库链接) 跟踪 `v2.0.0` 的进展及相关讨论。 +* **此限制是临时性的,并将在 `v2.0.0` 版本发布后解除。** 您可以通过 Issue [#10162](https://github.com/CherryHQ/cherry-studio/pull/10162) 跟踪 `v2.0.0` 的进展及相关讨论。 我们非常鼓励以下类型的贡献: * 错误修复 🐞 diff --git a/docs/dev.md b/docs/dev.md index 0fdff640ec..fe67742768 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -18,13 +18,13 @@ yarn ### Setup Node.js -Download and install [Node.js v20.x.x](https://nodejs.org/en/download) +Download and install [Node.js v22.x.x](https://nodejs.org/en/download) ### Setup Yarn ```bash corepack enable -corepack prepare yarn@4.6.0 --activate +corepack prepare yarn@4.9.1 --activate ``` ### Install Dependencies diff --git a/docs/technical/app-upgrade-config-en.md b/docs/technical/app-upgrade-config-en.md new file mode 100644 index 0000000000..0662abf236 --- /dev/null +++ b/docs/technical/app-upgrade-config-en.md @@ -0,0 +1,430 @@ +# Update Configuration System Design Document + +## Background + +Currently, AppUpdater directly queries the GitHub API to retrieve beta and rc update information. To support users in China, we need to fetch a static JSON configuration file from GitHub/GitCode based on IP geolocation, which contains update URLs for all channels. + +## Design Goals + +1. Support different configuration sources based on IP geolocation (GitHub/GitCode) +2. Support version compatibility control (e.g., users below v1.x must upgrade to v1.7.0 before upgrading to v2.0) +3. Easy to extend, supporting future multi-major-version upgrade paths (v1.6 → v1.7 → v2.0 → v2.8 → v3.0) +4. Maintain compatibility with existing electron-updater mechanism + +## Current Version Strategy + +- **v1.7.x** is the last version of the 1.x series +- Users **below v1.7.0** must first upgrade to v1.7.0 (or higher 1.7.x version) +- Users **v1.7.0 and above** can directly upgrade to v2.x.x + +## Automation Workflow + +The `x-files/app-upgrade-config/app-upgrade-config.json` file is synchronized by the [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow. The workflow runs the [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) helper so that every release tag automatically updates the JSON in `x-files/app-upgrade-config`. + +### Trigger Conditions + +- **Release events (`release: released/prereleased`)** + - Draft releases are ignored. + - When GitHub marks the release as _prerelease_, the tag must include `-beta`/`-rc` (with optional numeric suffix). Otherwise the workflow exits early. + - When GitHub marks the release as stable, the tag must match the latest release returned by the GitHub API. This prevents out-of-order updates when publishing historical tags. + - If the guard clauses pass, the version is tagged as `latest` or `beta/rc` based on its semantic suffix and propagated to the script through the `IS_PRERELEASE` flag. +- **Manual dispatch (`workflow_dispatch`)** + - Required input: `tag` (e.g., `v2.0.1`). Optional input: `is_prerelease` (defaults to `false`). + - When `is_prerelease=true`, the tag must carry a beta/rc suffix, mirroring the automatic validation. + - Manual runs still download the latest release metadata so that the workflow knows whether the tag represents the newest stable version (for documentation inside the PR body). + +### Workflow Steps + +1. **Guard + metadata preparation** – the `Check if should proceed` and `Prepare metadata` steps compute the target tag, prerelease flag, whether the tag is the newest release, and a `safe_tag` slug used for branch names. When any rule fails, the workflow stops without touching the config. +2. **Checkout source branches** – the default branch is checked out into `main/`, while the long-lived `x-files/app-upgrade-config` branch lives in `cs/`. All modifications happen in the latter directory. +3. **Install toolchain** – Node.js 22, Corepack, and frozen Yarn dependencies are installed inside `main/`. +4. **Run the update script** – `yarn tsx scripts/update-app-upgrade-config.ts --tag --config ../cs/app-upgrade-config.json --is-prerelease ` updates the JSON in-place. + - The script normalizes the tag (e.g., strips `v` prefix), detects the release channel (`latest`, `rc`, `beta`), and loads segment rules from `config/app-upgrade-segments.json`. + - It validates that prerelease flags and semantic suffixes agree, enforces locked segments, builds mirror feed URLs, and performs release-availability checks (GitHub HEAD request for every channel; GitCode GET for latest channels, falling back to `https://releases.cherry-ai.com` when gitcode is delayed). + - After updating the relevant channel entry, the script rewrites the config with semver-sort order and a new `lastUpdated` timestamp. +5. **Detect changes + create PR** – if `cs/app-upgrade-config.json` changed, the workflow opens a PR `chore/update-app-upgrade-config/` against `x-files/app-upgrade-config` with a commit message `🤖 chore: sync app-upgrade-config for `. Otherwise it logs that no update is required. + +### Manual Trigger Guide + +1. Open the Cherry Studio repository on GitHub → **Actions** tab → select **Update App Upgrade Config**. +2. Click **Run workflow**, choose the default branch (usually `main`), and fill in the `tag` input (e.g., `v2.1.0`). +3. Toggle `is_prerelease` only when the tag carries a prerelease suffix (`-beta`, `-rc`). Leave it unchecked for stable releases. +4. Start the run and wait for it to finish. Check the generated PR in the `x-files/app-upgrade-config` branch, verify the diff in `app-upgrade-config.json`, and merge once validated. + +## JSON Configuration File Format + +### File Location + +- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json` +- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json` + +**Note**: Both mirrors provide the same configuration file hosted on the `x-files/app-upgrade-config` branch. The client automatically selects the optimal mirror based on IP geolocation. + +### Configuration Structure (Current Implementation) + +```json +{ + "lastUpdated": "2025-01-05T00:00:00Z", + "versions": { + "1.6.7": { + "minCompatibleVersion": "1.0.0", + "description": "Last stable v1.7.x release - required intermediate version for users below v1.7", + "channels": { + "latest": { + "version": "1.6.7", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7" + } + }, + "rc": { + "version": "1.6.0-rc.5", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5" + } + }, + "beta": { + "version": "1.6.7-beta.3", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3" + } + } + } + }, + "2.0.0": { + "minCompatibleVersion": "1.7.0", + "description": "Major release v2.0 - required intermediate version for v2.x upgrades", + "channels": { + "latest": null, + "rc": null, + "beta": null + } + } + } +} +``` + +### Future Extension Example + +When releasing v3.0, if users need to first upgrade to v2.8, you can add: + +```json +{ + "2.8.0": { + "minCompatibleVersion": "2.0.0", + "description": "Stable v2.8 - required for v3 upgrade", + "channels": { + "latest": { + "version": "2.8.0", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0" + } + }, + "rc": null, + "beta": null + } + }, + "3.0.0": { + "minCompatibleVersion": "2.8.0", + "description": "Major release v3.0", + "channels": { + "latest": { + "version": "3.0.0", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/latest", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest" + } + }, + "rc": { + "version": "3.0.0-rc.1", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1" + } + }, + "beta": null + } + } +} +``` + +### Field Descriptions + +- `lastUpdated`: Last update time of the configuration file (ISO 8601 format) +- `versions`: Version configuration object, key is the version number, sorted by semantic versioning + - `minCompatibleVersion`: Minimum compatible version that can upgrade to this version + - `description`: Version description + - `channels`: Update channel configuration + - `latest`: Stable release channel + - `rc`: Release Candidate channel + - `beta`: Beta testing channel + - Each channel contains: + - `version`: Version number for this channel + - `feedUrls`: Multi-mirror URL configuration + - `github`: electron-updater feed URL for GitHub mirror + - `gitcode`: electron-updater feed URL for GitCode mirror + - `metadata`: Stable mapping info for automation + - `segmentId`: ID from `config/app-upgrade-segments.json` + - `segmentType`: Optional flag (`legacy` | `breaking` | `latest`) for documentation/debugging + +## TypeScript Type Definitions + +```typescript +// Mirror enum +enum UpdateMirror { + GITHUB = 'github', + GITCODE = 'gitcode' +} + +interface UpdateConfig { + lastUpdated: string + versions: { + [versionKey: string]: VersionConfig + } +} + +interface VersionConfig { + minCompatibleVersion: string + description: string + channels: { + latest: ChannelConfig | null + rc: ChannelConfig | null + beta: ChannelConfig | null + } + metadata?: { + segmentId: string + segmentType?: 'legacy' | 'breaking' | 'latest' + } +} + +interface ChannelConfig { + version: string + feedUrls: Record + // Equivalent to: + // feedUrls: { + // github: string + // gitcode: string + // } +} +``` + +## Segment Metadata & Breaking Markers + +- **Segment definitions** now live in `config/app-upgrade-segments.json`. Each segment describes a semantic-version range (or exact matches) plus metadata such as `segmentId`, `segmentType`, `minCompatibleVersion`, and per-channel feed URL templates. +- Each entry under `versions` carries a `metadata.segmentId`. This acts as the stable key that scripts use to decide which slot to update, even if the actual semantic version string changes. +- Mark major upgrade gateways (e.g., `2.0.0`) by giving the related segment a `segmentType: "breaking"` and (optionally) `lockedVersion`. This prevents automation from accidentally moving that entry when other 2.x builds ship. +- Adding another breaking hop (e.g., `3.0.0`) only requires defining a new segment in the JSON file; the automation will pick it up on the next run. + +## Automation Workflow + +Starting from this change, `.github/workflows/update-app-upgrade-config.yml` listens to GitHub release events (published + prerelease). The workflow: + +1. Checks out the default branch (for scripts) and the `x-files/app-upgrade-config` branch (where the config is hosted). +2. Runs `yarn tsx scripts/update-app-upgrade-config.ts --tag --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `x-files/app-upgrade-config` working tree. +3. If the file changed, it opens a PR against `x-files/app-upgrade-config` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`. + +You can run the same script locally via `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages aren’t published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes. + +## Version Matching Logic + +### Algorithm Flow + +1. Get user's current version (`currentVersion`) and requested channel (`requestedChannel`) +2. Get all version numbers from configuration file, sort in descending order by semantic versioning +3. Iterate through the sorted version list: + - Check if `currentVersion >= minCompatibleVersion` + - Check if the requested `channel` exists and is not `null` + - If conditions are met, return the channel configuration +4. If no matching version is found, return `null` + +### Pseudocode Implementation + +```typescript +function findCompatibleVersion( + currentVersion: string, + requestedChannel: UpgradeChannel, + config: UpdateConfig +): ChannelConfig | null { + // Get all version numbers and sort in descending order + const versions = Object.keys(config.versions).sort(semver.rcompare) + + for (const versionKey of versions) { + const versionConfig = config.versions[versionKey] + const channelConfig = versionConfig.channels[requestedChannel] + + // Check version compatibility and channel availability + if ( + semver.gte(currentVersion, versionConfig.minCompatibleVersion) && + channelConfig !== null + ) { + return channelConfig + } + } + + return null // No compatible version found +} +``` + +## Upgrade Path Examples + +### Scenario 1: v1.6.5 User Upgrade (Below 1.7) + +- **Current Version**: 1.6.5 +- **Requested Channel**: latest +- **Match Result**: 1.7.0 +- **Reason**: 1.6.5 >= 0.0.0 (satisfies 1.7.0's minCompatibleVersion), but doesn't satisfy 2.0.0's minCompatibleVersion (1.7.0) +- **Action**: Prompt user to upgrade to 1.7.0, which is the required intermediate version for v2.x upgrade + +### Scenario 2: v1.6.5 User Requests rc/beta + +- **Current Version**: 1.6.5 +- **Requested Channel**: rc or beta +- **Match Result**: 1.7.0 (latest) +- **Reason**: 1.7.0 version doesn't provide rc/beta channels (values are null) +- **Action**: Upgrade to 1.7.0 stable version + +### Scenario 3: v1.7.0 User Upgrades to Latest + +- **Current Version**: 1.7.0 +- **Requested Channel**: latest +- **Match Result**: 2.0.0 +- **Reason**: 1.7.0 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion) +- **Action**: Directly upgrade to 2.0.0 (current latest stable version) + +### Scenario 4: v1.7.2 User Upgrades to RC Version + +- **Current Version**: 1.7.2 +- **Requested Channel**: rc +- **Match Result**: 2.0.0-rc.1 +- **Reason**: 1.7.2 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion), and rc channel exists +- **Action**: Upgrade to 2.0.0-rc.1 + +### Scenario 5: v1.7.0 User Upgrades to Beta Version + +- **Current Version**: 1.7.0 +- **Requested Channel**: beta +- **Match Result**: 2.0.0-beta.1 +- **Reason**: 1.7.0 >= 1.7.0, and beta channel exists +- **Action**: Upgrade to 2.0.0-beta.1 + +### Scenario 6: v2.5.0 User Upgrade (Future) + +Assuming v2.8.0 and v3.0.0 configurations have been added: +- **Current Version**: 2.5.0 +- **Requested Channel**: latest +- **Match Result**: 2.8.0 +- **Reason**: 2.5.0 >= 2.0.0 (satisfies 2.8.0's minCompatibleVersion), but doesn't satisfy 3.0.0's requirement +- **Action**: Prompt user to upgrade to 2.8.0, which is the required intermediate version for v3.x upgrade + +## Code Changes + +### Main Modifications + +1. **New Methods** + - `_fetchUpdateConfig(ipCountry: string): Promise` - Fetch configuration file based on IP + - `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - Find compatible channel configuration + +2. **Modified Methods** + - `_getReleaseVersionFromGithub()` → Remove or refactor to `_getChannelFeedUrl()` + - `_setFeedUrl()` - Use new configuration system to replace existing logic + +3. **New Type Definitions** + - `UpdateConfig` + - `VersionConfig` + - `ChannelConfig` + +### Mirror Selection Logic + +The client automatically selects the optimal mirror based on IP geolocation: + +```typescript +private async _setFeedUrl() { + const currentVersion = app.getVersion() + const testPlan = configManager.getTestPlan() + const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST + + // Determine mirror based on IP country + const ipCountry = await getIpCountry() + const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github' + + // Fetch update config + const config = await this._fetchUpdateConfig(mirror) + + if (config) { + const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config) + if (channelConfig) { + // Select feed URL from the corresponding mirror + const feedUrl = channelConfig.feedUrls[mirror] + this._setChannel(requestedChannel, feedUrl) + return + } + } + + // Fallback logic + const defaultFeedUrl = mirror === 'gitcode' + ? FeedUrl.PRODUCTION + : FeedUrl.GITHUB_LATEST + this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl) +} + +private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise { + const configUrl = mirror === 'gitcode' + ? UpdateConfigUrl.GITCODE + : UpdateConfigUrl.GITHUB + + try { + const response = await net.fetch(configUrl, { + headers: { + 'User-Agent': generateUserAgent(), + 'Accept': 'application/json', + 'X-Client-Id': configManager.getClientId() + } + }) + return await response.json() as UpdateConfig + } catch (error) { + logger.error('Failed to fetch update config:', error) + return null + } +} +``` + +## Fallback and Error Handling Strategy + +1. **Configuration file fetch failure**: Log error, return current version, don't offer updates +2. **No matching version**: Notify user that current version doesn't support automatic upgrade +3. **Network exception**: Cache last successfully fetched configuration (optional) + +## GitHub Release Requirements + +To support intermediate version upgrades, the following files need to be retained: + +- **v1.7.0 release** and its latest*.yml files (as upgrade target for users below v1.7) +- Future intermediate versions (e.g., v2.8.0) need to retain corresponding release and latest*.yml files +- Complete installation packages for each version + +### Currently Required Releases + +| Version | Purpose | Must Retain | +|---------|---------|-------------| +| v1.7.0 | Upgrade target for users below 1.7 | ✅ Yes | +| v2.0.0-rc.1 | RC testing channel | ❌ Optional | +| v2.0.0-beta.1 | Beta testing channel | ❌ Optional | +| latest | Latest stable version (automatic) | ✅ Yes | + +## Advantages + +1. **Flexibility**: Supports arbitrarily complex upgrade paths +2. **Extensibility**: Adding new versions only requires adding new entries to the configuration file +3. **Maintainability**: Configuration is separated from code, allowing upgrade strategy adjustments without releasing new versions +4. **Multi-source support**: Automatically selects optimal configuration source based on geolocation +5. **Version control**: Enforces intermediate version upgrades, ensuring data migration and compatibility + +## Future Extensions + +- Support more granular version range control (e.g., `>=1.5.0 <1.8.0`) +- Support multi-step upgrade path hints (e.g., notify user needs 1.5 → 1.8 → 2.0) +- Support A/B testing and gradual rollout +- Support local caching and expiration strategy for configuration files diff --git a/docs/technical/app-upgrade-config-zh.md b/docs/technical/app-upgrade-config-zh.md new file mode 100644 index 0000000000..29f9f75d79 --- /dev/null +++ b/docs/technical/app-upgrade-config-zh.md @@ -0,0 +1,430 @@ +# 更新配置系统设计文档 + +## 背景 + +当前 AppUpdater 直接请求 GitHub API 获取 beta 和 rc 的更新信息。为了支持国内用户,需要根据 IP 地理位置,分别从 GitHub/GitCode 获取一个固定的 JSON 配置文件,该文件包含所有渠道的更新地址。 + +## 设计目标 + +1. 支持根据 IP 地理位置选择不同的配置源(GitHub/GitCode) +2. 支持版本兼容性控制(如 v1.x 以下必须先升级到 v1.7.0 才能升级到 v2.0) +3. 易于扩展,支持未来多个主版本的升级路径(v1.6 → v1.7 → v2.0 → v2.8 → v3.0) +4. 保持与现有 electron-updater 机制的兼容性 + +## 当前版本策略 + +- **v1.7.x** 是 1.x 系列的最后版本 +- **v1.7.0 以下**的用户必须先升级到 v1.7.0(或更高的 1.7.x 版本) +- **v1.7.0 及以上**的用户可以直接升级到 v2.x.x + +## 自动化工作流 + +`x-files/app-upgrade-config/app-upgrade-config.json` 由 [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow 自动同步。工作流会调用 [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) 脚本,根据指定 tag 更新 `x-files/app-upgrade-config` 分支上的配置文件。 + +### 触发条件 + +- **Release 事件(`release: released/prereleased`)** + - Draft release 会被忽略。 + - 当 GitHub 将 release 标记为 *prerelease* 时,tag 必须包含 `-beta`/`-rc`(可带序号),否则直接跳过。 + - 当 release 标记为稳定版时,tag 必须与 GitHub API 返回的最新稳定版本一致,防止发布历史 tag 时意外挂起工作流。 + - 满足上述条件后,工作流会根据语义化版本判断渠道(`latest`/`beta`/`rc`),并通过 `IS_PRERELEASE` 传递给脚本。 +- **手动触发(`workflow_dispatch`)** + - 必填:`tag`(例:`v2.0.1`);选填:`is_prerelease`(默认 `false`)。 + - 当 `is_prerelease=true` 时,同样要求 tag 带有 beta/rc 后缀。 + - 手动运行仍会请求 GitHub 最新 release 信息,用于在 PR 说明中标注该 tag 是否是最新稳定版。 + +### 工作流步骤 + +1. **检查与元数据准备**:`Check if should proceed` 和 `Prepare metadata` 步骤会计算 tag、prerelease 标志、是否最新版本以及用于分支名的 `safe_tag`。若任意校验失败,工作流立即退出。 +2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `cs/` 中,所有改动都发生在 `cs/`。 +3. **安装工具链**:安装 Node.js 22、启用 Corepack,并在 `main/` 目录执行 `yarn install --immutable`。 +4. **运行更新脚本**:执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag --config ../cs/app-upgrade-config.json --is-prerelease `。 + - 脚本会标准化 tag(去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。 + - 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用(latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。 + - 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON,并刷新 `lastUpdated`。 +5. **检测变更并创建 PR**:若 `cs/app-upgrade-config.json` 有变更,则创建 `chore/update-app-upgrade-config/` 分支,提交信息为 `🤖 chore: sync app-upgrade-config for `,并向 `x-files/app-upgrade-config` 提 PR;无变更则输出提示。 + +### 手动触发指南 + +1. 进入 Cherry Studio 仓库的 GitHub **Actions** 页面,选择 **Update App Upgrade Config** 工作流。 +2. 点击 **Run workflow**,保持默认分支(通常为 `main`),填写 `tag`(如 `v2.1.0`)。 +3. 只有在 tag 带 `-beta`/`-rc` 后缀时才勾选 `is_prerelease`,稳定版保持默认。 +4. 启动运行并等待完成,随后到 `x-files/app-upgrade-config` 分支的 PR 查看 `app-upgrade-config.json` 的变更并在验证后合并。 + +## JSON 配置文件格式 + +### 文件位置 + +- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json` +- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json` + +**说明**:两个镜像源提供相同的配置文件,统一托管在 `x-files/app-upgrade-config` 分支上。客户端根据 IP 地理位置自动选择最优镜像源。 + +### 配置结构(当前实际配置) + +```json +{ + "lastUpdated": "2025-01-05T00:00:00Z", + "versions": { + "1.6.7": { + "minCompatibleVersion": "1.0.0", + "description": "Last stable v1.7.x release - required intermediate version for users below v1.7", + "channels": { + "latest": { + "version": "1.6.7", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7" + } + }, + "rc": { + "version": "1.6.0-rc.5", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5" + } + }, + "beta": { + "version": "1.6.7-beta.3", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3", + "gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3" + } + } + } + }, + "2.0.0": { + "minCompatibleVersion": "1.7.0", + "description": "Major release v2.0 - required intermediate version for v2.x upgrades", + "channels": { + "latest": null, + "rc": null, + "beta": null + } + } + } +} +``` + +### 未来扩展示例 + +当需要发布 v3.0 时,如果需要强制用户先升级到 v2.8,可以添加: + +```json +{ + "2.8.0": { + "minCompatibleVersion": "2.0.0", + "description": "Stable v2.8 - required for v3 upgrade", + "channels": { + "latest": { + "version": "2.8.0", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0" + } + }, + "rc": null, + "beta": null + } + }, + "3.0.0": { + "minCompatibleVersion": "2.8.0", + "description": "Major release v3.0", + "channels": { + "latest": { + "version": "3.0.0", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/latest", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest" + } + }, + "rc": { + "version": "3.0.0-rc.1", + "feedUrls": { + "github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1", + "gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1" + } + }, + "beta": null + } + } +} +``` + +### 字段说明 + +- `lastUpdated`: 配置文件最后更新时间(ISO 8601 格式) +- `versions`: 版本配置对象,key 为版本号,按语义化版本排序 + - `minCompatibleVersion`: 可以升级到此版本的最低兼容版本 + - `description`: 版本描述 + - `channels`: 更新渠道配置 + - `latest`: 稳定版渠道 + - `rc`: Release Candidate 渠道 + - `beta`: Beta 测试渠道 + - 每个渠道包含: + - `version`: 该渠道的版本号 + - `feedUrls`: 多镜像源 URL 配置 + - `github`: GitHub 镜像源的 electron-updater feed URL + - `gitcode`: GitCode 镜像源的 electron-updater feed URL + - `metadata`: 自动化匹配所需的稳定标识 + - `segmentId`: 来自 `config/app-upgrade-segments.json` 的段位 ID + - `segmentType`: 可选字段(`legacy` | `breaking` | `latest`),便于文档/调试 + +## TypeScript 类型定义 + +```typescript +// 镜像源枚举 +enum UpdateMirror { + GITHUB = 'github', + GITCODE = 'gitcode' +} + +interface UpdateConfig { + lastUpdated: string + versions: { + [versionKey: string]: VersionConfig + } +} + +interface VersionConfig { + minCompatibleVersion: string + description: string + channels: { + latest: ChannelConfig | null + rc: ChannelConfig | null + beta: ChannelConfig | null + } + metadata?: { + segmentId: string + segmentType?: 'legacy' | 'breaking' | 'latest' + } +} + +interface ChannelConfig { + version: string + feedUrls: Record + // 等同于: + // feedUrls: { + // github: string + // gitcode: string + // } +} +``` + +## 段位元数据(Break Change 标记) + +- 所有段位定义(如 `legacy-v1`、`gateway-v2` 等)集中在 `config/app-upgrade-segments.json`,用于描述匹配范围、`segmentId`、`segmentType`、默认 `minCompatibleVersion/description` 以及各渠道的 URL 模板。 +- `versions` 下的每个节点都会带上 `metadata.segmentId`。自动脚本始终依据该 ID 来定位并更新条目,即便 key 从 `2.1.5` 切换到 `2.1.6` 也不会错位。 +- 如果某段需要锁死在特定版本(例如 `2.0.0` 的 break change),可在段定义中设置 `segmentType: "breaking"` 并提供 `lockedVersion`,脚本在遇到不匹配的 tag 时会短路报错,保证升级路径安全。 +- 面对未来新的断层(例如 `3.0.0`),只需要在段定义里新增一段,自动化即可识别并更新。 + +## 自动化工作流 + +`.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release(包含正常发布与 Pre Release)触发: + +1. 同时 Checkout 仓库默认分支(用于脚本)和 `x-files/app-upgrade-config` 分支(真实托管配置的分支)。 +2. 在默认分支目录执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。 +3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PR,Diff 仅包含该文件。 + +如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。 + +## 版本匹配逻辑 + +### 算法流程 + +1. 获取用户当前版本(`currentVersion`)和请求的渠道(`requestedChannel`) +2. 获取配置文件中所有版本号,按语义化版本从大到小排序 +3. 遍历排序后的版本列表: + - 检查 `currentVersion >= minCompatibleVersion` + - 检查请求的 `channel` 是否存在且不为 `null` + - 如果满足条件,返回该渠道配置 +4. 如果没有找到匹配版本,返回 `null` + +### 伪代码实现 + +```typescript +function findCompatibleVersion( + currentVersion: string, + requestedChannel: UpgradeChannel, + config: UpdateConfig +): ChannelConfig | null { + // 获取所有版本号并从大到小排序 + const versions = Object.keys(config.versions).sort(semver.rcompare) + + for (const versionKey of versions) { + const versionConfig = config.versions[versionKey] + const channelConfig = versionConfig.channels[requestedChannel] + + // 检查版本兼容性和渠道可用性 + if ( + semver.gte(currentVersion, versionConfig.minCompatibleVersion) && + channelConfig !== null + ) { + return channelConfig + } + } + + return null // 没有找到兼容版本 +} +``` + +## 升级路径示例 + +### 场景 1: v1.6.5 用户升级(低于 1.7) + +- **当前版本**: 1.6.5 +- **请求渠道**: latest +- **匹配结果**: 1.7.0 +- **原因**: 1.6.5 >= 0.0.0(满足 1.7.0 的 minCompatibleVersion),但不满足 2.0.0 的 minCompatibleVersion (1.7.0) +- **操作**: 提示用户升级到 1.7.0,这是升级到 v2.x 的必要中间版本 + +### 场景 2: v1.6.5 用户请求 rc/beta + +- **当前版本**: 1.6.5 +- **请求渠道**: rc 或 beta +- **匹配结果**: 1.7.0 (latest) +- **原因**: 1.7.0 版本不提供 rc/beta 渠道(值为 null) +- **操作**: 升级到 1.7.0 稳定版 + +### 场景 3: v1.7.0 用户升级到最新版 + +- **当前版本**: 1.7.0 +- **请求渠道**: latest +- **匹配结果**: 2.0.0 +- **原因**: 1.7.0 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion) +- **操作**: 直接升级到 2.0.0(当前最新稳定版) + +### 场景 4: v1.7.2 用户升级到 RC 版本 + +- **当前版本**: 1.7.2 +- **请求渠道**: rc +- **匹配结果**: 2.0.0-rc.1 +- **原因**: 1.7.2 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion),且 rc 渠道存在 +- **操作**: 升级到 2.0.0-rc.1 + +### 场景 5: v1.7.0 用户升级到 Beta 版本 + +- **当前版本**: 1.7.0 +- **请求渠道**: beta +- **匹配结果**: 2.0.0-beta.1 +- **原因**: 1.7.0 >= 1.7.0,且 beta 渠道存在 +- **操作**: 升级到 2.0.0-beta.1 + +### 场景 6: v2.5.0 用户升级(未来) + +假设已添加 v2.8.0 和 v3.0.0 配置: +- **当前版本**: 2.5.0 +- **请求渠道**: latest +- **匹配结果**: 2.8.0 +- **原因**: 2.5.0 >= 2.0.0(满足 2.8.0 的 minCompatibleVersion),但不满足 3.0.0 的要求 +- **操作**: 提示用户升级到 2.8.0,这是升级到 v3.x 的必要中间版本 + +## 代码改动计划 + +### 主要修改 + +1. **新增方法** + - `_fetchUpdateConfig(ipCountry: string): Promise` - 根据 IP 获取配置文件 + - `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - 查找兼容的渠道配置 + +2. **修改方法** + - `_getReleaseVersionFromGithub()` → 移除或重构为 `_getChannelFeedUrl()` + - `_setFeedUrl()` - 使用新的配置系统替代现有逻辑 + +3. **新增类型定义** + - `UpdateConfig` + - `VersionConfig` + - `ChannelConfig` + +### 镜像源选择逻辑 + +客户端根据 IP 地理位置自动选择最优镜像源: + +```typescript +private async _setFeedUrl() { + const currentVersion = app.getVersion() + const testPlan = configManager.getTestPlan() + const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST + + // 根据 IP 国家确定镜像源 + const ipCountry = await getIpCountry() + const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github' + + // 获取更新配置 + const config = await this._fetchUpdateConfig(mirror) + + if (config) { + const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config) + if (channelConfig) { + // 从配置中选择对应镜像源的 URL + const feedUrl = channelConfig.feedUrls[mirror] + this._setChannel(requestedChannel, feedUrl) + return + } + } + + // Fallback 逻辑 + const defaultFeedUrl = mirror === 'gitcode' + ? FeedUrl.PRODUCTION + : FeedUrl.GITHUB_LATEST + this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl) +} + +private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise { + const configUrl = mirror === 'gitcode' + ? UpdateConfigUrl.GITCODE + : UpdateConfigUrl.GITHUB + + try { + const response = await net.fetch(configUrl, { + headers: { + 'User-Agent': generateUserAgent(), + 'Accept': 'application/json', + 'X-Client-Id': configManager.getClientId() + } + }) + return await response.json() as UpdateConfig + } catch (error) { + logger.error('Failed to fetch update config:', error) + return null + } +} +``` + +## 降级和容错策略 + +1. **配置文件获取失败**: 记录错误日志,返回当前版本,不提供更新 +2. **没有匹配的版本**: 提示用户当前版本不支持自动升级 +3. **网络异常**: 缓存上次成功获取的配置(可选) + +## GitHub Release 要求 + +为支持中间版本升级,需要保留以下文件: + +- **v1.7.0 release** 及其 latest*.yml 文件(作为 v1.7 以下用户的升级目标) +- 未来如需强制中间版本(如 v2.8.0),需要保留对应的 release 和 latest*.yml 文件 +- 各版本的完整安装包 + +### 当前需要的 Release + +| 版本 | 用途 | 必须保留 | +|------|------|---------| +| v1.7.0 | 1.7 以下用户的升级目标 | ✅ 是 | +| v2.0.0-rc.1 | RC 测试渠道 | ❌ 可选 | +| v2.0.0-beta.1 | Beta 测试渠道 | ❌ 可选 | +| latest | 最新稳定版(自动) | ✅ 是 | + +## 优势 + +1. **灵活性**: 支持任意复杂的升级路径 +2. **可扩展性**: 新增版本只需在配置文件中添加新条目 +3. **可维护性**: 配置与代码分离,无需发版即可调整升级策略 +4. **多源支持**: 自动根据地理位置选择最优配置源 +5. **版本控制**: 强制中间版本升级,确保数据迁移和兼容性 + +## 未来扩展 + +- 支持更细粒度的版本范围控制(如 `>=1.5.0 <1.8.0`) +- 支持多步升级路径提示(如提示用户需要 1.5 → 1.8 → 2.0) +- 支持 A/B 测试和灰度发布 +- 支持配置文件的本地缓存和过期策略 diff --git a/docs/testplan-en.md b/docs/testplan-en.md index 0f7cd41473..fad894f22b 100644 --- a/docs/testplan-en.md +++ b/docs/testplan-en.md @@ -11,6 +11,8 @@ The Test Plan is divided into the RC channel and the Beta channel, with the foll Users can enable the "Test Plan" and select the version channel in the software's `Settings` > `About`. Please note that the versions in the "Test Plan" cannot guarantee data consistency, so be sure to back up your data before using them. +After enabling the RC channel or Beta channel, if a stable version is released, users will still be upgraded to the stable version. + Users are welcome to submit issues or provide feedback through other channels for any bugs encountered during testing. Your feedback is very important to us. ## Developer Guide diff --git a/docs/testplan-zh.md b/docs/testplan-zh.md index ed4913d4a4..77d25981de 100644 --- a/docs/testplan-zh.md +++ b/docs/testplan-zh.md @@ -11,6 +11,8 @@ 用户可以在软件的`设置`-`关于`中,开启“测试计划”并选择版本通道。请注意“测试计划”的版本无法保证数据的一致性,请使用前一定要备份数据。 +用户选择RC版通道或Beta版通道后,若发布了正式版,仍旧会升级到正式版。 + 用户在测试过程中发现的BUG,欢迎提交issue或通过其他渠道反馈。用户的反馈对我们非常重要。 ## 开发者指南 diff --git a/electron-builder.yml b/electron-builder.yml index 27fc176c5d..cdbf21408e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -21,6 +21,8 @@ files: - "**/*" - "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}" - "!electron.vite.config.{js,ts,mjs,cjs}}" + - "!.*" + - "!components.json" - "!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md,biome.jsonc}" - "!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}" - "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}" @@ -64,6 +66,13 @@ asarUnpack: - resources/** - "**/*.{metal,exp,lib}" - "node_modules/@img/sharp-libvips-*/**" +extraResources: + - from: "migrations/sqlite-drizzle" + to: "migrations/sqlite-drizzle" + # copy from node_modules/claude-code-plugins/plugins to resources/data/claude-code-pluginso + - from: "./node_modules/claude-code-plugins/plugins/" + to: "claude-code-plugins" + win: executableName: Cherry Studio artifactName: ${productName}-${version}-${arch}-setup.${ext} @@ -89,7 +98,6 @@ mac: entitlementsInherit: build/entitlements.mac.plist notarize: false artifactName: ${productName}-${version}-${arch}.${ext} - 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. @@ -127,60 +135,58 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-beta.2 + What's New in v1.7.0-rc.1 - New Features: - - Session Settings: Manage session-specific settings and model configurations independently - - Notes Full-Text Search: Search across all notes with match highlighting - - Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only) - - Intel OV OCR: Hardware-accelerated OCR using Intel NPU - - Auto-start API Server: Automatically starts when agents exist + 🎉 MAJOR NEW FEATURE: AI Agents + - Create and manage custom AI agents with specialized tools and permissions + - Dedicated agent sessions with persistent SQLite storage, separate from regular chats + - Real-time tool approval system - review and approve agent actions dynamically + - MCP (Model Context Protocol) integration for connecting external tools + - Slash commands support for quick agent interactions + - OpenAI-compatible REST API for agent access - Improvements: - - Agent model selection now requires explicit user choice - - Added Mistral AI provider support - - Added NewAPI generic provider support - - Improved navbar layout consistency across different modes - - Enhanced chat component responsiveness - - Better code block display on small screens - - Updated OVMS to 2025.3 official release - - Added Greek language support + ✨ New Features: + - AI Providers: Added support for Hugging Face, Mistral, Perplexity, and SophNet + - Knowledge Base: OpenMinerU document preprocessor, full-text search in notes, enhanced tool selection + - Image & OCR: Intel OVMS painting provider and Intel OpenVINO (NPU) OCR support + - MCP Management: Redesigned interface with dual-column layout for easier management + - Languages: Added German language support - Bug Fixes: - - Fixed GitHub Copilot gpt-5-codex streaming issues - - Fixed assistant creation failures - - Fixed translate auto-copy functionality - - Fixed miniapps external link opening - - Fixed message layout and overflow issues - - Fixed API key parsing to preserve spaces - - Fixed agent display in different navbar layouts + ⚡ Improvements: + - Upgraded to Electron 38.7.0 + - Enhanced system shutdown handling and automatic update checks + - Improved proxy bypass rules + + 🐛 Important Bug Fixes: + - Fixed streaming response issues across multiple AI providers + - Fixed session list scrolling problems + - Fixed knowledge base deletion errors - v1.7.0-beta.2 新特性 + v1.7.0-rc.1 新特性 - 新功能: - - 会话设置:独立管理会话特定的设置和模型配置 - - 笔记全文搜索:跨所有笔记搜索并高亮匹配内容 - - 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区) - - Intel OV OCR:使用 Intel NPU 的硬件加速 OCR - - 自动启动 API 服务器:当存在 Agent 时自动启动 + 🎉 重大更新:AI Agent 智能体系统 + - 创建和管理专属 AI Agent,配置专用工具和权限 + - 独立的 Agent 会话,使用 SQLite 持久化存储,与普通聊天分离 + - 实时工具审批系统 - 动态审查和批准 Agent 操作 + - MCP(模型上下文协议)集成,连接外部工具 + - 支持斜杠命令快速交互 + - 兼容 OpenAI 的 REST API 访问 - 改进: - - Agent 模型选择现在需要用户显式选择 - - 添加 Mistral AI 提供商支持 - - 添加 NewAPI 通用提供商支持 - - 改进不同模式下的导航栏布局一致性 - - 增强聊天组件响应式设计 - - 优化小屏幕代码块显示 - - 更新 OVMS 至 2025.3 正式版 - - 添加希腊语支持 + ✨ 新功能: + - AI 提供商:新增 Hugging Face、Mistral、Perplexity 和 SophNet 支持 + - 知识库:OpenMinerU 文档预处理器、笔记全文搜索、增强的工具选择 + - 图像与 OCR:Intel OVMS 绘图提供商和 Intel OpenVINO (NPU) OCR 支持 + - MCP 管理:重构管理界面,采用双列布局,更加方便管理 + - 语言:新增德语支持 - 问题修复: - - 修复 GitHub Copilot gpt-5-codex 流式传输问题 - - 修复助手创建失败 - - 修复翻译自动复制功能 - - 修复小程序外部链接打开 - - 修复消息布局和溢出问题 - - 修复 API 密钥解析以保留空格 - - 修复不同导航栏布局中的 Agent 显示 + ⚡ 改进: + - 升级到 Electron 38.7.0 + - 增强的系统关机处理和自动更新检查 + - 改进的代理绕过规则 + + 🐛 重要修复: + - 修复多个 AI 提供商的流式响应问题 + - 修复会话列表滚动问题 + - 修复知识库删除错误 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b4914539c7..441c0c1ffc 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ alias: { '@main': resolve('src/main'), '@types': resolve('src/renderer/src/types'), + '@data': resolve('src/main/data'), '@shared': resolve('packages/shared'), '@logger': resolve('src/main/services/LoggerService'), '@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'), @@ -61,7 +62,20 @@ export default defineConfig({ } }, build: { - sourcemap: isDev + sourcemap: isDev, + rollupOptions: { + // Unlike renderer which auto-discovers entries from HTML files, + // preload requires explicit entry point configuration for multiple scripts + input: { + index: resolve(__dirname, 'src/preload/index.ts'), + simplest: resolve(__dirname, 'src/preload/simplest.ts') // Minimal preload + }, + external: ['electron'], + output: { + entryFileNames: '[name].js', + format: 'cjs' + } + } } }, renderer: { @@ -90,12 +104,16 @@ export default defineConfig({ '@shared': resolve('packages/shared'), '@types': resolve('src/renderer/src/types'), '@logger': resolve('src/renderer/src/services/LoggerService'), + '@data': resolve('src/renderer/src/data'), '@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'), '@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'), '@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'), '@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'), '@cherrystudio/ai-core': resolve('packages/aiCore/src'), - '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src') + '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'), + '@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'), + '@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'), + '@cherrystudio/ui': resolve('packages/ui/src') } }, optimizeDeps: { @@ -115,7 +133,8 @@ export default defineConfig({ miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'), selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'), - traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html') + traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'), + dataRefactorMigrate: resolve(__dirname, 'src/renderer/dataRefactorMigrate.html') }, onwarn(warning, warn) { if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return diff --git a/eslint.config.mjs b/eslint.config.mjs index fcc952ed65..7a443ed688 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -72,8 +72,9 @@ export default defineConfig([ ...oxlint.configs['flat/eslint'], ...oxlint.configs['flat/typescript'], ...oxlint.configs['flat/unicorn'], + // Custom rules should be after oxlint to overwrite + // LoggerService Custom Rules - only apply to src directory { - // LoggerService Custom Rules - only apply to src directory files: ['src/**/*.{ts,tsx,js,jsx}'], ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'], rules: { @@ -87,6 +88,7 @@ export default defineConfig([ ] } }, + // i18n { files: ['**/*.{ts,tsx,js,jsx}'], languageOptions: { @@ -134,4 +136,25 @@ export default defineConfig([ 'i18n/no-template-in-t': 'warn' } }, + // ui migration + { + // Component Rules - prevent importing antd components when migration completed + files: ['**/*.{ts,tsx,js,jsx}'], + ignores: ['src/renderer/src/windows/dataRefactorTest/**/*.{ts,tsx}'], + rules: { + // 'no-restricted-imports': [ + // 'error', + // { + // paths: [ + // { + // name: 'antd', + // importNames: ['Flex', 'Switch', 'message', 'Button', 'Tooltip'], + // message: + // '❌ Do not import this component from antd. Use our custom components instead: import { ... } from "@cherrystudio/ui"' + // } + // ] + // } + // ] + } + }, ]) diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000000..fc11adc188 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,6 @@ +**THIS DIRECTORY IS NOT FOR RUNTIME USE** + +- Using `libsql` as the `sqlite3` driver, and `drizzle` as the ORM and database migration tool +- `migrations/sqlite-drizzle` contains auto-generated migration data. Please **DO NOT** modify it. +- If table structure changes, we should run migrations. +- To generate migrations, use the command `yarn run migrations:generate` diff --git a/migrations/sqlite-drizzle.config.ts b/migrations/sqlite-drizzle.config.ts new file mode 100644 index 0000000000..08ea6caccf --- /dev/null +++ b/migrations/sqlite-drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit' +export default defineConfig({ + out: './migrations/sqlite-drizzle', + schema: './src/main/data/db/schemas/*', + dialect: 'sqlite', + casing: 'snake_case' +}) diff --git a/migrations/sqlite-drizzle/0000_solid_lord_hawal.sql b/migrations/sqlite-drizzle/0000_solid_lord_hawal.sql new file mode 100644 index 0000000000..9e52692966 --- /dev/null +++ b/migrations/sqlite-drizzle/0000_solid_lord_hawal.sql @@ -0,0 +1,17 @@ +CREATE TABLE `app_state` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `description` text, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE TABLE `preference` ( + `scope` text NOT NULL, + `key` text NOT NULL, + `value` text, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE INDEX `scope_name_idx` ON `preference` (`scope`,`key`); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0000_snapshot.json b/migrations/sqlite-drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000000..51c5ed6cba --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0000_snapshot.json @@ -0,0 +1,114 @@ +{ + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "dialect": "sqlite", + "enums": {}, + "id": "de8009d7-95b9-4f99-99fa-4b8795708f21", + "internal": { + "indexes": {} + }, + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "app_state": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "description": { + "autoincrement": false, + "name": "description", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": true, + "primaryKey": false, + "type": "text" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": {}, + "name": "app_state", + "uniqueConstraints": {} + }, + "preference": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "scope": { + "autoincrement": false, + "name": "scope", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": false, + "primaryKey": false, + "type": "text" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": { + "scope_name_idx": { + "columns": ["scope", "key"], + "isUnique": false, + "name": "scope_name_idx" + } + }, + "name": "preference", + "uniqueConstraints": {} + } + }, + "version": "6", + "views": {} +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json new file mode 100644 index 0000000000..db2791fd7f --- /dev/null +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "dialect": "sqlite", + "entries": [ + { + "breakpoints": true, + "idx": 0, + "tag": "0000_solid_lord_hawal", + "version": "6", + "when": 1754745234572 + } + ], + "version": "7" +} diff --git a/package.json b/package.json index 71c8b75625..2eea286661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-beta.2", + "version": "2.0.0-alpha", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -50,14 +50,16 @@ "generate:icons": "electron-icon-builder --input=./build/logo.png --output=build", "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": "concurrently -n \"node,web,ui\" -c \"cyan,magenta,green\" \"npm run typecheck:node\" \"npm run typecheck:web\" \"npm run typecheck:ui\"", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", + "typecheck:ui": "cd packages/ui && npm run type-check", "check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts", "sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts", "update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", "auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", "update:languages": "tsx scripts/update-languages.ts", + "update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts", "test": "vitest run --silent", "test:main": "vitest run --project main", "test:renderer": "vitest run --project renderer", @@ -68,30 +70,37 @@ "test:e2e": "yarn playwright test", "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache", "test:scripts": "vitest scripts", - "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n && yarn format:check", + "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && biome lint --write && biome format --write && yarn typecheck && yarn check:i18n && yarn format:check", + "lint:ox": "oxlint --fix && biome lint --write && biome format --write", "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", "claude": "dotenv -e .env -- claude", + "migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts", "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": { - "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch", + "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", + "@paymoapp/electron-shutdown-handler": "^1.1.2", "@strongtz/win32-arm64-msvc": "^0.4.7", "express": "^5.1.0", "font-list": "^2.0.0", "graceful-fs": "^4.2.11", + "gray-matter": "^4.0.3", + "js-yaml": "^4.1.0", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", + "qrcode.react": "^4.2.0", "selection-hook": "^1.0.12", "sharp": "^0.34.3", + "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", @@ -101,17 +110,19 @@ "@agentic/exa": "^7.3.3", "@agentic/searxng": "^7.3.3", "@agentic/tavily": "^7.3.3", - "@ai-sdk/amazon-bedrock": "^3.0.35", - "@ai-sdk/google-vertex": "^3.0.40", - "@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch", - "@ai-sdk/mistral": "^2.0.19", - "@ai-sdk/perplexity": "^2.0.13", + "@ai-sdk/amazon-bedrock": "^3.0.53", + "@ai-sdk/cerebras": "^1.0.31", + "@ai-sdk/gateway": "^2.0.9", + "@ai-sdk/google-vertex": "^3.0.62", + "@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch", + "@ai-sdk/mistral": "^2.0.23", + "@ai-sdk/perplexity": "^2.0.17", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@anthropic-ai/sdk": "^0.41.0", "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch", - "@aws-sdk/client-bedrock": "^3.840.0", - "@aws-sdk/client-bedrock-runtime": "^3.840.0", - "@aws-sdk/client-s3": "^3.840.0", + "@aws-sdk/client-bedrock": "^3.910.0", + "@aws-sdk/client-bedrock-runtime": "^3.910.0", + "@aws-sdk/client-s3": "^3.910.0", "@biomejs/biome": "2.2.4", "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18", "@cherrystudio/embedjs": "^0.1.31", @@ -128,6 +139,7 @@ "@cherrystudio/embedjs-openai": "^0.1.31", "@cherrystudio/extension-table-plus": "workspace:^", "@cherrystudio/openai": "^6.5.0", + "@cherrystudio/ui": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -142,9 +154,9 @@ "@eslint/js": "^9.22.0", "@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", "@hello-pangea/dnd": "^18.0.1", - "@heroui/react": "^2.8.3", - "@kangfenmao/keyv-storage": "^0.1.0", - "@langchain/community": "^0.3.50", + "@langchain/community": "^1.0.0", + "@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", + "@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@mistralai/mistralai": "^1.7.5", "@modelcontextprotocol/sdk": "^1.17.5", "@mozilla/readability": "^0.6.0", @@ -199,6 +211,7 @@ "@types/fs-extra": "^11", "@types/he": "^1", "@types/html-to-text": "^9", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", @@ -228,7 +241,7 @@ "@viz-js/lang-dot": "^1.0.5", "@viz-js/viz": "^3.14.0", "@xyflow/react": "^12.4.4", - "ai": "^5.0.68", + "ai": "^5.0.90", "antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch", "archiver": "^7.0.1", "async-mutex": "^0.5.0", @@ -238,6 +251,7 @@ "check-disk-space": "3.4.0", "cheerio": "^1.1.2", "chokidar": "^4.0.3", + "claude-code-plugins": "1.0.3", "cli-progress": "^3.12.0", "clsx": "^2.1.1", "code-inspector-plugin": "^0.20.14", @@ -253,12 +267,12 @@ "dotenv-cli": "^7.4.2", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.5", - "electron": "38.4.0", - "electron-builder": "26.0.15", + "electron": "38.7.0", + "electron-builder": "26.1.0", "electron-devtools-installer": "^3.2.0", "electron-reload": "^2.0.0-alpha.1", "electron-store": "^8.2.0", - "electron-updater": "6.6.4", + "electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch", "electron-vite": "4.0.1", "electron-window-state": "^5.0.3", "emittery": "^1.0.3", @@ -345,6 +359,7 @@ "striptags": "^3.2.0", "styled-components": "^6.1.11", "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", @@ -370,19 +385,16 @@ "zod": "^4.1.5" }, "resolutions": { + "@smithy/types": "4.7.1", "@codemirror/language": "6.11.3", "@codemirror/lint": "6.8.5", "@codemirror/view": "6.38.1", - "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch", - "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", - "@langchain/openai@npm:>=0.1.0 <0.4.0": "patch:@langchain/openai@npm%3A0.3.16#~/.yarn/patches/@langchain-openai-npm-0.3.16-e525b59526.patch", - "app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch", - "app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch", + "@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", "atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch", "esbuild": "^0.25.0", "file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch", "libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch", - "node-abi": "4.12.0", + "node-abi": "4.24.0", "openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0", "openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0", "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", @@ -391,7 +403,7 @@ "undici": "6.21.2", "vite": "npm:rolldown-vite@7.1.5", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", - "@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch", + "@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch", "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-linux-arm": "0.34.3", @@ -400,6 +412,12 @@ "@img/sharp-win32-x64": "0.34.3", "openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0", "@tiptap/extension-code@npm:^3.9.0": "patch:@tiptap/extension-code@patch%3A@tiptap/extension-code@npm%253A3.9.0%23~/.yarn/patches/@tiptap-extension-code-npm-3.9.0-1fad1fecd8.patch%3A%3Aversion=3.9.0&hash=86c3dd#~/.yarn/patches/@tiptap-extension-code-patch-5e999c6f1b.patch" + "@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", + "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", + "@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", + "@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/packages/ai-sdk-provider/README.md b/packages/ai-sdk-provider/README.md new file mode 100644 index 0000000000..ecd9df2923 --- /dev/null +++ b/packages/ai-sdk-provider/README.md @@ -0,0 +1,39 @@ +# @cherrystudio/ai-sdk-provider + +CherryIN provider bundle for the [Vercel AI SDK](https://ai-sdk.dev/). +It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Anthropic and Gemini model ids to their CherryIN upstream equivalents. + +## Installation + +```bash +npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai +# or +yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai +``` + +> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed. + +## Usage + +```ts +import { createCherryIn, cherryIn } from '@cherrystudio/ai-sdk-provider' + +const cherryInProvider = createCherryIn({ + apiKey: process.env.CHERRYIN_API_KEY, + // optional overrides: + // baseURL: 'https://open.cherryin.net/v1', + // anthropicBaseURL: 'https://open.cherryin.net/anthropic', + // geminiBaseURL: 'https://open.cherryin.net/gemini/v1beta', +}) + +// Chat models will auto-route based on the model id prefix: +const openaiModel = cherryInProvider.chat('gpt-4o-mini') +const anthropicModel = cherryInProvider.chat('claude-3-5-sonnet-latest') +const geminiModel = cherryInProvider.chat('gemini-2.0-pro-exp') + +const { text } = await openaiModel.invoke('Hello CherryIN!') +``` + +The provider also exposes `completion`, `responses`, `embedding`, `image`, `transcription`, and `speech` helpers aligned with the upstream APIs. + +See [AI SDK docs](https://ai-sdk.dev/providers/community-providers/custom-providers) for configuring custom providers. diff --git a/packages/ai-sdk-provider/package.json b/packages/ai-sdk-provider/package.json new file mode 100644 index 0000000000..fd0aac2643 --- /dev/null +++ b/packages/ai-sdk-provider/package.json @@ -0,0 +1,64 @@ +{ + "name": "@cherrystudio/ai-sdk-provider", + "version": "0.1.0", + "description": "Cherry Studio AI SDK provider bundle with CherryIN routing.", + "keywords": [ + "ai-sdk", + "provider", + "cherryin", + "vercel-ai-sdk", + "cherry-studio" + ], + "author": "Cherry Studio", + "license": "MIT", + "homepage": "https://github.com/CherryHQ/cherry-studio", + "repository": { + "type": "git", + "url": "git+https://github.com/CherryHQ/cherry-studio.git", + "directory": "packages/ai-sdk-provider" + }, + "bugs": { + "url": "https://github.com/CherryHQ/cherry-studio/issues" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "dev": "tsc -w", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "peerDependencies": { + "@ai-sdk/anthropic": "^2.0.29", + "@ai-sdk/google": "^2.0.23", + "@ai-sdk/openai": "^2.0.64", + "ai": "^5.0.26" + }, + "dependencies": { + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.12" + }, + "devDependencies": { + "tsdown": "^0.13.3", + "typescript": "^5.8.2", + "vitest": "^3.2.4" + }, + "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + } +} diff --git a/packages/ai-sdk-provider/src/cherryin-provider.ts b/packages/ai-sdk-provider/src/cherryin-provider.ts new file mode 100644 index 0000000000..478380a411 --- /dev/null +++ b/packages/ai-sdk-provider/src/cherryin-provider.ts @@ -0,0 +1,319 @@ +import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal' +import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal' +import type { OpenAIProviderSettings } from '@ai-sdk/openai' +import { + OpenAIChatLanguageModel, + OpenAICompletionLanguageModel, + OpenAIEmbeddingModel, + OpenAIImageModel, + OpenAIResponsesLanguageModel, + OpenAISpeechModel, + OpenAITranscriptionModel +} from '@ai-sdk/openai/internal' +import { + type EmbeddingModelV2, + type ImageModelV2, + type LanguageModelV2, + type ProviderV2, + type SpeechModelV2, + type TranscriptionModelV2 +} from '@ai-sdk/provider' +import type { FetchFunction } from '@ai-sdk/provider-utils' +import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils' + +export const CHERRYIN_PROVIDER_NAME = 'cherryin' as const +export const DEFAULT_CHERRYIN_BASE_URL = 'https://open.cherryin.net/v1' +export const DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL = 'https://open.cherryin.net/v1' +export const DEFAULT_CHERRYIN_GEMINI_BASE_URL = 'https://open.cherryin.net/v1beta/models' + +const ANTHROPIC_PREFIX = /^anthropic\//i +const GEMINI_PREFIX = /^google\//i +// const GEMINI_EXCLUDED_SUFFIXES = ['-nothink', '-search'] + +type HeaderValue = string | undefined + +type HeadersInput = Record | (() => Record) + +export interface CherryInProviderSettings { + /** + * CherryIN API key. + * + * If omitted, the provider will read the `CHERRYIN_API_KEY` environment variable. + */ + apiKey?: string + /** + * Optional custom fetch implementation. + */ + fetch?: FetchFunction + /** + * Base URL for OpenAI-compatible CherryIN endpoints. + * + * Defaults to `https://open.cherryin.net/v1`. + */ + baseURL?: string + /** + * Base URL for Anthropic-compatible endpoints. + * + * Defaults to `https://open.cherryin.net/anthropic`. + */ + anthropicBaseURL?: string + /** + * Base URL for Gemini-compatible endpoints. + * + * Defaults to `https://open.cherryin.net/gemini/v1beta`. + */ + geminiBaseURL?: string + /** + * Optional static headers applied to every request. + */ + headers?: HeadersInput +} + +export interface CherryInProvider extends ProviderV2 { + (modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + languageModel(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + chat(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + responses(modelId: string): LanguageModelV2 + completion(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2 + embedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2 + textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2 + textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2 + image(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2 + imageModel(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2 + transcription(modelId: string): TranscriptionModelV2 + transcriptionModel(modelId: string): TranscriptionModelV2 + speech(modelId: string): SpeechModelV2 + speechModel(modelId: string): SpeechModelV2 +} + +const resolveApiKey = (options: CherryInProviderSettings): string => + loadApiKey({ + apiKey: options.apiKey, + environmentVariableName: 'CHERRYIN_API_KEY', + description: 'CherryIN' + }) + +const isAnthropicModel = (modelId: string) => ANTHROPIC_PREFIX.test(modelId) +const isGeminiModel = (modelId: string) => GEMINI_PREFIX.test(modelId) + +const createCustomFetch = (originalFetch?: any) => { + return async (url: string, options: any) => { + if (options?.body) { + try { + const body = JSON.parse(options.body) + if (body.tools && Array.isArray(body.tools) && body.tools.length === 0 && body.tool_choice) { + delete body.tool_choice + options.body = JSON.stringify(body) + } + } catch (error) { + // ignore error + } + } + + return originalFetch ? originalFetch(url, options) : fetch(url, options) + } +} +class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel { + constructor(modelId: string, settings: any) { + super(modelId, { + ...settings, + fetch: createCustomFetch(settings.fetch) + }) + } +} + +const resolveConfiguredHeaders = (headers?: HeadersInput): Record => { + if (typeof headers === 'function') { + return { ...headers() } + } + return headers ? { ...headers } : {} +} + +const toBearerToken = (authorization?: string) => (authorization ? authorization.replace(/^Bearer\s+/i, '') : undefined) + +const createJsonHeadersGetter = (options: CherryInProviderSettings): (() => Record) => { + return () => ({ + Authorization: `Bearer ${resolveApiKey(options)}`, + 'Content-Type': 'application/json', + ...resolveConfiguredHeaders(options.headers) + }) +} + +const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record) => { + return () => ({ + Authorization: `Bearer ${resolveApiKey(options)}`, + ...resolveConfiguredHeaders(options.headers) + }) +} + +export const createCherryIn = (options: CherryInProviderSettings = {}): CherryInProvider => { + const { + baseURL = DEFAULT_CHERRYIN_BASE_URL, + anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL, + geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL, + fetch + } = options + + const getJsonHeaders = createJsonHeadersGetter(options) + const getAuthHeaders = createAuthHeadersGetter(options) + + const url = ({ path }: { path: string; modelId: string }) => `${withoutTrailingSlash(baseURL)}${path}` + + const createAnthropicModel = (modelId: string) => + new AnthropicMessagesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.anthropic`, + baseURL: anthropicBaseURL, + headers: () => { + const headers = getJsonHeaders() + const apiKey = toBearerToken(headers.Authorization) + return { + ...headers, + 'x-api-key': apiKey + } + }, + fetch, + supportedUrls: () => ({ + 'image/*': [/^https?:\/\/.*$/] + }) + }) + + const createGeminiModel = (modelId: string) => + new GoogleGenerativeAILanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.google`, + baseURL: geminiBaseURL, + headers: () => { + const headers = getJsonHeaders() + const apiKey = toBearerToken(headers.Authorization) + return { + ...headers, + 'x-goog-api-key': apiKey + } + }, + fetch, + generateId: () => `${CHERRYIN_PROVIDER_NAME}-${Date.now()}`, + supportedUrls: () => ({}) + }) + + const createOpenAIChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new CherryInOpenAIChatLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.openai-chat`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => { + if (isAnthropicModel(modelId)) { + return createAnthropicModel(modelId) + } + if (isGeminiModel(modelId)) { + return createGeminiModel(modelId) + } + return new OpenAIResponsesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.openai`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + } + + const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new OpenAICompletionLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.completion`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createEmbeddingModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new OpenAIEmbeddingModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.embeddings`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createResponsesModel = (modelId: string) => + new OpenAIResponsesLanguageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.responses`, + url, + headers: () => ({ + ...getJsonHeaders() + }), + fetch + }) + + const createImageModel = (modelId: string, settings: OpenAIProviderSettings = {}) => + new OpenAIImageModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.image`, + url, + headers: () => ({ + ...getJsonHeaders(), + ...settings.headers + }), + fetch + }) + + const createTranscriptionModel = (modelId: string) => + new OpenAITranscriptionModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.transcription`, + url, + headers: () => ({ + ...getAuthHeaders() + }), + fetch + }) + + const createSpeechModel = (modelId: string) => + new OpenAISpeechModel(modelId, { + provider: `${CHERRYIN_PROVIDER_NAME}.speech`, + url, + headers: () => ({ + ...getJsonHeaders() + }), + fetch + }) + + const provider: CherryInProvider = function (modelId: string, settings?: OpenAIProviderSettings) { + if (new.target) { + throw new Error('CherryIN provider function cannot be called with the new keyword.') + } + + return createChatModel(modelId, settings) + } + + provider.languageModel = createChatModel + provider.chat = createOpenAIChatModel + + provider.responses = createResponsesModel + provider.completion = createCompletionModel + + provider.embedding = createEmbeddingModel + provider.textEmbedding = createEmbeddingModel + provider.textEmbeddingModel = createEmbeddingModel + + provider.image = createImageModel + provider.imageModel = createImageModel + + provider.transcription = createTranscriptionModel + provider.transcriptionModel = createTranscriptionModel + + provider.speech = createSpeechModel + provider.speechModel = createSpeechModel + + return provider +} + +export const cherryIn = createCherryIn() diff --git a/packages/ai-sdk-provider/src/index.ts b/packages/ai-sdk-provider/src/index.ts new file mode 100644 index 0000000000..d397dd5af5 --- /dev/null +++ b/packages/ai-sdk-provider/src/index.ts @@ -0,0 +1 @@ +export * from './cherryin-provider' diff --git a/packages/ai-sdk-provider/tsconfig.json b/packages/ai-sdk-provider/tsconfig.json new file mode 100644 index 0000000000..26ee731bb7 --- /dev/null +++ b/packages/ai-sdk-provider/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "noEmitOnError": false, + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} diff --git a/packages/ai-sdk-provider/tsdown.config.ts b/packages/ai-sdk-provider/tsdown.config.ts new file mode 100644 index 0000000000..0e07d34cac --- /dev/null +++ b/packages/ai-sdk-provider/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + index: 'src/index.ts' + }, + outDir: 'dist', + format: ['esm', 'cjs'], + clean: true, + dts: true, + tsconfig: 'tsconfig.json' +}) diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index eb9d000929..bb673392a2 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -36,14 +36,16 @@ "ai": "^5.0.26" }, "dependencies": { - "@ai-sdk/anthropic": "^2.0.27", - "@ai-sdk/azure": "^2.0.49", - "@ai-sdk/deepseek": "^1.0.23", - "@ai-sdk/openai": "^2.0.48", - "@ai-sdk/openai-compatible": "^1.0.22", + "@ai-sdk/anthropic": "^2.0.43", + "@ai-sdk/azure": "^2.0.66", + "@ai-sdk/deepseek": "^1.0.27", + "@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch", + "@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/openai-compatible": "^1.0.26", "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^3.0.12", - "@ai-sdk/xai": "^2.0.26", + "@ai-sdk/provider-utils": "^3.0.16", + "@ai-sdk/xai": "^2.0.31", + "@cherrystudio/ai-sdk-provider": "workspace:*", "zod": "^4.1.5" }, "devDependencies": { diff --git a/packages/aiCore/src/core/middleware/manager.ts b/packages/aiCore/src/core/middleware/manager.ts index bcb044b3a9..f285b8ecd1 100644 --- a/packages/aiCore/src/core/middleware/manager.ts +++ b/packages/aiCore/src/core/middleware/manager.ts @@ -2,7 +2,7 @@ * 中间件管理器 * 专注于 AI SDK 中间件的管理,与插件系统分离 */ -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' /** * 创建中间件列表 diff --git a/packages/aiCore/src/core/middleware/types.ts b/packages/aiCore/src/core/middleware/types.ts index 50b5210b53..f500b0a91d 100644 --- a/packages/aiCore/src/core/middleware/types.ts +++ b/packages/aiCore/src/core/middleware/types.ts @@ -1,7 +1,7 @@ /** * 中间件系统类型定义 */ -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' /** * 具名中间件接口 diff --git a/packages/aiCore/src/core/middleware/wrapper.ts b/packages/aiCore/src/core/middleware/wrapper.ts index 625eddbab3..059c82380f 100644 --- a/packages/aiCore/src/core/middleware/wrapper.ts +++ b/packages/aiCore/src/core/middleware/wrapper.ts @@ -2,7 +2,7 @@ * 模型包装工具函数 * 用于将中间件应用到LanguageModel上 */ -import { LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' import { wrapLanguageModel } from 'ai' /** diff --git a/packages/aiCore/src/core/models/ModelResolver.ts b/packages/aiCore/src/core/models/ModelResolver.ts index 20bf3d76d1..ed9fa910e0 100644 --- a/packages/aiCore/src/core/models/ModelResolver.ts +++ b/packages/aiCore/src/core/models/ModelResolver.ts @@ -5,7 +5,7 @@ * 集成了来自 ModelCreator 的特殊处理逻辑 */ -import { EmbeddingModelV2, ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { EmbeddingModelV2, ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' import { wrapModelWithMiddlewares } from '../middleware/wrapper' import { DEFAULT_SEPARATOR, globalRegistryManagement } from '../providers/RegistryManagement' diff --git a/packages/aiCore/src/core/models/types.ts b/packages/aiCore/src/core/models/types.ts index 57cb72366e..560d5bbeae 100644 --- a/packages/aiCore/src/core/models/types.ts +++ b/packages/aiCore/src/core/models/types.ts @@ -1,7 +1,7 @@ /** * Creation 模块类型定义 */ -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' import type { ProviderId, ProviderSettingsMap } from '../providers/types' diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index ffeb15185c..ecd53e6330 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -1,4 +1,4 @@ -import { ExtractProviderOptions, ProviderOptionsMap, TypedProviderOptions } from './types' +import type { ExtractProviderOptions, ProviderOptionsMap, TypedProviderOptions } from './types' /** * 创建特定供应商的选项 diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index a2cc7d9aff..274fdcee5c 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -10,7 +10,7 @@ import type { AiRequestContext } from '../../types' import { StreamEventManager } from './StreamEventManager' import { type TagConfig, TagExtractor } from './tagExtraction' import { ToolExecutor } from './ToolExecutor' -import { PromptToolUseConfig, ToolUseResult } from './type' +import type { PromptToolUseConfig, ToolUseResult } from './type' /** * 工具使用标签配置 diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts index 33ed6189ed..4937b25601 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts @@ -1,6 +1,6 @@ -import { ToolSet } from 'ai' +import type { ToolSet } from 'ai' -import { AiRequestContext } from '../..' +import type { AiRequestContext } from '../..' /** * 解析结果类型 diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index fd68da27d2..1d80b9156a 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -1,10 +1,11 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' -import { InferToolInput, InferToolOutput, type Tool } from 'ai' +import type { InferToolInput, InferToolOutput, Tool } from 'ai' -import { ProviderOptionsMap } from '../../../options/types' -import { OpenRouterSearchConfig } from './openrouter' +import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' +import type { ProviderOptionsMap } from '../../../options/types' +import type { OpenRouterSearchConfig } from './openrouter' /** * 从 AI SDK 的工具函数中提取参数类型,以确保类型安全。 @@ -94,3 +95,56 @@ export type WebSearchToolInputSchema = { google: InferToolInput 'openai-chat': InferToolInput } + +export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => { + switch (providerId) { + case 'openai': { + if (config.openai) { + if (!params.tools) params.tools = {} + params.tools.web_search = openai.tools.webSearch(config.openai) + } + break + } + case 'openai-chat': { + if (config['openai-chat']) { + if (!params.tools) params.tools = {} + params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) + } + break + } + + case 'anthropic': { + if (config.anthropic) { + if (!params.tools) params.tools = {} + params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) + } + break + } + + case 'google': { + // case 'google-vertex': + if (!params.tools) params.tools = {} + params.tools.web_search = google.tools.googleSearch(config.google || {}) + break + } + + case 'xai': { + if (config.xai) { + const searchOptions = createXaiOptions({ + searchParameters: { ...config.xai, mode: 'on' } + }) + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) + } + break + } + + case 'openrouter': { + if (config.openrouter) { + const searchOptions = createOpenRouterOptions(config.openrouter) + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) + } + break + } + } + return params +} diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts index abd3ce3e2c..23ea952323 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -2,14 +2,11 @@ * Web Search Plugin * 提供统一的网络搜索能力,支持多个 AI Provider */ -import { anthropic } from '@ai-sdk/anthropic' -import { google } from '@ai-sdk/google' -import { openai } from '@ai-sdk/openai' -import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import { definePlugin } from '../../' import type { AiRequestContext } from '../../types' -import { DEFAULT_WEB_SEARCH_CONFIG, WebSearchPluginConfig } from './helper' +import type { WebSearchPluginConfig } from './helper' +import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper' /** * 网络搜索插件 @@ -23,56 +20,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR transformParams: async (params: any, context: AiRequestContext) => { const { providerId } = context - switch (providerId) { - case 'openai': { - if (config.openai) { - if (!params.tools) params.tools = {} - params.tools.web_search = openai.tools.webSearch(config.openai) - } - break - } - case 'openai-chat': { - if (config['openai-chat']) { - if (!params.tools) params.tools = {} - params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) - } - break - } + switchWebSearchTool(providerId, config, params) - case 'anthropic': { - if (config.anthropic) { - if (!params.tools) params.tools = {} - params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) - } - break - } - - case 'google': { - // case 'google-vertex': - if (!params.tools) params.tools = {} - params.tools.web_search = google.tools.googleSearch(config.google || {}) - break - } - - case 'xai': { - if (config.xai) { - const searchOptions = createXaiOptions({ - searchParameters: { ...config.xai, mode: 'on' } - }) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) - } - break - } - - case 'openrouter': { - if (config.openrouter) { - const searchOptions = createOpenRouterOptions(config.openrouter) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) - } - break - } + if (providerId === 'cherryin' || providerId === 'cherryin-chat') { + // cherryin.gemini + const _providerId = params.model.provider.split('.')[1] + switchWebSearchTool(_providerId, config, params) } - return params } }) diff --git a/packages/aiCore/src/core/plugins/manager.ts b/packages/aiCore/src/core/plugins/manager.ts index 4c927ed1de..40f5836c44 100644 --- a/packages/aiCore/src/core/plugins/manager.ts +++ b/packages/aiCore/src/core/plugins/manager.ts @@ -1,4 +1,4 @@ -import { AiPlugin, AiRequestContext } from './types' +import type { AiPlugin, AiRequestContext } from './types' /** * 插件管理器 diff --git a/packages/aiCore/src/core/providers/HubProvider.ts b/packages/aiCore/src/core/providers/HubProvider.ts index 0283d634b0..e87274be98 100644 --- a/packages/aiCore/src/core/providers/HubProvider.ts +++ b/packages/aiCore/src/core/providers/HubProvider.ts @@ -5,7 +5,7 @@ * 例如: aihubmix:anthropic:claude-3.5-sonnet */ -import { ProviderV2 } from '@ai-sdk/provider' +import type { ProviderV2 } from '@ai-sdk/provider' import { customProvider } from 'ai' import { globalRegistryManagement } from './RegistryManagement' diff --git a/packages/aiCore/src/core/providers/RegistryManagement.ts b/packages/aiCore/src/core/providers/RegistryManagement.ts index a8aefd44b2..67d10f3f2f 100644 --- a/packages/aiCore/src/core/providers/RegistryManagement.ts +++ b/packages/aiCore/src/core/providers/RegistryManagement.ts @@ -4,7 +4,7 @@ * 基于 AI SDK 原生的 createProviderRegistry */ -import { EmbeddingModelV2, ImageModelV2, LanguageModelV2, ProviderV2 } from '@ai-sdk/provider' +import type { EmbeddingModelV2, ImageModelV2, LanguageModelV2, ProviderV2 } from '@ai-sdk/provider' import { createProviderRegistry, type ProviderRegistryProvider } from 'ai' type PROVIDERS = Record diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts index 0d507d5cc6..778b1b705a 100644 --- a/packages/aiCore/src/core/providers/schemas.ts +++ b/packages/aiCore/src/core/providers/schemas.ts @@ -10,10 +10,12 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createHuggingFace } from '@ai-sdk/huggingface' import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai' import { createOpenAICompatible } from '@ai-sdk/openai-compatible' -import { LanguageModelV2 } from '@ai-sdk/provider' +import type { LanguageModelV2 } from '@ai-sdk/provider' import { createXai } from '@ai-sdk/xai' +import { type CherryInProviderSettings, createCherryIn } from '@cherrystudio/ai-sdk-provider' import { createOpenRouter } from '@openrouter/ai-sdk-provider' -import { customProvider, Provider } from 'ai' +import type { Provider } from 'ai' +import { customProvider } from 'ai' import * as z from 'zod' /** @@ -30,6 +32,8 @@ export const baseProviderIds = [ 'azure-responses', 'deepseek', 'openrouter', + 'cherryin', + 'cherryin-chat', 'huggingface' ] as const @@ -135,6 +139,26 @@ export const baseProviders = [ creator: createOpenRouter, supportsImageGeneration: true }, + { + id: 'cherryin', + name: 'CherryIN', + creator: createCherryIn, + supportsImageGeneration: true + }, + { + id: 'cherryin-chat', + name: 'CherryIN Chat', + creator: (options: CherryInProviderSettings) => { + const provider = createCherryIn(options) + return customProvider({ + fallbackProvider: { + ...provider, + languageModel: (modelId: string) => provider.chat(modelId) + } + }) + }, + supportsImageGeneration: true + }, { id: 'huggingface', name: 'HuggingFace', diff --git a/packages/aiCore/src/core/providers/types.ts b/packages/aiCore/src/core/providers/types.ts index f862f43a75..6f1ec2c405 100644 --- a/packages/aiCore/src/core/providers/types.ts +++ b/packages/aiCore/src/core/providers/types.ts @@ -4,7 +4,7 @@ import { type DeepSeekProviderSettings } from '@ai-sdk/deepseek' import { type GoogleGenerativeAIProviderSettings } from '@ai-sdk/google' import { type OpenAIProviderSettings } from '@ai-sdk/openai' import { type OpenAICompatibleProviderSettings } from '@ai-sdk/openai-compatible' -import { +import type { EmbeddingModelV2 as EmbeddingModel, ImageModelV2 as ImageModel, LanguageModelV2 as LanguageModel, diff --git a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts index bde5779fd9..217319aacc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts @@ -1,4 +1,4 @@ -import { ImageModelV2 } from '@ai-sdk/provider' +import type { ImageModelV2 } from '@ai-sdk/provider' import { experimental_generateImage as aiGenerateImage, NoImageGeneratedError } from 'ai' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/aiCore/src/core/runtime/executor.ts b/packages/aiCore/src/core/runtime/executor.ts index ab764bacd6..85c1fb64de 100644 --- a/packages/aiCore/src/core/runtime/executor.ts +++ b/packages/aiCore/src/core/runtime/executor.ts @@ -2,12 +2,12 @@ * 运行时执行器 * 专注于插件化的AI调用处理 */ -import { ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { ImageModelV2, LanguageModelV2, LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModel } from 'ai' import { experimental_generateImage as _generateImage, generateObject as _generateObject, generateText as _generateText, - LanguageModel, streamObject as _streamObject, streamText as _streamText } from 'ai' diff --git a/packages/aiCore/src/core/runtime/index.ts b/packages/aiCore/src/core/runtime/index.ts index 37aa4fec34..3dae7d0c2a 100644 --- a/packages/aiCore/src/core/runtime/index.ts +++ b/packages/aiCore/src/core/runtime/index.ts @@ -11,7 +11,7 @@ export type { RuntimeConfig } from './types' // === 便捷工厂函数 === -import { LanguageModelV2Middleware } from '@ai-sdk/provider' +import type { LanguageModelV2Middleware } from '@ai-sdk/provider' import { type AiPlugin } from '../plugins' import { type ProviderId, type ProviderSettingsMap } from '../providers/types' diff --git a/packages/aiCore/src/core/runtime/pluginEngine.ts b/packages/aiCore/src/core/runtime/pluginEngine.ts index d0100d2bcb..a2fc08b927 100644 --- a/packages/aiCore/src/core/runtime/pluginEngine.ts +++ b/packages/aiCore/src/core/runtime/pluginEngine.ts @@ -1,6 +1,13 @@ /* eslint-disable @eslint-react/naming-convention/context-name */ -import { ImageModelV2 } from '@ai-sdk/provider' -import { experimental_generateImage, generateObject, generateText, LanguageModel, streamObject, streamText } from 'ai' +import type { ImageModelV2 } from '@ai-sdk/provider' +import type { + experimental_generateImage, + generateObject, + generateText, + LanguageModel, + streamObject, + streamText +} from 'ai' import { type AiPlugin, createContext, PluginManager } from '../plugins' import { type ProviderId } from '../providers/types' diff --git a/packages/aiCore/src/core/runtime/types.ts b/packages/aiCore/src/core/runtime/types.ts index fbdcf46333..b95e00be4f 100644 --- a/packages/aiCore/src/core/runtime/types.ts +++ b/packages/aiCore/src/core/runtime/types.ts @@ -1,8 +1,8 @@ /** * Runtime 层类型定义 */ -import { ImageModelV2 } from '@ai-sdk/provider' -import { experimental_generateImage, generateObject, generateText, streamObject, streamText } from 'ai' +import type { ImageModelV2 } from '@ai-sdk/provider' +import type { experimental_generateImage, generateObject, generateText, streamObject, streamText } from 'ai' import { type ModelConfig } from '../models/types' import { type AiPlugin } from '../plugins' diff --git a/packages/extension-table-plus/src/kit/index.ts b/packages/extension-table-plus/src/kit/index.ts index 00221c5bfe..eb0fefb595 100755 --- a/packages/extension-table-plus/src/kit/index.ts +++ b/packages/extension-table-plus/src/kit/index.ts @@ -1,4 +1,5 @@ -import { Extension, Node } from '@tiptap/core' +import type { Node } from '@tiptap/core' +import { Extension } from '@tiptap/core' import type { TableCellOptions } from '../cell/index.js' import { TableCell } from '../cell/index.js' diff --git a/packages/mcp-trace/trace-core/core/spanConvert.ts b/packages/mcp-trace/trace-core/core/spanConvert.ts index a226f5d108..1a5eafff06 100644 --- a/packages/mcp-trace/trace-core/core/spanConvert.ts +++ b/packages/mcp-trace/trace-core/core/spanConvert.ts @@ -1,7 +1,7 @@ import { SpanKind, SpanStatusCode } from '@opentelemetry/api' -import { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' -import { SpanEntity } from '../types/config' +import type { SpanEntity } from '../types/config' /** * convert ReadableSpan to SpanEntity diff --git a/packages/mcp-trace/trace-core/core/traceCache.ts b/packages/mcp-trace/trace-core/core/traceCache.ts index cc5ba795ff..6b181fa3c8 100644 --- a/packages/mcp-trace/trace-core/core/traceCache.ts +++ b/packages/mcp-trace/trace-core/core/traceCache.ts @@ -1,4 +1,4 @@ -import { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' export interface TraceCache { createSpan: (span: ReadableSpan) => void diff --git a/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts b/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts index 48d769daf8..0bc97b82e1 100644 --- a/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts +++ b/packages/mcp-trace/trace-core/exporters/FuncSpanExporter.ts @@ -1,5 +1,6 @@ -import { ExportResult, ExportResultCode } from '@opentelemetry/core' -import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { ExportResult } from '@opentelemetry/core' +import { ExportResultCode } from '@opentelemetry/core' +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' export type SaveFunction = (spans: ReadableSpan[]) => Promise diff --git a/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts b/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts index b20a61de06..3d3e1a73a3 100644 --- a/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts +++ b/packages/mcp-trace/trace-core/processors/CacheSpanProcessor.ts @@ -1,7 +1,9 @@ -import { Context, trace } from '@opentelemetry/api' -import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { Context } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' +import type { BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' -import { TraceCache } from '../core/traceCache' +import type { TraceCache } from '../core/traceCache' export class CacheBatchSpanProcessor extends BatchSpanProcessor { private cache: TraceCache diff --git a/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts b/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts index 41015b2082..c09ec352d6 100644 --- a/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts +++ b/packages/mcp-trace/trace-core/processors/EmitterSpanProcessor.ts @@ -1,6 +1,7 @@ -import { Context } from '@opentelemetry/api' -import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' -import { EventEmitter } from 'stream' +import type { Context } from '@opentelemetry/api' +import type { BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { EventEmitter } from 'stream' import { convertSpanToSpanEntity } from '../core/spanConvert' diff --git a/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts b/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts index 8a7281d955..ba88e322d2 100644 --- a/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts +++ b/packages/mcp-trace/trace-core/processors/FuncSpanProcessor.ts @@ -1,5 +1,7 @@ -import { Context, trace } from '@opentelemetry/api' -import { BatchSpanProcessor, BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import type { Context } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' +import type { BufferConfig, ReadableSpan, Span, SpanExporter } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' export type SpanFunction = (span: ReadableSpan) => void diff --git a/packages/mcp-trace/trace-core/types/config.ts b/packages/mcp-trace/trace-core/types/config.ts index 37f705eb8d..aa86353eae 100644 --- a/packages/mcp-trace/trace-core/types/config.ts +++ b/packages/mcp-trace/trace-core/types/config.ts @@ -1,5 +1,5 @@ -import { Link } from '@opentelemetry/api' -import { TimedEvent } from '@opentelemetry/sdk-trace-base' +import type { Link } from '@opentelemetry/api' +import type { TimedEvent } from '@opentelemetry/sdk-trace-base' export type AttributeValue = | string diff --git a/packages/mcp-trace/trace-node/nodeTracer.ts b/packages/mcp-trace/trace-node/nodeTracer.ts index aee9525010..79fa4c4393 100644 --- a/packages/mcp-trace/trace-node/nodeTracer.ts +++ b/packages/mcp-trace/trace-node/nodeTracer.ts @@ -1,11 +1,14 @@ -import { trace, Tracer } from '@opentelemetry/api' +import type { Tracer } from '@opentelemetry/api' +import { trace } from '@opentelemetry/api' import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' -import { BatchSpanProcessor, ConsoleSpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base' import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' -import { defaultConfig, TraceConfig } from '../trace-core/types/config' +import type { TraceConfig } from '../trace-core/types/config' +import { defaultConfig } from '../trace-core/types/config' export class NodeTracer { private static provider: NodeTracerProvider diff --git a/packages/mcp-trace/trace-web/TopicContextManager.ts b/packages/mcp-trace/trace-web/TopicContextManager.ts index a2688fc02f..2962ae868e 100644 --- a/packages/mcp-trace/trace-web/TopicContextManager.ts +++ b/packages/mcp-trace/trace-web/TopicContextManager.ts @@ -1,4 +1,5 @@ -import { Context, ContextManager, ROOT_CONTEXT } from '@opentelemetry/api' +import type { Context, ContextManager } from '@opentelemetry/api' +import { ROOT_CONTEXT } from '@opentelemetry/api' export class TopicContextManager implements ContextManager { private topicContextStack: Map diff --git a/packages/mcp-trace/trace-web/traceContextPromise.ts b/packages/mcp-trace/trace-web/traceContextPromise.ts index ee99722b71..d529c3ca58 100644 --- a/packages/mcp-trace/trace-web/traceContextPromise.ts +++ b/packages/mcp-trace/trace-web/traceContextPromise.ts @@ -1,4 +1,5 @@ -import { Context, context } from '@opentelemetry/api' +import type { Context } from '@opentelemetry/api' +import { context } from '@opentelemetry/api' const originalPromise = globalThis.Promise diff --git a/packages/mcp-trace/trace-web/webTracer.ts b/packages/mcp-trace/trace-web/webTracer.ts index 0b8af5813a..fdf221f3f1 100644 --- a/packages/mcp-trace/trace-web/webTracer.ts +++ b/packages/mcp-trace/trace-web/webTracer.ts @@ -1,9 +1,11 @@ import { W3CTraceContextPropagator } from '@opentelemetry/core' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' -import { BatchSpanProcessor, ConsoleSpanExporter, SpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base' +import { BatchSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base' import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' -import { defaultConfig, TraceConfig } from '../trace-core/types/config' +import type { TraceConfig } from '../trace-core/types/config' +import { defaultConfig } from '../trace-core/types/config' import { TopicContextManager } from './TopicContextManager' export const contextManager = new TopicContextManager() diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 2931015117..64a1261a75 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -2,7 +2,7 @@ export enum IpcChannel { App_GetCacheSize = 'app:get-cache-size', App_ClearCache = 'app:clear-cache', App_SetLaunchOnBoot = 'app:set-launch-on-boot', - App_SetLanguage = 'app:set-language', + // App_SetLanguage = 'app:set-language', App_SetEnableSpellCheck = 'app:set-enable-spell-check', App_SetSpellCheckLanguages = 'app:set-spell-check-languages', App_CheckForUpdate = 'app:check-for-update', @@ -14,7 +14,7 @@ export enum IpcChannel { App_SetLaunchToTray = 'app:set-launch-to-tray', App_SetTray = 'app:set-tray', App_SetTrayOnClose = 'app:set-tray-on-close', - App_SetTheme = 'app:set-theme', + // App_SetTheme = 'app:set-theme', App_SetAutoUpdate = 'app:set-auto-update', App_SetTestPlan = 'app:set-test-plan', App_SetTestChannel = 'app:set-test-channel', @@ -46,7 +46,7 @@ export enum IpcChannel { App_MacRequestProcessTrust = 'app:mac-request-process-trust', App_QuoteToMain = 'app:quote-to-main', - App_SetDisableHardwareAcceleration = 'app:set-disable-hardware-acceleration', + // App_SetDisableHardwareAcceleration = 'app:set-disable-hardware-acceleration', Notification_Send = 'notification:send', Notification_OnClick = 'notification:on-click', @@ -96,6 +96,10 @@ export enum IpcChannel { AgentMessage_PersistExchange = 'agent-message:persist-exchange', AgentMessage_GetHistory = 'agent-message:get-history', + AgentToolPermission_Request = 'agent-tool-permission:request', + AgentToolPermission_Response = 'agent-tool-permission:response', + AgentToolPermission_Result = 'agent-tool-permission:result', + //copilot Copilot_GetAuthMessage = 'copilot:get-auth-message', Copilot_GetCopilotToken = 'copilot:get-copilot-token', @@ -185,6 +189,7 @@ export enum IpcChannel { Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', File_IsTextFile = 'file:isTextFile', + File_ListDirectory = 'file:listDirectory', File_GetDirectoryStructure = 'file:getDirectoryStructure', File_CheckFileName = 'file:checkFileName', File_ValidateNotesDirectory = 'file:validateNotesDirectory', @@ -221,6 +226,22 @@ export enum IpcChannel { Backup_DeleteS3File = 'backup:deleteS3File', Backup_CheckS3Connection = 'backup:checkS3Connection', + // data migration + DataMigrate_CheckNeeded = 'data-migrate:check-needed', + DataMigrate_GetProgress = 'data-migrate:get-progress', + DataMigrate_Cancel = 'data-migrate:cancel', + DataMigrate_RequireBackup = 'data-migrate:require-backup', + DataMigrate_BackupCompleted = 'data-migrate:backup-completed', + DataMigrate_ShowBackupDialog = 'data-migrate:show-backup-dialog', + DataMigrate_StartFlow = 'data-migrate:start-flow', + DataMigrate_ProceedToBackup = 'data-migrate:proceed-to-backup', + DataMigrate_StartMigration = 'data-migrate:start-migration', + DataMigrate_RetryMigration = 'data-migrate:retry-migration', + DataMigrate_RestartApp = 'data-migrate:restart-app', + DataMigrate_CloseWindow = 'data-migrate:close-window', + DataMigrate_SendReduxData = 'data-migrate:send-redux-data', + DataMigrate_GetReduxData = 'data-migrate:get-redux-data', + // zip Zip_Compress = 'zip:compress', Zip_Decompress = 'zip:decompress', @@ -235,7 +256,8 @@ export enum IpcChannel { // events BackupProgress = 'backup-progress', - ThemeUpdated = 'theme:updated', + DataMigrateProgress = 'data-migrate-progress', + NativeThemeUpdated = 'native-theme:updated', RestoreProgress = 'restore-progress', UpdateError = 'update-error', UpdateAvailable = 'update-available', @@ -274,12 +296,6 @@ export enum IpcChannel { Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change', Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size', Selection_WriteToClipboard = 'selection:write-to-clipboard', - Selection_SetEnabled = 'selection:set-enabled', - Selection_SetTriggerMode = 'selection:set-trigger-mode', - Selection_SetFilterMode = 'selection:set-filter-mode', - Selection_SetFilterList = 'selection:set-filter-list', - Selection_SetFollowToolbar = 'selection:set-follow-toolbar', - Selection_SetRemeberWinSize = 'selection:set-remeber-win-size', Selection_ActionWindowClose = 'selection:action-window-close', Selection_ActionWindowMinimize = 'selection:action-window-minimize', Selection_ActionWindowPin = 'selection:action-window-pin', @@ -298,6 +314,27 @@ export enum IpcChannel { Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user', Memory_GetUsersList = 'memory:get-users-list', + // Data: Preference + Preference_Get = 'preference:get', + Preference_Set = 'preference:set', + Preference_GetMultiple = 'preference:get-multiple', + Preference_SetMultiple = 'preference:set-multiple', + Preference_GetAll = 'preference:get-all', + Preference_Subscribe = 'preference:subscribe', + Preference_Changed = 'preference:changed', + + // Data: Cache + Cache_Sync = 'cache:sync', + Cache_SyncBatch = 'cache:sync-batch', + + // Data: API Channels + DataApi_Request = 'data-api:request', + DataApi_Batch = 'data-api:batch', + DataApi_Transaction = 'data-api:transaction', + DataApi_Subscribe = 'data-api:subscribe', + DataApi_Unsubscribe = 'data-api:unsubscribe', + DataApi_Stream = 'data-api:stream', + // TRACE TRACE_SAVE_DATA = 'trace:saveData', TRACE_GET_DATA = 'trace:getData', @@ -318,6 +355,7 @@ export enum IpcChannel { ApiServer_Stop = 'api-server:stop', ApiServer_Restart = 'api-server:restart', ApiServer_GetStatus = 'api-server:get-status', + ApiServer_Ready = 'api-server:ready', // NOTE: This api is not be used. ApiServer_GetConfig = 'api-server:get-config', @@ -350,5 +388,21 @@ export enum IpcChannel { Ovms_StopOVMS = 'ovms:stop-ovms', // CherryAI - Cherryai_GetSignature = 'cherryai:get-signature' + Cherryai_GetSignature = 'cherryai:get-signature', + + // Claude Code Plugins + ClaudeCodePlugin_ListAvailable = 'claudeCodePlugin:list-available', + ClaudeCodePlugin_Install = 'claudeCodePlugin:install', + ClaudeCodePlugin_Uninstall = 'claudeCodePlugin:uninstall', + ClaudeCodePlugin_ListInstalled = 'claudeCodePlugin:list-installed', + ClaudeCodePlugin_InvalidateCache = 'claudeCodePlugin:invalidate-cache', + ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', + ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', + + // WebSocket + WebSocket_Start = 'webSocket:start', + WebSocket_Stop = 'webSocket:stop', + WebSocket_Status = 'webSocket:status', + WebSocket_SendFile = 'webSocket:send-file', + WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' } diff --git a/packages/shared/anthropic/index.ts b/packages/shared/anthropic/index.ts index 777cbd13e8..b9e9cb8846 100644 --- a/packages/shared/anthropic/index.ts +++ b/packages/shared/anthropic/index.ts @@ -9,9 +9,9 @@ */ import Anthropic from '@anthropic-ai/sdk' -import { TextBlockParam } from '@anthropic-ai/sdk/resources' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' -import { Provider } from '@types' +import type { Provider } from '@types' import type { ModelMessage } from 'ai' const logger = loggerService.withContext('anthropic-sdk') diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 3b38592005..caeb1ae8db 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -197,10 +197,20 @@ export enum FeedUrl { GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download' } -export enum UpgradeChannel { - LATEST = 'latest', // 最新稳定版本 - RC = 'rc', // 公测版本 - BETA = 'beta' // 预览版本 +export enum UpdateConfigUrl { + GITHUB = 'https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json', + GITCODE = 'https://raw.gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json' +} + +// export enum UpgradeChannel { +// LATEST = 'latest', // 最新稳定版本 +// RC = 'rc', // 公测版本 +// BETA = 'beta' // 预览版本 +// } + +export enum UpdateMirror { + GITHUB = 'github', + GITCODE = 'gitcode' } export const defaultTimeout = 10 * 1000 * 60 @@ -470,3 +480,6 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ }) } ] + +// resources/scripts should be maintained manually +export const HOME_CHERRY_DIR = '.cherrystudio' diff --git a/src/renderer/src/config/prompts.ts b/packages/shared/config/prompts.ts similarity index 100% rename from src/renderer/src/config/prompts.ts rename to packages/shared/config/prompts.ts diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 8012ed9022..5c42f1d2b2 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -1,4 +1,4 @@ -import { ProcessingStatus } from '@types' +import type { ProcessingStatus } from '@types' export type LoaderReturn = { entriesAdded: number @@ -31,3 +31,16 @@ export type WebviewKeyEvent = { shift: boolean alt: boolean } + +export interface WebSocketStatusResponse { + isRunning: boolean + port?: number + ip?: string + clientConnected: boolean +} + +export interface WebSocketCandidatesResponse { + host: string + interface: string + priority: number +} diff --git a/packages/shared/data/README.md b/packages/shared/data/README.md new file mode 100644 index 0000000000..b65af18e33 --- /dev/null +++ b/packages/shared/data/README.md @@ -0,0 +1,106 @@ +# Cherry Studio Shared Data + +This directory contains shared type definitions and schemas for the Cherry Studio data management systems. These files provide type safety and consistency across the entire application. + +## 📁 Directory Structure + +``` +packages/shared/data/ +├── api/ # Data API type system +│ ├── index.ts # Barrel exports for clean imports +│ ├── apiSchemas.ts # API endpoint definitions and mappings +│ ├── apiTypes.ts # Core request/response infrastructure types +│ ├── apiModels.ts # Business entity types and DTOs +│ ├── apiPaths.ts # API path definitions and utilities +│ └── errorCodes.ts # Standardized error handling +├── cache/ # Cache system type definitions +│ ├── cacheTypes.ts # Core cache infrastructure types +│ ├── cacheSchemas.ts # Cache key schemas and type mappings +│ └── cacheValueTypes.ts # Cache value type definitions +├── preference/ # Preference system type definitions +│ ├── preferenceTypes.ts # Core preference system types +│ └── preferenceSchemas.ts # Preference schemas and default values +└── README.md # This file +``` + +## 🏗️ System Overview + +This directory provides type definitions for three main data management systems: + +### API System (`api/`) +- **Purpose**: Type-safe IPC communication between Main and Renderer processes +- **Features**: RESTful patterns, error handling, business entity definitions +- **Usage**: Ensures type safety for all data API operations + +### Cache System (`cache/`) +- **Purpose**: Type definitions for three-layer caching architecture +- **Features**: Memory/shared/persist cache schemas, TTL support, hook integration +- **Usage**: Type-safe caching operations across the application + +### Preference System (`preference/`) +- **Purpose**: User configuration and settings management +- **Features**: 158 configuration items, default values, nested key support +- **Usage**: Type-safe preference access and synchronization + +## 📋 File Categories + +**Framework Infrastructure** - These are TypeScript type definitions that: +- ✅ Exist only at compile time +- ✅ Provide type safety and IntelliSense support +- ✅ Define contracts between application layers +- ✅ Enable static analysis and error detection + +## 📖 Usage Examples + +### API Types +```typescript +// Import API types +import type { DataRequest, DataResponse, ApiSchemas } from '@shared/data/api' +``` + +### Cache Types +```typescript +// Import cache types +import type { UseCacheKey, UseSharedCacheKey } from '@shared/data/cache' +``` + +### Preference Types +```typescript +// Import preference types +import type { PreferenceKeyType, PreferenceDefaultScopeType } from '@shared/data/preference' +``` + +## 🔧 Development Guidelines + +### Adding Cache Types +1. Add cache key to `cache/cacheSchemas.ts` +2. Define value type in `cache/cacheValueTypes.ts` +3. Update type mappings for type safety + +### Adding Preference Types +1. Add preference key to `preference/preferenceSchemas.ts` +2. Define default value and type +3. Preference system automatically picks up new keys + +### Adding API Types +1. Define business entities in `api/apiModels.ts` +2. Add endpoint definitions to `api/apiSchemas.ts` +3. Export types from `api/index.ts` + +### Best Practices +- Use `import type` for type-only imports +- Follow existing naming conventions +- Document complex types with JSDoc +- Maintain type safety across all imports + +## 🔗 Related Implementation + +### Main Process Services +- `src/main/data/CacheService.ts` - Main process cache management +- `src/main/data/PreferenceService.ts` - Preference management service +- `src/main/data/DataApiService.ts` - Data API coordination service + +### Renderer Process Services +- `src/renderer/src/data/CacheService.ts` - Renderer cache service +- `src/renderer/src/data/PreferenceService.ts` - Renderer preference service +- `src/renderer/src/data/DataApiService.ts` - Renderer API client \ No newline at end of file diff --git a/packages/shared/data/api/apiModels.ts b/packages/shared/data/api/apiModels.ts new file mode 100644 index 0000000000..08107a9729 --- /dev/null +++ b/packages/shared/data/api/apiModels.ts @@ -0,0 +1,107 @@ +/** + * Generic test model definitions + * Contains flexible types for comprehensive API testing + */ + +/** + * Generic test item entity - flexible structure for testing various scenarios + */ +export interface TestItem { + /** Unique identifier */ + id: string + /** Item title */ + title: string + /** Optional description */ + description?: string + /** Type category */ + type: string + /** Current status */ + status: string + /** Priority level */ + priority: string + /** Associated tags */ + tags: string[] + /** Creation timestamp */ + createdAt: string + /** Last update timestamp */ + updatedAt: string + /** Additional metadata */ + metadata: Record +} + +/** + * Data Transfer Objects (DTOs) for test operations + */ + +/** + * DTO for creating a new test item + */ +export interface CreateTestItemDto { + /** Item title */ + title: string + /** Optional description */ + description?: string + /** Type category */ + type?: string + /** Current status */ + status?: string + /** Priority level */ + priority?: string + /** Associated tags */ + tags?: string[] + /** Additional metadata */ + metadata?: Record +} + +/** + * DTO for updating an existing test item + */ +export interface UpdateTestItemDto { + /** Updated title */ + title?: string + /** Updated description */ + description?: string + /** Updated type */ + type?: string + /** Updated status */ + status?: string + /** Updated priority */ + priority?: string + /** Updated tags */ + tags?: string[] + /** Updated metadata */ + metadata?: Record +} + +/** + * Bulk operation types for batch processing + */ + +/** + * Request for bulk operations on multiple items + */ +export interface BulkOperationRequest { + /** Type of bulk operation to perform */ + operation: 'create' | 'update' | 'delete' | 'archive' | 'restore' + /** Array of data items to process */ + data: TData[] +} + +/** + * Response from a bulk operation + */ +export interface BulkOperationResponse { + /** Number of successfully processed items */ + successful: number + /** Number of items that failed processing */ + failed: number + /** Array of errors that occurred during processing */ + errors: Array<{ + /** Index of the item that failed */ + index: number + /** Error message */ + error: string + /** Optional additional error data */ + data?: any + }> +} diff --git a/packages/shared/data/api/apiPaths.ts b/packages/shared/data/api/apiPaths.ts new file mode 100644 index 0000000000..a947157869 --- /dev/null +++ b/packages/shared/data/api/apiPaths.ts @@ -0,0 +1,60 @@ +import type { ApiSchemas } from './apiSchemas' + +/** + * Template literal type utilities for converting parameterized paths to concrete paths + * This enables type-safe API calls with actual paths like '/test/items/123' instead of '/test/items/:id' + */ + +/** + * Convert parameterized path templates to concrete path types + * @example '/test/items/:id' -> '/test/items/${string}' + * @example '/topics/:id/messages' -> '/topics/${string}/messages' + */ +export type ResolvedPath = T extends `${infer Prefix}/:${string}/${infer Suffix}` + ? `${Prefix}/${string}/${ResolvedPath}` + : T extends `${infer Prefix}/:${string}` + ? `${Prefix}/${string}` + : T + +/** + * Generate all possible concrete paths from ApiSchemas + * This creates a union type of all valid API paths + */ +export type ConcreteApiPaths = { + [K in keyof ApiSchemas]: ResolvedPath +}[keyof ApiSchemas] + +/** + * Reverse lookup: from concrete path back to original template path + * Used to determine which ApiSchema entry matches a concrete path + */ +export type MatchApiPath = { + [K in keyof ApiSchemas]: Path extends ResolvedPath ? K : never +}[keyof ApiSchemas] + +/** + * Extract query parameters type for a given concrete path + */ +export type QueryParamsForPath = MatchApiPath extends keyof ApiSchemas + ? ApiSchemas[MatchApiPath] extends { GET: { query?: infer Q } } + ? Q + : Record + : Record + +/** + * Extract request body type for a given concrete path and HTTP method + */ +export type BodyForPath = MatchApiPath extends keyof ApiSchemas + ? ApiSchemas[MatchApiPath] extends { [M in Method]: { body: infer B } } + ? B + : any + : any + +/** + * Extract response type for a given concrete path and HTTP method + */ +export type ResponseForPath = MatchApiPath extends keyof ApiSchemas + ? ApiSchemas[MatchApiPath] extends { [M in Method]: { response: infer R } } + ? R + : any + : any diff --git a/packages/shared/data/api/apiSchemas.ts b/packages/shared/data/api/apiSchemas.ts new file mode 100644 index 0000000000..e405af806e --- /dev/null +++ b/packages/shared/data/api/apiSchemas.ts @@ -0,0 +1,487 @@ +// NOTE: Types are defined inline in the schema for simplicity +// If needed, specific types can be imported from './apiModels' +import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths' +import type { HttpMethod, PaginatedResponse, PaginationParams } from './apiTypes' + +// Re-export for external use +export type { ConcreteApiPaths } from './apiPaths' + +/** + * Complete API Schema definitions for Test API + * + * Each path defines the supported HTTP methods with their: + * - Request parameters (params, query, body) + * - Response types + * - Type safety guarantees + * + * This schema serves as the contract between renderer and main processes, + * enabling full TypeScript type checking across IPC boundaries. + */ +export interface ApiSchemas { + /** + * Test items collection endpoint + * @example GET /test/items?page=1&limit=10&search=hello + * @example POST /test/items { "title": "New Test Item" } + */ + '/test/items': { + /** List all test items with optional filtering and pagination */ + GET: { + query?: PaginationParams & { + /** Search items by title or description */ + search?: string + /** Filter by item type */ + type?: string + /** Filter by status */ + status?: string + } + response: PaginatedResponse + } + /** Create a new test item */ + POST: { + body: { + title: string + description?: string + type?: string + status?: string + priority?: string + tags?: string[] + metadata?: Record + } + response: any + } + } + + /** + * Individual test item endpoint + * @example GET /test/items/123 + * @example PUT /test/items/123 { "title": "Updated Title" } + * @example DELETE /test/items/123 + */ + '/test/items/:id': { + /** Get a specific test item by ID */ + GET: { + params: { id: string } + response: any + } + /** Update a specific test item */ + PUT: { + params: { id: string } + body: { + title?: string + description?: string + type?: string + status?: string + priority?: string + tags?: string[] + metadata?: Record + } + response: any + } + /** Delete a specific test item */ + DELETE: { + params: { id: string } + response: void + } + } + + /** + * Test search endpoint + * @example GET /test/search?query=hello&page=1&limit=20 + */ + '/test/search': { + /** Search test items */ + GET: { + query: { + /** Search query string */ + query: string + /** Page number for pagination */ + page?: number + /** Number of results per page */ + limit?: number + /** Additional filters */ + type?: string + status?: string + } + response: PaginatedResponse + } + } + + /** + * Test statistics endpoint + * @example GET /test/stats + */ + '/test/stats': { + /** Get comprehensive test statistics */ + GET: { + response: { + /** Total number of items */ + total: number + /** Item count grouped by type */ + byType: Record + /** Item count grouped by status */ + byStatus: Record + /** Item count grouped by priority */ + byPriority: Record + /** Recent activity timeline */ + recentActivity: Array<{ + /** Date of activity */ + date: string + /** Number of items on that date */ + count: number + }> + } + } + } + + /** + * Test bulk operations endpoint + * @example POST /test/bulk { "operation": "create", "data": [...] } + */ + '/test/bulk': { + /** Perform bulk operations on test items */ + POST: { + body: { + /** Operation type */ + operation: 'create' | 'update' | 'delete' + /** Array of data items to process */ + data: any[] + } + response: { + successful: number + failed: number + errors: string[] + } + } + } + + /** + * Test error simulation endpoint + * @example POST /test/error { "errorType": "timeout" } + */ + '/test/error': { + /** Simulate various error scenarios for testing */ + POST: { + body: { + /** Type of error to simulate */ + errorType: + | 'timeout' + | 'network' + | 'server' + | 'notfound' + | 'validation' + | 'unauthorized' + | 'ratelimit' + | 'generic' + } + response: never + } + } + + /** + * Test slow response endpoint + * @example POST /test/slow { "delay": 2000 } + */ + '/test/slow': { + /** Test slow response for performance testing */ + POST: { + body: { + /** Delay in milliseconds */ + delay: number + } + response: { + message: string + delay: number + timestamp: string + } + } + } + + /** + * Test data reset endpoint + * @example POST /test/reset + */ + '/test/reset': { + /** Reset all test data to initial state */ + POST: { + response: { + message: string + timestamp: string + } + } + } + + /** + * Test config endpoint + * @example GET /test/config + * @example PUT /test/config { "setting": "value" } + */ + '/test/config': { + /** Get test configuration */ + GET: { + response: Record + } + /** Update test configuration */ + PUT: { + body: Record + response: Record + } + } + + /** + * Test status endpoint + * @example GET /test/status + */ + '/test/status': { + /** Get system test status */ + GET: { + response: { + status: string + timestamp: string + version: string + uptime: number + environment: string + } + } + } + + /** + * Test performance endpoint + * @example GET /test/performance + */ + '/test/performance': { + /** Get performance metrics */ + GET: { + response: { + requestsPerSecond: number + averageLatency: number + memoryUsage: number + cpuUsage: number + uptime: number + } + } + } + + /** + * Batch execution of multiple requests + * @example POST /batch { "requests": [...], "parallel": true } + */ + '/batch': { + /** Execute multiple API requests in a single call */ + POST: { + body: { + /** Array of requests to execute */ + requests: Array<{ + /** HTTP method for the request */ + method: HttpMethod + /** API path for the request */ + path: string + /** URL parameters */ + params?: any + /** Request body */ + body?: any + }> + /** Execute requests in parallel vs sequential */ + parallel?: boolean + } + response: { + /** Results array matching input order */ + results: Array<{ + /** HTTP status code */ + status: number + /** Response data if successful */ + data?: any + /** Error information if failed */ + error?: any + }> + /** Batch execution metadata */ + metadata: { + /** Total execution duration in ms */ + duration: number + /** Number of successful requests */ + successCount: number + /** Number of failed requests */ + errorCount: number + } + } + } + } + + /** + * Atomic transaction of multiple operations + * @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } } + */ + '/transaction': { + /** Execute multiple operations in a database transaction */ + POST: { + body: { + /** Array of operations to execute atomically */ + operations: Array<{ + /** HTTP method for the operation */ + method: HttpMethod + /** API path for the operation */ + path: string + /** URL parameters */ + params?: any + /** Request body */ + body?: any + }> + /** Transaction configuration options */ + options?: { + /** Database isolation level */ + isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' + /** Rollback all operations on any error */ + rollbackOnError?: boolean + /** Transaction timeout in milliseconds */ + timeout?: number + } + } + response: Array<{ + /** HTTP status code */ + status: number + /** Response data if successful */ + data?: any + /** Error information if failed */ + error?: any + }> + } + } +} + +/** + * Simplified type extraction helpers + */ +export type ApiPaths = keyof ApiSchemas +export type ApiMethods = keyof ApiSchemas[TPath] & HttpMethod +export type ApiResponse = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { response: infer R } + ? R + : never + : never + : never + +export type ApiParams = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { params: infer P } + ? P + : never + : never + : never + +export type ApiQuery = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { query: infer Q } + ? Q + : never + : never + : never + +export type ApiBody = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { body: infer B } + ? B + : never + : never + : never + +/** + * Type-safe API client interface using concrete paths + * Accepts actual paths like '/test/items/123' instead of '/test/items/:id' + * Automatically infers query, body, and response types from ApiSchemas + */ +export interface ApiClient { + get( + path: TPath, + options?: { + query?: QueryParamsForPath + headers?: Record + } + ): Promise> + + post( + path: TPath, + options: { + body?: BodyForPath + query?: Record + headers?: Record + } + ): Promise> + + put( + path: TPath, + options: { + body: BodyForPath + query?: Record + headers?: Record + } + ): Promise> + + delete( + path: TPath, + options?: { + query?: Record + headers?: Record + } + ): Promise> + + patch( + path: TPath, + options: { + body?: BodyForPath + query?: Record + headers?: Record + } + ): Promise> +} + +/** + * Helper types to determine if parameters are required based on schema + */ +type HasRequiredQuery> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { query: any } + ? true + : false + : false + : false + +type HasRequiredBody> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { body: any } + ? true + : false + : false + : false + +type HasRequiredParams> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { params: any } + ? true + : false + : false + : false + +/** + * Handler function for a specific API endpoint + * Provides type-safe parameter extraction based on ApiSchemas + * Parameters are required or optional based on the schema definition + */ +export type ApiHandler> = ( + params: (HasRequiredParams extends true + ? { params: ApiParams } + : { params?: ApiParams }) & + (HasRequiredQuery extends true + ? { query: ApiQuery } + : { query?: ApiQuery }) & + (HasRequiredBody extends true ? { body: ApiBody } : { body?: ApiBody }) +) => Promise> + +/** + * Complete API implementation that must match ApiSchemas structure + * TypeScript will error if any endpoint is missing - this ensures exhaustive coverage + */ +export type ApiImplementation = { + [Path in ApiPaths]: { + [Method in ApiMethods]: ApiHandler + } +} diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts new file mode 100644 index 0000000000..e45c45603c --- /dev/null +++ b/packages/shared/data/api/apiTypes.ts @@ -0,0 +1,289 @@ +/** + * Core types for the Data API system + * Provides type definitions for request/response handling across renderer-main IPC communication + */ + +/** + * Standard HTTP methods supported by the Data API + */ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + +/** + * Request object structure for Data API calls + */ +export interface DataRequest { + /** Unique request identifier for tracking and correlation */ + id: string + /** HTTP method for the request */ + method: HttpMethod + /** API path (e.g., '/topics', '/topics/123') */ + path: string + /** URL parameters for the request */ + params?: Record + /** Request body data */ + body?: T + /** Request headers */ + headers?: Record + /** Additional metadata for request processing */ + metadata?: { + /** Request timestamp */ + timestamp: number + /** OpenTelemetry span context for tracing */ + spanContext?: any + /** Cache options for this specific request */ + cache?: CacheOptions + } +} + +/** + * Response object structure for Data API calls + */ +export interface DataResponse { + /** Request ID that this response corresponds to */ + id: string + /** HTTP status code */ + status: number + /** Response data if successful */ + data?: T + /** Error information if request failed */ + error?: DataApiError + /** Response metadata */ + metadata?: { + /** Request processing duration in milliseconds */ + duration: number + /** Whether response was served from cache */ + cached?: boolean + /** Cache TTL if applicable */ + cacheTtl?: number + /** Response timestamp */ + timestamp: number + } +} + +/** + * Standardized error structure for Data API + */ +export interface DataApiError { + /** Error code for programmatic handling */ + code: string + /** Human-readable error message */ + message: string + /** HTTP status code */ + status: number + /** Additional error details */ + details?: any + /** Error stack trace (development mode only) */ + stack?: string +} + +/** + * Standard error codes for Data API + */ +export enum ErrorCode { + // Client errors (4xx) + BAD_REQUEST = 'BAD_REQUEST', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', + VALIDATION_ERROR = 'VALIDATION_ERROR', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + + // Server errors (5xx) + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + DATABASE_ERROR = 'DATABASE_ERROR', + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', + + // Custom application errors + MIGRATION_ERROR = 'MIGRATION_ERROR', + PERMISSION_DENIED = 'PERMISSION_DENIED', + RESOURCE_LOCKED = 'RESOURCE_LOCKED', + CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION' +} + +/** + * Cache configuration options + */ +export interface CacheOptions { + /** Cache TTL in seconds */ + ttl?: number + /** Return stale data while revalidating in background */ + staleWhileRevalidate?: boolean + /** Custom cache key override */ + cacheKey?: string + /** Operations that should invalidate this cache entry */ + invalidateOn?: string[] + /** Whether to bypass cache entirely */ + noCache?: boolean +} + +/** + * Transaction request wrapper for atomic operations + */ +export interface TransactionRequest { + /** List of operations to execute in transaction */ + operations: DataRequest[] + /** Transaction options */ + options?: { + /** Database isolation level */ + isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' + /** Whether to rollback entire transaction on any error */ + rollbackOnError?: boolean + /** Transaction timeout in milliseconds */ + timeout?: number + } +} + +/** + * Batch request for multiple operations + */ +export interface BatchRequest { + /** List of requests to execute */ + requests: DataRequest[] + /** Whether to execute requests in parallel */ + parallel?: boolean + /** Stop on first error */ + stopOnError?: boolean +} + +/** + * Batch response containing results for all requests + */ +export interface BatchResponse { + /** Individual response for each request */ + results: DataResponse[] + /** Overall batch execution metadata */ + metadata: { + /** Total execution time */ + duration: number + /** Number of successful operations */ + successCount: number + /** Number of failed operations */ + errorCount: number + } +} + +/** + * Pagination parameters for list operations + */ +export interface PaginationParams { + /** Page number (1-based) */ + page?: number + /** Items per page */ + limit?: number + /** Cursor for cursor-based pagination */ + cursor?: string + /** Sort field and direction */ + sort?: { + field: string + order: 'asc' | 'desc' + } +} + +/** + * Paginated response wrapper + */ +export interface PaginatedResponse { + /** Items for current page */ + items: T[] + /** Total number of items */ + total: number + /** Current page number */ + page: number + /** Total number of pages */ + pageCount: number + /** Whether there are more pages */ + hasNext: boolean + /** Whether there are previous pages */ + hasPrev: boolean + /** Next cursor for cursor-based pagination */ + nextCursor?: string + /** Previous cursor for cursor-based pagination */ + prevCursor?: string +} + +/** + * Subscription options for real-time data updates + */ +export interface SubscriptionOptions { + /** Path pattern to subscribe to */ + path: string + /** Filters to apply to subscription */ + filters?: Record + /** Whether to receive initial data */ + includeInitial?: boolean + /** Custom subscription metadata */ + metadata?: Record +} + +/** + * Subscription callback function + */ +export type SubscriptionCallback = (data: T, event: SubscriptionEvent) => void + +/** + * Subscription event types + */ +export enum SubscriptionEvent { + CREATED = 'created', + UPDATED = 'updated', + DELETED = 'deleted', + INITIAL = 'initial', + ERROR = 'error' +} + +/** + * Middleware interface + */ +export interface Middleware { + /** Middleware name */ + name: string + /** Execution priority (lower = earlier) */ + priority?: number + /** Middleware execution function */ + execute(req: DataRequest, res: DataResponse, next: () => Promise): Promise +} + +/** + * Request context passed through middleware chain + */ +export interface RequestContext { + /** Original request */ + request: DataRequest + /** Response being built */ + response: DataResponse + /** Path that matched this request */ + path?: string + /** HTTP method */ + method?: HttpMethod + /** Authenticated user (if any) */ + user?: any + /** Additional context data */ + data: Map +} + +/** + * Base options for service operations + */ +export interface ServiceOptions { + /** Database transaction to use */ + transaction?: any + /** User context for authorization */ + user?: any + /** Additional service-specific options */ + metadata?: Record +} + +/** + * Standard service response wrapper + */ +export interface ServiceResult { + /** Whether operation was successful */ + success: boolean + /** Result data if successful */ + data?: T + /** Error information if failed */ + error?: DataApiError + /** Additional metadata */ + metadata?: Record +} diff --git a/packages/shared/data/api/errorCodes.ts b/packages/shared/data/api/errorCodes.ts new file mode 100644 index 0000000000..7ccb96c8c9 --- /dev/null +++ b/packages/shared/data/api/errorCodes.ts @@ -0,0 +1,194 @@ +/** + * Centralized error code definitions for the Data API system + * Provides consistent error handling across renderer and main processes + */ + +import type { DataApiError } from './apiTypes' +import { ErrorCode } from './apiTypes' + +// Re-export ErrorCode for convenience +export { ErrorCode } from './apiTypes' + +/** + * Error code to HTTP status mapping + */ +export const ERROR_STATUS_MAP: Record = { + // Client errors (4xx) + [ErrorCode.BAD_REQUEST]: 400, + [ErrorCode.UNAUTHORIZED]: 401, + [ErrorCode.FORBIDDEN]: 403, + [ErrorCode.NOT_FOUND]: 404, + [ErrorCode.METHOD_NOT_ALLOWED]: 405, + [ErrorCode.VALIDATION_ERROR]: 422, + [ErrorCode.RATE_LIMIT_EXCEEDED]: 429, + + // Server errors (5xx) + [ErrorCode.INTERNAL_SERVER_ERROR]: 500, + [ErrorCode.DATABASE_ERROR]: 500, + [ErrorCode.SERVICE_UNAVAILABLE]: 503, + + // Custom application errors (5xx) + [ErrorCode.MIGRATION_ERROR]: 500, + [ErrorCode.PERMISSION_DENIED]: 403, + [ErrorCode.RESOURCE_LOCKED]: 423, + [ErrorCode.CONCURRENT_MODIFICATION]: 409 +} + +/** + * Default error messages for each error code + */ +export const ERROR_MESSAGES: Record = { + [ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters', + [ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required', + [ErrorCode.FORBIDDEN]: 'Forbidden: Insufficient permissions', + [ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist', + [ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint', + [ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements', + [ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests', + + [ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred', + [ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data', + [ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable', + + [ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data', + [ErrorCode.PERMISSION_DENIED]: 'Permission denied: Operation not allowed for current user', + [ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation', + [ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user' +} + +/** + * Utility class for creating standardized Data API errors + */ +export class DataApiErrorFactory { + /** + * Create a DataApiError with standard properties + */ + static create(code: ErrorCode, customMessage?: string, details?: any, stack?: string): DataApiError { + return { + code, + message: customMessage || ERROR_MESSAGES[code], + status: ERROR_STATUS_MAP[code], + details, + stack: stack || undefined + } + } + + /** + * Create a validation error with field-specific details + */ + static validation(fieldErrors: Record, message?: string): DataApiError { + return this.create(ErrorCode.VALIDATION_ERROR, message || 'Request validation failed', { fieldErrors }) + } + + /** + * Create a not found error for specific resource + */ + static notFound(resource: string, id?: string): DataApiError { + const message = id ? `${resource} with id '${id}' not found` : `${resource} not found` + + return this.create(ErrorCode.NOT_FOUND, message, { resource, id }) + } + + /** + * Create a database error with query details + */ + static database(originalError: Error, operation?: string): DataApiError { + return this.create( + ErrorCode.DATABASE_ERROR, + `Database operation failed${operation ? `: ${operation}` : ''}`, + { + originalError: originalError.message, + operation + }, + originalError.stack + ) + } + + /** + * Create a permission denied error + */ + static permissionDenied(action: string, resource?: string): DataApiError { + const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}` + + return this.create(ErrorCode.PERMISSION_DENIED, message, { action, resource }) + } + + /** + * Create an internal server error from an unexpected error + */ + static internal(originalError: Error, context?: string): DataApiError { + const message = context + ? `Internal error in ${context}: ${originalError.message}` + : `Internal error: ${originalError.message}` + + return this.create( + ErrorCode.INTERNAL_SERVER_ERROR, + message, + { originalError: originalError.message, context }, + originalError.stack + ) + } + + /** + * Create a rate limit exceeded error + */ + static rateLimit(limit: number, windowMs: number): DataApiError { + return this.create(ErrorCode.RATE_LIMIT_EXCEEDED, `Rate limit exceeded: ${limit} requests per ${windowMs}ms`, { + limit, + windowMs + }) + } + + /** + * Create a resource locked error + */ + static resourceLocked(resource: string, id: string, lockedBy?: string): DataApiError { + const message = lockedBy + ? `${resource} '${id}' is locked by ${lockedBy}` + : `${resource} '${id}' is currently locked` + + return this.create(ErrorCode.RESOURCE_LOCKED, message, { resource, id, lockedBy }) + } + + /** + * Create a concurrent modification error + */ + static concurrentModification(resource: string, id: string): DataApiError { + return this.create(ErrorCode.CONCURRENT_MODIFICATION, `${resource} '${id}' was modified by another user`, { + resource, + id + }) + } +} + +/** + * Check if an error is a Data API error + */ +export function isDataApiError(error: any): error is DataApiError { + return ( + error && + typeof error === 'object' && + typeof error.code === 'string' && + typeof error.message === 'string' && + typeof error.status === 'number' + ) +} + +/** + * Convert a generic error to a DataApiError + */ +export function toDataApiError(error: unknown, context?: string): DataApiError { + if (isDataApiError(error)) { + return error + } + + if (error instanceof Error) { + return DataApiErrorFactory.internal(error, context) + } + + return DataApiErrorFactory.create( + ErrorCode.INTERNAL_SERVER_ERROR, + `Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`, + { originalError: error, context } + ) +} diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts new file mode 100644 index 0000000000..3b00e37473 --- /dev/null +++ b/packages/shared/data/api/index.ts @@ -0,0 +1,121 @@ +/** + * Cherry Studio Data API - Barrel Exports + * + * This file provides a centralized entry point for all data API types, + * schemas, and utilities. Import everything you need from this single location. + * + * @example + * ```typescript + * import { Topic, CreateTopicDto, ApiSchemas, DataRequest, ErrorCode } from '@/shared/data' + * ``` + */ + +// Core data API types and infrastructure +export type { + BatchRequest, + BatchResponse, + CacheOptions, + DataApiError, + DataRequest, + DataResponse, + HttpMethod, + Middleware, + PaginatedResponse, + PaginationParams, + RequestContext, + ServiceOptions, + ServiceResult, + SubscriptionCallback, + SubscriptionOptions, + TransactionRequest +} from './apiTypes' +export { ErrorCode, SubscriptionEvent } from './apiTypes' + +// Domain models and DTOs +export type { + BulkOperationRequest, + BulkOperationResponse, + CreateTestItemDto, + TestItem, + UpdateTestItemDto +} from './apiModels' + +// API schema definitions and type helpers +export type { + ApiBody, + ApiClient, + ApiMethods, + ApiParams, + ApiPaths, + ApiQuery, + ApiResponse, + ApiSchemas +} from './apiSchemas' + +// Path type utilities for template literal types +export type { + BodyForPath, + ConcreteApiPaths, + MatchApiPath, + QueryParamsForPath, + ResolvedPath, + ResponseForPath +} from './apiPaths' + +// Error handling utilities +export { + ErrorCode as DataApiErrorCode, + DataApiErrorFactory, + ERROR_MESSAGES, + ERROR_STATUS_MAP, + isDataApiError, + toDataApiError +} from './errorCodes' + +/** + * Re-export commonly used type combinations for convenience + */ + +// Import types for re-export convenience types +import type { CreateTestItemDto, TestItem, UpdateTestItemDto } from './apiModels' +import type { + BatchRequest, + BatchResponse, + DataApiError, + DataRequest, + DataResponse, + ErrorCode, + PaginatedResponse, + PaginationParams, + TransactionRequest +} from './apiTypes' +import type { DataApiErrorFactory } from './errorCodes' + +/** All test item-related types */ +export type TestItemTypes = { + TestItem: TestItem + CreateTestItemDto: CreateTestItemDto + UpdateTestItemDto: UpdateTestItemDto +} + +/** All error-related types and utilities */ +export type ErrorTypes = { + DataApiError: DataApiError + ErrorCode: ErrorCode + ErrorFactory: typeof DataApiErrorFactory +} + +/** All request/response types */ +export type RequestTypes = { + DataRequest: DataRequest + DataResponse: DataResponse + BatchRequest: BatchRequest + BatchResponse: BatchResponse + TransactionRequest: TransactionRequest +} + +/** All pagination-related types */ +export type PaginationTypes = { + PaginationParams: PaginationParams + PaginatedResponse: PaginatedResponse +} diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts new file mode 100644 index 0000000000..9be41126c0 --- /dev/null +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -0,0 +1,144 @@ +import type * as CacheValueTypes from './cacheValueTypes' + +/** + * Use cache schema for renderer hook + */ + +export type UseCacheSchema = { + // App state + 'app.dist.update_state': CacheValueTypes.CacheAppUpdateState + 'app.user.avatar': string + + // Chat context + 'chat.multi_select_mode': boolean + 'chat.selected_message_ids': string[] + 'chat.generating': boolean + 'chat.websearch.searching': boolean + 'chat.websearch.active_searches': CacheValueTypes.CacheActiveSearches + + // Minapp management + 'minapp.opened_keep_alive': CacheValueTypes.CacheMinAppType[] + 'minapp.current_id': string + 'minapp.show': boolean + 'minapp.opened_oneoff': CacheValueTypes.CacheMinAppType | null + + // Topic management + 'topic.active': CacheValueTypes.CacheTopic | null + 'topic.renaming': string[] + 'topic.newly_renamed': string[] + + // Test keys (for dataRefactorTest window) + // TODO: remove after testing + 'test-hook-memory-1': string + 'test-ttl-cache': string + 'test-protected-cache': string + 'test-deep-equal': { nested: { count: number }; tags: string[] } + 'test-performance': number + 'test-multi-hook': string + 'concurrent-test-1': number + 'concurrent-test-2': number + 'large-data-test': Record + 'test-number-cache': number + 'test-object-cache': { name: string; count: number; active: boolean } +} + +export const DefaultUseCache: UseCacheSchema = { + // App state + 'app.dist.update_state': { + info: null, + checking: false, + downloading: false, + downloaded: false, + downloadProgress: 0, + available: false + }, + 'app.user.avatar': '', + + // Chat context + 'chat.multi_select_mode': false, + 'chat.selected_message_ids': [], + 'chat.generating': false, + 'chat.websearch.searching': false, + 'chat.websearch.active_searches': {}, + + // Minapp management + 'minapp.opened_keep_alive': [], + 'minapp.current_id': '', + 'minapp.show': false, + 'minapp.opened_oneoff': null, + + // Topic management + 'topic.active': null, + 'topic.renaming': [], + 'topic.newly_renamed': [], + + // Test keys (for dataRefactorTest window) + // TODO: remove after testing + 'test-hook-memory-1': 'default-memory-value', + 'test-ttl-cache': 'test-ttl-cache', + 'test-protected-cache': 'protected-value', + 'test-deep-equal': { nested: { count: 0 }, tags: ['initial'] }, + 'test-performance': 0, + 'test-multi-hook': 'hook-1-default', + 'concurrent-test-1': 0, + 'concurrent-test-2': 0, + 'large-data-test': {}, + 'test-number-cache': 42, + 'test-object-cache': { name: 'test', count: 0, active: true } +} + +/** + * Use shared cache schema for renderer hook + */ +export type UseSharedCacheSchema = { + 'example-key': string + + // Test keys (for dataRefactorTest window) + // TODO: remove after testing + 'test-hook-shared-1': string + 'test-multi-hook': string + 'concurrent-shared': number +} + +export const DefaultUseSharedCache: UseSharedCacheSchema = { + 'example-key': 'example default value', + + // Test keys (for dataRefactorTest window) + // TODO: remove after testing + 'concurrent-shared': 0, + 'test-hook-shared-1': 'default-shared-value', + 'test-multi-hook': 'hook-3-shared' +} + +/** + * Persist cache schema defining allowed keys and their value types + * This ensures type safety and prevents key conflicts + */ +export type RendererPersistCacheSchema = { + 'example-key': string + + // Test keys (for dataRefactorTest window) + // TODO: remove after testing + 'example-1': string + 'example-2': string + 'example-3': string + 'example-4': string +} + +export const DefaultRendererPersistCache: RendererPersistCacheSchema = { + 'example-key': 'example default value', + + // Test keys (for dataRefactorTest window) + // TODO: remove after testing + 'example-1': 'example default value', + 'example-2': 'example default value', + 'example-3': 'example default value', + 'example-4': 'example default value' +} + +/** + * Type-safe cache key + */ +export type RendererPersistCacheKey = keyof RendererPersistCacheSchema +export type UseCacheKey = keyof UseCacheSchema +export type UseSharedCacheKey = keyof UseSharedCacheSchema diff --git a/packages/shared/data/cache/cacheTypes.ts b/packages/shared/data/cache/cacheTypes.ts new file mode 100644 index 0000000000..1ae71919bc --- /dev/null +++ b/packages/shared/data/cache/cacheTypes.ts @@ -0,0 +1,43 @@ +/** + * Cache types and interfaces for CacheService + * + * Supports three-layer caching architecture: + * 1. Memory cache (cross-component within renderer) + * 2. Shared cache (cross-window via IPC) + * 3. Persist cache (cross-window with localStorage persistence) + */ + +/** + * Cache entry with optional TTL support + */ +export interface CacheEntry { + value: T + expireAt?: number // Unix timestamp +} + +/** + * Cache synchronization message for IPC communication + */ +export interface CacheSyncMessage { + type: 'shared' | 'persist' + key: string + value: any + ttl?: number +} + +/** + * Batch cache synchronization message + */ +export interface CacheSyncBatchMessage { + type: 'shared' | 'persist' + entries: Array<{ + key: string + value: any + ttl?: number + }> +} + +/** + * Cache subscription callback + */ +export type CacheSubscriber = () => void diff --git a/packages/shared/data/cache/cacheValueTypes.ts b/packages/shared/data/cache/cacheValueTypes.ts new file mode 100644 index 0000000000..095f46a131 --- /dev/null +++ b/packages/shared/data/cache/cacheValueTypes.ts @@ -0,0 +1,18 @@ +import type { MinAppType, Topic, WebSearchStatus } from '@types' +import type { UpdateInfo } from 'builder-util-runtime' + +export type CacheAppUpdateState = { + info: UpdateInfo | null + checking: boolean + downloading: boolean + downloaded: boolean + downloadProgress: number + available: boolean +} + +export type CacheActiveSearches = Record + +// For cache schema, we use any for complex types to avoid circular dependencies +// The actual type checking will be done at runtime by the cache system +export type CacheMinAppType = MinAppType +export type CacheTopic = Topic diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts new file mode 100644 index 0000000000..6b758c0ff5 --- /dev/null +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -0,0 +1,687 @@ +/** + * Auto-generated preferences configuration + * Generated at: 2025-09-16T03:17:03.354Z + * + * This file is automatically generated from classification.json + * To update this file, modify classification.json and run: + * node .claude/data-classify/scripts/generate-preferences.js + * + * === AUTO-GENERATED CONTENT START === + */ + +import { TRANSLATE_PROMPT } from '@shared/config/prompts' +import * as PreferenceTypes from '@shared/data/preference/preferenceTypes' + +/* eslint @typescript-eslint/member-ordering: ["error", { + "interfaces": { "order": "alphabetically" }, + "typeLiterals": { "order": "alphabetically" } +}] */ + +export interface PreferenceSchemas { + default: { + // redux/settings/enableDeveloperMode + 'app.developer_mode.enabled': boolean + // redux/settings/disableHardwareAcceleration + 'app.disable_hardware_acceleration': boolean + // redux/settings/autoCheckUpdate + 'app.dist.auto_update.enabled': boolean + // redux/settings/testChannel + 'app.dist.test_plan.channel': PreferenceTypes.UpgradeChannel + // redux/settings/testPlan + 'app.dist.test_plan.enabled': boolean + // redux/settings/language + 'app.language': PreferenceTypes.LanguageVarious | null + // redux/settings/launchOnBoot + 'app.launch_on_boot': boolean + // redux/settings/notification.assistant + 'app.notification.assistant.enabled': boolean + // redux/settings/notification.backup + 'app.notification.backup.enabled': boolean + // redux/settings/notification.knowledge + 'app.notification.knowledge.enabled': boolean + // redux/settings/enableDataCollection + 'app.privacy.data_collection.enabled': boolean + // redux/settings/proxyBypassRules + 'app.proxy.bypass_rules': string + // redux/settings/proxyMode + 'app.proxy.mode': PreferenceTypes.ProxyMode + // redux/settings/proxyUrl + 'app.proxy.url': string + // redux/settings/enableSpellCheck + 'app.spell_check.enabled': boolean + // redux/settings/spellCheckLanguages + 'app.spell_check.languages': string[] + // redux/settings/tray + 'app.tray.enabled': boolean + // redux/settings/trayOnClose + 'app.tray.on_close': boolean + // redux/settings/launchToTray + 'app.tray.on_launch': boolean + // redux/settings/userId + 'app.user.id': string + // redux/settings/userName + 'app.user.name': string + // electronStore/ZoomFactor/ZoomFactor + 'app.zoom_factor': number + // redux/settings/clickAssistantToShowTopic + 'assistant.click_to_show_topic': boolean + // redux/settings/assistantIconType + 'assistant.icon_type': PreferenceTypes.AssistantIconType + // redux/settings/showAssistants + 'assistant.tab.show': boolean + // redux/settings/assistantsTabSortType + 'assistant.tab.sort_type': PreferenceTypes.AssistantTabSortType + // redux/settings/codeCollapsible + 'chat.code.collapsible': boolean + // redux/settings/codeEditor.autocompletion + 'chat.code.editor.autocompletion': boolean + // redux/settings/codeEditor.enabled + 'chat.code.editor.enabled': boolean + // redux/settings/codeEditor.foldGutter + 'chat.code.editor.fold_gutter': boolean + // redux/settings/codeEditor.highlightActiveLine + 'chat.code.editor.highlight_active_line': boolean + // redux/settings/codeEditor.keymap + 'chat.code.editor.keymap': boolean + // redux/settings/codeEditor.themeDark + 'chat.code.editor.theme_dark': string + // redux/settings/codeEditor.themeLight + 'chat.code.editor.theme_light': string + // redux/settings/codeExecution.enabled + 'chat.code.execution.enabled': boolean + // redux/settings/codeExecution.timeoutMinutes + 'chat.code.execution.timeout_minutes': number + // redux/settings/codeFancyBlock + 'chat.code.fancy_block': boolean + // redux/settings/codeImageTools + 'chat.code.image_tools': boolean + // redux/settings/codePreview.themeDark + 'chat.code.preview.theme_dark': string + // redux/settings/codePreview.themeLight + 'chat.code.preview.theme_light': string + // redux/settings/codeShowLineNumbers + 'chat.code.show_line_numbers': boolean + // redux/settings/codeViewer.themeDark + 'chat.code.viewer.theme_dark': string + // redux/settings/codeViewer.themeLight + 'chat.code.viewer.theme_light': string + // redux/settings/codeWrappable + 'chat.code.wrappable': boolean + // redux/settings/pasteLongTextAsFile + 'chat.input.paste_long_text_as_file': boolean + // redux/settings/pasteLongTextThreshold + 'chat.input.paste_long_text_threshold': number + // redux/settings/enableQuickPanelTriggers + 'chat.input.quick_panel.triggers_enabled': boolean + // redux/settings/sendMessageShortcut + 'chat.input.send_message_shortcut': PreferenceTypes.SendMessageShortcut + // redux/settings/showInputEstimatedTokens + 'chat.input.show_estimated_tokens': boolean + // redux/settings/autoTranslateWithSpace + 'chat.input.translate.auto_translate_with_space': boolean + // redux/settings/showTranslateConfirm + 'chat.input.translate.show_confirm': boolean + // redux/settings/confirmDeleteMessage + 'chat.message.confirm_delete': boolean + // redux/settings/confirmRegenerateMessage + 'chat.message.confirm_regenerate': boolean + // redux/settings/messageFont + 'chat.message.font': string + // redux/settings/fontSize + 'chat.message.font_size': number + // redux/settings/mathEngine + 'chat.message.math.engine': PreferenceTypes.MathEngine + // redux/settings/mathEnableSingleDollar + 'chat.message.math.single_dollar': boolean + // redux/settings/foldDisplayMode + 'chat.message.multi_model.fold_display_mode': PreferenceTypes.MultiModelFoldDisplayMode + // redux/settings/gridColumns + 'chat.message.multi_model.grid_columns': number + // redux/settings/gridPopoverTrigger + 'chat.message.multi_model.grid_popover_trigger': PreferenceTypes.MultiModelGridPopoverTrigger + // redux/settings/multiModelMessageStyle + 'chat.message.multi_model.style': PreferenceTypes.MultiModelMessageStyle + // redux/settings/messageNavigation + 'chat.message.navigation_mode': PreferenceTypes.ChatMessageNavigationMode + // redux/settings/renderInputMessageAsMarkdown + 'chat.message.render_as_markdown': boolean + // redux/settings/showMessageDivider + 'chat.message.show_divider': boolean + // redux/settings/showMessageOutline + 'chat.message.show_outline': boolean + // redux/settings/showPrompt + 'chat.message.show_prompt': boolean + // redux/settings/messageStyle + 'chat.message.style': PreferenceTypes.ChatMessageStyle + // redux/settings/thoughtAutoCollapse + 'chat.message.thought.auto_collapse': boolean + // redux/settings/narrowMode + 'chat.narrow_mode': boolean + // redux/settings/skipBackupFile + 'data.backup.general.skip_backup_file': boolean + // redux/settings/localBackupAutoSync + 'data.backup.local.auto_sync': boolean + // redux/settings/localBackupDir + 'data.backup.local.dir': string + // redux/settings/localBackupMaxBackups + 'data.backup.local.max_backups': number + // redux/settings/localBackupSkipBackupFile + 'data.backup.local.skip_backup_file': boolean + // redux/settings/localBackupSyncInterval + 'data.backup.local.sync_interval': number + // redux/nutstore/nutstoreAutoSync + 'data.backup.nutstore.auto_sync': boolean + // redux/nutstore/nutstoreMaxBackups + 'data.backup.nutstore.max_backups': number + // redux/nutstore/nutstorePath + 'data.backup.nutstore.path': string + // redux/nutstore/nutstoreSkipBackupFile + 'data.backup.nutstore.skip_backup_file': boolean + // redux/nutstore/nutstoreSyncInterval + 'data.backup.nutstore.sync_interval': number + // redux/nutstore/nutstoreToken + 'data.backup.nutstore.token': string + // redux/settings/s3.accessKeyId + 'data.backup.s3.access_key_id': string + // redux/settings/s3.autoSync + 'data.backup.s3.auto_sync': boolean + // redux/settings/s3.bucket + 'data.backup.s3.bucket': string + // redux/settings/s3.endpoint + 'data.backup.s3.endpoint': string + // redux/settings/s3.maxBackups + 'data.backup.s3.max_backups': number + // redux/settings/s3.region + 'data.backup.s3.region': string + // redux/settings/s3.root + 'data.backup.s3.root': string + // redux/settings/s3.secretAccessKey + 'data.backup.s3.secret_access_key': string + // redux/settings/s3.skipBackupFile + 'data.backup.s3.skip_backup_file': boolean + // redux/settings/s3.syncInterval + 'data.backup.s3.sync_interval': number + // redux/settings/webdavAutoSync + 'data.backup.webdav.auto_sync': boolean + // redux/settings/webdavDisableStream + 'data.backup.webdav.disable_stream': boolean + // redux/settings/webdavHost + 'data.backup.webdav.host': string + // redux/settings/webdavMaxBackups + 'data.backup.webdav.max_backups': number + // redux/settings/webdavPass + 'data.backup.webdav.pass': string + // redux/settings/webdavPath + 'data.backup.webdav.path': string + // redux/settings/webdavSkipBackupFile + 'data.backup.webdav.skip_backup_file': boolean + // redux/settings/webdavSyncInterval + 'data.backup.webdav.sync_interval': number + // redux/settings/webdavUser + 'data.backup.webdav.user': string + // redux/settings/excludeCitationsInExport + 'data.export.markdown.exclude_citations': boolean + // redux/settings/forceDollarMathInMarkdown + 'data.export.markdown.force_dollar_math': boolean + // redux/settings/markdownExportPath + 'data.export.markdown.path': string | null + // redux/settings/showModelNameInMarkdown + 'data.export.markdown.show_model_name': boolean + // redux/settings/showModelProviderInMarkdown + 'data.export.markdown.show_model_provider': boolean + // redux/settings/standardizeCitationsInExport + 'data.export.markdown.standardize_citations': boolean + // redux/settings/useTopicNamingForMessageTitle + 'data.export.markdown.use_topic_naming_for_message_title': boolean + // redux/settings/exportMenuOptions.docx + 'data.export.menus.docx': boolean + // redux/settings/exportMenuOptions.image + 'data.export.menus.image': boolean + // redux/settings/exportMenuOptions.joplin + 'data.export.menus.joplin': boolean + // redux/settings/exportMenuOptions.markdown + 'data.export.menus.markdown': boolean + // redux/settings/exportMenuOptions.markdown_reason + 'data.export.menus.markdown_reason': boolean + // redux/settings/exportMenuOptions.notes + 'data.export.menus.notes': boolean + // redux/settings/exportMenuOptions.notion + 'data.export.menus.notion': boolean + // redux/settings/exportMenuOptions.obsidian + 'data.export.menus.obsidian': boolean + // redux/settings/exportMenuOptions.plain_text + 'data.export.menus.plain_text': boolean + // redux/settings/exportMenuOptions.siyuan + 'data.export.menus.siyuan': boolean + // redux/settings/exportMenuOptions.yuque + 'data.export.menus.yuque': boolean + // redux/settings/joplinExportReasoning + 'data.integration.joplin.export_reasoning': boolean + // redux/settings/joplinToken + 'data.integration.joplin.token': string + // redux/settings/joplinUrl + 'data.integration.joplin.url': string + // redux/settings/notionApiKey + 'data.integration.notion.api_key': string + // redux/settings/notionDatabaseID + 'data.integration.notion.database_id': string + // redux/settings/notionExportReasoning + 'data.integration.notion.export_reasoning': boolean + // redux/settings/notionPageNameKey + 'data.integration.notion.page_name_key': string + // redux/settings/defaultObsidianVault + 'data.integration.obsidian.default_vault': string + // redux/settings/siyuanApiUrl + 'data.integration.siyuan.api_url': string | null + // redux/settings/siyuanBoxId + 'data.integration.siyuan.box_id': string | null + // redux/settings/siyuanRootPath + 'data.integration.siyuan.root_path': string | null + // redux/settings/siyuanToken + 'data.integration.siyuan.token': string | null + // redux/settings/yuqueRepoId + 'data.integration.yuque.repo_id': string + // redux/settings/yuqueToken + 'data.integration.yuque.token': string + // redux/settings/yuqueUrl + 'data.integration.yuque.url': string + // redux/settings/apiServer.apiKey + 'feature.csaas.api_key': string + // redux/settings/apiServer.enabled + 'feature.csaas.enabled': boolean + // redux/settings/apiServer.host + 'feature.csaas.host': string + // redux/settings/apiServer.port + 'feature.csaas.port': number + // redux/settings/maxKeepAliveMinapps + 'feature.minapp.max_keep_alive': number + // redux/settings/minappsOpenLinkExternal + 'feature.minapp.open_link_external': boolean + // redux/settings/showOpenedMinappsInSidebar + 'feature.minapp.show_opened_in_sidebar': boolean + // redux/note/settings.defaultEditMode + 'feature.notes.default_edit_mode': string + // redux/note/settings.defaultViewMode + 'feature.notes.default_view_mode': string + // redux/note/settings.fontFamily + 'feature.notes.font_family': string + // redux/note/settings.fontSize + 'feature.notes.font_size': number + // redux/note/settings.isFullWidth + 'feature.notes.full_width': boolean + // redux/note/notesPath + 'feature.notes.path': string + // redux/note/settings.showTabStatus + 'feature.notes.show_tab_status': boolean + // redux/note/settings.showTableOfContents + 'feature.notes.show_table_of_contents': boolean + // redux/note/settings.showWorkspace + 'feature.notes.show_workspace': boolean + // redux/note/sortType + 'feature.notes.sort_type': string + // redux/settings/clickTrayToShowQuickAssistant + 'feature.quick_assistant.click_tray_to_show': boolean + // redux/settings/enableQuickAssistant + 'feature.quick_assistant.enabled': boolean + // redux/settings/readClipboardAtStartup + 'feature.quick_assistant.read_clipboard_at_startup': boolean + // redux/selectionStore/actionItems + 'feature.selection.action_items': PreferenceTypes.SelectionActionItem[] + // redux/selectionStore/actionWindowOpacity + 'feature.selection.action_window_opacity': number + // redux/selectionStore/isAutoClose + 'feature.selection.auto_close': boolean + // redux/selectionStore/isAutoPin + 'feature.selection.auto_pin': boolean + // redux/selectionStore/isCompact + 'feature.selection.compact': boolean + // redux/selectionStore/selectionEnabled + 'feature.selection.enabled': boolean + // redux/selectionStore/filterList + 'feature.selection.filter_list': string[] + // redux/selectionStore/filterMode + 'feature.selection.filter_mode': PreferenceTypes.SelectionFilterMode + // redux/selectionStore/isFollowToolbar + 'feature.selection.follow_toolbar': boolean + // redux/selectionStore/isRemeberWinSize + 'feature.selection.remember_win_size': boolean + // redux/selectionStore/triggerMode + 'feature.selection.trigger_mode': PreferenceTypes.SelectionTriggerMode + // redux/settings/translateModelPrompt + 'feature.translate.model_prompt': string + // redux/settings/targetLanguage + 'feature.translate.target_language': string + // redux/shortcuts/shortcuts.exit_fullscreen + 'shortcut.app.exit_fullscreen': Record + // redux/shortcuts/shortcuts.search_message + 'shortcut.app.search_message': Record + // redux/shortcuts/shortcuts.show_app + 'shortcut.app.show_main_window': Record + // redux/shortcuts/shortcuts.mini_window + 'shortcut.app.show_mini_window': Record + // redux/shortcuts/shortcuts.show_settings + 'shortcut.app.show_settings': Record + // redux/shortcuts/shortcuts.toggle_show_assistants + 'shortcut.app.toggle_show_assistants': Record + // redux/shortcuts/shortcuts.zoom_in + 'shortcut.app.zoom_in': Record + // redux/shortcuts/shortcuts.zoom_out + 'shortcut.app.zoom_out': Record + // redux/shortcuts/shortcuts.zoom_reset + 'shortcut.app.zoom_reset': Record + // redux/shortcuts/shortcuts.clear_topic + 'shortcut.chat.clear': Record + // redux/shortcuts/shortcuts.copy_last_message + 'shortcut.chat.copy_last_message': Record + // redux/shortcuts/shortcuts.search_message_in_chat + 'shortcut.chat.search_message': Record + // redux/shortcuts/shortcuts.toggle_new_context + 'shortcut.chat.toggle_new_context': Record + // redux/shortcuts/shortcuts.selection_assistant_select_text + 'shortcut.selection.get_text': Record + // redux/shortcuts/shortcuts.selection_assistant_toggle + 'shortcut.selection.toggle_enabled': Record + // redux/shortcuts/shortcuts.new_topic + 'shortcut.topic.new': Record + // redux/settings/enableTopicNaming + 'topic.naming.enabled': boolean + // redux/settings/topicNamingPrompt + 'topic.naming_prompt': string + // redux/settings/topicPosition + 'topic.position': string + // redux/settings/pinTopicsToTop + 'topic.tab.pin_to_top': boolean + // redux/settings/showTopics + 'topic.tab.show': boolean + // redux/settings/showTopicTime + 'topic.tab.show_time': boolean + // redux/settings/customCss + 'ui.custom_css': string + // redux/settings/navbarPosition + 'ui.navbar.position': 'left' | 'top' + // redux/settings/sidebarIcons.disabled + 'ui.sidebar.icons.invisible': PreferenceTypes.SidebarIcon[] + // redux/settings/sidebarIcons.visible + 'ui.sidebar.icons.visible': PreferenceTypes.SidebarIcon[] + // redux/settings/theme + 'ui.theme_mode': PreferenceTypes.ThemeMode + // redux/settings/userTheme.userCodeFontFamily + 'ui.theme_user.code_font_family': string + // redux/settings/userTheme.colorPrimary + 'ui.theme_user.color_primary': string + // redux/settings/userTheme.userFontFamily + 'ui.theme_user.font_family': string + // redux/settings/windowStyle + 'ui.window_style': PreferenceTypes.WindowStyle + } +} + +/* eslint sort-keys: ["error", "asc", {"caseSensitive": true, "natural": false}] */ +export const DefaultPreferences: PreferenceSchemas = { + default: { + 'app.developer_mode.enabled': false, + 'app.disable_hardware_acceleration': false, + 'app.dist.auto_update.enabled': true, + 'app.dist.test_plan.channel': PreferenceTypes.UpgradeChannel.LATEST, + 'app.dist.test_plan.enabled': false, + 'app.language': null, + 'app.launch_on_boot': false, + 'app.notification.assistant.enabled': false, + 'app.notification.backup.enabled': false, + 'app.notification.knowledge.enabled': false, + 'app.privacy.data_collection.enabled': false, + 'app.proxy.bypass_rules': '', + 'app.proxy.mode': 'system', + 'app.proxy.url': '', + 'app.spell_check.enabled': false, + 'app.spell_check.languages': [], + 'app.tray.enabled': true, + 'app.tray.on_close': true, + 'app.tray.on_launch': false, + 'app.user.id': 'uuid()', + 'app.user.name': '', + 'app.zoom_factor': 1, + 'assistant.click_to_show_topic': true, + 'assistant.icon_type': 'emoji', + 'assistant.tab.show': true, + 'assistant.tab.sort_type': 'list', + 'chat.code.collapsible': false, + 'chat.code.editor.autocompletion': true, + 'chat.code.editor.enabled': false, + 'chat.code.editor.fold_gutter': false, + 'chat.code.editor.highlight_active_line': false, + 'chat.code.editor.keymap': false, + 'chat.code.editor.theme_dark': 'auto', + 'chat.code.editor.theme_light': 'auto', + 'chat.code.execution.enabled': false, + 'chat.code.execution.timeout_minutes': 1, + 'chat.code.fancy_block': true, + 'chat.code.image_tools': false, + 'chat.code.preview.theme_dark': 'auto', + 'chat.code.preview.theme_light': 'auto', + 'chat.code.show_line_numbers': false, + 'chat.code.viewer.theme_dark': 'auto', + 'chat.code.viewer.theme_light': 'auto', + 'chat.code.wrappable': false, + 'chat.input.paste_long_text_as_file': false, + 'chat.input.paste_long_text_threshold': 1500, + 'chat.input.quick_panel.triggers_enabled': false, + 'chat.input.send_message_shortcut': 'Enter', + 'chat.input.show_estimated_tokens': false, + 'chat.input.translate.auto_translate_with_space': false, + 'chat.input.translate.show_confirm': true, + 'chat.message.confirm_delete': true, + 'chat.message.confirm_regenerate': true, + 'chat.message.font': 'system', + 'chat.message.font_size': 14, + 'chat.message.math.engine': 'KaTeX', + 'chat.message.math.single_dollar': true, + 'chat.message.multi_model.fold_display_mode': 'expanded', + 'chat.message.multi_model.grid_columns': 2, + 'chat.message.multi_model.grid_popover_trigger': 'click', + 'chat.message.multi_model.style': 'horizontal', + 'chat.message.navigation_mode': 'none', + 'chat.message.render_as_markdown': false, + 'chat.message.show_divider': true, + 'chat.message.show_outline': false, + 'chat.message.show_prompt': true, + 'chat.message.style': 'plain', + 'chat.message.thought.auto_collapse': true, + 'chat.narrow_mode': false, + 'data.backup.general.skip_backup_file': false, + 'data.backup.local.auto_sync': false, + 'data.backup.local.dir': '', + 'data.backup.local.max_backups': 0, + 'data.backup.local.skip_backup_file': false, + 'data.backup.local.sync_interval': 0, + 'data.backup.nutstore.auto_sync': false, + 'data.backup.nutstore.max_backups': 0, + 'data.backup.nutstore.path': '/cherry-studio', + 'data.backup.nutstore.skip_backup_file': false, + 'data.backup.nutstore.sync_interval': 0, + 'data.backup.nutstore.token': '', + 'data.backup.s3.access_key_id': '', + 'data.backup.s3.auto_sync': false, + 'data.backup.s3.bucket': '', + 'data.backup.s3.endpoint': '', + 'data.backup.s3.max_backups': 0, + 'data.backup.s3.region': '', + 'data.backup.s3.root': '', + 'data.backup.s3.secret_access_key': '', + 'data.backup.s3.skip_backup_file': false, + 'data.backup.s3.sync_interval': 0, + 'data.backup.webdav.auto_sync': false, + 'data.backup.webdav.disable_stream': false, + 'data.backup.webdav.host': '', + 'data.backup.webdav.max_backups': 0, + 'data.backup.webdav.pass': '', + 'data.backup.webdav.path': '/cherry-studio', + 'data.backup.webdav.skip_backup_file': false, + 'data.backup.webdav.sync_interval': 0, + 'data.backup.webdav.user': '', + 'data.export.markdown.exclude_citations': false, + 'data.export.markdown.force_dollar_math': false, + 'data.export.markdown.path': null, + 'data.export.markdown.show_model_name': false, + 'data.export.markdown.show_model_provider': false, + 'data.export.markdown.standardize_citations': false, + 'data.export.markdown.use_topic_naming_for_message_title': false, + 'data.export.menus.docx': true, + 'data.export.menus.image': true, + 'data.export.menus.joplin': true, + 'data.export.menus.markdown': true, + 'data.export.menus.markdown_reason': true, + 'data.export.menus.notes': true, + 'data.export.menus.notion': true, + 'data.export.menus.obsidian': true, + 'data.export.menus.plain_text': true, + 'data.export.menus.siyuan': true, + 'data.export.menus.yuque': true, + 'data.integration.joplin.export_reasoning': false, + 'data.integration.joplin.token': '', + 'data.integration.joplin.url': '', + 'data.integration.notion.api_key': '', + 'data.integration.notion.database_id': '', + 'data.integration.notion.export_reasoning': false, + 'data.integration.notion.page_name_key': 'Name', + 'data.integration.obsidian.default_vault': '', + 'data.integration.siyuan.api_url': null, + 'data.integration.siyuan.box_id': null, + 'data.integration.siyuan.root_path': null, + 'data.integration.siyuan.token': null, + 'data.integration.yuque.repo_id': '', + 'data.integration.yuque.token': '', + 'data.integration.yuque.url': '', + 'feature.csaas.api_key': '`cs-sk-${uuid()}`', + 'feature.csaas.enabled': false, + 'feature.csaas.host': 'localhost', + 'feature.csaas.port': 23333, + 'feature.minapp.max_keep_alive': 3, + 'feature.minapp.open_link_external': false, + 'feature.minapp.show_opened_in_sidebar': true, + 'feature.notes.default_edit_mode': 'preview', + 'feature.notes.default_view_mode': 'edit', + 'feature.notes.font_family': 'default', + 'feature.notes.font_size': 16, + 'feature.notes.full_width': true, + 'feature.notes.path': '', + 'feature.notes.show_tab_status': true, + 'feature.notes.show_table_of_contents': true, + 'feature.notes.show_workspace': true, + 'feature.notes.sort_type': 'sort_a2z', + 'feature.quick_assistant.click_tray_to_show': false, + 'feature.quick_assistant.enabled': false, + 'feature.quick_assistant.read_clipboard_at_startup': true, + 'feature.selection.action_items': [ + { + enabled: true, + icon: 'languages', + id: 'translate', + isBuiltIn: true, + name: 'selection.action.builtin.translate' + }, + { + enabled: true, + icon: 'file-question', + id: 'explain', + isBuiltIn: true, + name: 'selection.action.builtin.explain' + }, + { enabled: true, icon: 'scan-text', id: 'summary', isBuiltIn: true, name: 'selection.action.builtin.summary' }, + { + enabled: true, + icon: 'search', + id: 'search', + isBuiltIn: true, + name: 'selection.action.builtin.search', + searchEngine: 'Google|https://www.google.com/search?q={{queryString}}' + }, + { enabled: true, icon: 'clipboard-copy', id: 'copy', isBuiltIn: true, name: 'selection.action.builtin.copy' }, + { enabled: false, icon: 'wand-sparkles', id: 'refine', isBuiltIn: true, name: 'selection.action.builtin.refine' }, + { enabled: false, icon: 'quote', id: 'quote', isBuiltIn: true, name: 'selection.action.builtin.quote' } + ], + 'feature.selection.action_window_opacity': 100, + 'feature.selection.auto_close': false, + 'feature.selection.auto_pin': false, + 'feature.selection.compact': false, + 'feature.selection.enabled': false, + 'feature.selection.filter_list': [], + 'feature.selection.filter_mode': PreferenceTypes.SelectionFilterMode.Default, + 'feature.selection.follow_toolbar': true, + 'feature.selection.remember_win_size': false, + 'feature.selection.trigger_mode': PreferenceTypes.SelectionTriggerMode.Selected, + 'feature.translate.model_prompt': TRANSLATE_PROMPT, + 'feature.translate.target_language': 'en-us', + 'shortcut.app.exit_fullscreen': { editable: false, enabled: true, key: ['Escape'], system: true }, + 'shortcut.app.search_message': { + editable: true, + enabled: true, + key: ['CommandOrControl', 'Shift', 'F'], + system: false + }, + 'shortcut.app.show_main_window': { editable: true, enabled: true, key: [], system: true }, + 'shortcut.app.show_mini_window': { editable: true, enabled: false, key: ['CommandOrControl', 'E'], system: true }, + 'shortcut.app.show_settings': { editable: false, enabled: true, key: ['CommandOrControl', ','], system: true }, + 'shortcut.app.toggle_show_assistants': { + editable: true, + enabled: true, + key: ['CommandOrControl', '['], + system: false + }, + 'shortcut.app.zoom_in': { editable: false, enabled: true, key: ['CommandOrControl', '='], system: true }, + 'shortcut.app.zoom_out': { editable: false, enabled: true, key: ['CommandOrControl', '-'], system: true }, + 'shortcut.app.zoom_reset': { editable: false, enabled: true, key: ['CommandOrControl', '0'], system: true }, + 'shortcut.chat.clear': { editable: true, enabled: true, key: ['CommandOrControl', 'L'], system: false }, + 'shortcut.chat.copy_last_message': { + editable: true, + enabled: false, + key: ['CommandOrControl', 'Shift', 'C'], + system: false + }, + 'shortcut.chat.search_message': { editable: true, enabled: true, key: ['CommandOrControl', 'F'], system: false }, + 'shortcut.chat.toggle_new_context': { + editable: true, + enabled: true, + key: ['CommandOrControl', 'K'], + system: false + }, + 'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true }, + 'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true }, + 'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false }, + 'topic.naming.enabled': true, + 'topic.naming_prompt': '', + 'topic.position': 'left', + 'topic.tab.pin_to_top': false, + 'topic.tab.show': true, + 'topic.tab.show_time': false, + 'ui.custom_css': '', + 'ui.navbar.position': 'top', + 'ui.sidebar.icons.invisible': [], + 'ui.sidebar.icons.visible': [ + 'assistants', + 'store', + 'paintings', + 'translate', + 'minapp', + 'knowledge', + 'files', + 'code_tools', + 'notes' + ], + 'ui.theme_mode': PreferenceTypes.ThemeMode.system, + 'ui.theme_user.code_font_family': '', + 'ui.theme_user.color_primary': '#00b96b', + 'ui.theme_user.font_family': '', + 'ui.window_style': 'opaque' + } +} + +// === AUTO-GENERATED CONTENT END === + +/** + * 生成统计: + * - 总配置项: 197 + * - electronStore项: 1 + * - redux项: 196 + * - localStorage项: 0 + */ diff --git a/packages/shared/data/preference/preferenceTypes.ts b/packages/shared/data/preference/preferenceTypes.ts new file mode 100644 index 0000000000..182504e4dd --- /dev/null +++ b/packages/shared/data/preference/preferenceTypes.ts @@ -0,0 +1,97 @@ +import type { PreferenceSchemas } from './preferenceSchemas' + +export type PreferenceDefaultScopeType = PreferenceSchemas['default'] +export type PreferenceKeyType = keyof PreferenceDefaultScopeType + +export type PreferenceUpdateOptions = { + optimistic: boolean +} + +export type PreferenceShortcutType = { + key: string[] + editable: boolean + enabled: boolean + system: boolean +} + +export enum SelectionTriggerMode { + Selected = 'selected', + Ctrlkey = 'ctrlkey', + Shortcut = 'shortcut' +} + +export enum SelectionFilterMode { + Default = 'default', + Whitelist = 'whitelist', + Blacklist = 'blacklist' +} + +export type SelectionActionItem = { + id: string + name: string + enabled: boolean + isBuiltIn: boolean + icon?: string + prompt?: string + assistantId?: string + selectedText?: string + searchEngine?: string +} + +export enum ThemeMode { + light = 'light', + dark = 'dark', + system = 'system' +} + +/** 有限的UI语言 */ +export type LanguageVarious = + | 'zh-CN' + | 'zh-TW' + | 'el-GR' + | 'en-US' + | 'es-ES' + | 'fr-FR' + | 'ja-JP' + | 'pt-PT' + | 'ru-RU' + | 'de-DE' + +export type WindowStyle = 'transparent' | 'opaque' + +export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter' + +export type AssistantTabSortType = 'tags' | 'list' + +export type SidebarIcon = + | 'assistants' + | 'store' + | 'paintings' + | 'translate' + | 'minapp' + | 'knowledge' + | 'files' + | 'code_tools' + | 'notes' + +export type AssistantIconType = 'model' | 'emoji' | 'none' + +export type ProxyMode = 'system' | 'custom' | 'none' + +export type MultiModelFoldDisplayMode = 'expanded' | 'compact' + +export type MathEngine = 'KaTeX' | 'MathJax' | 'none' + +export enum UpgradeChannel { + LATEST = 'latest', // 最新稳定版本 + RC = 'rc', // 公测版本 + BETA = 'beta' // 预览版本 +} + +export type ChatMessageStyle = 'plain' | 'bubble' + +export type ChatMessageNavigationMode = 'none' | 'buttons' | 'anchor' + +export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' + +export type MultiModelGridPopoverTrigger = 'hover' | 'click' diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore new file mode 100644 index 0000000000..df5ef5bc6d --- /dev/null +++ b/packages/ui/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +*.log +.DS_Store + +# Storybook build output +storybook-static/ + +# IDE +.vscode/ +.idea/ + +# Temporary files +*.tmp +*.temp diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts new file mode 100644 index 0000000000..c4f6756680 --- /dev/null +++ b/packages/ui/.storybook/main.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from 'node:url' + +import type { StorybookConfig } from '@storybook/react-vite' +import { dirname, resolve } from 'path' + +const config: StorybookConfig = { + stories: ['../stories/components/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [getAbsolutePath('@storybook/addon-docs'), getAbsolutePath('@storybook/addon-themes')], + framework: getAbsolutePath('@storybook/react-vite'), + viteFinal: async (config) => { + const { mergeConfig } = await import('vite') + const tailwindPlugin = (await import('@tailwindcss/vite')).default + return mergeConfig(config, { + plugins: [tailwindPlugin()], + resolve: { + alias: { + '@cherrystudio/ui': resolve('src') + } + } + }) + } +} + +export default config + +function getAbsolutePath(value: string): any { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))) +} diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx new file mode 100644 index 0000000000..295cf2b614 --- /dev/null +++ b/packages/ui/.storybook/preview.tsx @@ -0,0 +1,26 @@ +import '../stories/tailwind.css' + +import { withThemeByClassName } from '@storybook/addon-themes' +import type { Preview } from '@storybook/react' + +const preview: Preview = { + parameters: { + backgrounds: { + options: { + light: { name: 'Light', value: 'hsla(0, 0%, 97%, 1)' }, + dark: { name: 'Dark', value: 'hsla(240, 6%, 10%, 1)' } + } + } + }, + decorators: [ + withThemeByClassName({ + themes: { + light: '', + dark: 'dark' + }, + defaultTheme: 'light' + }) + ] +} + +export default preview diff --git a/packages/ui/MIGRATION_STATUS.md b/packages/ui/MIGRATION_STATUS.md new file mode 100644 index 0000000000..5a082e5aca --- /dev/null +++ b/packages/ui/MIGRATION_STATUS.md @@ -0,0 +1,150 @@ +# Cherry Studio UI Migration Plan + +## Overview + +This document outlines the detailed plan for migrating Cherry Studio from antd + styled-components to shadcn/ui + Tailwind CSS. We will adopt a progressive migration strategy to ensure system stability and development efficiency, while gradually implementing UI refactoring in collaboration with UI designers. + +## Migration Strategy + +### Target Tech Stack + +- **UI Component Library**: shadcn/ui (replacing antd and previously migrated HeroUI) +- **Styling Solution**: Tailwind CSS v4 (replacing styled-components) +- **Design System**: Custom CSS variable system (`--cs-*` namespace) +- **Theme System**: CSS variables + Tailwind CSS theme + +### Migration Principles + +1. **Backward Compatibility**: Old components continue working until new components are fully available +2. **Progressive Migration**: Migrate components one by one to avoid large-scale rewrites +3. **Feature Parity**: Ensure new components have all the functionality of old components +4. **Design Consistency**: Follow new design system specifications (see [README.md](./README.md)) +5. **Performance Priority**: Optimize bundle size and rendering performance +6. **Designer Collaboration**: Work with UI designers for gradual component encapsulation and UI optimization + +## Usage Example + +```typescript +// Import components from @cherrystudio/ui +import { Spinner, DividerWithText, InfoTooltip } from '@cherrystudio/ui' + +// Use in components +function MyComponent() { + return ( +
+ + + +
+ ) +} +``` + +## Directory Structure + +```text +@packages/ui/ +├── src/ +│ ├── components/ # Main components directory +│ │ ├── primitives/ # Basic/primitive components (Avatar, ErrorBoundary, Selector, etc.) +│ │ │ └── shadcn-io/ # shadcn/ui components (dropzone, etc.) +│ │ ├── icons/ # Icon components (Icon, FileIcons, etc.) +│ │ └── composites/ # Composite components (CodeEditor, ListItem, etc.) +│ ├── hooks/ # Custom React Hooks +│ ├── styles/ # Global styles and CSS variables +│ ├── types/ # TypeScript type definitions +│ ├── utils/ # Utility functions +│ └── index.ts # Main export file +``` + +### Component Classification Guide + +When submitting PRs, please place components in the correct directory based on their function: + +- **primitives**: Basic and primitive UI elements, shadcn/ui components + - `Avatar`: Avatar components + - `ErrorBoundary`: Error boundary components + - `Selector`: Selection components + - `shadcn-io/`: Direct shadcn/ui components or adaptations +- **icons**: All icon-related components + - `Icon`: Icon factory and basic icons + - `FileIcons`: File-specific icons + - Loading/spinner icons (SvgSpinners180Ring, ToolsCallingIcon, etc.) +- **composites**: Complex components made from multiple primitives + - `CodeEditor`: Code editing components + - `ListItem`: List item components + - `ThinkingEffect`: Animation components + - Form and interaction components (DraggableList, EditableNumber, etc.) + +## Component Extraction Criteria + +### Extraction Standards + +1. **Usage Frequency**: Component is used in ≥ 3 places in the codebase +2. **Future Reusability**: Expected to be used in multiple scenarios in the future +3. **Business Complexity**: Component contains complex interaction logic or state management +4. **Maintenance Cost**: Centralized management can reduce maintenance overhead +5. **Design Consistency**: Components that require unified visual and interaction experience +6. **Test Coverage**: As common components, they facilitate unit test writing and maintenance + +### Extraction Principles + +- **Single Responsibility**: Each component should only handle one clear function +- **Highly Configurable**: Provide flexible configuration options through props +- **Backward Compatible**: New versions maintain API backward compatibility +- **Complete Documentation**: Provide clear API documentation and usage examples +- **Type Safety**: Use TypeScript to ensure type safety + +### Cases Not Recommended for Extraction + +- Simple display components used only on a single page +- Overly customized business logic components +- Components tightly coupled to specific data sources + +## Migration Steps + +| Phase | Status | Main Tasks | Description | +| --- | --- | --- | --- | +| **Phase 1** | ✅ **Completed** | **Design System Integration** | • Converted design tokens from todocss.css to tokens.css with `--cs-*` namespace
• Created theme.css mapping all design tokens to standard Tailwind classes
• Extended Tailwind with semantic spacing (5xs~8xl) and radius (4xs~3xl) systems
• Established two usage modes: full override and selective override
• Cleaned up main package's conflicting Shadcn theme definitions | +| **Phase 2** | ⏳ **To Start** | **Component Migration and Optimization** | • Filter components for migration based on extraction criteria
• Remove antd dependencies, replace with shadcn/ui
• Remove HeroUI dependencies, replace with shadcn/ui
• Remove styled-components, replace with Tailwind CSS + design system variables
• Optimize component APIs and type definitions | +| **Phase 3** | ⏳ **To Start** | **UI Refactoring and Optimization** | • Gradually implement UI refactoring with UI designers
• Ensure visual consistency and user experience
• Performance optimization and code quality improvement | + +## Notes + +1. **Do NOT migrate** components with these dependencies (can be migrated after decoupling): + - window.api calls + - Redux (useSelector, useDispatch, etc.) + - Other external data sources + +2. **Can migrate** but need decoupling later: + - Components using i18n (change i18n to props) + - Components using antd (replace with shadcn/ui later) + - Components using HeroUI (replace with shadcn/ui later) + +3. **Submission Guidelines**: + - Each PR should focus on one category of components + - Ensure all migrated components are exported + - Follow component extraction criteria, only migrate qualified components + +## Design System Integration + +### CSS Variable System + +- All design tokens use `--cs-*` namespace (e.g., `--cs-primary`, `--cs-red-500`) +- Complete color palette: 17 colors × 11 shades each +- Semantic spacing system: `5xs` through `8xl` (16 levels) +- Semantic radius system: `4xs` through `3xl` plus `round` (11 levels) +- Full light/dark mode support +- See [README.md](./README.md) for usage documentation + +### Migration Priority Adjustment + +1. **High Priority**: Basic components (buttons, inputs, tags, etc.) +2. **Medium Priority**: Display components (cards, lists, tables, etc.) +3. **Low Priority**: Composite components and business-coupled components + +### UI Designer Collaboration + +- All component designs need confirmation from UI designers +- Gradually implement UI refactoring to maintain visual consistency +- New components must comply with design system specifications diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000000..4769676c75 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,263 @@ +# @cherrystudio/ui + +Cherry Studio UI 组件库 - 为 Cherry Studio 设计的 React 组件集合 + +## ✨ 特性 + +- 🎨 **设计系统**: 完整的 CherryStudio 设计令牌(17种颜色 × 11个色阶 + 语义化主题) +- 🌓 **Dark Mode**: 开箱即用的深色模式支持 +- 🚀 **Tailwind v4**: 基于最新 Tailwind CSS v4 构建 +- 📦 **灵活导入**: 2种样式导入方式,满足不同使用场景 +- 🔷 **TypeScript**: 完整的类型定义和智能提示 +- 🎯 **零冲突**: CSS 变量隔离,不覆盖用户主题 + +--- + +## 🚀 快速开始 + +### 安装 + +```bash +npm install @cherrystudio/ui +# peer dependencies +npm install framer-motion react react-dom tailwindcss +``` + +### 两种使用方式 + +#### 方式 1:完整覆盖 ✨ + +使用完整的 CherryStudio 设计系统,所有 Tailwind 类名映射到设计系统。 + +```css +/* app.css */ +@import '@cherrystudio/ui/styles/theme.css'; +``` + +**特点**: + +- ✅ 直接使用标准 Tailwind 类名(`bg-primary`、`bg-red-500`、`p-md`、`rounded-lg`) +- ✅ 所有颜色使用设计师定义的值 +- ✅ 扩展的 Spacing 系统(`p-5xs` ~ `p-8xl`,共 16 个语义化尺寸) +- ✅ 扩展的 Radius 系统(`rounded-4xs` ~ `rounded-3xl`,共 11 个圆角) +- ⚠️ 会完全覆盖 Tailwind 默认主题 + +**示例**: + +```tsx + + +{/* 扩展的工具类 */} +
最小间距 (0.5rem)
+
超小间距 (1rem)
+
小间距 (1.5rem)
+
中等间距 (2.5rem)
+
大间距 (3.5rem)
+
超大间距 (5rem)
+
最大间距 (15rem)
+ +
最小圆角 (0.25rem)
+
小圆角 (1rem)
+
中等圆角 (2rem)
+
大圆角 (3rem)
+
完全圆角 (999px)
+``` + +#### 方式 2:选择性覆盖 🎯 + +只导入设计令牌(CSS 变量),手动选择要覆盖的部分。 + +```css +/* app.css */ +@import 'tailwindcss'; +@import '@cherrystudio/ui/styles/tokens.css'; + +/* 只使用部分设计系统 */ +@theme { + --color-primary: var(--cs-primary); /* 使用 CS 的主色 */ + --color-red-500: oklch(...); /* 使用自己的红色 */ + --spacing-md: var(--cs-size-md); /* 使用 CS 的间距 */ + --radius-lg: 1rem; /* 使用自己的圆角 */ +} +``` + +**特点**: + +- ✅ 不覆盖任何 Tailwind 默认主题 +- ✅ 通过 CSS 变量访问所有设计令牌(`var(--cs-primary)`、`var(--cs-red-500)`) +- ✅ 精细控制哪些使用 CS、哪些保持原样 +- ✅ 适合有自己设计系统但想借用部分 CS 设计令牌的场景 + +**示例**: + +```tsx +{/* 通过 CSS 变量使用 CS 设计令牌 */} + + +{/* 保持原有的 Tailwind 类名不受影响 */} +
+ 使用 Tailwind 默认的红色 +
+ +{/* 可用的 CSS 变量 */} +
+``` + +### Provider 配置 + +在你的 App 根组件中添加 HeroUI Provider: + +```tsx +import { HeroUIProvider } from '@heroui/react' + +function App() { + return ( + + {/* 你的应用内容 */} + + ) +} +``` + +## 使用 + +### 基础组件 + +```tsx +import { Button, Input } from '@cherrystudio/ui' + +function App() { + return ( +
+ + console.log(value)} + /> +
+ ) +} +``` + +### 分模块导入 + +```tsx +// 只导入组件 +import { Button } from '@cherrystudio/ui/components' + +// 只导入工具函数 +import { cn, formatFileSize } from '@cherrystudio/ui/utils' +``` + +## 开发 + +```bash +# 安装依赖 +yarn install + +# 开发模式(监听文件变化) +yarn dev + +# 构建 +yarn build + +# 类型检查 +yarn type-check + +# 运行测试 +yarn test +``` + +## 目录结构 + +```text +src/ +├── components/ # React 组件 +│ ├── Button/ # 按钮组件 +│ ├── Input/ # 输入框组件 +│ └── index.ts # 组件导出 +├── hooks/ # React Hooks +├── utils/ # 工具函数 +├── types/ # 类型定义 +└── index.ts # 主入口文件 +``` + +## 组件列表 + +### Button 按钮 + +支持多种变体和尺寸的按钮组件。 + +**Props:** + +- `variant`: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' +- `size`: 'sm' | 'md' | 'lg' +- `loading`: boolean +- `fullWidth`: boolean +- `leftIcon` / `rightIcon`: React.ReactNode + +### Input 输入框 + +带有错误处理和密码显示切换的输入框组件。 + +**Props:** + +- `type`: 'text' | 'password' | 'email' | 'number' +- `error`: boolean +- `errorMessage`: string +- `onChange`: (value: string) => void + +## Hooks + +### useDebounce + +防抖处理,延迟执行状态更新。 + +### useLocalStorage + +本地存储的 React Hook 封装。 + +### useClickOutside + +检测点击元素外部区域。 + +### useCopyToClipboard + +复制文本到剪贴板。 + +## 工具函数 + +### cn(...inputs) + +基于 clsx 的类名合并工具,支持条件类名。 + +### formatFileSize(bytes) + +格式化文件大小显示。 + +### debounce(func, delay) + +防抖函数。 + +### throttle(func, delay) + +节流函数。 + +## 许可证 + +MIT diff --git a/components.json b/packages/ui/components.json similarity index 51% rename from components.json rename to packages/ui/components.json index c5aceeb3ce..b5c2f24eff 100644 --- a/components.json +++ b/packages/ui/components.json @@ -1,11 +1,11 @@ { "$schema": "https://ui.shadcn.com/schema.json", "aliases": { - "components": "@renderer/ui/third-party", - "hooks": "@renderer/hooks", - "lib": "@renderer/lib", - "ui": "@renderer/ui", - "utils": "@renderer/utils" + "components": "@cherrystudio/ui/components", + "hooks": "@cherrystudio/ui/hooks", + "lib": "@cherrystudio/ui/lib", + "ui": "@cherrystudio/ui/components/primitives", + "utils": "@cherrystudio/ui/utils" }, "iconLibrary": "lucide", "rsc": false, @@ -13,7 +13,7 @@ "tailwind": { "baseColor": "zinc", "config": "", - "css": "src/renderer/src/assets/styles/tailwind.css", + "css": "src/styles/theme.css", "cssVariables": true, "prefix": "" }, diff --git a/packages/ui/design-reference/README.md b/packages/ui/design-reference/README.md new file mode 100644 index 0000000000..e7e132f460 --- /dev/null +++ b/packages/ui/design-reference/README.md @@ -0,0 +1,15 @@ +# Design Reference + +本文件夹包含设计师提供的原始设计令牌文件,仅作为参考使用。 + +## 文件说明 + +### *.hsla.css +为hsla格式的色值 + +## 注意事项 + +⚠️ **请勿直接使用此文件夹中的文件** +- 这些文件仅供参考 +- 实际使用请导入 `src/styles/` 中的文件 + diff --git a/packages/ui/design-reference/primitive.hsla.css b/packages/ui/design-reference/primitive.hsla.css new file mode 100644 index 0000000000..7ac9155e9d --- /dev/null +++ b/packages/ui/design-reference/primitive.hsla.css @@ -0,0 +1,309 @@ +/** + * Primitive Colors - Light Mode + * 基础色板 - 所有原始颜色定义 + */ + +:root { + /* Neutral */ + --cs-neutral-50: hsla(0, 0%, 98%, 1); + --cs-neutral-100: hsla(0, 0%, 96%, 1); + --cs-neutral-200: hsla(0, 0%, 90%, 1); + --cs-neutral-300: hsla(0, 0%, 83%, 1); + --cs-neutral-400: hsla(0, 0%, 64%, 1); + --cs-neutral-500: hsla(0, 0%, 45%, 1); + --cs-neutral-600: hsla(215, 14%, 34%, 1); + --cs-neutral-700: hsla(0, 0%, 25%, 1); + --cs-neutral-800: hsla(0, 0%, 15%, 1); + --cs-neutral-900: hsla(0, 0%, 9%, 1); + --cs-neutral-950: hsla(0, 0%, 4%, 1); + + /* Stone */ + --cs-stone-50: hsla(60, 9%, 98%, 1); + --cs-stone-100: hsla(60, 5%, 96%, 1); + --cs-stone-200: hsla(20, 6%, 90%, 1); + --cs-stone-300: hsla(24, 6%, 83%, 1); + --cs-stone-400: hsla(24, 5%, 64%, 1); + --cs-stone-500: hsla(25, 5%, 45%, 1); + --cs-stone-600: hsla(33, 5%, 32%, 1); + --cs-stone-700: hsla(30, 6%, 25%, 1); + --cs-stone-800: hsla(12, 6%, 15%, 1); + --cs-stone-900: hsla(24, 10%, 10%, 1); + --cs-stone-950: hsla(20, 14%, 4%, 1); + + /* Zinc */ + --cs-zinc-50: hsla(0, 0%, 98%, 1); + --cs-zinc-100: hsla(240, 5%, 96%, 1); + --cs-zinc-200: hsla(240, 6%, 90%, 1); + --cs-zinc-300: hsla(240, 5%, 84%, 1); + --cs-zinc-400: hsla(240, 5%, 65%, 1); + --cs-zinc-500: hsla(240, 4%, 46%, 1); + --cs-zinc-600: hsla(240, 5%, 34%, 1); + --cs-zinc-700: hsla(240, 5%, 26%, 1); + --cs-zinc-800: hsla(240, 4%, 16%, 1); + --cs-zinc-900: hsla(240, 6%, 10%, 1); + --cs-zinc-950: hsla(240, 10%, 4%, 1); + + /* Slate */ + --cs-slate-50: hsla(210, 40%, 98%, 1); + --cs-slate-100: hsla(210, 40%, 96%, 1); + --cs-slate-200: hsla(214, 32%, 91%, 1); + --cs-slate-300: hsla(213, 27%, 84%, 1); + --cs-slate-400: hsla(215, 20%, 65%, 1); + --cs-slate-500: hsla(215, 16%, 47%, 1); + --cs-slate-600: hsla(215, 19%, 35%, 1); + --cs-slate-700: hsla(215, 25%, 27%, 1); + --cs-slate-800: hsla(217, 33%, 17%, 1); + --cs-slate-900: hsla(222, 47%, 11%, 1); + --cs-slate-950: hsla(229, 84%, 5%, 1); + + /* Gray */ + --cs-gray-50: hsla(210, 20%, 98%, 1); + --cs-gray-100: hsla(220, 14%, 96%, 1); + --cs-gray-200: hsla(220, 13%, 91%, 1); + --cs-gray-300: hsla(216, 12%, 84%, 1); + --cs-gray-400: hsla(218, 11%, 65%, 1); + --cs-gray-500: hsla(220, 9%, 46%, 1); + --cs-gray-600: hsla(0, 0%, 32%, 1); + --cs-gray-700: hsla(217, 19%, 27%, 1); + --cs-gray-800: hsla(215, 28%, 17%, 1); + --cs-gray-900: hsla(221, 39%, 11%, 1); + --cs-gray-950: hsla(224, 71%, 4%, 1); + + /* Red */ + --cs-red-50: hsla(0, 86%, 97%, 1); + --cs-red-100: hsla(0, 93%, 94%, 1); + --cs-red-200: hsla(0, 96%, 89%, 1); + --cs-red-300: hsla(0, 94%, 82%, 1); + --cs-red-400: hsla(0, 91%, 71%, 1); + --cs-red-500: hsla(0, 84%, 60%, 1); + --cs-red-600: hsla(0, 72%, 51%, 1); + --cs-red-700: hsla(0, 74%, 42%, 1); + --cs-red-800: hsla(0, 70%, 35%, 1); + --cs-red-900: hsla(0, 63%, 31%, 1); + --cs-red-950: hsla(0, 75%, 15%, 1); + + /* Orange */ + --cs-orange-50: hsla(33, 100%, 96%, 1); + --cs-orange-100: hsla(34, 100%, 92%, 1); + --cs-orange-200: hsla(32, 98%, 83%, 1); + --cs-orange-300: hsla(31, 97%, 72%, 1); + --cs-orange-400: hsla(27, 96%, 61%, 1); + --cs-orange-500: hsla(25, 95%, 53%, 1); + --cs-orange-600: hsla(21, 90%, 48%, 1); + --cs-orange-700: hsla(17, 88%, 40%, 1); + --cs-orange-800: hsla(15, 79%, 34%, 1); + --cs-orange-900: hsla(15, 75%, 28%, 1); + --cs-orange-950: hsla(13, 81%, 15%, 1); + + /* Amber */ + --cs-amber-50: hsla(48, 100%, 96%, 1); + --cs-amber-100: hsla(48, 96%, 89%, 1); + --cs-amber-200: hsla(48, 97%, 77%, 1); + --cs-amber-300: hsla(46, 97%, 65%, 1); + --cs-amber-400: hsla(43, 96%, 56%, 1); + --cs-amber-500: hsla(38, 92%, 50%, 1); + --cs-amber-600: hsla(32, 95%, 44%, 1); + --cs-amber-700: hsla(26, 90%, 37%, 1); + --cs-amber-800: hsla(23, 83%, 31%, 1); + --cs-amber-900: hsla(22, 78%, 26%, 1); + --cs-amber-950: hsla(21, 92%, 14%, 1); + + /* Yellow */ + --cs-yellow-50: hsla(55, 92%, 95%, 1); + --cs-yellow-100: hsla(55, 97%, 88%, 1); + --cs-yellow-200: hsla(53, 98%, 77%, 1); + --cs-yellow-300: hsla(50, 98%, 64%, 1); + --cs-yellow-400: hsla(48, 96%, 53%, 1); + --cs-yellow-500: hsla(45, 93%, 47%, 1); + --cs-yellow-600: hsla(41, 96%, 40%, 1); + --cs-yellow-700: hsla(35, 92%, 33%, 1); + --cs-yellow-800: hsla(32, 81%, 29%, 1); + --cs-yellow-900: hsla(28, 73%, 26%, 1); + --cs-yellow-950: hsla(26, 83%, 14%, 1); + + /* Lime (品牌主色) */ + --cs-lime-50: hsla(78, 92%, 95%, 1); + --cs-lime-100: hsla(80, 89%, 89%, 1); + --cs-lime-200: hsla(81, 88%, 80%, 1); + --cs-lime-300: hsla(82, 85%, 67%, 1); + --cs-lime-400: hsla(83, 78%, 55%, 1); + --cs-lime-500: hsla(84, 81%, 44%, 1); + --cs-lime-600: hsla(85, 85%, 35%, 1); + --cs-lime-700: hsla(86, 78%, 27%, 1); + --cs-lime-800: hsla(86, 69%, 23%, 1); + --cs-lime-900: hsla(88, 61%, 20%, 1); + --cs-lime-950: hsla(89, 80%, 10%, 1); + + /* Green */ + --cs-green-50: hsla(138, 76%, 97%, 1); + --cs-green-100: hsla(141, 84%, 93%, 1); + --cs-green-200: hsla(141, 79%, 85%, 1); + --cs-green-300: hsla(142, 77%, 73%, 1); + --cs-green-400: hsla(142, 69%, 58%, 1); + --cs-green-500: hsla(142, 71%, 45%, 1); + --cs-green-600: hsla(142, 76%, 36%, 1); + --cs-green-700: hsla(142, 72%, 29%, 1); + --cs-green-800: hsla(143, 64%, 24%, 1); + --cs-green-900: hsla(144, 61%, 20%, 1); + --cs-green-950: hsla(145, 80%, 10%, 1); + + /* Emerald */ + --cs-emerald-50: hsla(152, 81%, 96%, 1); + --cs-emerald-100: hsla(149, 80%, 90%, 1); + --cs-emerald-200: hsla(152, 76%, 80%, 1); + --cs-emerald-300: hsla(156, 72%, 67%, 1); + --cs-emerald-400: hsla(158, 64%, 52%, 1); + --cs-emerald-500: hsla(160, 84%, 39%, 1); + --cs-emerald-600: hsla(161, 94%, 30%, 1); + --cs-emerald-700: hsla(163, 94%, 24%, 1); + --cs-emerald-800: hsla(163, 88%, 20%, 1); + --cs-emerald-900: hsla(164, 86%, 16%, 1); + --cs-emerald-950: hsla(166, 91%, 9%, 1); + + /* Teal */ + --cs-teal-50: hsla(166, 76%, 97%, 1); + --cs-teal-100: hsla(167, 85%, 89%, 1); + --cs-teal-200: hsla(168, 84%, 78%, 1); + --cs-teal-300: hsla(171, 77%, 64%, 1); + --cs-teal-400: hsla(172, 66%, 50%, 1); + --cs-teal-500: hsla(173, 80%, 40%, 1); + --cs-teal-600: hsla(175, 84%, 32%, 1); + --cs-teal-700: hsla(175, 77%, 26%, 1); + --cs-teal-800: hsla(176, 69%, 22%, 1); + --cs-teal-900: hsla(176, 61%, 19%, 1); + --cs-teal-950: hsla(179, 84%, 10%, 1); + + /* Cyan */ + --cs-cyan-50: hsla(183, 100%, 96%, 1); + --cs-cyan-100: hsla(185, 96%, 90%, 1); + --cs-cyan-200: hsla(186, 94%, 82%, 1); + --cs-cyan-300: hsla(187, 92%, 69%, 1); + --cs-cyan-400: hsla(188, 86%, 53%, 1); + --cs-cyan-500: hsla(189, 94%, 43%, 1); + --cs-cyan-600: hsla(192, 91%, 36%, 1); + --cs-cyan-700: hsla(193, 82%, 31%, 1); + --cs-cyan-800: hsla(194, 70%, 27%, 1); + --cs-cyan-900: hsla(196, 64%, 24%, 1); + --cs-cyan-950: hsla(197, 79%, 15%, 1); + + /* Sky */ + --cs-sky-50: hsla(204, 100%, 97%, 1); + --cs-sky-100: hsla(204, 94%, 94%, 1); + --cs-sky-200: hsla(201, 94%, 86%, 1); + --cs-sky-300: hsla(199, 95%, 74%, 1); + --cs-sky-400: hsla(198, 93%, 60%, 1); + --cs-sky-500: hsla(199, 89%, 48%, 1); + --cs-sky-600: hsla(200, 98%, 39%, 1); + --cs-sky-700: hsla(201, 96%, 32%, 1); + --cs-sky-800: hsla(201, 90%, 27%, 1); + --cs-sky-900: hsla(202, 80%, 24%, 1); + --cs-sky-950: hsla(204, 80%, 16%, 1); + + /* Blue */ + --cs-blue-50: hsla(214, 100%, 97%, 1); + --cs-blue-100: hsla(214, 95%, 93%, 1); + --cs-blue-200: hsla(213, 97%, 87%, 1); + --cs-blue-300: hsla(212, 96%, 78%, 1); + --cs-blue-400: hsla(213, 94%, 68%, 1); + --cs-blue-500: hsla(217, 91%, 60%, 1); + --cs-blue-600: hsla(221, 83%, 53%, 1); + --cs-blue-700: hsla(224, 76%, 48%, 1); + --cs-blue-800: hsla(226, 71%, 40%, 1); + --cs-blue-900: hsla(224, 64%, 33%, 1); + --cs-blue-950: hsla(226, 57%, 21%, 1); + + /* Indigo */ + --cs-indigo-50: hsla(226, 100%, 97%, 1); + --cs-indigo-100: hsla(226, 100%, 94%, 1); + --cs-indigo-200: hsla(228, 96%, 89%, 1); + --cs-indigo-300: hsla(230, 94%, 82%, 1); + --cs-indigo-400: hsla(234, 89%, 74%, 1); + --cs-indigo-500: hsla(239, 84%, 67%, 1); + --cs-indigo-600: hsla(243, 75%, 59%, 1); + --cs-indigo-700: hsla(245, 58%, 51%, 1); + --cs-indigo-800: hsla(244, 55%, 41%, 1); + --cs-indigo-900: hsla(242, 47%, 34%, 1); + --cs-indigo-950: hsla(244, 47%, 20%, 1); + + /* Violet */ + --cs-violet-50: hsla(250, 100%, 98%, 1); + --cs-violet-100: hsla(251, 91%, 95%, 1); + --cs-violet-200: hsla(251, 95%, 92%, 1); + --cs-violet-300: hsla(253, 95%, 85%, 1); + --cs-violet-400: hsla(255, 92%, 76%, 1); + --cs-violet-500: hsla(258, 90%, 66%, 1); + --cs-violet-600: hsla(262, 83%, 58%, 1); + --cs-violet-700: hsla(263, 70%, 50%, 1); + --cs-violet-800: hsla(263, 69%, 42%, 1); + --cs-violet-900: hsla(264, 67%, 35%, 1); + --cs-violet-950: hsla(262, 78%, 23%, 1); + + /* Purple */ + --cs-purple-50: hsla(270, 100%, 98%, 1); + --cs-purple-100: hsla(269, 100%, 95%, 1); + --cs-purple-200: hsla(269, 100%, 92%, 1); + --cs-purple-300: hsla(269, 97%, 85%, 1); + --cs-purple-400: hsla(270, 95%, 75%, 1); + --cs-purple-500: hsla(271, 91%, 65%, 1); + --cs-purple-600: hsla(271, 81%, 56%, 1); + --cs-purple-700: hsla(272, 72%, 47%, 1); + --cs-purple-800: hsla(273, 67%, 39%, 1); + --cs-purple-900: hsla(274, 66%, 32%, 1); + --cs-purple-950: hsla(274, 87%, 21%, 1); + + /* Fuchsia */ + --cs-fuchsia-50: hsla(289, 100%, 98%, 1); + --cs-fuchsia-100: hsla(287, 100%, 95%, 1); + --cs-fuchsia-200: hsla(288, 96%, 91%, 1); + --cs-fuchsia-300: hsla(291, 93%, 83%, 1); + --cs-fuchsia-400: hsla(292, 91%, 73%, 1); + --cs-fuchsia-500: hsla(292, 84%, 61%, 1); + --cs-fuchsia-600: hsla(293, 69%, 49%, 1); + --cs-fuchsia-700: hsla(295, 72%, 40%, 1); + --cs-fuchsia-800: hsla(295, 70%, 33%, 1); + --cs-fuchsia-900: hsla(297, 64%, 28%, 1); + --cs-fuchsia-950: hsla(297, 90%, 16%, 1); + + /* Pink */ + --cs-pink-50: hsla(327, 73%, 97%, 1); + --cs-pink-100: hsla(326, 78%, 95%, 1); + --cs-pink-200: hsla(326, 85%, 90%, 1); + --cs-pink-300: hsla(327, 87%, 82%, 1); + --cs-pink-400: hsla(329, 86%, 70%, 1); + --cs-pink-500: hsla(330, 81%, 60%, 1); + --cs-pink-600: hsla(333, 71%, 51%, 1); + --cs-pink-700: hsla(335, 78%, 42%, 1); + --cs-pink-800: hsla(336, 74%, 35%, 1); + --cs-pink-900: hsla(336, 69%, 30%, 1); + --cs-pink-950: hsla(336, 84%, 17%, 1); + + /* Rose */ + --cs-rose-50: hsla(356, 100%, 97%, 1); + --cs-rose-100: hsla(356, 100%, 95%, 1); + --cs-rose-200: hsla(353, 96%, 90%, 1); + --cs-rose-300: hsla(353, 96%, 82%, 1); + --cs-rose-400: hsla(351, 95%, 71%, 1); + --cs-rose-500: hsla(350, 89%, 60%, 1); + --cs-rose-600: hsla(347, 77%, 50%, 1); + --cs-rose-700: hsla(345, 83%, 41%, 1); + --cs-rose-800: hsla(343, 80%, 35%, 1); + --cs-rose-900: hsla(342, 75%, 30%, 1); + --cs-rose-950: hsla(343, 88%, 16%, 1); + + /* Black & White */ + --cs-black: hsla(0, 0%, 0%, 1); + --cs-white: hsla(0, 0%, 100%, 1); + + /* Brand (Cherry Studio 品牌专属色) */ + --cs-brand-50: hsla(132, 64%, 97%, 1); + --cs-brand-100: hsla(132, 64%, 93%, 1); + --cs-brand-200: hsla(132, 64%, 85%, 1); + --cs-brand-300: hsla(132, 64%, 73%, 1); + --cs-brand-400: hsla(132, 64%, 63%, 1); + --cs-brand-500: hsla(132, 64%, 53%, 1); + --cs-brand-600: hsla(132, 64%, 43%, 1); + --cs-brand-700: hsla(132, 64%, 33%, 1); + --cs-brand-800: hsla(132, 64%, 23%, 1); + --cs-brand-900: hsla(132, 64%, 13%, 1); + --cs-brand-950: hsla(132, 64%, 8%, 1); +} diff --git a/packages/ui/design-reference/semantic.hsla.css b/packages/ui/design-reference/semantic.hsla.css new file mode 100644 index 0000000000..0291c108f3 --- /dev/null +++ b/packages/ui/design-reference/semantic.hsla.css @@ -0,0 +1,81 @@ +/** + * Semantic Colors - Light Mode + * 语义化颜色 - 基于 Primitive Colors 的语义化映射 + */ + +:root { + /* Brand Colors */ + --cs-primary: var(--cs-brand-500); + --cs-primary-hover: var(--cs-brand-300); + --cs-destructive: var(--cs-red-500); + --cs-destructive-hover: var(--cs-red-400); + --cs-success: var(--cs-green-500); + --cs-warning: var(--cs-amber-500); + + /* Background & Foreground */ + --cs-background: var(--cs-zinc-50); + --cs-background-subtle: hsla(0, 0%, 0%, 0.02); + --cs-foreground: hsla(0, 0%, 0%, 0.9); + --cs-foreground-secondary: hsla(0, 0%, 0%, 0.6); + --cs-foreground-muted: hsla(0, 0%, 0%, 0.4); + + /* Card & Popover */ + --cs-card: var(--cs-white); + --cs-popover: var(--cs-white); + + /* Border */ + --cs-border: hsla(0, 0%, 0%, 0.1); + --cs-border-hover: hsla(0, 0%, 0%, 0.2); + --cs-border-active: hsla(0, 0%, 0%, 0.3); + + /* Ring (Focus) */ + --cs-ring: color-mix(in srgb, var(--cs-primary) 40%, transparent); + + /* UI Element Colors */ + --cs-secondary: hsla(0, 0%, 0%, 0.05); /* Secondary Button Background */ + --cs-secondary-hover: hsla(0, 0%, 0%, 0.85); + --cs-secondary-active: hsla(0, 0%, 0%, 0.7); + --cs-muted: hsla(0, 0%, 0%, 0.05); /* Muted/Subtle Background */ + --cs-accent: hsla(0, 0%, 0%, 0.05); /* Accent Background */ + --cs-ghost-hover: hsla(0, 0%, 0%, 0.05); /* Ghost Button Hover */ + --cs-ghost-active: hsla(0, 0%, 0%, 0.1); /* Ghost Button Active */ + + /* Sidebar */ + --cs-sidebar: var(--cs-white); + --cs-sidebar-accent: hsla(0, 0%, 0%, 0.05); +} + +/* Dark Mode */ +.dark { + /* Background & Foreground */ + --cs-background: var(--cs-zinc-900); + --cs-background-subtle: hsla(0, 0%, 100%, 0.02); + --cs-foreground: hsla(0, 0%, 100%, 0.9); + --cs-foreground-secondary: hsla(0, 0%, 100%, 0.6); + --cs-foreground-muted: hsla(0, 0%, 100%, 0.4); + + /* Card & Popover */ + --cs-card: var(--cs-black); + --cs-popover: var(--cs-black); + + /* Border */ + --cs-border: hsla(0, 0%, 100%, 0.1); + --cs-border-hover: hsla(0, 0%, 100%, 0.2); + --cs-border-active: hsla(0, 0%, 100%, 0.3); + + /* Ring (Focus) - 保持不变 */ + --cs-ring: hsla(84, 81%, 44%, 0.4); + + /* UI Element Colors - Dark Mode */ + --cs-secondary: hsla(0, 0%, 100%, 0.1); /* Secondary Button Background */ + --cs-secondary-hover: hsla(0, 0%, 100%, 0.2); + --cs-secondary-active: hsla(0, 0%, 100%, 0.25); + --cs-muted: hsla(0, 0%, 100%, 0.1); /* Muted/Subtle Background */ + --cs-accent: hsla(0, 0%, 100%, 0.1); /* Accent Background */ + --cs-ghost-hover: hsla(0, 0%, 100%, 0.1); /* Ghost Button Hover */ + --cs-ghost-active: hsla(0, 0%, 100%, 0.15); /* Ghost Button Active */ + + /* Sidebar */ + --cs-sidebar: var(--cs-black); + --cs-sidebar-accent: hsla(0, 0%, 100%, 0.1); +} diff --git a/packages/ui/design-reference/status.hsla.css b/packages/ui/design-reference/status.hsla.css new file mode 100644 index 0000000000..d49019a1b5 --- /dev/null +++ b/packages/ui/design-reference/status.hsla.css @@ -0,0 +1,55 @@ +/** + * Status Colors - Light Mode & Dark Mode + * 状态颜色 - Error, Success, Warning + */ + +:root { + /* Status Colors - Error */ + --cs-error-base: var(--cs-red-500); /* #ef4444 */ + --cs-error-text: var(--cs-red-800); /* #991b1b */ + --cs-error-bg: var(--cs-red-50); /* #fef2f2 */ + --cs-error-text-hover: var(--cs-red-700); /* #b91c1c */ + --cs-error-bg-hover: var(--cs-red-100); /* #fee2e2 */ + --cs-error-border: var(--cs-red-200); /* #fecaca */ + --cs-error-border-hover: var(--cs-red-300); /* #fca5a5 */ + --cs-error-active: var(--cs-red-600); /* #dc2626 */ + + /* Status Colors - Success */ + --cs-success-base: var(--cs-green-500); /* #22c55e */ + --cs-success-text-hover: var(--cs-green-700); /* #15803d */ + --cs-success-bg: var(--cs-green-50); /* #f0fdf4 */ + --cs-success-bg-hover: var(--cs-green-200); /* #bbf7d0 */ + + /* Status Colors - Warning */ + --cs-warning-base: var(--cs-amber-400); /* #fbbf24 */ + --cs-warning-text-hover: var(--cs-amber-700); /* #b45309 */ + --cs-warning-bg: var(--cs-amber-50); /* #fffbeb */ + --cs-warning-bg-hover: var(--cs-amber-100); /* #fef3c7 */ + --cs-warning-active: var(--cs-amber-600); /* #d97706 */ +} + +/* Dark Mode */ +.dark { + /* Status Colors - Error (Dark Mode) */ + --cs-error-base: var(--cs-red-400); /* #f87171 */ + --cs-error-text: var(--cs-red-100); /* #fee2e2 */ + --cs-error-bg: var(--cs-red-900); /* #7f1d1d */ + --cs-error-text-hover: var(--cs-red-200); /* #fecaca */ + --cs-error-bg-hover: var(--cs-red-800); /* #991b1b */ + --cs-error-border: var(--cs-red-700); /* #b91c1c */ + --cs-error-border-hover: var(--cs-red-600); /* #dc2626 */ + --cs-error-active: var(--cs-red-300); /* #fca5a5 */ + + /* Status Colors - Success (Dark Mode) */ + --cs-success-base: var(--cs-green-400); /* #4ade80 */ + --cs-success-text-hover: var(--cs-green-200); /* #bbf7d0 */ + --cs-success-bg: var(--cs-green-900); /* #14532d */ + --cs-success-bg-hover: var(--cs-green-800); /* #166534 */ + + /* Status Colors - Warning (Dark Mode) */ + --cs-warning-base: var(--cs-amber-400); /* #fbbf24 */ + --cs-warning-text-hover: var(--cs-amber-200); /* #fde68a */ + --cs-warning-bg: var(--cs-amber-900); /* #78350f */ + --cs-warning-bg-hover: var(--cs-amber-800); /* #92400e */ + --cs-warning-active: var(--cs-amber-600); /* #d97706 */ +} diff --git a/packages/ui/design-reference/theme.css b/packages/ui/design-reference/theme.css new file mode 100644 index 0000000000..29d83a0cba --- /dev/null +++ b/packages/ui/design-reference/theme.css @@ -0,0 +1,450 @@ +/** + * Generated from Design Tokens + * + * ⚠️ DO NOT EDIT DIRECTLY! + * This file is auto-generated from tokens/ directory. + * To make changes, edit files in tokens/ and run: npm run tokens:build + * + * Generated on: 2025-11-07T08:56:09.444Z + */ + +@theme { + /* ==================== */ + /* Primitive Colors */ + /* ==================== */ + --color-neutral-50: hsla(0, 0%, 98%, 1); + --color-neutral-100: hsla(0, 0%, 96%, 1); + --color-neutral-200: hsla(0, 0%, 90%, 1); + --color-neutral-300: hsla(0, 0%, 83%, 1); + --color-neutral-400: hsla(0, 0%, 64%, 1); + --color-neutral-500: hsla(0, 0%, 45%, 1); + --color-neutral-600: hsla(215, 14%, 34%, 1); + --color-neutral-700: hsla(0, 0%, 25%, 1); + --color-neutral-800: hsla(0, 0%, 15%, 1); + --color-neutral-900: hsla(0, 0%, 9%, 1); + --color-neutral-950: hsla(0, 0%, 4%, 1); + --color-stone-50: hsla(60, 9%, 98%, 1); + --color-stone-100: hsla(60, 5%, 96%, 1); + --color-stone-200: hsla(20, 6%, 90%, 1); + --color-stone-300: hsla(24, 6%, 83%, 1); + --color-stone-400: hsla(24, 5%, 64%, 1); + --color-stone-500: hsla(25, 5%, 45%, 1); + --color-stone-600: hsla(33, 5%, 32%, 1); + --color-stone-700: hsla(30, 6%, 25%, 1); + --color-stone-800: hsla(12, 6%, 15%, 1); + --color-stone-900: hsla(24, 10%, 10%, 1); + --color-stone-950: hsla(20, 14%, 4%, 1); + --color-zinc-50: hsla(0, 0%, 98%, 1); + --color-zinc-100: hsla(240, 5%, 96%, 1); + --color-zinc-200: hsla(240, 6%, 90%, 1); + --color-zinc-300: hsla(240, 5%, 84%, 1); + --color-zinc-400: hsla(240, 5%, 65%, 1); + --color-zinc-500: hsla(240, 4%, 46%, 1); + --color-zinc-600: hsla(240, 5%, 34%, 1); + --color-zinc-700: hsla(240, 5%, 26%, 1); + --color-zinc-800: hsla(240, 4%, 16%, 1); + --color-zinc-900: hsla(240, 6%, 10%, 1); + --color-zinc-950: hsla(240, 10%, 4%, 1); + --color-slate-50: hsla(210, 40%, 98%, 1); + --color-slate-100: hsla(210, 40%, 96%, 1); + --color-slate-200: hsla(214, 32%, 91%, 1); + --color-slate-300: hsla(213, 27%, 84%, 1); + --color-slate-400: hsla(215, 20%, 65%, 1); + --color-slate-500: hsla(215, 16%, 47%, 1); + --color-slate-600: hsla(215, 19%, 35%, 1); + --color-slate-700: hsla(215, 25%, 27%, 1); + --color-slate-800: hsla(217, 33%, 17%, 1); + --color-slate-900: hsla(222, 47%, 11%, 1); + --color-slate-950: hsla(229, 84%, 5%, 1); + --color-gray-50: hsla(210, 20%, 98%, 1); + --color-gray-100: hsla(220, 14%, 96%, 1); + --color-gray-200: hsla(220, 13%, 91%, 1); + --color-gray-300: hsla(216, 12%, 84%, 1); + --color-gray-400: hsla(218, 11%, 65%, 1); + --color-gray-500: hsla(220, 9%, 46%, 1); + --color-gray-600: hsla(0, 0%, 32%, 1); + --color-gray-700: hsla(217, 19%, 27%, 1); + --color-gray-800: hsla(215, 28%, 17%, 1); + --color-gray-900: hsla(221, 39%, 11%, 1); + --color-gray-950: hsla(224, 71%, 4%, 1); + --color-red-50: hsla(0, 86%, 97%, 1); + --color-red-100: hsla(0, 93%, 94%, 1); + --color-red-200: hsla(0, 96%, 89%, 1); + --color-red-300: hsla(0, 94%, 82%, 1); + --color-red-400: hsla(0, 91%, 71%, 1); + --color-red-500: hsla(0, 84%, 60%, 1); + --color-red-600: hsla(0, 72%, 51%, 1); + --color-red-700: hsla(0, 74%, 42%, 1); + --color-red-800: hsla(0, 70%, 35%, 1); + --color-red-900: hsla(0, 63%, 31%, 1); + --color-red-950: hsla(0, 75%, 15%, 1); + --color-orange-50: hsla(33, 100%, 96%, 1); + --color-orange-100: hsla(34, 100%, 92%, 1); + --color-orange-200: hsla(32, 98%, 83%, 1); + --color-orange-300: hsla(31, 97%, 72%, 1); + --color-orange-400: hsla(27, 96%, 61%, 1); + --color-orange-500: hsla(25, 95%, 53%, 1); + --color-orange-600: hsla(21, 90%, 48%, 1); + --color-orange-700: hsla(17, 88%, 40%, 1); + --color-orange-800: hsla(15, 79%, 34%, 1); + --color-orange-900: hsla(15, 75%, 28%, 1); + --color-orange-950: hsla(13, 81%, 15%, 1); + --color-amber-50: hsla(48, 100%, 96%, 1); + --color-amber-100: hsla(48, 96%, 89%, 1); + --color-amber-200: hsla(48, 97%, 77%, 1); + --color-amber-300: hsla(46, 97%, 65%, 1); + --color-amber-400: hsla(43, 96%, 56%, 1); + --color-amber-500: hsla(38, 92%, 50%, 1); + --color-amber-600: hsla(32, 95%, 44%, 1); + --color-amber-700: hsla(26, 90%, 37%, 1); + --color-amber-800: hsla(23, 83%, 31%, 1); + --color-amber-900: hsla(22, 78%, 26%, 1); + --color-amber-950: hsla(21, 92%, 14%, 1); + --color-yellow-50: hsla(55, 92%, 95%, 1); + --color-yellow-100: hsla(55, 97%, 88%, 1); + --color-yellow-200: hsla(53, 98%, 77%, 1); + --color-yellow-300: hsla(50, 98%, 64%, 1); + --color-yellow-400: hsla(48, 96%, 53%, 1); + --color-yellow-500: hsla(45, 93%, 47%, 1); + --color-yellow-600: hsla(41, 96%, 40%, 1); + --color-yellow-700: hsla(35, 92%, 33%, 1); + --color-yellow-800: hsla(32, 81%, 29%, 1); + --color-yellow-900: hsla(28, 73%, 26%, 1); + --color-yellow-950: hsla(26, 83%, 14%, 1); + --color-lime-50: hsla(78, 92%, 95%, 1); + --color-lime-100: hsla(80, 89%, 89%, 1); + --color-lime-200: hsla(81, 88%, 80%, 1); + --color-lime-300: hsla(82, 85%, 67%, 1); + --color-lime-400: hsla(83, 78%, 55%, 1); + --color-lime-500: hsla(84, 81%, 44%, 1); + --color-lime-600: hsla(85, 85%, 35%, 1); + --color-lime-700: hsla(86, 78%, 27%, 1); + --color-lime-800: hsla(86, 69%, 23%, 1); + --color-lime-900: hsla(88, 61%, 20%, 1); + --color-lime-950: hsla(89, 80%, 10%, 1); + --color-green-50: hsla(138, 76%, 97%, 1); + --color-green-100: hsla(141, 84%, 93%, 1); + --color-green-200: hsla(141, 79%, 85%, 1); + --color-green-300: hsla(142, 77%, 73%, 1); + --color-green-400: hsla(142, 69%, 58%, 1); + --color-green-500: hsla(142, 71%, 45%, 1); + --color-green-600: hsla(142, 76%, 36%, 1); + --color-green-700: hsla(142, 72%, 29%, 1); + --color-green-800: hsla(143, 64%, 24%, 1); + --color-green-900: hsla(144, 61%, 20%, 1); + --color-green-950: hsla(145, 80%, 10%, 1); + --color-emerald-50: hsla(152, 81%, 96%, 1); + --color-emerald-100: hsla(149, 80%, 90%, 1); + --color-emerald-200: hsla(152, 76%, 80%, 1); + --color-emerald-300: hsla(156, 72%, 67%, 1); + --color-emerald-400: hsla(158, 64%, 52%, 1); + --color-emerald-500: hsla(160, 84%, 39%, 1); + --color-emerald-600: hsla(161, 94%, 30%, 1); + --color-emerald-700: hsla(163, 94%, 24%, 1); + --color-emerald-800: hsla(163, 88%, 20%, 1); + --color-emerald-900: hsla(164, 86%, 16%, 1); + --color-emerald-950: hsla(166, 91%, 9%, 1); + --color-teal-50: hsla(166, 76%, 97%, 1); + --color-teal-100: hsla(167, 85%, 89%, 1); + --color-teal-200: hsla(168, 84%, 78%, 1); + --color-teal-300: hsla(171, 77%, 64%, 1); + --color-teal-400: hsla(172, 66%, 50%, 1); + --color-teal-500: hsla(173, 80%, 40%, 1); + --color-teal-600: hsla(175, 84%, 32%, 1); + --color-teal-700: hsla(175, 77%, 26%, 1); + --color-teal-800: hsla(176, 69%, 22%, 1); + --color-teal-900: hsla(176, 61%, 19%, 1); + --color-teal-950: hsla(179, 84%, 10%, 1); + --color-cyan-50: hsla(183, 100%, 96%, 1); + --color-cyan-100: hsla(185, 96%, 90%, 1); + --color-cyan-200: hsla(186, 94%, 82%, 1); + --color-cyan-300: hsla(187, 92%, 69%, 1); + --color-cyan-400: hsla(188, 86%, 53%, 1); + --color-cyan-500: hsla(189, 94%, 43%, 1); + --color-cyan-600: hsla(192, 91%, 36%, 1); + --color-cyan-700: hsla(193, 82%, 31%, 1); + --color-cyan-800: hsla(194, 70%, 27%, 1); + --color-cyan-900: hsla(196, 64%, 24%, 1); + --color-cyan-950: hsla(197, 79%, 15%, 1); + --color-sky-50: hsla(204, 100%, 97%, 1); + --color-sky-100: hsla(204, 94%, 94%, 1); + --color-sky-200: hsla(201, 94%, 86%, 1); + --color-sky-300: hsla(199, 95%, 74%, 1); + --color-sky-400: hsla(198, 93%, 60%, 1); + --color-sky-500: hsla(199, 89%, 48%, 1); + --color-sky-600: hsla(200, 98%, 39%, 1); + --color-sky-700: hsla(201, 96%, 32%, 1); + --color-sky-800: hsla(201, 90%, 27%, 1); + --color-sky-900: hsla(202, 80%, 24%, 1); + --color-sky-950: hsla(204, 80%, 16%, 1); + --color-blue-50: hsla(214, 100%, 97%, 1); + --color-blue-100: hsla(214, 95%, 93%, 1); + --color-blue-200: hsla(213, 97%, 87%, 1); + --color-blue-300: hsla(212, 96%, 78%, 1); + --color-blue-400: hsla(213, 94%, 68%, 1); + --color-blue-500: hsla(217, 91%, 60%, 1); + --color-blue-600: hsla(221, 83%, 53%, 1); + --color-blue-700: hsla(224, 76%, 48%, 1); + --color-blue-800: hsla(226, 71%, 40%, 1); + --color-blue-900: hsla(224, 64%, 33%, 1); + --color-blue-950: hsla(226, 57%, 21%, 1); + --color-indigo-50: hsla(226, 100%, 97%, 1); + --color-indigo-100: hsla(226, 100%, 94%, 1); + --color-indigo-200: hsla(228, 96%, 89%, 1); + --color-indigo-300: hsla(230, 94%, 82%, 1); + --color-indigo-400: hsla(234, 89%, 74%, 1); + --color-indigo-500: hsla(239, 84%, 67%, 1); + --color-indigo-600: hsla(243, 75%, 59%, 1); + --color-indigo-700: hsla(245, 58%, 51%, 1); + --color-indigo-800: hsla(244, 55%, 41%, 1); + --color-indigo-900: hsla(242, 47%, 34%, 1); + --color-indigo-950: hsla(244, 47%, 20%, 1); + --color-violet-50: hsla(250, 100%, 98%, 1); + --color-violet-100: hsla(251, 91%, 95%, 1); + --color-violet-200: hsla(251, 95%, 92%, 1); + --color-violet-300: hsla(253, 95%, 85%, 1); + --color-violet-400: hsla(255, 92%, 76%, 1); + --color-violet-500: hsla(258, 90%, 66%, 1); + --color-violet-600: hsla(262, 83%, 58%, 1); + --color-violet-700: hsla(263, 70%, 50%, 1); + --color-violet-800: hsla(263, 69%, 42%, 1); + --color-violet-900: hsla(264, 67%, 35%, 1); + --color-violet-950: hsla(262, 78%, 23%, 1); + --color-purple-50: hsla(270, 100%, 98%, 1); + --color-purple-100: hsla(269, 100%, 95%, 1); + --color-purple-200: hsla(269, 100%, 92%, 1); + --color-purple-300: hsla(269, 97%, 85%, 1); + --color-purple-400: hsla(270, 95%, 75%, 1); + --color-purple-500: hsla(271, 91%, 65%, 1); + --color-purple-600: hsla(271, 81%, 56%, 1); + --color-purple-700: hsla(272, 72%, 47%, 1); + --color-purple-800: hsla(273, 67%, 39%, 1); + --color-purple-900: hsla(274, 66%, 32%, 1); + --color-purple-950: hsla(274, 87%, 21%, 1); + --color-fuchsia-50: hsla(289, 100%, 98%, 1); + --color-fuchsia-100: hsla(287, 100%, 95%, 1); + --color-fuchsia-200: hsla(288, 96%, 91%, 1); + --color-fuchsia-300: hsla(291, 93%, 83%, 1); + --color-fuchsia-400: hsla(292, 91%, 73%, 1); + --color-fuchsia-500: hsla(292, 84%, 61%, 1); + --color-fuchsia-600: hsla(293, 69%, 49%, 1); + --color-fuchsia-700: hsla(295, 72%, 40%, 1); + --color-fuchsia-800: hsla(295, 70%, 33%, 1); + --color-fuchsia-900: hsla(297, 64%, 28%, 1); + --color-fuchsia-950: hsla(297, 90%, 16%, 1); + --color-pink-50: hsla(327, 73%, 97%, 1); + --color-pink-100: hsla(326, 78%, 95%, 1); + --color-pink-200: hsla(326, 85%, 90%, 1); + --color-pink-300: hsla(327, 87%, 82%, 1); + --color-pink-400: hsla(329, 86%, 70%, 1); + --color-pink-500: hsla(330, 81%, 60%, 1); + --color-pink-600: hsla(333, 71%, 51%, 1); + --color-pink-700: hsla(335, 78%, 42%, 1); + --color-pink-800: hsla(336, 74%, 35%, 1); + --color-pink-900: hsla(336, 69%, 30%, 1); + --color-pink-950: hsla(336, 84%, 17%, 1); + --color-rose-50: hsla(356, 100%, 97%, 1); + --color-rose-100: hsla(356, 100%, 95%, 1); + --color-rose-200: hsla(353, 96%, 90%, 1); + --color-rose-300: hsla(353, 96%, 82%, 1); + --color-rose-400: hsla(351, 95%, 71%, 1); + --color-rose-500: hsla(350, 89%, 60%, 1); + --color-rose-600: hsla(347, 77%, 50%, 1); + --color-rose-700: hsla(345, 83%, 41%, 1); + --color-rose-800: hsla(343, 80%, 35%, 1); + --color-rose-900: hsla(342, 75%, 30%, 1); + --color-rose-950: hsla(343, 88%, 16%, 1); + --color-brand-50: hsla(132, 64%, 97%, 1); + --color-brand-100: hsla(132, 64%, 93%, 1); + --color-brand-200: hsla(132, 64%, 85%, 1); + --color-brand-300: hsla(132, 64%, 73%, 1); + --color-brand-400: hsla(132, 64%, 63%, 1); + --color-brand-500: hsla(132, 64%, 53%, 1); + --color-brand-600: hsla(132, 64%, 43%, 1); + --color-brand-700: hsla(132, 64%, 33%, 1); + --color-brand-800: hsla(132, 64%, 23%, 1); + --color-brand-900: hsla(132, 64%, 13%, 1); + --color-brand-950: hsla(132, 64%, 8%, 1); + + /* ==================== */ + /* Semantic Colors */ + /* ==================== */ + --color-primary: hsla(132, 64%, 53%, 1); + --color-primary-hover: hsla(132, 64%, 73%, 1); + --color-destructive: hsla(0, 84%, 60%, 1); + --color-destructive-hover: hsla(0, 91%, 71%, 1); + --color-background: hsla(0, 0%, 98%, 1); + --color-background-subtle: hsla(0, 0%, 0%, 0.02); + --color-foreground: hsla(0, 0%, 0%, 0.9); + --color-foreground-secondary: hsla(0, 0%, 0%, 0.6); + --color-foreground-muted: hsla(0, 0%, 0%, 0.4); + --color-card: hsla(0, 0%, 100%, 1); + --color-popover: hsla(0, 0%, 100%, 1); + --color-border: hsla(0, 0%, 0%, 0.1); + --color-border-hover: hsla(0, 0%, 0%, 0.2); + --color-border-active: hsla(0, 0%, 0%, 0.3); + --color-ring: color-mix(in srgb, hsla(132, 64%, 53%, 1) 40%, transparent); + --color-secondary: hsla(0, 0%, 0%, 0.05); + --color-secondary-hover: hsla(0, 0%, 0%, 0.85); + --color-secondary-active: hsla(0, 0%, 0%, 0.7); + --color-muted: hsla(0, 0%, 0%, 0.05); + --color-accent: hsla(0, 0%, 0%, 0.05); + --color-ghost-hover: hsla(0, 0%, 0%, 0.05); + --color-ghost-active: hsla(0, 0%, 0%, 0.1); + --color-sidebar: hsla(0, 0%, 100%, 1); + --color-sidebar-accent: hsla(0, 0%, 0%, 0.05); + --color-border-width-sm: 1px; + --color-border-width-md: 2px; + --color-border-width-lg: 3px; + + /* ==================== */ + /* Status Colors */ + /* ==================== */ + --color-error-base: hsla(0, 84%, 60%, 1); + --color-error-text: hsla(0, 70%, 35%, 1); + --color-error-bg: hsla(0, 86%, 97%, 1); + --color-error-text-hover: hsla(0, 74%, 42%, 1); + --color-error-bg-hover: hsla(0, 93%, 94%, 1); + --color-error-border: hsla(0, 96%, 89%, 1); + --color-error-border-hover: hsla(0, 94%, 82%, 1); + --color-error-active: hsla(0, 72%, 51%, 1); + --color-success-base: hsla(142, 71%, 45%, 1); + --color-success-text-hover: hsla(142, 72%, 29%, 1); + --color-success-bg: hsla(138, 76%, 97%, 1); + --color-success-bg-hover: hsla(141, 79%, 85%, 1); + --color-warning-base: hsla(43, 96%, 56%, 1); + --color-warning-text-hover: hsla(26, 90%, 37%, 1); + --color-warning-bg: hsla(48, 100%, 96%, 1); + --color-warning-bg-hover: hsla(48, 96%, 89%, 1); + --color-warning-active: hsla(32, 95%, 44%, 1); + + /* ==================== */ + /* Spacing */ + /* ==================== */ + --spacing-5xs: 0.25rem; + --spacing-4xs: 0.5rem; + --spacing-3xs: 0.75rem; + --spacing-2xs: 1rem; + --spacing-xs: 1.5rem; + --spacing-sm: 2rem; + --spacing-md: 2.5rem; + --spacing-lg: 3rem; + --spacing-xl: 3.5rem; + --spacing-2xl: 4rem; + --spacing-3xl: 4.5rem; + --spacing-4xl: 5rem; + --spacing-5xl: 5.5rem; + --spacing-6xl: 6rem; + --spacing-7xl: 6.5rem; + --spacing-8xl: 7rem; + + /* ==================== */ + /* Radius */ + /* ==================== */ + --radius-4xs: 0.25rem; /* 4px */ + --radius-3xs: 0.5rem; /* 8px */ + --radius-2xs: 0.75rem; /* 12px */ + --radius-xs: 1rem; /* 16px */ + --radius-sm: 1.5rem; /* 24px */ + --radius-md: 2rem; /* 32px */ + --radius-lg: 2.5rem; /* 40px */ + --radius-xl: 3rem; /* 48px */ + --radius-2xl: 3.5rem; /* 56px */ + --radius-3xl: 4rem; /* 64px */ + --radius-round: 999px; /* 完全圆角 */ + + /* ==================== */ + /* Typography */ + /* ==================== */ + --font-family-heading: Inter; + --font-family-body: Inter; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-bold: 700; + --font-size-body-xs: 0.75rem; + --font-size-body-sm: 0.875rem; + --font-size-body-md: 1rem; + --font-size-body-lg: 1.125rem; + --font-size-heading-xs: 1.25rem; + --font-size-heading-sm: 1.5rem; + --font-size-heading-md: 2rem; + --font-size-heading-lg: 2.5rem; + --font-size-heading-xl: 3rem; + --font-size-heading-2xl: 3.75rem; + --line-height-body-xs: 1.25rem; + --line-height-body-sm: 1.5rem; + --line-height-body-md: 1.5rem; + --line-height-body-lg: 1.75rem; + --line-height-heading-xs: 2rem; + --line-height-heading-sm: 2.5rem; + --line-height-heading-md: 3rem; + --line-height-heading-lg: 3.75rem; + --line-height-heading-xl: 5rem; + --paragraph-spacing-body-xs: 0.75rem; + --paragraph-spacing-body-sm: 0.875rem; + --paragraph-spacing-body-md: 1rem; + --paragraph-spacing-body-lg: 1.125rem; + --paragraph-spacing-heading-xs: 1.25rem; + --paragraph-spacing-heading-sm: 1.5rem; + --paragraph-spacing-heading-md: 2rem; + --paragraph-spacing-heading-lg: 2.5rem; + --paragraph-spacing-heading-xl: 3rem; + --paragraph-spacing-heading-2xl: 3.75rem; +} + +/* ==================== */ +/* Dark Mode */ +/* ==================== */ +@layer theme { + .dark { + --color-background: hsla(240, 6%, 10%, 1); + --color-background-subtle: hsla(0, 0%, 100%, 0.02); + --color-foreground: hsla(0, 0%, 100%, 0.9); + --color-foreground-secondary: hsla(0, 0%, 100%, 0.6); + --color-foreground-muted: hsla(0, 0%, 100%, 0.4); + --color-card: hsla(0, 0%, 0%, 1); + --color-popover: hsla(0, 0%, 0%, 1); + --color-border: hsla(0, 0%, 100%, 0.1); + --color-border-hover: hsla(0, 0%, 100%, 0.2); + --color-border-active: hsla(0, 0%, 100%, 0.3); + --color-ring: hsla(84, 81%, 44%, 0.4); + --color-secondary: hsla(0, 0%, 100%, 0.1); + --color-secondary-hover: hsla(0, 0%, 100%, 0.2); + --color-secondary-active: hsla(0, 0%, 100%, 0.25); + --color-muted: hsla(0, 0%, 100%, 0.1); + --color-accent: hsla(0, 0%, 100%, 0.1); + --color-ghost-hover: hsla(0, 0%, 100%, 0.1); + --color-ghost-active: hsla(0, 0%, 100%, 0.15); + --color-sidebar: hsla(0, 0%, 0%, 1); + --color-sidebar-accent: hsla(0, 0%, 100%, 0.1); + --color-error-base: hsla(0, 91%, 71%, 1); + --color-error-text: hsla(0, 93%, 94%, 1); + --color-error-bg: hsla(0, 63%, 31%, 1); + --color-error-text-hover: hsla(0, 96%, 89%, 1); + --color-error-bg-hover: hsla(0, 70%, 35%, 1); + --color-error-border: hsla(0, 74%, 42%, 1); + --color-error-border-hover: hsla(0, 72%, 51%, 1); + --color-error-active: hsla(0, 94%, 82%, 1); + --color-success-base: hsla(142, 69%, 58%, 1); + --color-success-text-hover: hsla(141, 79%, 85%, 1); + --color-success-bg: hsla(144, 61%, 20%, 1); + --color-success-bg-hover: hsla(143, 64%, 24%, 1); + --color-warning-base: hsla(43, 96%, 56%, 1); + --color-warning-text-hover: hsla(48, 97%, 77%, 1); + --color-warning-bg: hsla(22, 78%, 26%, 1); + --color-warning-bg-hover: hsla(23, 83%, 31%, 1); + --color-warning-active: hsla(32, 95%, 44%, 1); + } +} + +/* ==================== */ +/* Base Styles */ +/* ==================== */ +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/ui/design-reference/todocss.css b/packages/ui/design-reference/todocss.css new file mode 100644 index 0000000000..0f7ed153db --- /dev/null +++ b/packages/ui/design-reference/todocss.css @@ -0,0 +1,870 @@ +:root { + /* Typography: Desktop mode */ + --Font_family--Heading: Inter; + --Font_weight--Regular: 400px; + --Font_size--Heading--2xl: 60px; + --Font_size--Heading--xl: 48px; + --Font_size--Heading--lg: 40px; + --Font_size--Heading--md: 32px; + --Font_size--Heading--sm: 24px; + --Font_size--Heading--xs: 20px; + --Line_height--Heading--xl: 80px; + --Line_height--Body--lg: 28px; + --Line_height--Body--md: 24px; + --Line_height--Body--sm: 24px; + --Line_height--Body--xs: 20px; + --Paragraph_spacing--Body--lg: 18px; + --Paragraph_spacing--Body--md: 16px; + --Paragraph_spacing--Body--sm: 14px; + --Paragraph_spacing--Body--xs: 12px; + --Line_height--Heading--lg: 60px; + --Line_height--Heading--md: 48px; + --Line_height--Heading--sm: 40px; + --Line_height--Heading--xs: 32px; + --Font_size--Body--lg: 18px; + --Font_size--Body--md: 16px; + --Font_size--Body--sm: 14px; + --Font_size--Body--xs: 12px; + --Font_weight--Italic: 400px; + --Font_weight--Medium: 500px; + --Font_weight--Bold: 700px; + --Font_family--Body: Inter; + --Paragraph_spacing--Heading--2xl: 60px; + --Paragraph_spacing--Heading--xl: 48px; + --Paragraph_spacing--Heading--lg: 40px; + --Paragraph_spacing--Heading--md: 32px; + --Paragraph_spacing--Heading--sm: 24px; + --Paragraph_spacing--Heading--xs: 20px; + --typography_components--h1--font-family: Inter; + --typography_components--h2--font-family: Inter; + --typography_components--h2--font-size: 30px; + --typography_components--h2--line-height: 36px; + --typography_components--h2--font-weight: 600; + --typography_components--h2--letter-spacing: -0.4000000059604645px; + --typography_components--h1--font-size: 36px; + --typography_components--h1--font-size-lg: 48px; + --typography_components--h1--line-height: 40px; + --typography_components--h1--font-weight: 800; + --typography_components--h1--letter-spacing: -0.4000000059604645px; + --typography_components--h3--font-family: Inter; + --typography_components--h3--font-size: 24px; + --typography_components--h3--line-height: 32px; + --typography_components--h3--font-weight: 600; + --typography_components--h3--letter-spacing: -0.4000000059604645px; + --typography_components--h4--font-family: Inter; + --typography_components--h4--font-size: 20px; + --typography_components--h4--line-height: 28px; + --typography_components--h4--font-weight: 600; + --typography_components--h4--letter-spacing: -0.4000000059604645px; + --typography_components--p--font-family: Inter; + --typography_components--p--font-size: 16px; + --typography_components--p--line-height: 28px; + --typography_components--p--font-weight: 400; + --typography_components--p--letter-spacing: 0px; + --typography_components--blockquote--font-family: Inter; + --typography_components--blockquote--font-size: 16px; + --typography_components--blockquote--line-height: 24px; + --typography_components--blockquote--letter-spacing: 0px; + --typography_components--blockquote--font-style: italic; + --typography_components--list--font-family: Inter; + --typography_components--list--font-size: 16px; + --typography_components--list--line-height: 28px; + --typography_components--list--letter-spacing: 0px; + --typography_components--inline_code--font-family: Menlo; + --typography_components--inline_code--font-size: 14px; + --typography_components--inline_code--line-height: 20px; + --typography_components--inline_code--font-weight: 600; + --typography_components--inline_code--letter-spacing: 0px; + --typography_components--lead--font-family: Inter; + --typography_components--lead--font-size: 20px; + --typography_components--lead--line-height: 28px; + --typography_components--lead--font-weight: 400; + --typography_components--lead--letter-spacing: 0px; + --typography_components--large--font-family: Inter; + --typography_components--large--font-size: 18px; + --typography_components--large--line-height: 28px; + --typography_components--large--font-weight: 600; + --typography_components--large--letter-spacing: 0px; + --typography_components--small--font-family: Inter; + --typography_components--small--font-size: 14px; + --typography_components--small--line-height: 14px; + --typography_components--small--font-weight: 500; + --typography_components--table--font-family: Inter; + --typography_components--table--font-size: 16px; + --typography_components--table--font-weight: 400; + --typography_components--table--font-weight-bold: 700; + --typography_components--table--letter-spacing: 0px; + + /* Spacing and sizing: Desktop */ + --Border_width--sm: 1px; + --Border_width--md: 2px; + --Border_width--lg: 3px; + --Radius--4xs: 4px; + --Radius--3xs: 8px; + --Radius--2xs: 12px; + --Radius--xs: 16px; + --Radius--sm: 24px; + --Radius--md: 32px; + --Radius--lg: 40px; + --Radius--xl: 48px; + --Radius--2xl: 56px; + --Radius--3xl: 64px; + --Radius--round: 999px; + --Spacing--5xs: 4px; + --Spacing--4xs: 8px; + --Spacing--3xs: 12px; + --Spacing--2xs: 16px; + --Spacing--xs: 24px; + --Spacing--sm: 32px; + --Spacing--md: 40px; + --Spacing--lg: 48px; + --Spacing--xl: 56px; + --Spacing--2xl: 64px; + --Spacing--3xl: 72px; + --Spacing--4xl: 80px; + --Spacing--5xl: 88px; + --Spacing--6xl: 96px; + --Spacing--7xl: 104px; + --Spacing--8xl: 112px; + --Sizing--5xs: 4px; + --Sizing--4xs: 8px; + --Sizing--3xs: 12px; + --Sizing--2xs: 16px; + --Sizing--xs: 24px; + --Sizing--sm: 32px; + --Sizing--md: 40px; + --Sizing--lg: 48px; + --Sizing--xl: 56px; + --Sizing--2xl: 64px; + --Sizing--3xl: 72px; + --Sizing--4xl: 80px; + --Sizing--5xl: 88px; + + /* Color: Light mode */ + --Opacity--Red--Red-100: var(--Primitive--Red--600); + --Opacity--Red--Red-80: hsla(0, 72%, 51%, 0.8); + --Opacity--Red--Red-60: hsla(0, 72%, 51%, 0.6); + --Opacity--Red--Red-40: hsla(0, 72%, 51%, 0.4); + --Opacity--Red--Red-20: hsla(0, 72%, 51%, 0.2); + --Opacity--Red--Red-10: hsla(0, 72%, 51%, 0.1); + --Opacity--Green--Green-100: var(--Primitive--Green--600); + --Opacity--Green--Green-80: hsla(142, 76%, 36%, 0.8); + --Opacity--Green--Green-60: hsla(142, 76%, 36%, 0.6); + --Opacity--Green--Green-40: hsla(142, 76%, 36%, 0.4); + --Opacity--Green--Green-20: hsla(142, 76%, 36%, 0.2); + --Opacity--Green--Green-10: hsla(142, 76%, 36%, 0.1); + --Opacity--Yellow--Yellow-100: var(--Primitive--Amber--400); + --Opacity--Yellow--Yellow-80: hsla(48, 96%, 53%, 0.8); + --Opacity--Yellow--Yellow-60: hsla(48, 96%, 53%, 0.6); + --Opacity--Yellow--Yellow-40: hsla(48, 96%, 53%, 0.4); + --Opacity--Yellow--Yellow-20: hsla(48, 96%, 53%, 0.2); + --Opacity--Yellow--Yellow-10: hsla(48, 96%, 53%, 0.1); + --Opacity--Violet--Violet-100: var(--Primitive--Violet--500); + --Opacity--Violet--Violet-80: hsla(258, 90%, 66%, 0.8); + --Opacity--Violet--Violet-60: hsla(258, 90%, 66%, 0.6); + --Opacity--Violet--Violet-40: hsla(258, 90%, 66%, 0.4); + --Opacity--Violet--Violet-20: hsla(258, 90%, 66%, 0.2); + --Opacity--Violet--Violet-10: hsla(258, 90%, 66%, 0.1); + --Opacity--Indigo--Indigo-100: var(--Primitive--Indigo--500); + --Opacity--Indigo--Indigo-80: hsla(239, 84%, 67%, 0.8); + --Opacity--Indigo--Indigo-60: hsla(239, 84%, 67%, 0.6); + --Opacity--Indigo--Indigo-40: hsla(239, 84%, 67%, 0.4); + --Opacity--Indigo--Indigo-20: hsla(239, 84%, 67%, 0.2); + --Opacity--Indigo--Indigo-10: hsla(239, 84%, 67%, 0.1); + --Opacity--Blue--Blue-100: var(--Primitive--Blue--500); + --Opacity--Blue--Blue-80: hsla(217, 91%, 60%, 0.8); + --Opacity--Blue--Blue-60: hsla(217, 91%, 60%, 0.6); + --Opacity--Blue--Blue-40: hsla(217, 91%, 60%, 0.4); + --Opacity--Blue--Blue-20: hsla(217, 91%, 60%, 0.2); + --Opacity--Blue--Blue-10: hsla(217, 91%, 60%, 0.1); + --Opacity--Grey--Grey-100: var(--Primitive--Gray--500); + --Opacity--Grey--Grey-80: hsla(220, 9%, 46%, 0.8); + --Opacity--Grey--Grey-60: hsla(220, 9%, 46%, 0.6); + --Opacity--Grey--Grey-40: hsla(220, 9%, 46%, 0.4); + --Opacity--Grey--Grey-20: hsla(220, 9%, 46%, 0.2); + --Opacity--Grey--Grey-10: hsla(220, 9%, 46%, 0.1); + --Opacity--White--White-100: var(--Primitive--White); + --Opacity--White--White-80: hsla(0, 0%, 100%, 0.8); + --Opacity--White--White-60: hsla(0, 0%, 100%, 0.6); + --Opacity--White--White-40: hsla(0, 0%, 100%, 0.4); + --Opacity--White--White-20: hsla(0, 0%, 100%, 0.2); + --Opacity--White--White-10: hsla(0, 0%, 100%, 0.1); + --Opacity--White--White-0: hsla(0, 0%, 100%, 0); + --Status--Error--colorErrorBg: var(--color--Red--50); + --Status--Error--colorErrorBgHover: var(--color--Red--100); + --Status--Error--colorErrorBorder: var(--color--Red--200); + --Status--Error--colorErrorBorderHover: var(--color--Red--300); + --Status--Error--colorErrorBase: var(--color--Red--500); + --Status--Error--colorErrorActive: var(--color--Red--600); + --Status--Error--colorErrorTextHover: var(--color--Red--700); + --Status--Error--colorErrorText: var(--color--Red--800); + --Status--Success--colorSuccessBg: var(--color--Green--50); + --Status--Success--colorSuccessBgHover: var(--color--Green--100); + --Status--Success--colorSuccessBase: var(--color--Green--500); + --Status--Success--colorSuccessTextHover: var(--color--Green--700); + --Status--Warning--colorWarningBg: var(--color--Yellow--50); + --Status--Warning--colorWarningBgHover: var(--color--Yellow--100); + --Status--Warning--colorWarningBase: var(--color--Yellow--500); + --Status--Warning--colorWarningActive: var(--color--Yellow--600); + --Status--Warning--colorWarningTextHover: var(--color--Yellow--700); + --Primitive--Black: hsla(0, 0%, 0%, 1); + --Primitive--White: hsla(0, 0%, 100%, 1); + --Brand--Base_Colors--Primary: var(--Primitive--Lime--500); + --Primitive--Neutral--50: hsla(0, 0%, 98%, 1); + --Primitive--Neutral--100: hsla(0, 0%, 96%, 1); + --Primitive--Neutral--200: hsla(0, 0%, 90%, 1); + --Primitive--Neutral--300: hsla(0, 0%, 83%, 1); + --Primitive--Neutral--400: hsla(0, 0%, 64%, 1); + --Primitive--Neutral--500: hsla(0, 0%, 45%, 1); + --Primitive--Neutral--600: hsla(215, 14%, 34%, 1); + --Primitive--Neutral--700: hsla(0, 0%, 25%, 1); + --Primitive--Neutral--800: hsla(0, 0%, 15%, 1); + --Primitive--Neutral--900: hsla(0, 0%, 9%, 1); + --Primitive--Neutral--950: hsla(0, 0%, 4%, 1); + --Primitive--Stone--50: hsla(60, 9%, 98%, 1); + --Primitive--Stone--100: hsla(60, 5%, 96%, 1); + --Primitive--Stone--200: hsla(20, 6%, 90%, 1); + --Primitive--Stone--300: hsla(24, 6%, 83%, 1); + --Primitive--Stone--400: hsla(24, 5%, 64%, 1); + --Primitive--Stone--500: hsla(25, 5%, 45%, 1); + --Primitive--Stone--600: hsla(33, 5%, 32%, 1); + --Primitive--Stone--700: hsla(30, 6%, 25%, 1); + --Primitive--Stone--800: hsla(12, 6%, 15%, 1); + --Primitive--Stone--900: hsla(24, 10%, 10%, 1); + --Primitive--Stone--950: hsla(20, 14%, 4%, 1); + --Primitive--Zinc--50: hsla(0, 0%, 98%, 1); + --Primitive--Zinc--100: hsla(240, 5%, 96%, 1); + --Primitive--Zinc--200: hsla(240, 6%, 90%, 1); + --Primitive--Zinc--300: hsla(240, 5%, 84%, 1); + --Primitive--Zinc--400: hsla(240, 5%, 65%, 1); + --Primitive--Zinc--500: hsla(240, 4%, 46%, 1); + --Primitive--Zinc--600: hsla(240, 5%, 34%, 1); + --Primitive--Zinc--700: hsla(240, 5%, 26%, 1); + --Primitive--Zinc--800: hsla(240, 4%, 16%, 1); + --Primitive--Zinc--900: hsla(240, 6%, 10%, 1); + --Primitive--Zinc--950: hsla(240, 10%, 4%, 1); + --Primitive--Slate--50: hsla(210, 40%, 98%, 1); + --Primitive--Slate--100: hsla(210, 40%, 96%, 1); + --Primitive--Slate--200: hsla(214, 32%, 91%, 1); + --Primitive--Slate--300: hsla(213, 27%, 84%, 1); + --Primitive--Slate--400: hsla(215, 20%, 65%, 1); + --Primitive--Slate--500: hsla(215, 16%, 47%, 1); + --Primitive--Slate--600: hsla(215, 19%, 35%, 1); + --Primitive--Slate--700: hsla(215, 25%, 27%, 1); + --Primitive--Slate--800: hsla(217, 33%, 17%, 1); + --Primitive--Slate--900: hsla(222, 47%, 11%, 1); + --Primitive--Slate--950: hsla(229, 84%, 5%, 1); + --Primitive--Gray--50: hsla(210, 20%, 98%, 1); + --Primitive--Gray--100: hsla(220, 14%, 96%, 1); + --Primitive--Gray--200: hsla(220, 13%, 91%, 1); + --Primitive--Gray--300: hsla(216, 12%, 84%, 1); + --Primitive--Gray--400: hsla(218, 11%, 65%, 1); + --Primitive--Gray--500: hsla(220, 9%, 46%, 1); + --Primitive--Gray--600: hsla(0, 0%, 32%, 1); + --Primitive--Gray--700: hsla(217, 19%, 27%, 1); + --Primitive--Gray--800: hsla(215, 28%, 17%, 1); + --Primitive--Gray--900: hsla(221, 39%, 11%, 1); + --Primitive--Gray--950: hsla(224, 71%, 4%, 1); + --Primitive--Red--50: hsla(0, 86%, 97%, 1); + --Primitive--Red--100: hsla(0, 93%, 94%, 1); + --Primitive--Red--200: hsla(0, 96%, 89%, 1); + --Primitive--Red--300: hsla(0, 94%, 82%, 1); + --Primitive--Red--400: hsla(0, 91%, 71%, 1); + --Primitive--Red--500: hsla(0, 84%, 60%, 1); + --Primitive--Red--600: hsla(0, 72%, 51%, 1); + --Primitive--Red--700: hsla(0, 74%, 42%, 1); + --Primitive--Red--800: hsla(0, 70%, 35%, 1); + --Primitive--Red--900: hsla(0, 63%, 31%, 1); + --Primitive--Red--950: hsla(0, 75%, 15%, 1); + --Primitive--Orange--50: hsla(33, 100%, 96%, 1); + --Primitive--Orange--100: hsla(34, 100%, 92%, 1); + --Primitive--Orange--200: hsla(32, 98%, 83%, 1); + --Primitive--Orange--300: hsla(31, 97%, 72%, 1); + --Primitive--Orange--400: hsla(27, 96%, 61%, 1); + --Primitive--Orange--500: hsla(25, 95%, 53%, 1); + --Primitive--Orange--600: hsla(21, 90%, 48%, 1); + --Primitive--Orange--700: hsla(17, 88%, 40%, 1); + --Primitive--Orange--800: hsla(15, 79%, 34%, 1); + --Primitive--Orange--900: hsla(15, 75%, 28%, 1); + --Primitive--Orange--950: hsla(13, 81%, 15%, 1); + --Primitive--Amber--50: hsla(48, 100%, 96%, 1); + --Primitive--Amber--100: hsla(48, 96%, 89%, 1); + --Primitive--Amber--200: hsla(48, 97%, 77%, 1); + --Primitive--Amber--300: hsla(46, 97%, 65%, 1); + --Primitive--Amber--400: hsla(43, 96%, 56%, 1); + --Primitive--Amber--500: hsla(38, 92%, 50%, 1); + --Primitive--Amber--600: hsla(32, 95%, 44%, 1); + --Primitive--Amber--700: hsla(26, 90%, 37%, 1); + --Primitive--Amber--800: hsla(23, 83%, 31%, 1); + --Primitive--Amber--900: hsla(22, 78%, 26%, 1); + --Primitive--Amber--950: hsla(21, 92%, 14%, 1); + --Primitive--Yellow--50: hsla(55, 92%, 95%, 1); + --Primitive--Yellow--100: hsla(55, 97%, 88%, 1); + --Primitive--Yellow--200: hsla(53, 98%, 77%, 1); + --Primitive--Yellow--300: hsla(50, 98%, 64%, 1); + --Primitive--Yellow--400: hsla(48, 96%, 53%, 1); + --Primitive--Yellow--500: hsla(45, 93%, 47%, 1); + --Primitive--Yellow--600: hsla(41, 96%, 40%, 1); + --Primitive--Yellow--700: hsla(35, 92%, 33%, 1); + --Primitive--Yellow--800: hsla(32, 81%, 29%, 1); + --Primitive--Yellow--900: hsla(28, 73%, 26%, 1); + --Primitive--Yellow--950: hsla(26, 83%, 14%, 1); + --Primitive--Lime--50: hsla(78, 92%, 95%, 1); + --Primitive--Lime--100: hsla(80, 89%, 89%, 1); + --Primitive--Lime--200: hsla(81, 88%, 80%, 1); + --Primitive--Lime--300: hsla(82, 85%, 67%, 1); + --Primitive--Lime--400: hsla(83, 78%, 55%, 1); + --Primitive--Lime--500: hsla(84, 81%, 44%, 1); + --Primitive--Lime--600: hsla(85, 85%, 35%, 1); + --Primitive--Lime--700: hsla(86, 78%, 27%, 1); + --Primitive--Lime--800: hsla(86, 69%, 23%, 1); + --Primitive--Lime--900: hsla(88, 61%, 20%, 1); + --Primitive--Lime--950: hsla(89, 80%, 10%, 1); + --Primitive--Green--50: hsla(138, 76%, 97%, 1); + --Primitive--Green--100: hsla(141, 84%, 93%, 1); + --Primitive--Green--200: hsla(141, 79%, 85%, 1); + --Primitive--Green--300: hsla(142, 77%, 73%, 1); + --Primitive--Green--400: hsla(142, 69%, 58%, 1); + --Primitive--Green--500: hsla(142, 71%, 45%, 1); + --Primitive--Green--600: hsla(142, 76%, 36%, 1); + --Primitive--Green--700: hsla(142, 72%, 29%, 1); + --Primitive--Green--800: hsla(143, 64%, 24%, 1); + --Primitive--Green--900: hsla(144, 61%, 20%, 1); + --Primitive--Green--950: hsla(145, 80%, 10%, 1); + --Primitive--Emerald--50: hsla(152, 81%, 96%, 1); + --Primitive--Emerald--100: hsla(149, 80%, 90%, 1); + --Primitive--Emerald--200: hsla(152, 76%, 80%, 1); + --Primitive--Emerald--300: hsla(156, 72%, 67%, 1); + --Primitive--Emerald--400: hsla(158, 64%, 52%, 1); + --Primitive--Emerald--500: hsla(160, 84%, 39%, 1); + --Primitive--Emerald--600: hsla(161, 94%, 30%, 1); + --Primitive--Emerald--700: hsla(163, 94%, 24%, 1); + --Primitive--Emerald--800: hsla(163, 88%, 20%, 1); + --Primitive--Emerald--900: hsla(164, 86%, 16%, 1); + --Primitive--Emerald--950: hsla(166, 91%, 9%, 1); + --Primitive--Teal--50: hsla(166, 76%, 97%, 1); + --Primitive--Teal--100: hsla(167, 85%, 89%, 1); + --Primitive--Teal--200: hsla(168, 84%, 78%, 1); + --Primitive--Teal--300: hsla(171, 77%, 64%, 1); + --Primitive--Teal--400: hsla(172, 66%, 50%, 1); + --Primitive--Teal--500: hsla(173, 80%, 40%, 1); + --Primitive--Teal--600: hsla(175, 84%, 32%, 1); + --Primitive--Teal--700: hsla(175, 77%, 26%, 1); + --Primitive--Teal--800: hsla(176, 69%, 22%, 1); + --Primitive--Teal--900: hsla(176, 61%, 19%, 1); + --Primitive--Teal--950: hsla(179, 84%, 10%, 1); + --Primitive--Cyan--50: hsla(183, 100%, 96%, 1); + --Primitive--Cyan--100: hsla(185, 96%, 90%, 1); + --Primitive--Cyan--200: hsla(186, 94%, 82%, 1); + --Primitive--Cyan--300: hsla(187, 92%, 69%, 1); + --Primitive--Cyan--400: hsla(188, 86%, 53%, 1); + --Primitive--Cyan--500: hsla(189, 94%, 43%, 1); + --Primitive--Cyan--600: hsla(192, 91%, 36%, 1); + --Primitive--Cyan--700: hsla(193, 82%, 31%, 1); + --Primitive--Cyan--800: hsla(194, 70%, 27%, 1); + --Primitive--Cyan--900: hsla(196, 64%, 24%, 1); + --Primitive--Cyan--950: hsla(197, 79%, 15%, 1); + --Primitive--Sky--50: hsla(204, 100%, 97%, 1); + --Primitive--Sky--100: hsla(204, 94%, 94%, 1); + --Primitive--Sky--200: hsla(201, 94%, 86%, 1); + --Primitive--Sky--300: hsla(199, 95%, 74%, 1); + --Primitive--Sky--400: hsla(198, 93%, 60%, 1); + --Primitive--Sky--500: hsla(199, 89%, 48%, 1); + --Primitive--Sky--600: hsla(200, 98%, 39%, 1); + --Primitive--Sky--700: hsla(201, 96%, 32%, 1); + --Primitive--Sky--800: hsla(201, 90%, 27%, 1); + --Primitive--Sky--900: hsla(202, 80%, 24%, 1); + --Primitive--Sky--950: hsla(204, 80%, 16%, 1); + --Primitive--Blue--50: hsla(214, 100%, 97%, 1); + --Primitive--Blue--100: hsla(214, 95%, 93%, 1); + --Primitive--Blue--200: hsla(213, 97%, 87%, 1); + --Primitive--Blue--300: hsla(212, 96%, 78%, 1); + --Primitive--Blue--400: hsla(213, 94%, 68%, 1); + --Primitive--Blue--500: hsla(217, 91%, 60%, 1); + --Primitive--Blue--600: hsla(221, 83%, 53%, 1); + --Primitive--Blue--700: hsla(224, 76%, 48%, 1); + --Primitive--Blue--800: hsla(226, 71%, 40%, 1); + --Primitive--Blue--900: hsla(224, 64%, 33%, 1); + --Primitive--Blue--950: hsla(226, 57%, 21%, 1); + --Primitive--Indigo--50: hsla(226, 100%, 97%, 1); + --Primitive--Indigo--100: hsla(226, 100%, 94%, 1); + --Primitive--Indigo--200: hsla(228, 96%, 89%, 1); + --Primitive--Indigo--300: hsla(230, 94%, 82%, 1); + --Primitive--Indigo--400: hsla(234, 89%, 74%, 1); + --Primitive--Indigo--500: hsla(239, 84%, 67%, 1); + --Primitive--Indigo--600: hsla(243, 75%, 59%, 1); + --Primitive--Indigo--700: hsla(245, 58%, 51%, 1); + --Primitive--Indigo--800: hsla(244, 55%, 41%, 1); + --Primitive--Indigo--900: hsla(242, 47%, 34%, 1); + --Primitive--Indigo--950: hsla(244, 47%, 20%, 1); + --Primitive--Violet--50: hsla(250, 100%, 98%, 1); + --Primitive--Violet--100: hsla(251, 91%, 95%, 1); + --Primitive--Violet--200: hsla(251, 95%, 92%, 1); + --Primitive--Violet--300: hsla(253, 95%, 85%, 1); + --Primitive--Violet--400: hsla(255, 92%, 76%, 1); + --Primitive--Violet--500: hsla(258, 90%, 66%, 1); + --Primitive--Violet--600: hsla(262, 83%, 58%, 1); + --Primitive--Violet--700: hsla(263, 70%, 50%, 1); + --Primitive--Violet--800: hsla(263, 69%, 42%, 1); + --Primitive--Violet--900: hsla(264, 67%, 35%, 1); + --Primitive--Violet--950: hsla(262, 78%, 23%, 1); + --Primitive--Purple--50: hsla(270, 100%, 98%, 1); + --Primitive--Purple--100: hsla(269, 100%, 95%, 1); + --Primitive--Purple--200: hsla(269, 100%, 92%, 1); + --Primitive--Purple--300: hsla(269, 97%, 85%, 1); + --Primitive--Purple--400: hsla(270, 95%, 75%, 1); + --Primitive--Purple--500: hsla(271, 91%, 65%, 1); + --Primitive--Purple--600: hsla(271, 81%, 56%, 1); + --Primitive--Purple--700: hsla(272, 72%, 47%, 1); + --Primitive--Purple--800: hsla(273, 67%, 39%, 1); + --Primitive--Purple--900: hsla(274, 66%, 32%, 1); + --Primitive--Purple--950: hsla(274, 87%, 21%, 1); + --Primitive--Fuchsia--50: hsla(289, 100%, 98%, 1); + --Primitive--Fuchsia--100: hsla(287, 100%, 95%, 1); + --Primitive--Fuchsia--200: hsla(288, 96%, 91%, 1); + --Primitive--Fuchsia--300: hsla(291, 93%, 83%, 1); + --Primitive--Fuchsia--400: hsla(292, 91%, 73%, 1); + --Primitive--Fuchsia--500: hsla(292, 84%, 61%, 1); + --Primitive--Fuchsia--600: hsla(293, 69%, 49%, 1); + --Primitive--Fuchsia--700: hsla(295, 72%, 40%, 1); + --Primitive--Fuchsia--800: hsla(295, 70%, 33%, 1); + --Primitive--Fuchsia--900: hsla(297, 64%, 28%, 1); + --Primitive--Fuchsia--950: hsla(297, 90%, 16%, 1); + --Primitive--Pink--50: hsla(327, 73%, 97%, 1); + --Primitive--Pink--100: hsla(326, 78%, 95%, 1); + --Primitive--Pink--200: hsla(326, 85%, 90%, 1); + --Primitive--Pink--300: hsla(327, 87%, 82%, 1); + --Primitive--Pink--400: hsla(329, 86%, 70%, 1); + --Primitive--Pink--500: hsla(330, 81%, 60%, 1); + --Primitive--Pink--600: hsla(333, 71%, 51%, 1); + --Primitive--Pink--700: hsla(335, 78%, 42%, 1); + --Primitive--Pink--800: hsla(336, 74%, 35%, 1); + --Primitive--Pink--900: hsla(336, 69%, 30%, 1); + --Primitive--Pink--950: hsla(336, 84%, 17%, 1); + --Primitive--Rose--50: hsla(356, 100%, 97%, 1); + --Primitive--Rose--100: hsla(356, 100%, 95%, 1); + --Primitive--Rose--200: hsla(353, 96%, 90%, 1); + --Primitive--Rose--300: hsla(353, 96%, 82%, 1); + --Primitive--Rose--400: hsla(351, 95%, 71%, 1); + --Primitive--Rose--500: hsla(350, 89%, 60%, 1); + --Primitive--Rose--600: hsla(347, 77%, 50%, 1); + --Primitive--Rose--700: hsla(345, 83%, 41%, 1); + --Primitive--Rose--800: hsla(343, 80%, 35%, 1); + --Primitive--Rose--900: hsla(342, 75%, 30%, 1); + --Primitive--Rose--950: hsla(343, 88%, 16%, 1); + --Brand--Base_Colors--Destructive: var(--Primitive--Red--500); + --Brand--Base_Colors--Success: var(--Primitive--Green--500); + --Brand--Base_Colors--Warning: var(--Primitive--Amber--500); + --Brand--Base_Colors--White: var(--Primitive--White); + --Brand--Base_Colors--Black: var(--Primitive--Black); + --Brand--Semantic_Colors--Background: var(--Primitive--Zinc--50); /*页面背景色:应用在整个页面的最底层。*/ + --Brand--Semantic_Colors--Background-subtle: hsla( + 0, + 0%, + 0%, + 0.02 + ); /*细微背景色:用于需要与主背景有微弱区分的区域,如代码块背景。*/ + --Brand--Semantic_Colors--Foreground: hsla(0, 0%, 0%, 0.9); /*主要前景/文字色:用于正文、标题等。*/ + --Brand--Semantic_Colors--Foreground-secondary: hsla(0, 0%, 0%, 0.6); /*次要前景/文字色:用于辅助性文本、描述。*/ + --Brand--Semantic_Colors--Foreground-muted: hsla(0, 0%, 0%, 0.4); /*静默前景/文字色:用于禁用状态的文字、占位符。*/ + --Brand--Semantic_Colors--Border: hsla(0, 0%, 0%, 0.1); /*默认边框色:用于卡片、输入框、分隔线。*/ + --Brand--Semantic_Colors--Border-hover: hsla(0, 0%, 0%, 0.2); /*激活边框色:用于元素被按下或激活时的边框。*/ + --Brand--Semantic_Colors--Border-active: hsla(0, 0%, 0%, 0.3); /*激活边框色:用于元素被按下或激活时的边框。*/ + --Brand--Semantic_Colors--Ring: hsla( + 84, + 81%, + 44%, + 0.4 + ); /*聚焦环颜色:用于输入框等元素在聚焦 (Focus) 状态下的外发光。*/ + --Brand--UI_Element_Colors--Modal--Backdrop: hsla(0, 0%, 0%, 0.4); + --Brand--UI_Element_Colors--Modal--Thumb: hsla(0, 0%, 0%, 0.2); + --Brand--UI_Element_Colors--Modal--Thumb_Hover: hsla(0, 0%, 0%, 0.3); + --Brand--UI_Element_Colors--Icon--Default: var(--Brand--Semantic_Colors--Foreground-secondary); + --Brand--UI_Element_Colors--Icon--Hover: var(--Brand--Semantic_Colors--Foreground); + --Brand--UI_Element_Colors--Input_Select--Background: var(--Brand--Base_Colors--White); + --Brand--UI_Element_Colors--Input_Select--Border: var(--Brand--Semantic_Colors--Border); + --Brand--UI_Element_Colors--Input_Select--Border_Hover: var(--Brand--Semantic_Colors--Border-hover); + --Brand--UI_Element_Colors--Input_Select--Border_Focus: var(--Brand--Base_Colors--Primary); + --Brand--UI_Element_Colors--Primary_Button--Background: var(--Brand--Base_Colors--Primary); + --Brand--UI_Element_Colors--Card_Container--Background: var(--Brand--Base_Colors--White); + --Brand--UI_Element_Colors--Card_Container--Border: var(--Brand--Semantic_Colors--Border); + --Brand--UI_Element_Colors--Ghost_Button--Background: hsla(0, 0%, 0%, 0); + --Brand--UI_Element_Colors--Ghost_Button--Text: var(--Brand--Semantic_Colors--Foreground); + --Brand--UI_Element_Colors--Ghost_Button--Background_Hover: hsla(0, 0%, 0%, 0.05); + --Brand--UI_Element_Colors--Ghost_Button--Background_Active: hsla(0, 0%, 0%, 0.1); + --Brand--UI_Element_Colors--Secondary_Button--Background: hsla(0, 0%, 0%, 0.05); + --Brand--UI_Element_Colors--Secondary_Button--Text: var(--Brand--Semantic_Colors--Foreground); + --Brand--UI_Element_Colors--Secondary_Button--Background_Hover: hsla(0, 0%, 0%, 0.85); + --Brand--UI_Element_Colors--Secondary_Button--Background_Active: hsla(0, 0%, 0%, 0.7); + --Brand--UI_Element_Colors--Secondary_Button--Border: var(--Brand--Semantic_Colors--Border); + --Brand--UI_Element_Colors--Primary_Button--Text: var(--Brand--Base_Colors--White); + --Brand--UI_Element_Colors--Primary_Button--Background_Hover: hsla(84, 81%, 44%, 0.85); + --Brand--UI_Element_Colors--Primary_Button--2nd_Background: hsla(84, 81%, 44%, 0.1); + --Brand--UI_Element_Colors--Primary_Button--3rd_Background: hsla(84, 81%, 44%, 0.05); + --Brand--UI_Element_Colors--Primary_Button--Background_Active: hsla(84, 81%, 44%, 0.7); + --Boolean: false; + + /* Color: Dark mode */ + --Opacity--Red--Red-100: var(--Primitive--Red--600); + --Opacity--Red--Red-80: hsla(0, 72%, 51%, 0.8); + --Opacity--Red--Red-60: hsla(0, 72%, 51%, 0.6); + --Opacity--Red--Red-40: hsla(0, 72%, 51%, 0.4); + --Opacity--Red--Red-20: hsla(0, 72%, 51%, 0.2); + --Opacity--Red--Red-10: hsla(0, 72%, 51%, 0.1); + --Opacity--Green--Green-100: var(--Primitive--Green--600); + --Opacity--Green--Green-80: hsla(142, 76%, 36%, 0.8); + --Opacity--Green--Green-60: hsla(142, 76%, 36%, 0.6); + --Opacity--Green--Green-40: hsla(142, 76%, 36%, 0.4); + --Opacity--Green--Green-20: hsla(142, 76%, 36%, 0.2); + --Opacity--Green--Green-10: hsla(142, 76%, 36%, 0.1); + --Opacity--Yellow--Yellow-100: var(--Primitive--Yellow--400); + --Opacity--Yellow--Yellow-80: hsla(48, 96%, 53%, 0.8); + --Opacity--Yellow--Yellow-60: hsla(48, 96%, 53%, 0.6); + --Opacity--Yellow--Yellow-40: hsla(48, 96%, 53%, 0.4); + --Opacity--Yellow--Yellow-20: hsla(48, 96%, 53%, 0.2); + --Opacity--Yellow--Yellow-10: hsla(48, 96%, 53%, 0.1); + --Opacity--Violet--Violet-100: var(--Primitive--Violet--500); + --Opacity--Violet--Violet-80: hsla(258, 90%, 66%, 0.8); + --Opacity--Violet--Violet-60: hsla(258, 90%, 66%, 0.6); + --Opacity--Violet--Violet-40: hsla(258, 90%, 66%, 0.4); + --Opacity--Violet--Violet-20: hsla(258, 90%, 66%, 0.2); + --Opacity--Violet--Violet-10: hsla(258, 90%, 66%, 0.1); + --Opacity--Indigo--Indigo-100: var(--Primitive--Indigo--500); + --Opacity--Indigo--Indigo-80: hsla(239, 84%, 67%, 0.8); + --Opacity--Indigo--Indigo-60: hsla(239, 84%, 67%, 0.6); + --Opacity--Indigo--Indigo-40: hsla(239, 84%, 67%, 0.4); + --Opacity--Indigo--Indigo-20: hsla(239, 84%, 67%, 0.2); + --Opacity--Indigo--Indigo-10: hsla(239, 84%, 67%, 0.1); + --Opacity--Blue--Blue-100: var(--Primitive--Blue--500); + --Opacity--Blue--Blue-80: hsla(217, 91%, 60%, 0.8); + --Opacity--Blue--Blue-60: hsla(217, 91%, 60%, 0.6); + --Opacity--Blue--Blue-40: hsla(217, 91%, 60%, 0.4); + --Opacity--Blue--Blue-20: hsla(217, 91%, 60%, 0.2); + --Opacity--Blue--Blue-10: hsla(217, 91%, 60%, 0.1); + --Opacity--Grey--Grey-100: var(--Primitive--Gray--500); + --Opacity--Grey--Grey-80: hsla(220, 9%, 46%, 0.8); + --Opacity--Grey--Grey-60: hsla(220, 9%, 46%, 0.6); + --Opacity--Grey--Grey-40: hsla(220, 9%, 46%, 0.4); + --Opacity--Grey--Grey-20: hsla(220, 9%, 46%, 0.2); + --Opacity--Grey--Grey-10: hsla(220, 9%, 46%, 0.1); + --Opacity--White--White-100: var(--Primitive--White); + --Opacity--White--White-80: hsla(0, 0%, 100%, 0.8); + --Opacity--White--White-60: hsla(0, 0%, 100%, 0.6); + --Opacity--White--White-40: hsla(0, 0%, 100%, 0.4); + --Opacity--White--White-20: hsla(0, 0%, 100%, 0.2); + --Opacity--White--White-10: hsla(0, 0%, 100%, 0.1); + --Opacity--White--White-0: hsla(0, 0%, 100%, 0); + --Status--Error--colorErrorBg: var(--color--Red--900); + --Status--Error--colorErrorBgHover: var(--color--Red--800); + --Status--Error--colorErrorBorder: var(--color--Red--700); + --Status--Error--colorErrorBorderHover: var(--color--Red--600); + --Status--Error--colorErrorBase: var(--color--Red--400); + --Status--Error--colorErrorActive: var(--color--Red--300); + --Status--Error--colorErrorTextHover: var(--color--Red--200); + --Status--Error--colorErrorText: var(--color--Red--100); + --Status--Success--colorSuccessBg: var(--color--Green--900); + --Status--Success--colorSuccessBgHover: var(--color--Green--800); + --Status--Success--colorSuccessBase: var(--color--Green--400); + --Status--Success--colorSuccessTextHover: var(--color--Green--200); + --Status--Warning--colorWarningBg: var(--color--Yellow--900); + --Status--Warning--colorWarningBgHover: var(--color--Yellow--800); + --Status--Warning--colorWarningBase: var(--color--Yellow--400); + --Status--Warning--colorWarningActive: var(--color--Yellow--300); + --Status--Warning--colorWarningTextHover: var(--color--Yellow--200); + --Primitive--Black: hsla(0, 0%, 0%, 1); + --Primitive--White: hsla(0, 0%, 100%, 1); + --Brand--Base_Colors--Primary: var(--Primitive--Lime--500); + --Primitive--Neutral--50: hsla(0, 0%, 98%, 1); + --Primitive--Neutral--100: hsla(0, 0%, 96%, 1); + --Primitive--Neutral--200: hsla(0, 0%, 90%, 1); + --Primitive--Neutral--300: hsla(0, 0%, 83%, 1); + --Primitive--Neutral--400: hsla(0, 0%, 64%, 1); + --Primitive--Neutral--500: hsla(0, 0%, 45%, 1); + --Primitive--Neutral--600: hsla(215, 14%, 34%, 1); + --Primitive--Neutral--700: hsla(0, 0%, 25%, 1); + --Primitive--Neutral--800: hsla(0, 0%, 15%, 1); + --Primitive--Neutral--900: hsla(0, 0%, 9%, 1); + --Primitive--Neutral--950: hsla(0, 0%, 4%, 1); + --Primitive--Stone--50: hsla(60, 9%, 98%, 1); + --Primitive--Stone--100: hsla(60, 5%, 96%, 1); + --Primitive--Stone--200: hsla(20, 6%, 90%, 1); + --Primitive--Stone--300: hsla(24, 6%, 83%, 1); + --Primitive--Stone--400: hsla(24, 5%, 64%, 1); + --Primitive--Stone--500: hsla(25, 5%, 45%, 1); + --Primitive--Stone--600: hsla(33, 5%, 32%, 1); + --Primitive--Stone--700: hsla(30, 6%, 25%, 1); + --Primitive--Stone--800: hsla(12, 6%, 15%, 1); + --Primitive--Stone--900: hsla(24, 10%, 10%, 1); + --Primitive--Stone--950: hsla(20, 14%, 4%, 1); + --Primitive--Zinc--50: hsla(0, 0%, 98%, 1); + --Primitive--Zinc--100: hsla(240, 5%, 96%, 1); + --Primitive--Zinc--200: hsla(240, 6%, 90%, 1); + --Primitive--Zinc--300: hsla(240, 5%, 84%, 1); + --Primitive--Zinc--400: hsla(240, 5%, 65%, 1); + --Primitive--Zinc--500: hsla(240, 4%, 46%, 1); + --Primitive--Zinc--600: hsla(240, 5%, 34%, 1); + --Primitive--Zinc--700: hsla(240, 5%, 26%, 1); + --Primitive--Zinc--800: hsla(240, 4%, 16%, 1); + --Primitive--Zinc--900: hsla(240, 6%, 10%, 1); + --Primitive--Zinc--950: hsla(240, 10%, 4%, 1); + --Primitive--Slate--50: hsla(210, 40%, 98%, 1); + --Primitive--Slate--100: hsla(210, 40%, 96%, 1); + --Primitive--Slate--200: hsla(214, 32%, 91%, 1); + --Primitive--Slate--300: hsla(213, 27%, 84%, 1); + --Primitive--Slate--400: hsla(215, 20%, 65%, 1); + --Primitive--Slate--500: hsla(215, 16%, 47%, 1); + --Primitive--Slate--600: hsla(215, 19%, 35%, 1); + --Primitive--Slate--700: hsla(215, 25%, 27%, 1); + --Primitive--Slate--800: hsla(217, 33%, 17%, 1); + --Primitive--Slate--900: hsla(222, 47%, 11%, 1); + --Primitive--Slate--950: hsla(229, 84%, 5%, 1); + --Primitive--Gray--50: hsla(210, 20%, 98%, 1); + --Primitive--Gray--100: hsla(220, 14%, 96%, 1); + --Primitive--Gray--200: hsla(220, 13%, 91%, 1); + --Primitive--Gray--300: hsla(216, 12%, 84%, 1); + --Primitive--Gray--400: hsla(218, 11%, 65%, 1); + --Primitive--Gray--500: hsla(220, 9%, 46%, 1); + --Primitive--Gray--600: hsla(0, 0%, 32%, 1); + --Primitive--Gray--700: hsla(217, 19%, 27%, 1); + --Primitive--Gray--800: hsla(215, 28%, 17%, 1); + --Primitive--Gray--900: hsla(221, 39%, 11%, 1); + --Primitive--Gray--950: hsla(224, 71%, 4%, 1); + --Primitive--Red--50: hsla(0, 86%, 97%, 1); + --Primitive--Red--100: hsla(0, 93%, 94%, 1); + --Primitive--Red--200: hsla(0, 96%, 89%, 1); + --Primitive--Red--300: hsla(0, 94%, 82%, 1); + --Primitive--Red--400: hsla(0, 91%, 71%, 1); + --Primitive--Red--500: hsla(0, 84%, 60%, 1); + --Primitive--Red--600: hsla(0, 72%, 51%, 1); + --Primitive--Red--700: hsla(0, 74%, 42%, 1); + --Primitive--Red--800: hsla(0, 70%, 35%, 1); + --Primitive--Red--900: hsla(0, 63%, 31%, 1); + --Primitive--Red--950: hsla(0, 75%, 15%, 1); + --Primitive--Orange--50: hsla(33, 100%, 96%, 1); + --Primitive--Orange--100: hsla(34, 100%, 92%, 1); + --Primitive--Orange--200: hsla(32, 98%, 83%, 1); + --Primitive--Orange--300: hsla(31, 97%, 72%, 1); + --Primitive--Orange--400: hsla(27, 96%, 61%, 1); + --Primitive--Orange--500: hsla(25, 95%, 53%, 1); + --Primitive--Orange--600: hsla(21, 90%, 48%, 1); + --Primitive--Orange--700: hsla(17, 88%, 40%, 1); + --Primitive--Orange--800: hsla(15, 79%, 34%, 1); + --Primitive--Orange--900: hsla(15, 75%, 28%, 1); + --Primitive--Orange--950: hsla(13, 81%, 15%, 1); + --Primitive--Amber--50: hsla(48, 100%, 96%, 1); + --Primitive--Amber--100: hsla(48, 96%, 89%, 1); + --Primitive--Amber--200: hsla(48, 97%, 77%, 1); + --Primitive--Amber--300: hsla(46, 97%, 65%, 1); + --Primitive--Amber--400: hsla(43, 96%, 56%, 1); + --Primitive--Amber--500: hsla(38, 92%, 50%, 1); + --Primitive--Amber--600: hsla(32, 95%, 44%, 1); + --Primitive--Amber--700: hsla(26, 90%, 37%, 1); + --Primitive--Amber--800: hsla(23, 83%, 31%, 1); + --Primitive--Amber--900: hsla(22, 78%, 26%, 1); + --Primitive--Amber--950: hsla(21, 92%, 14%, 1); + --Primitive--Yellow--50: hsla(55, 92%, 95%, 1); + --Primitive--Yellow--100: hsla(55, 97%, 88%, 1); + --Primitive--Yellow--200: hsla(53, 98%, 77%, 1); + --Primitive--Yellow--300: hsla(50, 98%, 64%, 1); + --Primitive--Yellow--400: hsla(48, 96%, 53%, 1); + --Primitive--Yellow--500: hsla(45, 93%, 47%, 1); + --Primitive--Yellow--600: hsla(41, 96%, 40%, 1); + --Primitive--Yellow--700: hsla(35, 92%, 33%, 1); + --Primitive--Yellow--800: hsla(32, 81%, 29%, 1); + --Primitive--Yellow--900: hsla(28, 73%, 26%, 1); + --Primitive--Yellow--950: hsla(26, 83%, 14%, 1); + --Primitive--Lime--50: hsla(78, 92%, 95%, 1); + --Primitive--Lime--100: hsla(80, 89%, 89%, 1); + --Primitive--Lime--200: hsla(81, 88%, 80%, 1); + --Primitive--Lime--300: hsla(82, 85%, 67%, 1); + --Primitive--Lime--400: hsla(83, 78%, 55%, 1); + --Primitive--Lime--500: hsla(84, 81%, 44%, 1); + --Primitive--Lime--600: hsla(85, 85%, 35%, 1); + --Primitive--Lime--700: hsla(86, 78%, 27%, 1); + --Primitive--Lime--800: hsla(86, 69%, 23%, 1); + --Primitive--Lime--900: hsla(88, 61%, 20%, 1); + --Primitive--Lime--950: hsla(89, 80%, 10%, 1); + --Primitive--Green--50: hsla(138, 76%, 97%, 1); + --Primitive--Green--100: hsla(141, 84%, 93%, 1); + --Primitive--Green--200: hsla(141, 79%, 85%, 1); + --Primitive--Green--300: hsla(142, 77%, 73%, 1); + --Primitive--Green--400: hsla(142, 69%, 58%, 1); + --Primitive--Green--500: hsla(142, 71%, 45%, 1); + --Primitive--Green--600: hsla(142, 76%, 36%, 1); + --Primitive--Green--700: hsla(142, 72%, 29%, 1); + --Primitive--Green--800: hsla(143, 64%, 24%, 1); + --Primitive--Green--900: hsla(144, 61%, 20%, 1); + --Primitive--Green--950: hsla(145, 80%, 10%, 1); + --Primitive--Emerald--50: hsla(152, 81%, 96%, 1); + --Primitive--Emerald--100: hsla(149, 80%, 90%, 1); + --Primitive--Emerald--200: hsla(152, 76%, 80%, 1); + --Primitive--Emerald--300: hsla(156, 72%, 67%, 1); + --Primitive--Emerald--400: hsla(158, 64%, 52%, 1); + --Primitive--Emerald--500: hsla(160, 84%, 39%, 1); + --Primitive--Emerald--600: hsla(161, 94%, 30%, 1); + --Primitive--Emerald--700: hsla(163, 94%, 24%, 1); + --Primitive--Emerald--800: hsla(163, 88%, 20%, 1); + --Primitive--Emerald--900: hsla(164, 86%, 16%, 1); + --Primitive--Emerald--950: hsla(166, 91%, 9%, 1); + --Primitive--Teal--50: hsla(166, 76%, 97%, 1); + --Primitive--Teal--100: hsla(167, 85%, 89%, 1); + --Primitive--Teal--200: hsla(168, 84%, 78%, 1); + --Primitive--Teal--300: hsla(171, 77%, 64%, 1); + --Primitive--Teal--400: hsla(172, 66%, 50%, 1); + --Primitive--Teal--500: hsla(173, 80%, 40%, 1); + --Primitive--Teal--600: hsla(175, 84%, 32%, 1); + --Primitive--Teal--700: hsla(175, 77%, 26%, 1); + --Primitive--Teal--800: hsla(176, 69%, 22%, 1); + --Primitive--Teal--900: hsla(176, 61%, 19%, 1); + --Primitive--Teal--950: hsla(179, 84%, 10%, 1); + --Primitive--Cyan--50: hsla(183, 100%, 96%, 1); + --Primitive--Cyan--100: hsla(185, 96%, 90%, 1); + --Primitive--Cyan--200: hsla(186, 94%, 82%, 1); + --Primitive--Cyan--300: hsla(187, 92%, 69%, 1); + --Primitive--Cyan--400: hsla(188, 86%, 53%, 1); + --Primitive--Cyan--500: hsla(189, 94%, 43%, 1); + --Primitive--Cyan--600: hsla(192, 91%, 36%, 1); + --Primitive--Cyan--700: hsla(193, 82%, 31%, 1); + --Primitive--Cyan--800: hsla(194, 70%, 27%, 1); + --Primitive--Cyan--900: hsla(196, 64%, 24%, 1); + --Primitive--Cyan--950: hsla(197, 79%, 15%, 1); + --Primitive--Sky--50: hsla(204, 100%, 97%, 1); + --Primitive--Sky--100: hsla(204, 94%, 94%, 1); + --Primitive--Sky--200: hsla(201, 94%, 86%, 1); + --Primitive--Sky--300: hsla(199, 95%, 74%, 1); + --Primitive--Sky--400: hsla(198, 93%, 60%, 1); + --Primitive--Sky--500: hsla(199, 89%, 48%, 1); + --Primitive--Sky--600: hsla(200, 98%, 39%, 1); + --Primitive--Sky--700: hsla(201, 96%, 32%, 1); + --Primitive--Sky--800: hsla(201, 90%, 27%, 1); + --Primitive--Sky--900: hsla(202, 80%, 24%, 1); + --Primitive--Sky--950: hsla(204, 80%, 16%, 1); + --Primitive--Blue--50: hsla(214, 100%, 97%, 1); + --Primitive--Blue--100: hsla(214, 95%, 93%, 1); + --Primitive--Blue--200: hsla(213, 97%, 87%, 1); + --Primitive--Blue--300: hsla(212, 96%, 78%, 1); + --Primitive--Blue--400: hsla(213, 94%, 68%, 1); + --Primitive--Blue--500: hsla(217, 91%, 60%, 1); + --Primitive--Blue--600: hsla(221, 83%, 53%, 1); + --Primitive--Blue--700: hsla(224, 76%, 48%, 1); + --Primitive--Blue--800: hsla(226, 71%, 40%, 1); + --Primitive--Blue--900: hsla(224, 64%, 33%, 1); + --Primitive--Blue--950: hsla(226, 57%, 21%, 1); + --Primitive--Indigo--50: hsla(226, 100%, 97%, 1); + --Primitive--Indigo--100: hsla(226, 100%, 94%, 1); + --Primitive--Indigo--200: hsla(228, 96%, 89%, 1); + --Primitive--Indigo--300: hsla(230, 94%, 82%, 1); + --Primitive--Indigo--400: hsla(234, 89%, 74%, 1); + --Primitive--Indigo--500: hsla(239, 84%, 67%, 1); + --Primitive--Indigo--600: hsla(243, 75%, 59%, 1); + --Primitive--Indigo--700: hsla(245, 58%, 51%, 1); + --Primitive--Indigo--800: hsla(244, 55%, 41%, 1); + --Primitive--Indigo--900: hsla(242, 47%, 34%, 1); + --Primitive--Indigo--950: hsla(244, 47%, 20%, 1); + --Primitive--Violet--50: hsla(250, 100%, 98%, 1); + --Primitive--Violet--100: hsla(251, 91%, 95%, 1); + --Primitive--Violet--200: hsla(251, 95%, 92%, 1); + --Primitive--Violet--300: hsla(253, 95%, 85%, 1); + --Primitive--Violet--400: hsla(255, 92%, 76%, 1); + --Primitive--Violet--500: hsla(258, 90%, 66%, 1); + --Primitive--Violet--600: hsla(262, 83%, 58%, 1); + --Primitive--Violet--700: hsla(263, 70%, 50%, 1); + --Primitive--Violet--800: hsla(263, 69%, 42%, 1); + --Primitive--Violet--900: hsla(264, 67%, 35%, 1); + --Primitive--Violet--950: hsla(262, 78%, 23%, 1); + --Primitive--Purple--50: hsla(270, 100%, 98%, 1); + --Primitive--Purple--100: hsla(269, 100%, 95%, 1); + --Primitive--Purple--200: hsla(269, 100%, 92%, 1); + --Primitive--Purple--300: hsla(269, 97%, 85%, 1); + --Primitive--Purple--400: hsla(270, 95%, 75%, 1); + --Primitive--Purple--500: hsla(271, 91%, 65%, 1); + --Primitive--Purple--600: hsla(271, 81%, 56%, 1); + --Primitive--Purple--700: hsla(272, 72%, 47%, 1); + --Primitive--Purple--800: hsla(273, 67%, 39%, 1); + --Primitive--Purple--900: hsla(274, 66%, 32%, 1); + --Primitive--Purple--950: hsla(274, 87%, 21%, 1); + --Primitive--Fuchsia--50: hsla(289, 100%, 98%, 1); + --Primitive--Fuchsia--100: hsla(287, 100%, 95%, 1); + --Primitive--Fuchsia--200: hsla(288, 96%, 91%, 1); + --Primitive--Fuchsia--300: hsla(291, 93%, 83%, 1); + --Primitive--Fuchsia--400: hsla(292, 91%, 73%, 1); + --Primitive--Fuchsia--500: hsla(292, 84%, 61%, 1); + --Primitive--Fuchsia--600: hsla(293, 69%, 49%, 1); + --Primitive--Fuchsia--700: hsla(295, 72%, 40%, 1); + --Primitive--Fuchsia--800: hsla(295, 70%, 33%, 1); + --Primitive--Fuchsia--900: hsla(297, 64%, 28%, 1); + --Primitive--Fuchsia--950: hsla(297, 90%, 16%, 1); + --Primitive--Pink--50: hsla(327, 73%, 97%, 1); + --Primitive--Pink--100: hsla(326, 78%, 95%, 1); + --Primitive--Pink--200: hsla(326, 85%, 90%, 1); + --Primitive--Pink--300: hsla(327, 87%, 82%, 1); + --Primitive--Pink--400: hsla(329, 86%, 70%, 1); + --Primitive--Pink--500: hsla(330, 81%, 60%, 1); + --Primitive--Pink--600: hsla(333, 71%, 51%, 1); + --Primitive--Pink--700: hsla(335, 78%, 42%, 1); + --Primitive--Pink--800: hsla(336, 74%, 35%, 1); + --Primitive--Pink--900: hsla(336, 69%, 30%, 1); + --Primitive--Pink--950: hsla(336, 84%, 17%, 1); + --Primitive--Rose--50: hsla(356, 100%, 97%, 1); + --Primitive--Rose--100: hsla(356, 100%, 95%, 1); + --Primitive--Rose--200: hsla(353, 96%, 90%, 1); + --Primitive--Rose--300: hsla(353, 96%, 82%, 1); + --Primitive--Rose--400: hsla(351, 95%, 71%, 1); + --Primitive--Rose--500: hsla(350, 89%, 60%, 1); + --Primitive--Rose--600: hsla(347, 77%, 50%, 1); + --Primitive--Rose--700: hsla(345, 83%, 41%, 1); + --Primitive--Rose--800: hsla(343, 80%, 35%, 1); + --Primitive--Rose--900: hsla(342, 75%, 30%, 1); + --Primitive--Rose--950: hsla(343, 88%, 16%, 1); + --Brand--Base_Colors--Destructive: var(--Primitive--Red--500); + --Brand--Base_Colors--Success: var(--Primitive--Green--500); + --Brand--Base_Colors--Warning: var(--Primitive--Amber--500); + --Brand--Base_Colors--White: var(--Primitive--White); + --Brand--Base_Colors--Black: var(--Primitive--Black); + --Brand--Semantic_Colors--Background: var(--Primitive--Zinc--900); /*页面背景色:应用在整个页面的最底层。*/ + --Brand--Semantic_Colors--Background-subtle: hsla( + 0, + 0%, + 100%, + 0.02 + ); /*细微背景色:用于需要与主背景有微弱区分的区域,如代码块背景。*/ + --Brand--Semantic_Colors--Foreground: hsla(0, 0%, 100%, 0.9); /*主要前景/文字色:用于正文、标题等。*/ + --Brand--Semantic_Colors--Foreground-secondary: hsla(0, 0%, 100%, 0.6); /*次要前景/文字色:用于辅助性文本、描述。*/ + --Brand--Semantic_Colors--Foreground-muted: hsla(0, 0%, 100%, 0.4); /*静默前景/文字色:用于禁用状态的文字、占位符。*/ + --Brand--Semantic_Colors--Border: hsla(0, 0%, 100%, 0.1); /*默认边框色:用于卡片、输入框、分隔线。*/ + --Brand--Semantic_Colors--Border-hover: hsla(0, 0%, 100%, 0.2); /*激活边框色:用于元素被按下或激活时的边框。*/ + --Brand--Semantic_Colors--Border-active: hsla(0, 0%, 100%, 0.3); /*激活边框色:用于元素被按下或激活时的边框。*/ + --Brand--Semantic_Colors--Ring: hsla( + 84, + 81%, + 44%, + 0.4 + ); /*聚焦环颜色:用于输入框等元素在聚焦 (Focus) 状态下的外发光。*/ + --Brand--UI_Element_Colors--Modal--Backdrop: hsla(0, 0%, 0%, 0.06); + --Brand--UI_Element_Colors--Modal--Thumb: hsla(0, 0%, 100%, 0.2); + --Brand--UI_Element_Colors--Modal--Thumb_Hover: hsla(0, 0%, 100%, 0.3); + --Brand--UI_Element_Colors--Icon--Default: var(--Brand--Semantic_Colors--Foreground-secondary); + --Brand--UI_Element_Colors--Icon--Hover: var(--Brand--Semantic_Colors--Foreground); + --Brand--UI_Element_Colors--Input_Select--Background: var(--Brand--Base_Colors--Black); + --Brand--UI_Element_Colors--Input_Select--Border: var(--Brand--Semantic_Colors--Border); + --Brand--UI_Element_Colors--Input_Select--Border_Hover: var(--Brand--Semantic_Colors--Border-hover); + --Brand--UI_Element_Colors--Input_Select--Border_Focus: var(--Brand--Base_Colors--Primary); + --Brand--UI_Element_Colors--Primary_Button--Background: var(--Brand--Base_Colors--Primary); + --Brand--UI_Element_Colors--Card_Container--Background: var(--Brand--Base_Colors--Black); + --Brand--UI_Element_Colors--Card_Container--Border: var(--Brand--Semantic_Colors--Border); + --Brand--UI_Element_Colors--Ghost_Button--Background: hsla(0, 0%, 100%, 0); + --Brand--UI_Element_Colors--Ghost_Button--Text: var(--Brand--Semantic_Colors--Foreground); + --Brand--UI_Element_Colors--Ghost_Button--Background_Hover: var(--Opacity--White--White-10); + --Brand--UI_Element_Colors--Ghost_Button--Background_Active: hsla(0, 0%, 100%, 0.15); + --Brand--UI_Element_Colors--Secondary_Button--Background: var(--Opacity--White--White-10); + --Brand--UI_Element_Colors--Secondary_Button--Text: var(--Brand--Semantic_Colors--Foreground); + --Brand--UI_Element_Colors--Secondary_Button--Background_Hover: var(--Opacity--White--White-20); + --Brand--UI_Element_Colors--Secondary_Button--Background_Active: hsla(0, 0%, 100%, 0.25); + --Brand--UI_Element_Colors--Secondary_Button--Border: var(--Brand--Semantic_Colors--Border); + --Brand--UI_Element_Colors--Primary_Button--Text: var(--Brand--Base_Colors--White); + --Brand--UI_Element_Colors--Primary_Button--Background_Hover: hsla(84, 81%, 44%, 0.85); + --Brand--UI_Element_Colors--Primary_Button--2nd_Background: hsla(84, 81%, 44%, 0.1); + --Brand--UI_Element_Colors--Primary_Button--3rd_Background: hsla(84, 81%, 44%, 0.05); + --Brand--UI_Element_Colors--Primary_Button--Background_Active: hsla(84, 81%, 44%, 0.7); + --Boolean: false; +} diff --git a/packages/ui/icons/302ai.svg b/packages/ui/icons/302ai.svg new file mode 100644 index 0000000000..5d9b55b3fa --- /dev/null +++ b/packages/ui/icons/302ai.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/ui/icons/DMXAPI-to-img.svg b/packages/ui/icons/DMXAPI-to-img.svg new file mode 100644 index 0000000000..a839782d66 --- /dev/null +++ b/packages/ui/icons/DMXAPI-to-img.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/DMXAPI.svg b/packages/ui/icons/DMXAPI.svg new file mode 100644 index 0000000000..d3ca99244e --- /dev/null +++ b/packages/ui/icons/DMXAPI.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/Voyage.svg b/packages/ui/icons/Voyage.svg new file mode 100644 index 0000000000..537376784c --- /dev/null +++ b/packages/ui/icons/Voyage.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui/icons/aiOnly.svg b/packages/ui/icons/aiOnly.svg new file mode 100644 index 0000000000..a2369ed410 --- /dev/null +++ b/packages/ui/icons/aiOnly.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/aihubmix.svg b/packages/ui/icons/aihubmix.svg new file mode 100644 index 0000000000..98cdcadb4b --- /dev/null +++ b/packages/ui/icons/aihubmix.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/alayanew.svg b/packages/ui/icons/alayanew.svg new file mode 100644 index 0000000000..83a5071a0a --- /dev/null +++ b/packages/ui/icons/alayanew.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/anthropic.svg b/packages/ui/icons/anthropic.svg new file mode 100644 index 0000000000..7704cbf1c9 --- /dev/null +++ b/packages/ui/icons/anthropic.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui/icons/aws-bedrock.svg b/packages/ui/icons/aws-bedrock.svg new file mode 100644 index 0000000000..de14833d45 --- /dev/null +++ b/packages/ui/icons/aws-bedrock.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/azureai.svg b/packages/ui/icons/azureai.svg new file mode 100644 index 0000000000..6a6627d70c --- /dev/null +++ b/packages/ui/icons/azureai.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/baichuan.svg b/packages/ui/icons/baichuan.svg new file mode 100644 index 0000000000..8b6a98929b --- /dev/null +++ b/packages/ui/icons/baichuan.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/baidu-cloud.svg b/packages/ui/icons/baidu-cloud.svg new file mode 100644 index 0000000000..ad4dbda3f2 --- /dev/null +++ b/packages/ui/icons/baidu-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/icons/bailian.svg b/packages/ui/icons/bailian.svg new file mode 100644 index 0000000000..784ee64497 --- /dev/null +++ b/packages/ui/icons/bailian.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/bocha.svg b/packages/ui/icons/bocha.svg new file mode 100644 index 0000000000..44ff57220d --- /dev/null +++ b/packages/ui/icons/bocha.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/icons/burncloud.svg b/packages/ui/icons/burncloud.svg new file mode 100644 index 0000000000..7185b8827d --- /dev/null +++ b/packages/ui/icons/burncloud.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/bytedance.svg b/packages/ui/icons/bytedance.svg new file mode 100644 index 0000000000..e0c21c49a6 --- /dev/null +++ b/packages/ui/icons/bytedance.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/icons/cephalon.svg b/packages/ui/icons/cephalon.svg new file mode 100644 index 0000000000..c895385491 --- /dev/null +++ b/packages/ui/icons/cephalon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/cherryin.svg b/packages/ui/icons/cherryin.svg new file mode 100644 index 0000000000..74536e15df --- /dev/null +++ b/packages/ui/icons/cherryin.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/cohere.svg b/packages/ui/icons/cohere.svg new file mode 100644 index 0000000000..a9727f89f8 --- /dev/null +++ b/packages/ui/icons/cohere.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/ui/icons/dashscope.svg b/packages/ui/icons/dashscope.svg new file mode 100644 index 0000000000..3bb2e62176 --- /dev/null +++ b/packages/ui/icons/dashscope.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/deepseek.svg b/packages/ui/icons/deepseek.svg new file mode 100644 index 0000000000..c6632fb7b0 --- /dev/null +++ b/packages/ui/icons/deepseek.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/doc2x.svg b/packages/ui/icons/doc2x.svg new file mode 100644 index 0000000000..f19f56d195 --- /dev/null +++ b/packages/ui/icons/doc2x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/icons/doubao.svg b/packages/ui/icons/doubao.svg new file mode 100644 index 0000000000..3efdfeeca4 --- /dev/null +++ b/packages/ui/icons/doubao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/ui/icons/exa.svg b/packages/ui/icons/exa.svg new file mode 100644 index 0000000000..0d2a401e31 --- /dev/null +++ b/packages/ui/icons/exa.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/fireworks.svg b/packages/ui/icons/fireworks.svg new file mode 100644 index 0000000000..85e6548c14 --- /dev/null +++ b/packages/ui/icons/fireworks.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/gemini.svg b/packages/ui/icons/gemini.svg new file mode 100644 index 0000000000..5986406a09 --- /dev/null +++ b/packages/ui/icons/gemini.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/gitee-ai.svg b/packages/ui/icons/gitee-ai.svg new file mode 100644 index 0000000000..f561588527 --- /dev/null +++ b/packages/ui/icons/gitee-ai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/github.svg b/packages/ui/icons/github.svg new file mode 100644 index 0000000000..cf6b05f172 --- /dev/null +++ b/packages/ui/icons/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/google.svg b/packages/ui/icons/google.svg new file mode 100644 index 0000000000..e207b34a48 --- /dev/null +++ b/packages/ui/icons/google.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/gpustack.svg b/packages/ui/icons/gpustack.svg new file mode 100644 index 0000000000..1876692196 --- /dev/null +++ b/packages/ui/icons/gpustack.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/graph-rag.svg b/packages/ui/icons/graph-rag.svg new file mode 100644 index 0000000000..f948b88179 --- /dev/null +++ b/packages/ui/icons/graph-rag.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui/icons/grok.svg b/packages/ui/icons/grok.svg new file mode 100644 index 0000000000..97da4e29be --- /dev/null +++ b/packages/ui/icons/grok.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/icons/groq.svg b/packages/ui/icons/groq.svg new file mode 100644 index 0000000000..f5b9b67284 --- /dev/null +++ b/packages/ui/icons/groq.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui/icons/huggingface.svg b/packages/ui/icons/huggingface.svg new file mode 100644 index 0000000000..e1175839c2 --- /dev/null +++ b/packages/ui/icons/huggingface.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/hyperbolic.svg b/packages/ui/icons/hyperbolic.svg new file mode 100644 index 0000000000..2354a47e4a --- /dev/null +++ b/packages/ui/icons/hyperbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/infini.svg b/packages/ui/icons/infini.svg new file mode 100644 index 0000000000..e5886fc4d3 --- /dev/null +++ b/packages/ui/icons/infini.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/ui/icons/intel.svg b/packages/ui/icons/intel.svg new file mode 100644 index 0000000000..bdb0a56be5 --- /dev/null +++ b/packages/ui/icons/intel.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/icons/jimeng.svg b/packages/ui/icons/jimeng.svg new file mode 100644 index 0000000000..2c38ede87f --- /dev/null +++ b/packages/ui/icons/jimeng.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/jina.svg b/packages/ui/icons/jina.svg new file mode 100644 index 0000000000..88a4958696 --- /dev/null +++ b/packages/ui/icons/jina.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/icons/lanyun.svg b/packages/ui/icons/lanyun.svg new file mode 100644 index 0000000000..fc0383eab4 --- /dev/null +++ b/packages/ui/icons/lanyun.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/lepton.svg b/packages/ui/icons/lepton.svg new file mode 100644 index 0000000000..5983db5fb1 --- /dev/null +++ b/packages/ui/icons/lepton.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/icons/lmstudio.svg b/packages/ui/icons/lmstudio.svg new file mode 100644 index 0000000000..791b5e3e15 --- /dev/null +++ b/packages/ui/icons/lmstudio.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui/icons/longcat.svg b/packages/ui/icons/longcat.svg new file mode 100644 index 0000000000..18af084275 --- /dev/null +++ b/packages/ui/icons/longcat.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/ui/icons/macos.svg b/packages/ui/icons/macos.svg new file mode 100644 index 0000000000..1b8a606bbf --- /dev/null +++ b/packages/ui/icons/macos.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/mcprouter.svg b/packages/ui/icons/mcprouter.svg new file mode 100644 index 0000000000..196ab78196 --- /dev/null +++ b/packages/ui/icons/mcprouter.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/meta.svg b/packages/ui/icons/meta.svg new file mode 100644 index 0000000000..9014995bcb --- /dev/null +++ b/packages/ui/icons/meta.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/mineru.svg b/packages/ui/icons/mineru.svg new file mode 100644 index 0000000000..e7cccfa096 --- /dev/null +++ b/packages/ui/icons/mineru.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/minimax.svg b/packages/ui/icons/minimax.svg new file mode 100644 index 0000000000..6f1f1e06ee --- /dev/null +++ b/packages/ui/icons/minimax.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/mistral.svg b/packages/ui/icons/mistral.svg new file mode 100644 index 0000000000..bee0c6ceab --- /dev/null +++ b/packages/ui/icons/mistral.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/ui/icons/mixedbread-1.svg b/packages/ui/icons/mixedbread-1.svg new file mode 100644 index 0000000000..3b1a91f2bf --- /dev/null +++ b/packages/ui/icons/mixedbread-1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/mixedbread.svg b/packages/ui/icons/mixedbread.svg new file mode 100644 index 0000000000..180923f69c --- /dev/null +++ b/packages/ui/icons/mixedbread.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/moonshot.svg b/packages/ui/icons/moonshot.svg new file mode 100644 index 0000000000..c1e2349e71 --- /dev/null +++ b/packages/ui/icons/moonshot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/icons/netease-youdao.svg b/packages/ui/icons/netease-youdao.svg new file mode 100644 index 0000000000..9fe8ee01b3 --- /dev/null +++ b/packages/ui/icons/netease-youdao.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/icons/newapi.svg b/packages/ui/icons/newapi.svg new file mode 100644 index 0000000000..00c52495ea --- /dev/null +++ b/packages/ui/icons/newapi.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/nomic.svg b/packages/ui/icons/nomic.svg new file mode 100644 index 0000000000..4261f28669 --- /dev/null +++ b/packages/ui/icons/nomic.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/nvidia.svg b/packages/ui/icons/nvidia.svg new file mode 100644 index 0000000000..71a18de8c9 --- /dev/null +++ b/packages/ui/icons/nvidia.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/o3.svg b/packages/ui/icons/o3.svg new file mode 100644 index 0000000000..305fbd3cd5 --- /dev/null +++ b/packages/ui/icons/o3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/ocoolai.svg b/packages/ui/icons/ocoolai.svg new file mode 100644 index 0000000000..162a027707 --- /dev/null +++ b/packages/ui/icons/ocoolai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/ollama.svg b/packages/ui/icons/ollama.svg new file mode 100644 index 0000000000..cf2773773a --- /dev/null +++ b/packages/ui/icons/ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/openai.svg b/packages/ui/icons/openai.svg new file mode 100644 index 0000000000..08932f01d2 --- /dev/null +++ b/packages/ui/icons/openai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/openrouter.svg b/packages/ui/icons/openrouter.svg new file mode 100644 index 0000000000..61305c11d3 --- /dev/null +++ b/packages/ui/icons/openrouter.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/paddleocr.svg b/packages/ui/icons/paddleocr.svg new file mode 100644 index 0000000000..1642eff9e1 --- /dev/null +++ b/packages/ui/icons/paddleocr.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/perplexity.svg b/packages/ui/icons/perplexity.svg new file mode 100644 index 0000000000..5134503045 --- /dev/null +++ b/packages/ui/icons/perplexity.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/ph8.svg b/packages/ui/icons/ph8.svg new file mode 100644 index 0000000000..1ca4605182 --- /dev/null +++ b/packages/ui/icons/ph8.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/ppio.svg b/packages/ui/icons/ppio.svg new file mode 100644 index 0000000000..2110f20962 --- /dev/null +++ b/packages/ui/icons/ppio.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/qiniu.svg b/packages/ui/icons/qiniu.svg new file mode 100644 index 0000000000..0956d63500 --- /dev/null +++ b/packages/ui/icons/qiniu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/searxng.svg b/packages/ui/icons/searxng.svg new file mode 100644 index 0000000000..b1f52d42d5 --- /dev/null +++ b/packages/ui/icons/searxng.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/ui/icons/silicon.svg b/packages/ui/icons/silicon.svg new file mode 100644 index 0000000000..eed86a2360 --- /dev/null +++ b/packages/ui/icons/silicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/sophnet.svg b/packages/ui/icons/sophnet.svg new file mode 100644 index 0000000000..3a657ec2fb --- /dev/null +++ b/packages/ui/icons/sophnet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/ui/icons/step.svg b/packages/ui/icons/step.svg new file mode 100644 index 0000000000..86273c0f0b --- /dev/null +++ b/packages/ui/icons/step.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/ui/icons/tavily.svg b/packages/ui/icons/tavily.svg new file mode 100644 index 0000000000..13766953eb --- /dev/null +++ b/packages/ui/icons/tavily.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/tencent-cloud-ti.svg b/packages/ui/icons/tencent-cloud-ti.svg new file mode 100644 index 0000000000..387c86b4c0 --- /dev/null +++ b/packages/ui/icons/tencent-cloud-ti.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/icons/tesseract-js.svg b/packages/ui/icons/tesseract-js.svg new file mode 100644 index 0000000000..af2b0f877d --- /dev/null +++ b/packages/ui/icons/tesseract-js.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/together.svg b/packages/ui/icons/together.svg new file mode 100644 index 0000000000..7cf402a08c --- /dev/null +++ b/packages/ui/icons/together.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/icons/tokenflux.svg b/packages/ui/icons/tokenflux.svg new file mode 100644 index 0000000000..9033cd890b --- /dev/null +++ b/packages/ui/icons/tokenflux.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/ui/icons/vertexai.svg b/packages/ui/icons/vertexai.svg new file mode 100644 index 0000000000..2b40e4b90a --- /dev/null +++ b/packages/ui/icons/vertexai.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/ui/icons/volcengine.svg b/packages/ui/icons/volcengine.svg new file mode 100644 index 0000000000..5fee048ddd --- /dev/null +++ b/packages/ui/icons/volcengine.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/ui/icons/xirang.svg b/packages/ui/icons/xirang.svg new file mode 100644 index 0000000000..e0876dc540 --- /dev/null +++ b/packages/ui/icons/xirang.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/zero-one.svg b/packages/ui/icons/zero-one.svg new file mode 100644 index 0000000000..3a53979f9e --- /dev/null +++ b/packages/ui/icons/zero-one.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/ui/icons/zhipu.svg b/packages/ui/icons/zhipu.svg new file mode 100644 index 0000000000..88a45da6b0 --- /dev/null +++ b/packages/ui/icons/zhipu.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000000..a02e3c71e6 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,148 @@ +{ + "name": "@cherrystudio/ui", + "version": "1.0.0-alpha.1", + "description": "Cherry Studio UI Component Library - React Components for Cherry Studio", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "react-native": "dist/index.js", + "scripts": { + "build": "tsdown", + "dev": "tsc -w", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src --ext .ts,.tsx --fix", + "type-check": "tsc --noEmit -p tsconfig.json --composite false", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "icons:generate": "tsx scripts/generate-icons.ts" + }, + "keywords": [ + "ui", + "components", + "react", + "tailwindcss", + "typescript", + "cherry-studio" + ], + "author": "Cherry Studio", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/CherryHQ/cherry-studio.git" + }, + "bugs": { + "url": "https://github.com/CherryHQ/cherry-studio/issues" + }, + "homepage": "https://github.com/CherryHQ/cherry-studio#readme", + "peerDependencies": { + "@heroui/react": "^2.8.4", + "framer-motion": "^11.0.0 || ^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.1.13" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^0.545.0", + "react-dropzone": "^14.3.8", + "tailwind-merge": "^2.5.5" + }, + "devDependencies": { + "@heroui/react": "^2.8.4", + "@storybook/addon-docs": "^10.0.5", + "@storybook/addon-themes": "^10.0.5", + "@storybook/react-vite": "^10.0.5", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", + "@svgr/plugin-prettier": "^8.1.0", + "@svgr/plugin-svgo": "^8.1.0", + "@types/react": "^19.0.12", + "@types/react-dom": "^19.0.4", + "@types/styled-components": "^5.1.34", + "@uiw/codemirror-extensions-langs": "^4.25.1", + "@uiw/codemirror-themes-all": "^4.25.1", + "@uiw/react-codemirror": "^4.25.1", + "antd": "^5.22.5", + "eslint-plugin-storybook": "10.0.5", + "framer-motion": "^12.23.12", + "linguist-languages": "^9.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "storybook": "^10.0.5", + "styled-components": "^6.1.15", + "tsdown": "^0.15.5", + "tsx": "^4.20.6", + "typescript": "^5.6.2", + "vitest": "^3.2.4" + }, + "resolutions": { + "@codemirror/language": "6.11.3", + "@codemirror/lint": "6.8.5", + "@codemirror/view": "6.38.1" + }, + "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "react-native": "./dist/index.js", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./components": { + "types": "./dist/components/index.d.ts", + "react-native": "./dist/components/index.js", + "import": "./dist/components/index.mjs", + "require": "./dist/components/index.js", + "default": "./dist/components/index.js" + }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "react-native": "./dist/hooks/index.js", + "import": "./dist/hooks/index.mjs", + "require": "./dist/hooks/index.js", + "default": "./dist/hooks/index.js" + }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "react-native": "./dist/utils/index.js", + "import": "./dist/utils/index.mjs", + "require": "./dist/utils/index.js", + "default": "./dist/utils/index.js" + }, + "./icons": { + "types": "./dist/components/icons/index.d.ts", + "react-native": "./dist/components/icons/index.js", + "import": "./dist/components/icons/index.mjs", + "require": "./dist/components/icons/index.js", + "default": "./dist/components/icons/index.js" + }, + "./styles": "./src/styles/index.css", + "./styles/tokens.css": "./src/styles/tokens.css", + "./styles/theme.css": "./src/styles/theme.css", + "./styles/index.css": "./src/styles/index.css" + }, + "packageManager": "yarn@4.9.1" +} diff --git a/packages/ui/scripts/generate-icons.ts b/packages/ui/scripts/generate-icons.ts new file mode 100644 index 0000000000..eed250af56 --- /dev/null +++ b/packages/ui/scripts/generate-icons.ts @@ -0,0 +1,170 @@ +/** + * Generate React components from SVG files using @svgr/core + * Simple approach: use SVGR defaults + component name handling + */ +import { transform } from '@svgr/core' +import fs from 'fs/promises' +import path from 'path' + +const ICONS_DIR = path.join(__dirname, '../icons') +const OUTPUT_DIR = path.join(__dirname, '../src/components/icons/logos') + +/** + * Convert filename to PascalCase component name + * Handle numeric prefix: 302ai -> Ai302 + */ +function toPascalCase(filename: string): string { + const name = filename.replace(/\.svg$/, '') + + if (/^\d/.test(name)) { + const match = name.match(/^(\d+)(.*)$/) + if (match) { + const [, numbers, rest] = match + const restCamel = rest.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) + return restCamel.charAt(0).toUpperCase() + restCamel.slice(1) + numbers + } + } + + // Convert kebab-case to PascalCase: aws-bedrock -> AwsBedrock + return name + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') +} + +/** + * Convert kebab-case to camelCase for filename + */ +function toCamelCase(filename: string): string { + const name = filename.replace(/\.svg$/, '') + const parts = name.split('-') + + if (parts.length === 1) { + return parts[0] + } + + return ( + parts[0] + + parts + .slice(1) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join('') + ) +} + +/** + * Generate a single icon component + */ +async function generateIcon(svgFile: string): Promise<{ filename: string; componentName: string }> { + const svgPath = path.join(ICONS_DIR, svgFile) + const svgCode = await fs.readFile(svgPath, 'utf-8') + + const componentName = toPascalCase(svgFile) + const outputFilename = toCamelCase(svgFile) + '.tsx' + const outputPath = path.join(OUTPUT_DIR, outputFilename) + + // Use SVGR with simple config + let jsCode = await transform( + svgCode, + { + plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx', '@svgr/plugin-prettier'], + icon: true, + typescript: true, + jsxRuntime: 'automatic', + svgoConfig: { + plugins: [ + // { + // name: 'preset-default', + // params: { + // overrides: { + // removeViewBox: false, + // // Important: Keep IDs but make them unique per component + // cleanupIds: false + // } + // } + // }, + { + // Add unique prefix to all IDs based on component name + name: 'prefixIds', + params: { + prefix: componentName.toLowerCase() + } + } + ] + } + }, + { componentName } + ) + + // Add named export + jsCode = jsCode.replace( + `export default ${componentName};`, + `export { ${componentName} };\nexport default ${componentName};` + ) + + await fs.writeFile(outputPath, jsCode, 'utf-8') + + return { filename: outputFilename, componentName } +} + +/** + * Generate index.ts file + */ +async function generateIndex(components: Array<{ filename: string; componentName: string }>) { + const exports = components + .map(({ filename, componentName }) => { + const basename = filename.replace('.tsx', '') + return `export { ${componentName} } from './${basename}'` + }) + .sort() + .join('\n') + + const indexContent = `/** + * Auto-generated icon exports + * Do not edit manually + * + * Generated at: ${new Date().toISOString()} + * Total icons: ${components.length} + */ + +${exports} +` + + await fs.writeFile(path.join(OUTPUT_DIR, 'index.ts'), indexContent, 'utf-8') +} + +/** + * Main function + */ +async function main() { + console.log('🔧 Starting icon generation...\n') + + // Ensure output directory exists + await fs.mkdir(OUTPUT_DIR, { recursive: true }) + + // Get all SVG files + const files = await fs.readdir(ICONS_DIR) + const svgFiles = files.filter((f) => f.endsWith('.svg')) + + console.log(`📁 Found ${svgFiles.length} SVG files\n`) + + const components: Array<{ filename: string; componentName: string }> = [] + + for (const svgFile of svgFiles) { + try { + const result = await generateIcon(svgFile) + components.push(result) + console.log(`✅ ${svgFile} -> ${result.filename} (${result.componentName})`) + } catch (error) { + console.error(`❌ Failed to process ${svgFile}:`, error) + } + } + + // Generate index.ts + console.log('\n📝 Generating index.ts...') + await generateIndex(components) + + console.log(`\n✨ Generation complete! Successfully processed ${components.length}/${svgFiles.length} files`) +} + +main() diff --git a/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx b/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000000..6bd56727ef --- /dev/null +++ b/packages/ui/src/components/composites/CodeEditor/CodeEditor.tsx @@ -0,0 +1,139 @@ +import type { BasicSetupOptions } from '@uiw/react-codemirror' +import CodeMirror, { Annotation, EditorView } from '@uiw/react-codemirror' +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import { memo } from 'react' + +import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks' +import type { CodeEditorProps } from './types' +import { prepareCodeChanges } from './utils' + +/** + * A code editor component based on CodeMirror. + * This is a wrapper of ReactCodeMirror. + */ +const CodeEditor = ({ + ref, + value, + placeholder, + language, + languageConfig, + onSave, + onChange, + onBlur, + onHeightChange, + height, + maxHeight, + minHeight, + options, + extensions, + theme = 'light', + fontSize = 16, + style, + className, + editable = true, + readOnly = false, + expanded = true, + wrapped = true +}: CodeEditorProps) => { + const basicSetup = useMemo(() => { + return { + dropCursor: true, + allowMultipleSelections: true, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + rectangularSelection: true, + crosshairCursor: true, + highlightActiveLineGutter: false, + highlightSelectionMatches: true, + closeBracketsKeymap: options?.keymap, + searchKeymap: options?.keymap, + foldKeymap: options?.keymap, + completionKeymap: options?.keymap, + lintKeymap: options?.keymap, + ...(options as BasicSetupOptions) + } + }, [options]) + + const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? '')) + const editorViewRef = useRef(null) + + const langExtensions = useLanguageExtensions(language, options?.lint, languageConfig) + + const handleSave = useCallback(() => { + const currentDoc = editorViewRef.current?.state.doc.toString() ?? '' + onSave?.(currentDoc) + }, [onSave]) + + // Calculate changes during streaming response to update EditorView + // Cannot handle user editing code during streaming response (and probably doesn't need to) + useEffect(() => { + if (!editorViewRef.current) return + + const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '') + const currentDoc = editorViewRef.current.state.doc.toString() + + const changes = prepareCodeChanges(currentDoc, newContent) + + if (changes && changes.length > 0) { + editorViewRef.current.dispatch({ + changes, + annotations: [Annotation.define().of(true)] + }) + } + }, [options?.stream, value]) + + const saveKeymapExtension = useSaveKeymap({ onSave, enabled: options?.keymap }) + const blurExtension = useBlurHandler({ onBlur }) + const heightListenerExtension = useHeightListener({ onHeightChange }) + + const customExtensions = useMemo(() => { + return [ + ...(extensions ?? []), + ...langExtensions, + ...(wrapped ? [EditorView.lineWrapping] : []), + saveKeymapExtension, + blurExtension, + heightListenerExtension + ].flat() + }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) + + useImperativeHandle(ref, () => ({ + save: handleSave + })) + + return ( + { + editorViewRef.current = view + onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0) + }} + onChange={(value, viewUpdate) => { + if (onChange && viewUpdate.docChanged) onChange(value) + }} + basicSetup={basicSetup} + style={{ + fontSize, + marginTop: 0, + borderRadius: 'inherit', + ...style + }} + className={`code-editor ${className ?? ''}`} + /> + ) +} + +CodeEditor.displayName = 'CodeEditor' + +export default memo(CodeEditor) diff --git a/packages/ui/src/components/composites/CodeEditor/__tests__/utils.test.ts b/packages/ui/src/components/composites/CodeEditor/__tests__/utils.test.ts new file mode 100644 index 0000000000..ebc9a3d7d3 --- /dev/null +++ b/packages/ui/src/components/composites/CodeEditor/__tests__/utils.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getNormalizedExtension } from '../utils' + +const hoisted = vi.hoisted(() => ({ + languages: { + svg: { extensions: ['.svg'] }, + TypeScript: { extensions: ['.ts'] } + } +})) + +vi.mock('@shared/config/languages', () => ({ + languages: hoisted.languages +})) + +describe('getNormalizedExtension', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return custom mapping for custom language', async () => { + await expect(getNormalizedExtension('svg')).resolves.toBe('xml') + await expect(getNormalizedExtension('SVG')).resolves.toBe('xml') + }) + + it('should prefer custom mapping when both custom and linguist exist', async () => { + await expect(getNormalizedExtension('svg')).resolves.toBe('xml') + }) + + it('should return linguist mapping when available (strip leading dot)', async () => { + await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts') + }) + + it('should return extension when input already looks like extension (leading dot)', async () => { + await expect(getNormalizedExtension('.json')).resolves.toBe('json') + }) + + it('should return language as-is when no rules matched', async () => { + await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage') + }) +}) diff --git a/packages/ui/src/components/composites/CodeEditor/hooks.ts b/packages/ui/src/components/composites/CodeEditor/hooks.ts new file mode 100644 index 0000000000..f362774067 --- /dev/null +++ b/packages/ui/src/components/composites/CodeEditor/hooks.ts @@ -0,0 +1,204 @@ +import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup +import { EditorView } from '@codemirror/view' +import type { Extension } from '@uiw/react-codemirror' +import { keymap } from '@uiw/react-codemirror' +import { useEffect, useMemo, useState } from 'react' + +import type { LanguageConfig } from './types' +import { getNormalizedExtension } from './utils' + +/** 语言对应的 linter 加载器 + * key: 语言文件扩展名(不包含 `.`) + */ +const linterLoaders: Record Promise> = { + json: async () => { + const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter) + return linter(jsonParseLinter()) + } +} + +/** + * 特殊语言加载器 + * key: 语言文件扩展名(不包含 `.`) + */ +const specialLanguageLoaders: Record Promise> = { + dot: async () => { + const mod = await import('@viz-js/lang-dot') + return mod.dot() + }, + // @uiw/codemirror-extensions-langs 4.25.1 移除了 mermaid 支持,这里加回来 + mmd: async () => { + const mod = await import('codemirror-lang-mermaid') + return mod.mermaid() + } +} + +/** + * 加载语言扩展 + */ +async function loadLanguageExtension(language: string, languageConfig?: LanguageConfig): Promise { + const fileExt = await getNormalizedExtension(language, languageConfig) + + // 尝试加载特殊语言 + const specialLoader = specialLanguageLoaders[fileExt] + if (specialLoader) { + try { + return await specialLoader() + } catch (error) { + console.debug(`Failed to load language ${language} (${fileExt})`, error as Error) + return null + } + } + + // 回退到 uiw/codemirror 包含的语言 + try { + const { loadLanguage } = await import('@uiw/codemirror-extensions-langs') + const extension = loadLanguage(fileExt as any) + return extension || null + } catch (error) { + console.debug(`Failed to load language ${language} (${fileExt})`, error as Error) + return null + } +} + +/** + * 加载 linter 扩展 + */ +async function loadLinterExtension(language: string, languageConfig?: LanguageConfig): Promise { + const fileExt = await getNormalizedExtension(language, languageConfig) + + const loader = linterLoaders[fileExt] + if (!loader) return null + + try { + return await loader() + } catch (error) { + console.debug(`Failed to load linter for ${language} (${fileExt})`, error as Error) + return null + } +} + +/** + * 加载语言相关扩展 + */ +export const useLanguageExtensions = (language: string, lint?: boolean, languageConfig?: LanguageConfig) => { + const [extensions, setExtensions] = useState([]) + + useEffect(() => { + let cancelled = false + + const loadAllExtensions = async () => { + try { + // 加载所有扩展 + const [languageResult, linterResult] = await Promise.allSettled([ + loadLanguageExtension(language, languageConfig), + lint ? loadLinterExtension(language, languageConfig) : Promise.resolve(null) + ]) + + if (cancelled) return + + const results: Extension[] = [] + + // 语言扩展 + if (languageResult.status === 'fulfilled' && languageResult.value) { + results.push(languageResult.value) + } + + // linter 扩展 + if (linterResult.status === 'fulfilled' && linterResult.value) { + results.push(linterResult.value) + } + + setExtensions(results) + } catch (error) { + if (!cancelled) { + console.debug('Failed to load language extensions:', error as Error) + setExtensions([]) + } + } + } + + loadAllExtensions() + + return () => { + cancelled = true + } + }, [language, lint, languageConfig]) + + return extensions +} + +interface UseSaveKeymapProps { + onSave?: (content: string) => void + enabled?: boolean +} + +/** + * CodeMirror 扩展,用于处理保存快捷键 (Cmd/Ctrl + S) + * @param onSave 保存时触发的回调函数 + * @param enabled 是否启用此快捷键 + * @returns 扩展或空数组 + */ +export function useSaveKeymap({ onSave, enabled = true }: UseSaveKeymapProps) { + return useMemo(() => { + if (!enabled || !onSave) { + return [] + } + + return keymap.of([ + { + key: 'Mod-s', + run: (view: EditorView) => { + onSave(view.state.doc.toString()) + return true + }, + preventDefault: true + } + ]) + }, [onSave, enabled]) +} + +interface UseBlurHandlerProps { + onBlur?: (content: string) => void +} + +/** + * CodeMirror 扩展,用于处理编辑器的 blur 事件 + * @param onBlur blur 事件触发时的回调函数 + * @returns 扩展或空数组 + */ +export function useBlurHandler({ onBlur }: UseBlurHandlerProps) { + return useMemo(() => { + if (!onBlur) { + return [] + } + return EditorView.domEventHandlers({ + blur: (_event, view) => { + onBlur(view.state.doc.toString()) + } + }) + }, [onBlur]) +} + +interface UseHeightListenerProps { + onHeightChange?: (scrollHeight: number) => void +} + +/** + * CodeMirror 扩展,用于监听编辑器高度变化 + * @param onHeightChange 高度变化时触发的回调函数 + * @returns 扩展或空数组 + */ +export function useHeightListener({ onHeightChange }: UseHeightListenerProps) { + return useMemo(() => { + if (!onHeightChange) { + return [] + } + + return EditorView.updateListener.of((update) => { + if (update.docChanged || update.heightChanged) { + onHeightChange(update.view.scrollDOM?.scrollHeight ?? 0) + } + }) + }, [onHeightChange]) +} diff --git a/packages/ui/src/components/composites/CodeEditor/index.ts b/packages/ui/src/components/composites/CodeEditor/index.ts new file mode 100644 index 0000000000..4a2e55f9fb --- /dev/null +++ b/packages/ui/src/components/composites/CodeEditor/index.ts @@ -0,0 +1,3 @@ +export { default } from './CodeEditor' +export * from './types' +export { getCmThemeByName, getCmThemeNames } from './utils' diff --git a/packages/ui/src/components/composites/CodeEditor/types.ts b/packages/ui/src/components/composites/CodeEditor/types.ts new file mode 100644 index 0000000000..8aa4507e4f --- /dev/null +++ b/packages/ui/src/components/composites/CodeEditor/types.ts @@ -0,0 +1,114 @@ +import type { BasicSetupOptions, Extension } from '@uiw/react-codemirror' + +export type CodeMirrorTheme = 'light' | 'dark' | 'none' | Extension + +/** Language data structure for file extension mapping */ +export interface LanguageData { + type: string + aliases?: string[] + extensions?: string[] +} + +/** Language configuration mapping language names to their data */ +export type LanguageConfig = Record + +export interface CodeEditorHandles { + save?: () => void +} + +export interface CodeEditorProps { + ref?: React.RefObject + /** Value used in controlled mode, e.g., code blocks. */ + value: string + /** Placeholder when the editor content is empty. */ + placeholder?: string | HTMLElement + /** + * Code language string. + * - Case-insensitive. + * - Supports common names: javascript, json, python, etc. + * - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc. + * - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc. + */ + language: string + /** + * Language configuration for extension mapping. + * If not provided, will use a default minimal configuration. + * @optional + */ + languageConfig?: LanguageConfig + /** Fired when ref.save() is called or the save shortcut is triggered. */ + onSave?: (newContent: string) => void + /** Fired when the editor content changes. */ + onChange?: (newContent: string) => void + /** Fired when the editor loses focus. */ + onBlur?: (newContent: string) => void + /** Fired when the editor height changes. */ + onHeightChange?: (scrollHeight: number) => void + /** + * Fixed editor height, not exceeding maxHeight. + * Only works when expanded is false. + */ + height?: string + /** + * Maximum editor height. + * Only works when expanded is false. + */ + maxHeight?: string + /** Minimum editor height. */ + minHeight?: string + /** Editor options that extend BasicSetupOptions. */ + options?: { + /** + * Whether to enable special treatment for stream response. + * @default false + */ + stream?: boolean + /** + * Whether to enable linting. + * @default false + */ + lint?: boolean + /** + * Whether to enable keymap. + * @default false + */ + keymap?: boolean + } & BasicSetupOptions + /** Additional extensions for CodeMirror. */ + extensions?: Extension[] + /** + * CodeMirror theme name: 'light', 'dark', 'none', Extension. + * @default 'light' + */ + theme?: CodeMirrorTheme + /** + * Font size that overrides the app setting. + * @default 16 + */ + fontSize?: number + /** Style overrides for the editor, passed directly to CodeMirror's style property. */ + style?: React.CSSProperties + /** CSS class name appended to the default `code-editor` class. */ + className?: string + /** + * Whether the editor view is editable. + * @default true + */ + editable?: boolean + /** + * Set the editor state to read only but keep some user interactions, e.g., keymaps. + * @default false + */ + readOnly?: boolean + /** + * Whether the editor is expanded. + * If true, the height and maxHeight props are ignored. + * @default true + */ + expanded?: boolean + /** + * Whether the code lines are wrapped. + * @default true + */ + wrapped?: boolean +} diff --git a/packages/ui/src/components/composites/CodeEditor/utils.ts b/packages/ui/src/components/composites/CodeEditor/utils.ts new file mode 100644 index 0000000000..7fb09fdb48 --- /dev/null +++ b/packages/ui/src/components/composites/CodeEditor/utils.ts @@ -0,0 +1,268 @@ +import * as cmThemes from '@uiw/codemirror-themes-all' +import type { Extension } from '@uiw/react-codemirror' +import diff from 'fast-diff' + +import type { CodeMirrorTheme, LanguageConfig } from './types' + +/** + * Computes code changes using fast-diff and converts them to CodeMirror changes. + * Could handle all types of changes, though insertions are most common during streaming responses. + * @param oldCode The old code content + * @param newCode The new code content + * @returns An array of changes for EditorView.dispatch + */ +export function prepareCodeChanges(oldCode: string, newCode: string) { + const diffResult = diff(oldCode, newCode) + + const changes: { from: number; to: number; insert: string }[] = [] + let offset = 0 + + // operation: 1=insert, -1=delete, 0=equal + for (const [operation, text] of diffResult) { + if (operation === 1) { + changes.push({ + from: offset, + to: offset, + insert: text + }) + } else if (operation === -1) { + changes.push({ + from: offset, + to: offset + text.length, + insert: '' + }) + offset += text.length + } else { + offset += text.length + } + } + + return changes +} + +// Custom language file extension mapping +// key: language name in lowercase +// value: file extension +const _customLanguageExtensions: Record = { + svg: 'xml', + vab: 'vb', + graphviz: 'dot' +} + +// Default minimal language configuration for common languages +const _defaultLanguageConfig: LanguageConfig = { + JavaScript: { + type: 'programming', + extensions: ['.js', '.mjs', '.cjs'], + aliases: ['js', 'node'] + }, + TypeScript: { + type: 'programming', + extensions: ['.ts'], + aliases: ['ts'] + }, + Python: { + type: 'programming', + extensions: ['.py'], + aliases: ['python3', 'py'] + }, + Java: { + type: 'programming', + extensions: ['.java'] + }, + 'C++': { + type: 'programming', + extensions: ['.cpp', '.cc', '.cxx'], + aliases: ['cpp'] + }, + C: { + type: 'programming', + extensions: ['.c'] + }, + 'C#': { + type: 'programming', + extensions: ['.cs'], + aliases: ['csharp'] + }, + HTML: { + type: 'markup', + extensions: ['.html', '.htm'] + }, + CSS: { + type: 'markup', + extensions: ['.css'] + }, + JSON: { + type: 'data', + extensions: ['.json'] + }, + XML: { + type: 'data', + extensions: ['.xml'] + }, + YAML: { + type: 'data', + extensions: ['.yml', '.yaml'] + }, + SQL: { + type: 'data', + extensions: ['.sql'] + }, + Shell: { + type: 'programming', + extensions: ['.sh', '.bash'], + aliases: ['bash', 'sh'] + }, + Go: { + type: 'programming', + extensions: ['.go'], + aliases: ['golang'] + }, + Rust: { + type: 'programming', + extensions: ['.rs'] + }, + PHP: { + type: 'programming', + extensions: ['.php'] + }, + Ruby: { + type: 'programming', + extensions: ['.rb'], + aliases: ['rb'] + }, + Swift: { + type: 'programming', + extensions: ['.swift'] + }, + Kotlin: { + type: 'programming', + extensions: ['.kt'] + }, + Dart: { + type: 'programming', + extensions: ['.dart'] + }, + R: { + type: 'programming', + extensions: ['.r'] + }, + MATLAB: { + type: 'programming', + extensions: ['.m'] + } +} + +/** + * Get the file extension of the language, by language name + * - First, exact match + * - Then, case-insensitive match + * - Finally, match aliases + * If there are multiple file extensions, only the first one will be returned + * @param language language name + * @param languageConfig optional language configuration, defaults to a minimal config + * @returns file extension + */ +export function getExtensionByLanguage(language: string, languageConfig?: LanguageConfig): string { + const languages = languageConfig || _defaultLanguageConfig + const lowerLanguage = language.toLowerCase() + + // Exact match language name + const directMatch = languages[language] + if (directMatch?.extensions?.[0]) { + return directMatch.extensions[0] + } + + // Case-insensitive match language name + for (const [langName, data] of Object.entries(languages)) { + if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) { + return data.extensions[0] + } + } + + // Match aliases + for (const [, data] of Object.entries(languages)) { + if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) { + return data.extensions?.[0] || `.${language}` + } + } + + // Fallback to language name + return `.${language}` +} + +/** + * Get the file extension of the language, for @uiw/codemirror-extensions-langs + * - First, search for custom extensions + * - Then, search for language configuration extensions + * - Finally, assume the name is already an extension + * @param language language name + * @param languageConfig optional language configuration + * @returns file extension (without `.` prefix) + */ +export async function getNormalizedExtension(language: string, languageConfig?: LanguageConfig) { + let lang = language + + // If the language name looks like an extension, remove the dot + if (language.startsWith('.') && language.length > 1) { + lang = language.slice(1) + } + + const lowerLanguage = lang.toLowerCase() + + // 1. Search for custom extensions + const customExt = _customLanguageExtensions[lowerLanguage] + if (customExt) { + return customExt + } + + // 2. Search for language configuration extensions + const linguistExt = getExtensionByLanguage(lang, languageConfig) + if (linguistExt) { + return linguistExt.slice(1) + } + + // Fallback to language name + return lang +} + +/** + * Get the list of CodeMirror theme names + * - Include auto, light, dark + * - Include all themes in @uiw/codemirror-themes-all + * + * A more robust approach might be to hardcode the theme list + * @returns theme name list + */ +export function getCmThemeNames(): string[] { + return ['auto', 'light', 'dark'] + .concat(Object.keys(cmThemes)) + .filter((item) => typeof (cmThemes as any)[item] !== 'function') + .filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string)) +} + +/** + * Get the CodeMirror theme object by theme name + * @param name theme name + * @returns theme object + */ +export function getCmThemeByName(name: string): CodeMirrorTheme { + // 1. Search for the extension of the corresponding theme in @uiw/codemirror-themes-all + const candidate = (cmThemes as Record)[name] + if ( + Object.prototype.hasOwnProperty.call(cmThemes, name) && + typeof candidate !== 'function' && + !/^defaultSettings/i.test(name) && + !/(Style)$/.test(name) + ) { + return candidate as Extension + } + + // 2. Basic string theme + if (name === 'light' || name === 'dark' || name === 'none') { + return name + } + + // 3. If not found, fallback to light + return 'light' +} diff --git a/packages/ui/src/components/composites/CollapsibleSearchBar/index.tsx b/packages/ui/src/components/composites/CollapsibleSearchBar/index.tsx new file mode 100644 index 0000000000..322004f02b --- /dev/null +++ b/packages/ui/src/components/composites/CollapsibleSearchBar/index.tsx @@ -0,0 +1,106 @@ +// Original path: src/renderer/src/components/CollapsibleSearchBar.tsx +import type { InputRef } from 'antd' +import { Input } from 'antd' +import { Search } from 'lucide-react' +import { motion } from 'motion/react' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' + +import { Tooltip } from '../../primitives/tooltip' + +interface CollapsibleSearchBarProps { + onSearch: (text: string) => void + placeholder?: string + tooltip?: string + icon?: React.ReactNode + maxWidth?: string | number + style?: React.CSSProperties +} + +/** + * A collapsible search bar for list headers + * Renders as an icon initially, expands to full search input when clicked + */ +const CollapsibleSearchBar = ({ + onSearch, + placeholder = 'Search', + tooltip = 'Search', + icon = , + maxWidth = '100%', + style +}: CollapsibleSearchBarProps) => { + const [searchVisible, setSearchVisible] = useState(false) + const [searchText, setSearchText] = useState('') + const inputRef = useRef(null) + + const handleTextChange = useCallback( + (text: string) => { + setSearchText(text) + onSearch(text) + }, + [onSearch] + ) + + const handleClear = useCallback(() => { + setSearchText('') + setSearchVisible(false) + onSearch('') + }, [onSearch]) + + useEffect(() => { + if (searchVisible && inputRef.current) { + inputRef.current.focus() + } + }, [searchVisible]) + + return ( +
+ + handleTextChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation() + handleTextChange('') + if (!searchText) setSearchVisible(false) + } + }} + onBlur={() => { + if (!searchText) setSearchVisible(false) + }} + onClear={handleClear} + style={{ width: '100%', ...style }} + /> + + setSearchVisible(true)}> + + {icon} + + +
+ ) +} + +export default memo(CollapsibleSearchBar) diff --git a/packages/ui/src/components/composites/DraggableList/index.tsx b/packages/ui/src/components/composites/DraggableList/index.tsx new file mode 100644 index 0000000000..4dd9622c9c --- /dev/null +++ b/packages/ui/src/components/composites/DraggableList/index.tsx @@ -0,0 +1,8 @@ +// Original path: src/renderer/src/components/DraggableList/index.tsx +export { default as DraggableList } from './list' +export { useDraggableReorder } from './useDraggableReorder' +export { + default as DraggableVirtualList, + type DraggableVirtualListProps, + type DraggableVirtualListRef +} from './virtual-list' diff --git a/packages/ui/src/components/composites/DraggableList/list.tsx b/packages/ui/src/components/composites/DraggableList/list.tsx new file mode 100644 index 0000000000..e7fcadbab9 --- /dev/null +++ b/packages/ui/src/components/composites/DraggableList/list.tsx @@ -0,0 +1,109 @@ +// Original path: src/renderer/src/components/DraggableList/list.tsx +import type { + DroppableProps, + DropResult, + OnDragEndResponder, + OnDragStartResponder, + ResponderProvided +} from '@hello-pangea/dnd' +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' +import type { HTMLAttributes, Key } from 'react' +import { useCallback } from 'react' + +// Inline utility function from @renderer/utils +function droppableReorder(list: T[], sourceIndex: number, destIndex: number, len: number = 1): T[] { + const result = Array.from(list) + const removed = result.splice(sourceIndex, len) + + if (sourceIndex < destIndex) { + result.splice(destIndex - len + 1, 0, ...removed) + } else { + result.splice(destIndex, 0, ...removed) + } + return result +} + +interface Props { + list: T[] + style?: React.CSSProperties + listStyle?: React.CSSProperties + listProps?: HTMLAttributes + children: (item: T, index: number) => React.ReactNode + itemKey?: keyof T | ((item: T) => Key) + onUpdate: (list: T[]) => void + onDragStart?: OnDragStartResponder + onDragEnd?: OnDragEndResponder + droppableProps?: Partial +} + +function DraggableList({ + children, + list, + style, + listStyle, + listProps, + itemKey, + droppableProps, + onDragStart, + onUpdate, + onDragEnd +}: Props) { + const _onDragEnd = (result: DropResult, provided: ResponderProvided) => { + onDragEnd?.(result, provided) + if (result.destination) { + const sourceIndex = result.source.index + const destIndex = result.destination.index + if (sourceIndex !== destIndex) { + const reorderAgents = droppableReorder(list, sourceIndex, destIndex) + onUpdate(reorderAgents) + } + } + } + + const getId = useCallback( + (item: T) => { + if (typeof itemKey === 'function') return itemKey(item) + if (itemKey) return item[itemKey] as Key + if (typeof item === 'string') return item as Key + if (item && typeof item === 'object' && 'id' in item) return item.id as Key + return undefined + }, + [itemKey] + ) + + return ( + + + {(provided) => ( +
+
+ {list.map((item, index) => { + const draggableId = String(getId(item) ?? index) + return ( + + {(provided) => ( +
+ {children(item, index)} +
+ )} +
+ ) + })} +
+ {provided.placeholder} +
+ )} +
+
+ ) +} + +export default DraggableList diff --git a/packages/ui/src/components/composites/DraggableList/sort.ts b/packages/ui/src/components/composites/DraggableList/sort.ts new file mode 100644 index 0000000000..1341c3e6b5 --- /dev/null +++ b/packages/ui/src/components/composites/DraggableList/sort.ts @@ -0,0 +1,20 @@ +/** + * 用于 dnd 列表的元素重新排序方法。支持多元素"拖动"排序。 + * @template {T} 列表元素的类型 + * @param {T[]} list 要重新排序的列表 + * @param {number} sourceIndex 起始元素索引 + * @param {number} destIndex 目标元素索引 + * @param {number} [len=1] 要移动的元素数量,默认为 1 + * @returns {T[]} 重新排序后的列表 + */ +export function droppableReorder(list: T[], sourceIndex: number, destIndex: number, len: number = 1): T[] { + const result = Array.from(list) + const removed = result.splice(sourceIndex, len) + + if (sourceIndex < destIndex) { + result.splice(destIndex - len + 1, 0, ...removed) + } else { + result.splice(destIndex, 0, ...removed) + } + return result +} diff --git a/packages/ui/src/components/composites/DraggableList/useDraggableReorder.ts b/packages/ui/src/components/composites/DraggableList/useDraggableReorder.ts new file mode 100644 index 0000000000..b3133c83ee --- /dev/null +++ b/packages/ui/src/components/composites/DraggableList/useDraggableReorder.ts @@ -0,0 +1,80 @@ +// Original path: src/renderer/src/components/DraggableList/useDraggableReorder.ts +import type { DropResult } from '@hello-pangea/dnd' +import type { Key } from 'react' +import { useCallback, useMemo } from 'react' + +interface UseDraggableReorderParams { + /** 原始的、完整的数据列表 */ + originalList: T[] + /** 当前在界面上渲染的、可能被过滤的列表 */ + filteredList: T[] + /** 用于更新原始列表状态的函数 */ + onUpdate: (newList: T[]) => void + /** 用于从列表项中获取唯一ID的属性名或函数 */ + itemKey: keyof T | ((item: T) => Key) +} + +/** + * 增强拖拽排序能力,处理"过滤后列表"与"原始列表"的索引映射问题。 + * + * @template T 列表项的类型 + * @param params - { originalList, filteredList, onUpdate, idKey } + * @returns 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey } + */ +export function useDraggableReorder({ + originalList, + filteredList, + onUpdate, + itemKey +}: UseDraggableReorderParams) { + const getId = useCallback( + (item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)), + [itemKey] + ) + + // 创建从 item ID 到其在 *原始列表* 中索引的映射 + const itemIndexMap = useMemo(() => { + const map = new Map() + originalList.forEach((item, index) => { + map.set(getId(item), index) + }) + return map + }, [originalList, getId]) + + // 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引 + const getItemKey = useCallback( + (index: number): Key => { + const item = filteredList[index] + // 如果找不到item,返回视图索引兜底 + if (!item) return index + + const originalIndex = itemIndexMap.get(getId(item)) + return originalIndex ?? index + }, + [filteredList, itemIndexMap, getId] + ) + + // 创建 onDragEnd 回调,封装了所有重排逻辑 + const onDragEnd = useCallback( + (result: DropResult) => { + if (!result.destination) return + + // 使用 getItemKey 将视图索引转换为数据索引 + const sourceOriginalIndex = getItemKey(result.source.index) as number + const destOriginalIndex = getItemKey(result.destination.index) as number + + if (sourceOriginalIndex === destOriginalIndex) return + + // 操作原始列表的副本 + const newList = [...originalList] + const [movedItem] = newList.splice(sourceOriginalIndex, 1) + newList.splice(destOriginalIndex, 0, movedItem) + + // 调用外部更新函数 + onUpdate(newList) + }, + [originalList, onUpdate, getItemKey] + ) + + return { onDragEnd, itemKey: getItemKey } +} diff --git a/packages/ui/src/components/composites/DraggableList/virtual-list.tsx b/packages/ui/src/components/composites/DraggableList/virtual-list.tsx new file mode 100644 index 0000000000..b85ac71edd --- /dev/null +++ b/packages/ui/src/components/composites/DraggableList/virtual-list.tsx @@ -0,0 +1,256 @@ +import type { + DroppableProps, + DropResult, + OnDragEndResponder, + OnDragStartResponder, + ResponderProvided +} from '@hello-pangea/dnd' +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' +import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' +import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react' + +import Scrollbar from '../Scrollbar' +import { droppableReorder } from './sort' + +export interface DraggableVirtualListRef { + measure: () => void + scrollElement: () => HTMLDivElement | null + scrollToOffset: (offset: number, options?: ScrollToOptions) => void + scrollToIndex: (index: number, options?: ScrollToOptions) => void + resizeItem: (index: number, size: number) => void + getTotalSize: () => number + getVirtualItems: () => VirtualItem[] + getVirtualIndexes: () => number[] +} + +/** + * 泛型 Props,用于配置 DraggableVirtualList。 + * + * @template T 列表元素的类型 + * @property {string} [className] 根节点附加 class + * @property {React.CSSProperties} [style] 根节点附加样式 + * @property {React.CSSProperties} [itemStyle] 元素内容区域的附加样式 + * @property {React.CSSProperties} [itemContainerStyle] 元素拖拽容器的附加样式 + * @property {Partial} [droppableProps] 透传给 Droppable 的额外配置 + * @property {(list: T[]) => void} [onUpdate] 拖拽排序完成后的回调,返回新的列表顺序(可被 useDraggableReorder 替代) + * @property {OnDragStartResponder} [onDragStart] 开始拖拽时的回调 + * @property {OnDragEndResponder} [onDragEnd] 结束拖拽时的回调 + * @property {T[]} list 渲染的数据源 + * @property {(index: number) => Key} [itemKey] 提供给虚拟列表的行 key,若不提供默认使用 index + * @property {number} [overscan=5] 前后额外渲染的行数,提升快速滚动时的体验 + * @property {React.ReactNode} [header] 列表头部内容 + * @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数 + */ +export interface DraggableVirtualListProps { + ref?: React.Ref + className?: string + style?: React.CSSProperties + scrollerStyle?: React.CSSProperties + itemStyle?: React.CSSProperties + itemContainerStyle?: React.CSSProperties + droppableProps?: Partial + onUpdate?: (list: T[]) => void + onDragStart?: OnDragStartResponder + onDragEnd?: OnDragEndResponder + list: T[] + itemKey?: (index: number) => Key + estimateSize?: (index: number) => number + overscan?: number + header?: React.ReactNode + children: (item: T, index: number) => React.ReactNode + disabled?: boolean +} + +/** + * 带虚拟滚动与拖拽排序能力的(垂直)列表组件。 + * - 滚动容器由该组件内部管理。 + * @template T 列表元素的类型 + * @param {DraggableVirtualListProps} props 组件参数 + * @returns {React.ReactElement} + */ +function DraggableVirtualList({ + ref, + className, + style, + scrollerStyle, + itemStyle, + itemContainerStyle, + droppableProps, + onDragStart, + onUpdate, + onDragEnd, + list, + itemKey, + estimateSize: _estimateSize, + overscan = 5, + header, + children, + disabled +}: DraggableVirtualListProps): React.ReactElement { + const _onDragEnd = (result: DropResult, provided: ResponderProvided) => { + onDragEnd?.(result, provided) + if (onUpdate && result.destination) { + const sourceIndex = result.source.index + const destIndex = result.destination.index + if (sourceIndex !== destIndex) { + const reorderAgents = droppableReorder(list, sourceIndex, destIndex) + onUpdate(reorderAgents) + } + } + } + + // 虚拟列表滚动容器的 ref + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: list?.length ?? 0, + getScrollElement: useCallback(() => parentRef.current, []), + getItemKey: itemKey, + estimateSize: useCallback((index) => _estimateSize?.(index) ?? 50, [_estimateSize]), + overscan + }) + + useImperativeHandle( + ref, + () => ({ + measure: () => virtualizer.measure(), + scrollElement: () => virtualizer.scrollElement, + scrollToOffset: (offset, options) => virtualizer.scrollToOffset(offset, options), + scrollToIndex: (index, options) => virtualizer.scrollToIndex(index, options), + resizeItem: (index, size) => virtualizer.resizeItem(index, size), + getTotalSize: () => virtualizer.getTotalSize(), + getVirtualItems: () => virtualizer.getVirtualItems(), + getVirtualIndexes: () => virtualizer.getVirtualItems().map((item) => item.index) + }), + [virtualizer] + ) + + return ( +
+ + {header} + { + const item = list[rubric.source.index] + return ( +
+ {item && children(item, rubric.source.index)} +
+ ) + }} + {...droppableProps}> + {(provided) => { + // 让 dnd 和虚拟列表共享同一个滚动容器 + const setRefs = (el: HTMLDivElement | null) => { + provided.innerRef(el) + parentRef.current = el + } + + return ( + +
+ {virtualizer.getVirtualItems().map((virtualItem) => ( + + ))} +
+
+ ) + }} +
+
+
+ ) +} + +/** + * 渲染单个可拖拽的虚拟列表项,高度为动态测量 + */ +const VirtualRow = memo( + ({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer, disabled }: any) => { + const item = list[virtualItem.index] + const draggableId = String(virtualItem.key) + return ( + + {(provided) => { + const setDragRefs = (el: HTMLElement | null) => { + provided.innerRef(el) + virtualizer.measureElement(el) + } + + const dndStyle = provided.draggableProps.style + const virtualizerTransform = `translateY(${virtualItem.start}px)` + + // dnd 的 transform 负责拖拽时的位移和让位动画, + // virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置, + // 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。 + const combinedTransform = dndStyle?.transform + ? `${dndStyle.transform} ${virtualizerTransform}` + : virtualizerTransform + + return ( +
+
+ {item && children(item, virtualItem.index)} +
+
+ ) + }} +
+ ) + } +) + +export default DraggableVirtualList diff --git a/packages/ui/src/components/composites/EditableNumber/index.tsx b/packages/ui/src/components/composites/EditableNumber/index.tsx new file mode 100644 index 0000000000..c147d58107 --- /dev/null +++ b/packages/ui/src/components/composites/EditableNumber/index.tsx @@ -0,0 +1,115 @@ +// Original path: src/renderer/src/components/EditableNumber/index.tsx +import { InputNumber } from 'antd' +import type { FC } from 'react' +import { useEffect, useState } from 'react' +import styled from 'styled-components' + +export interface EditableNumberProps { + value?: number | null + min?: number + max?: number + step?: number + precision?: number + placeholder?: string + disabled?: boolean + changeOnBlur?: boolean + onChange?: (value: number | null) => void + onBlur?: () => void + style?: React.CSSProperties + className?: string + size?: 'small' | 'middle' | 'large' + suffix?: string + prefix?: string + align?: 'start' | 'center' | 'end' +} + +const EditableNumber: FC = ({ + value, + min, + max, + step = 0.01, + precision, + placeholder, + disabled = false, + onChange, + onBlur, + changeOnBlur = false, + style, + className, + size = 'middle', + align = 'end' +}) => { + const [isEditing, setIsEditing] = useState(false) + const [inputValue, setInputValue] = useState(value) + + useEffect(() => { + setInputValue(value) + }, [value]) + + const handleFocus = () => { + if (disabled) return + setIsEditing(true) + } + + const handleInputChange = (newValue: number | null) => { + onChange?.(newValue ?? null) + } + + const handleBlur = () => { + setIsEditing(false) + onBlur?.() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur() + } else if (e.key === 'Escape') { + e.stopPropagation() + setInputValue(value) + setIsEditing(false) + } + } + + return ( + + + + {value ?? placeholder} + + + ) +} + +const Container = styled.div` + display: inline-block; + position: relative; +` + +const DisplayText = styled.div<{ + $align: 'start' | 'center' | 'end' + $isEditing: boolean +}>` + position: absolute; + inset: 0; + display: ${({ $isEditing }) => ($isEditing ? 'none' : 'flex')}; + align-items: center; + justify-content: ${({ $align }) => $align}; + pointer-events: none; +` + +export default EditableNumber diff --git a/packages/ui/src/components/composites/Ellipsis/index.tsx b/packages/ui/src/components/composites/Ellipsis/index.tsx new file mode 100644 index 0000000000..c4c296079c --- /dev/null +++ b/packages/ui/src/components/composites/Ellipsis/index.tsx @@ -0,0 +1,28 @@ +// Original: src/renderer/src/components/Ellipsis/index.tsx +import type { HTMLAttributes } from 'react' + +import { cn } from '../../../utils' + +type Props = { + maxLine?: number + className?: string + ref?: React.Ref +} & HTMLAttributes + +const Ellipsis = (props: Props) => { + const { maxLine = 1, children, className, ref, ...rest } = props + + const ellipsisClasses = cn( + 'overflow-hidden text-ellipsis', + maxLine > 1 ? `line-clamp-${maxLine} break-words` : 'block whitespace-nowrap', + className + ) + + return ( +
+ {children} +
+ ) +} + +export default Ellipsis diff --git a/packages/ui/src/components/composites/ExpandableText/index.tsx b/packages/ui/src/components/composites/ExpandableText/index.tsx new file mode 100644 index 0000000000..1e13fd1d14 --- /dev/null +++ b/packages/ui/src/components/composites/ExpandableText/index.tsx @@ -0,0 +1,50 @@ +// Original: src/renderer/src/components/ExpandableText.tsx +import { Button } from '@heroui/react' +import { memo, useCallback, useState } from 'react' + +interface ExpandableTextProps { + text: string + style?: React.CSSProperties + className?: string + expandText?: string + collapseText?: string + lineClamp?: number + ref?: React.RefObject +} + +const ExpandableText = ({ + text, + style, + className = '', + expandText = 'Expand', + collapseText = 'Collapse', + lineClamp = 1, + ref +}: ExpandableTextProps) => { + const [isExpanded, setIsExpanded] = useState(false) + + const toggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev) + }, []) + + return ( +
+
+ {text} +
+ +
+ ) +} + +ExpandableText.displayName = 'ExpandableText' + +export default memo(ExpandableText) diff --git a/packages/ui/src/components/composites/Flex/index.tsx b/packages/ui/src/components/composites/Flex/index.tsx new file mode 100644 index 0000000000..522a5574d7 --- /dev/null +++ b/packages/ui/src/components/composites/Flex/index.tsx @@ -0,0 +1,63 @@ +import React from 'react' + +import { cn } from '../../../utils' + +export interface BoxProps extends React.ComponentProps<'div'> {} + +export const Box = ({ children, className, ...props }: BoxProps & { children?: React.ReactNode }) => { + return ( +
+ {children} +
+ ) +} + +export interface FlexProps extends BoxProps {} + +export const Flex = ({ children, className, ...props }: FlexProps & { children?: React.ReactNode }) => { + return ( + + {children} + + ) +} + +export const RowFlex = ({ children, className, ...props }: FlexProps & { children?: React.ReactNode }) => { + return ( + + {children} + + ) +} + +export const SpaceBetweenRowFlex = ({ children, className, ...props }: FlexProps & { children?: React.ReactNode }) => { + return ( + + {children} + + ) +} +export const ColFlex = ({ children, className, ...props }: FlexProps & { children?: React.ReactNode }) => { + return ( + + {children} + + ) +} + +export const Center = ({ children, className, ...props }: FlexProps & { children?: React.ReactNode }) => { + return ( + + {children} + + ) +} + +export default { + Box, + Flex, + RowFlex, + SpaceBetweenRowFlex, + ColFlex, + Center +} diff --git a/packages/ui/src/components/composites/HorizontalScrollContainer/index.tsx b/packages/ui/src/components/composites/HorizontalScrollContainer/index.tsx new file mode 100644 index 0000000000..1d1875c3a0 --- /dev/null +++ b/packages/ui/src/components/composites/HorizontalScrollContainer/index.tsx @@ -0,0 +1,181 @@ +// Original: src/renderer/src/components/HorizontalScrollContainer/index.tsx +import { ChevronRight } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +import Scrollbar from '../Scrollbar' + +/** + * 水平滚动容器 + * @param children 子元素 + * @param dependencies 依赖项 + * @param scrollDistance 滚动距离 + * @param className 类名 + * @param gap 间距 + * @param expandable 是否可展开 + */ +export interface HorizontalScrollContainerProps { + children: React.ReactNode + dependencies?: readonly unknown[] + scrollDistance?: number + className?: string + gap?: string + expandable?: boolean +} + +const HorizontalScrollContainer: React.FC = ({ + children, + dependencies = [], + scrollDistance = 200, + className, + gap = '8px', + expandable = false +}) => { + const scrollRef = useRef(null) + const [canScroll, setCanScroll] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [isScrolledToEnd, setIsScrolledToEnd] = useState(false) + + const handleScrollRight = (event: React.MouseEvent) => { + scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' }) + event.stopPropagation() + } + + const handleContainerClick = (e: React.MouseEvent) => { + if (expandable) { + // 确保不是点击了其他交互元素(如 tag 的关闭按钮) + const target = e.target as HTMLElement + if (!target.closest('[data-no-expand]')) { + setIsExpanded(!isExpanded) + } + } + } + + const checkScrollability = () => { + const scrollElement = scrollRef.current + if (scrollElement) { + const parentElement = scrollElement.parentElement + const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth + + // 确保容器不会超出可用宽度 + const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth) + setCanScroll(canScrollValue) + + // 检查是否滚动到最右侧 + if (canScrollValue) { + const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1 + setIsScrolledToEnd(isAtEnd) + } else { + setIsScrolledToEnd(false) + } + } + } + + useEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) return + + checkScrollability() + + const handleScroll = () => { + checkScrollability() + } + + const resizeObserver = new ResizeObserver(checkScrollability) + resizeObserver.observe(scrollElement) + + scrollElement.addEventListener('scroll', handleScroll) + window.addEventListener('resize', checkScrollability) + + return () => { + resizeObserver.disconnect() + scrollElement.removeEventListener('scroll', handleScroll) + window.removeEventListener('resize', checkScrollability) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies) + + return ( + + + {children} + + {canScroll && !isExpanded && !isScrolledToEnd && ( + + + + )} + + ) +} + +const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>` + display: flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + position: relative; + cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')}; + + ${(props) => + !props.$disableHoverButton && + ` + &:hover { + .scroll-right-button { + opacity: 1; + } + } + `} +` + +const ScrollContent = styled(Scrollbar)<{ + $gap: string + $isExpanded?: boolean + $expandable?: boolean +}>` + display: flex; + overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')}; + overflow-y: hidden; + white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')}; + gap: ${(props) => props.$gap}; + flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')}; + + &::-webkit-scrollbar { + display: none; + } +` + +const ScrollButton = styled.div` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + z-index: 1; + opacity: 0; + transition: opacity 0.2s ease-in-out; + cursor: pointer; + background: var(--color-background); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + 0 6px 16px 0 rgba(0, 0, 0, 0.08), + 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 9px 28px 8px rgba(0, 0, 0, 0.05); + color: var(--color-text-2); + + &:hover { + color: var(--color-text); + background: var(--color-list-item); + } +` + +export default HorizontalScrollContainer diff --git a/packages/ui/src/components/composites/IconTooltips/HelpTooltip.tsx b/packages/ui/src/components/composites/IconTooltips/HelpTooltip.tsx new file mode 100644 index 0000000000..444201ac4a --- /dev/null +++ b/packages/ui/src/components/composites/IconTooltips/HelpTooltip.tsx @@ -0,0 +1,19 @@ +// Original path: src/renderer/src/components/TooltipIcons/HelpTooltip.tsx +import { HelpCircle } from 'lucide-react' + +import { Tooltip } from '../../primitives/tooltip' +import type { IconTooltipProps } from './types' + +export const HelpTooltip = ({ iconProps, ...rest }: IconTooltipProps) => { + return ( + + + + ) +} diff --git a/packages/ui/src/components/composites/IconTooltips/InfoTooltip.tsx b/packages/ui/src/components/composites/IconTooltips/InfoTooltip.tsx new file mode 100644 index 0000000000..517b3cb298 --- /dev/null +++ b/packages/ui/src/components/composites/IconTooltips/InfoTooltip.tsx @@ -0,0 +1,19 @@ +// Original: src/renderer/src/components/TooltipIcons/InfoTooltip.tsx +import { Info } from 'lucide-react' + +import { Tooltip } from '../../primitives/tooltip' +import type { IconTooltipProps } from './types' + +export const InfoTooltip = ({ iconProps, ...rest }: IconTooltipProps) => { + return ( + + + + ) +} diff --git a/packages/ui/src/components/composites/IconTooltips/WarnTooltip.tsx b/packages/ui/src/components/composites/IconTooltips/WarnTooltip.tsx new file mode 100644 index 0000000000..87fa8fb9c5 --- /dev/null +++ b/packages/ui/src/components/composites/IconTooltips/WarnTooltip.tsx @@ -0,0 +1,19 @@ +// Original path: src/renderer/src/components/TooltipIcons/WarnTooltip.tsx +import { AlertTriangle } from 'lucide-react' + +import { Tooltip } from '../../primitives/tooltip' +import type { IconTooltipProps } from './types' + +export const WarnTooltip = ({ iconProps, ...rest }: IconTooltipProps) => { + return ( + + + + ) +} diff --git a/packages/ui/src/components/composites/IconTooltips/index.tsx b/packages/ui/src/components/composites/IconTooltips/index.tsx new file mode 100644 index 0000000000..a88bfa3683 --- /dev/null +++ b/packages/ui/src/components/composites/IconTooltips/index.tsx @@ -0,0 +1,4 @@ +export { HelpTooltip } from './HelpTooltip' +export { InfoTooltip } from './InfoTooltip' +export type { IconTooltipProps } from './types' +export { WarnTooltip } from './WarnTooltip' diff --git a/packages/ui/src/components/composites/IconTooltips/types.ts b/packages/ui/src/components/composites/IconTooltips/types.ts new file mode 100644 index 0000000000..7a8a722b26 --- /dev/null +++ b/packages/ui/src/components/composites/IconTooltips/types.ts @@ -0,0 +1,7 @@ +import type { LucideProps } from 'lucide-react' + +import type { TooltipProps } from '../../primitives/tooltip' + +export interface IconTooltipProps extends TooltipProps { + iconProps?: LucideProps +} diff --git a/packages/ui/src/components/composites/ImageToolButton/index.tsx b/packages/ui/src/components/composites/ImageToolButton/index.tsx new file mode 100644 index 0000000000..232c571393 --- /dev/null +++ b/packages/ui/src/components/composites/ImageToolButton/index.tsx @@ -0,0 +1,23 @@ +// Original path: src/renderer/src/components/Preview/ImageToolButton.tsx +import { memo } from 'react' + +import { Button } from '../../primitives/button' +import { Tooltip } from '../../primitives/tooltip' + +interface ImageToolButtonProps { + tooltip: string + icon: React.ReactNode + onPress: () => void +} + +const ImageToolButton = ({ tooltip, icon, onPress }: ImageToolButtonProps) => { + return ( + + + + ) +} + +export default memo(ImageToolButton) diff --git a/packages/ui/src/components/composites/ListItem/index.tsx b/packages/ui/src/components/composites/ListItem/index.tsx new file mode 100644 index 0000000000..196fdb2949 --- /dev/null +++ b/packages/ui/src/components/composites/ListItem/index.tsx @@ -0,0 +1,61 @@ +// Original path: src/renderer/src/components/ListItem/index.tsx +import { Tooltip } from '@heroui/react' +import type { ReactNode } from 'react' + +import { cn } from '../../../utils' + +interface ListItemProps { + active?: boolean + icon?: ReactNode + title: ReactNode + subtitle?: string + titleStyle?: React.CSSProperties + onClick?: () => void + rightContent?: ReactNode + style?: React.CSSProperties + className?: string + ref?: React.Ref +} + +const ListItem = ({ + active, + icon, + title, + subtitle, + titleStyle, + onClick, + rightContent, + style, + className, + ref +}: ListItemProps) => { + return ( +
+
+ {icon && {icon}} +
+ +
+ {title} +
+
+ {subtitle && ( +
{subtitle}
+ )} +
+ {rightContent &&
{rightContent}
} +
+
+ ) +} + +export default ListItem diff --git a/packages/ui/src/components/composites/MaxContextCount/index.tsx b/packages/ui/src/components/composites/MaxContextCount/index.tsx new file mode 100644 index 0000000000..0ded7db8dd --- /dev/null +++ b/packages/ui/src/components/composites/MaxContextCount/index.tsx @@ -0,0 +1,31 @@ +/** + * @deprecated 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。此组件与业务逻辑耦合,不适合通用 UI 库。 + * + * This component has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. This component is coupled with business logic and not suitable for a general UI library. + */ + +// Original path: src/renderer/src/components/MaxContextCount.tsx +import { Infinity as InfinityIcon } from 'lucide-react' +import type { CSSProperties } from 'react' + +const MAX_CONTEXT_COUNT = 100 + +type Props = { + maxContext: number + style?: CSSProperties + size?: number + className?: string + ref?: React.Ref +} + +export default function MaxContextCount({ maxContext, style, size = 14, className, ref }: Props) { + return maxContext === MAX_CONTEXT_COUNT ? ( + + ) : ( + + {maxContext.toString()} + + ) +} diff --git a/packages/ui/src/components/composites/Scrollbar/index.tsx b/packages/ui/src/components/composites/Scrollbar/index.tsx new file mode 100644 index 0000000000..238b797f1f --- /dev/null +++ b/packages/ui/src/components/composites/Scrollbar/index.tsx @@ -0,0 +1,76 @@ +// Original: src/renderer/src/components/Scrollbar/index.tsx +import { throttle } from 'lodash' +import type { FC } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +export interface ScrollbarProps extends Omit, 'onScroll'> { + ref?: React.Ref + onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll +} + +const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => { + const [isScrolling, setIsScrolling] = useState(false) + const timeoutRef = useRef(null) + + const clearScrollingTimeout = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + }, []) + + const handleScroll = useCallback(() => { + setIsScrolling(true) + clearScrollingTimeout() + timeoutRef.current = setTimeout(() => { + setIsScrolling(false) + timeoutRef.current = null + }, 1500) + }, [clearScrollingTimeout]) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [ + handleScroll + ]) + + // Combined scroll handler + const combinedOnScroll = useCallback(() => { + throttledInternalScrollHandler() + if (externalOnScroll) { + externalOnScroll() + } + }, [throttledInternalScrollHandler, externalOnScroll]) + + useEffect(() => { + return () => { + clearScrollingTimeout() + throttledInternalScrollHandler.cancel() + } + }, [throttledInternalScrollHandler, clearScrollingTimeout]) + + return ( + + {children} + + ) +} + +const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>` + overflow-y: auto; + &::-webkit-scrollbar-thumb { + transition: background 2s ease; + background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')}; + &:hover { + background: var(--color-scrollbar-thumb-hover); + } + } +` + +Scrollbar.displayName = 'Scrollbar' + +export default Scrollbar diff --git a/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx b/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx new file mode 100644 index 0000000000..e9e048fd6a --- /dev/null +++ b/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx @@ -0,0 +1,116 @@ +import type { DraggableSyntheticListeners } from '@dnd-kit/core' +import type { Transform } from '@dnd-kit/utilities' +import { CSS } from '@dnd-kit/utilities' +import React, { useEffect } from 'react' +import styled from 'styled-components' + +import { cn } from '../../../utils' +import type { RenderItemType } from './types' + +interface ItemRendererProps { + ref?: React.Ref + index?: number + item: T + renderItem: RenderItemType + dragging?: boolean + dragOverlay?: boolean + ghost?: boolean + transform?: Transform | null + transition?: string | null + listeners?: DraggableSyntheticListeners + itemStyle?: React.CSSProperties +} + +export function ItemRenderer({ + ref, + index, + item, + renderItem, + dragging, + dragOverlay, + ghost, + transform, + transition, + listeners, + itemStyle, + ...props +}: ItemRendererProps) { + useEffect(() => { + if (!dragOverlay) { + return + } + + document.body.style.cursor = 'grabbing' + + return () => { + document.body.style.cursor = '' + } + }, [dragOverlay]) + + const style = { + transition, + transform: CSS.Transform.toString(transform ?? null) + } as React.CSSProperties + + return ( + + + {renderItem(item, { dragging: !!dragging })} + + + ) +} + +const ItemWrapper = styled.div` + box-sizing: border-box; + transform-origin: 0 0; + touch-action: manipulation; + + &.dragOverlay { + --scale: 1.02; + z-index: 999; + position: relative; + } +` + +const DraggableItem = styled.div` + position: relative; + box-sizing: border-box; + cursor: pointer; /* default cursor for items */ + touch-action: manipulation; + transform-origin: 50% 50%; + transform: scale(var(--scale, 1)); + + &.dragging:not(.dragOverlay) { + z-index: 0; + opacity: 0.25; + + &:not(.ghost) { + opacity: 0; + } + } + + &.dragOverlay { + cursor: inherit; + animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22); + transform: scale(var(--scale)); + opacity: 1; + pointer-events: none; /* prevent pointer events on drag overlay */ + } + + @keyframes pop { + 0% { + transform: scale(1); + } + 100% { + transform: scale(var(--scale)); + } + } +` diff --git a/src/renderer/src/components/dnd/Sortable.tsx b/packages/ui/src/components/composites/Sortable/Sortable.tsx similarity index 95% rename from src/renderer/src/components/dnd/Sortable.tsx rename to packages/ui/src/components/composites/Sortable/Sortable.tsx index 8a681e337a..0f6892e7d4 100644 --- a/src/renderer/src/components/dnd/Sortable.tsx +++ b/packages/ui/src/components/composites/Sortable/Sortable.tsx @@ -1,14 +1,18 @@ -import { +import type { Active, + DragEndEvent, + DragStartEvent, + DropAnimation, + Modifier, + Over, + UniqueIdentifier +} from '@dnd-kit/core' +import { defaultDropAnimationSideEffects, DndContext, DragOverlay, - DropAnimation, KeyboardSensor, - Modifier, - Over, TouchSensor, - UniqueIdentifier, useSensor, useSensors } from '@dnd-kit/core' @@ -31,7 +35,7 @@ import styled from 'styled-components' import { ItemRenderer } from './ItemRenderer' import { SortableItem } from './SortableItem' -import { RenderItemType } from './types' +import type { RenderItemType } from './types' import { PortalSafePointerSensor } from './utils' interface SortableProps { @@ -44,7 +48,7 @@ interface SortableProps { /** Callback when drag starts, will be passed to dnd-kit's onDragStart */ onDragStart?: (event: { active: Active }) => void /** Callback when drag ends, will be passed to dnd-kit's onDragEnd */ - onDragEnd?: (event: { over: Over }) => void + onDragEnd?: (event: { over: Over | null }) => void /** Function to render individual item, receives item data and drag state */ renderItem: RenderItemType /** Layout type - 'list' for vertical/horizontal list, 'grid' for grid layout */ @@ -126,14 +130,14 @@ function Sortable({ const activeIndex = activeId ? getIndex(activeId) : -1 - const handleDragStart = ({ active }) => { + const handleDragStart = ({ active }: DragStartEvent) => { customOnDragStart?.({ active }) if (active) { setActiveId(active.id) } } - const handleDragEnd = ({ over }) => { + const handleDragEnd = ({ over }: DragEndEvent) => { setActiveId(null) customOnDragEnd?.({ over }) diff --git a/src/renderer/src/components/dnd/SortableItem.tsx b/packages/ui/src/components/composites/Sortable/SortableItem.tsx similarity index 95% rename from src/renderer/src/components/dnd/SortableItem.tsx rename to packages/ui/src/components/composites/Sortable/SortableItem.tsx index ec91f54da8..2b8d2ee905 100644 --- a/src/renderer/src/components/dnd/SortableItem.tsx +++ b/packages/ui/src/components/composites/Sortable/SortableItem.tsx @@ -1,7 +1,7 @@ import { useSortable } from '@dnd-kit/sortable' import { ItemRenderer } from './ItemRenderer' -import { RenderItemType } from './types' +import type { RenderItemType } from './types' interface SortableItemProps { item: T diff --git a/packages/ui/src/components/composites/Sortable/index.ts b/packages/ui/src/components/composites/Sortable/index.ts new file mode 100644 index 0000000000..716f49710d --- /dev/null +++ b/packages/ui/src/components/composites/Sortable/index.ts @@ -0,0 +1 @@ +export { default as Sortable } from './Sortable' diff --git a/src/renderer/src/components/dnd/types.ts b/packages/ui/src/components/composites/Sortable/types.ts similarity index 100% rename from src/renderer/src/components/dnd/types.ts rename to packages/ui/src/components/composites/Sortable/types.ts diff --git a/src/renderer/src/components/dnd/utils.ts b/packages/ui/src/components/composites/Sortable/utils.ts similarity index 100% rename from src/renderer/src/components/dnd/utils.ts rename to packages/ui/src/components/composites/Sortable/utils.ts diff --git a/packages/ui/src/components/composites/ThinkingEffect/defaultVariants.ts b/packages/ui/src/components/composites/ThinkingEffect/defaultVariants.ts new file mode 100644 index 0000000000..73afaa6342 --- /dev/null +++ b/packages/ui/src/components/composites/ThinkingEffect/defaultVariants.ts @@ -0,0 +1,38 @@ +import type { Variants } from 'motion/react' +export const lightbulbVariants: Variants = { + active: { + opacity: [1, 0.2, 1], + transition: { + duration: 1.2, + ease: 'easeInOut', + times: [0, 0.5, 1], + repeat: Infinity + } + }, + idle: { + opacity: 1, + transition: { + duration: 0.3, + ease: 'easeInOut' + } + } +} + +export const lightbulbSoftVariants: Variants = { + active: { + opacity: [1, 0.5, 1], + transition: { + duration: 2, + ease: 'easeInOut', + times: [0, 0.5, 1], + repeat: Infinity + } + }, + idle: { + opacity: 1, + transition: { + duration: 0.3, + ease: 'easeInOut' + } + } +} diff --git a/packages/ui/src/components/composites/ThinkingEffect/index.tsx b/packages/ui/src/components/composites/ThinkingEffect/index.tsx new file mode 100644 index 0000000000..6c542bf3d4 --- /dev/null +++ b/packages/ui/src/components/composites/ThinkingEffect/index.tsx @@ -0,0 +1,136 @@ +/** + * @deprecated 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。此组件是 AI 思考特效,可能需要保留在主项目中而不是 UI 库。 + * + * This component has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. This is an AI thinking effect component that may need to stay in the main project. + */ + +// Original path: src/renderer/src/components/ThinkingEffect.tsx +import { isEqual } from 'lodash' +import { ChevronRight, Lightbulb } from 'lucide-react' +import { motion } from 'motion/react' +import React, { useEffect, useMemo, useState } from 'react' + +import { cn } from '../../../utils' +import { lightbulbVariants } from './defaultVariants' + +interface ThinkingEffectProps { + isThinking: boolean + thinkingTimeText: React.ReactNode + content: string + expanded: boolean + className?: string + ref?: React.Ref +} + +const ThinkingEffect: React.FC = ({ + isThinking, + thinkingTimeText, + content, + expanded, + className, + ref +}) => { + const [messages, setMessages] = useState([]) + useEffect(() => { + const allLines = (content || '').split('\n') + const newMessages = isThinking ? allLines.slice(0, -1) : allLines + const validMessages = newMessages.filter((line) => line.trim() !== '') + + if (!isEqual(messages, validMessages)) { + setMessages(validMessages) + } + }, [content, isThinking, messages]) + + const showThinking = useMemo(() => { + return isThinking && !expanded + }, [expanded, isThinking]) + + const LINE_HEIGHT = 14 + + const containerHeight = useMemo(() => { + if (!showThinking || messages.length < 1) return 38 + return Math.min(75, Math.max(messages.length + 1, 2) * LINE_HEIGHT + 25) + }, [showThinking, messages.length]) + + return ( +
+
+ + + +
+ +
+
+ {thinkingTimeText} +
+ + {showThinking && ( +
+ + {messages.map((message, index) => { + if (index < messages.length - 5) return null + + return ( +
+ {message} +
+ ) + })} +
+
+ )} +
+ +
+ +
+
+ ) +} + +export default ThinkingEffect diff --git a/packages/ui/src/components/icons/FileIcons/index.tsx b/packages/ui/src/components/icons/FileIcons/index.tsx new file mode 100644 index 0000000000..1c0a924254 --- /dev/null +++ b/packages/ui/src/components/icons/FileIcons/index.tsx @@ -0,0 +1,86 @@ +// Original path: src/renderer/src/components/Icons/FileIcons.tsx +import type { CSSProperties, SVGProps } from 'react' + +interface BaseFileIconProps extends SVGProps { + size?: string | number + text?: string +} + +const textStyle: CSSProperties = { + fontStyle: 'italic', + fontSize: '7.70985px', + lineHeight: 0.8, + fontFamily: "'Times New Roman'", + textAlign: 'center', + writingMode: 'horizontal-tb', + direction: 'ltr', + textAnchor: 'middle', + fill: 'none', + stroke: '#000000', + strokeWidth: '0.289119', + strokeLinejoin: 'round', + strokeDasharray: 'none' +} + +const tspanStyle: CSSProperties = { + fontStyle: 'normal', + fontVariant: 'normal', + fontWeight: 'normal', + fontStretch: 'condensed', + fontSize: '7.70985px', + lineHeight: 0.8, + fontFamily: 'Arial', + fill: '#000000', + fillOpacity: 1, + strokeWidth: '0.289119', + strokeDasharray: 'none' +} + +const BaseFileIcon = ({ size = '1.1em', text = 'SVG', ...props }: BaseFileIconProps) => ( + + + + + + + {text} + + + +) + +/** + * @deprecated 此图标使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。 + * + * This icon has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. + */ +export const FileSvgIcon = (props: Omit) => + +/** + * @deprecated 此图标使用频率仅为 2 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。 + * + * This icon has only 2 usages and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. + */ +export const FilePngIcon = (props: Omit) => diff --git a/packages/ui/src/components/icons/Icon/index.tsx b/packages/ui/src/components/icons/Icon/index.tsx new file mode 100644 index 0000000000..bace2666a6 --- /dev/null +++ b/packages/ui/src/components/icons/Icon/index.tsx @@ -0,0 +1,53 @@ +import type { LucideIcon } from 'lucide-react' +import { + AlignLeft, + Copy, + Eye, + Pencil, + RefreshCw, + RotateCcw, + ScanLine, + Search, + Trash, + WrapText, + Wrench +} from 'lucide-react' +import React from 'react' + +// 创建一个 Icon 工厂函数 +export function createIcon(IconComponent: LucideIcon, defaultSize: string | number = '1rem') { + const Icon = ({ + ref, + ...props + }: React.ComponentProps & { ref?: React.RefObject }) => ( + + ) + Icon.displayName = `Icon(${IconComponent.displayName || IconComponent.name})` + return Icon +} + +// 预定义的常用图标(向后兼容,只导入需要的图标) +export const CopyIcon = createIcon(Copy) +export const DeleteIcon = createIcon(Trash) +export const EditIcon = createIcon(Pencil) +export const RefreshIcon = createIcon(RefreshCw) +export const ResetIcon = createIcon(RotateCcw) + +/** + * @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。 + * + * This icon has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. + */ +export const ToolIcon = createIcon(Wrench) + +export const VisionIcon = createIcon(Eye) +export const WebSearchIcon = createIcon(Search) +export const WrapIcon = createIcon(WrapText) +export const UnWrapIcon = createIcon(AlignLeft) +export const OcrIcon = createIcon(ScanLine) + +// 导出 createIcon 以便用户自行创建图标组件 +export type { LucideIcon } +export type { LucideProps } from 'lucide-react' diff --git a/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx b/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx new file mode 100644 index 0000000000..fcdcec348f --- /dev/null +++ b/packages/ui/src/components/icons/SvgSpinners180Ring/index.tsx @@ -0,0 +1,37 @@ +/** + * @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。 + * + * This component has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. + */ + +// Original path: src/renderer/src/components/Icons/SvgSpinners180Ring.tsx +import type { SVGProps } from 'react' + +import { cn } from '../../../utils' + +interface SvgSpinners180RingProps extends SVGProps { + size?: number | string +} + +export function SvgSpinners180Ring(props: SvgSpinners180RingProps) { + const { size = '1em', className, ...svgProps } = props + + return ( + + {/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */} + + + ) +} + +export default SvgSpinners180Ring diff --git a/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx b/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx new file mode 100644 index 0000000000..c3180ea76d --- /dev/null +++ b/packages/ui/src/components/icons/ToolsCallingIcon/index.tsx @@ -0,0 +1,32 @@ +/** + * @deprecated 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。建议直接使用 lucide-react 的 Wrench 图标。 + * + * This component has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. Consider using Wrench icon from lucide-react directly. + */ + +// Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx +import { Tooltip, type TooltipProps } from '@heroui/react' +import { Wrench } from 'lucide-react' +import React from 'react' + +import { cn } from '../../../utils' + +interface ToolsCallingIconProps extends React.HTMLAttributes { + className?: string + iconClassName?: string + TooltipProps?: TooltipProps +} + +const ToolsCallingIcon = ({ className, iconClassName, TooltipProps, ...props }: ToolsCallingIconProps) => { + return ( +
+ + + +
+ ) +} + +export default ToolsCallingIcon diff --git a/packages/ui/src/components/icons/index.ts b/packages/ui/src/components/icons/index.ts new file mode 100644 index 0000000000..ce739c93e2 --- /dev/null +++ b/packages/ui/src/components/icons/index.ts @@ -0,0 +1,6 @@ +/** + * Icons 模块统一导出 + */ + +// 导出所有生成的彩色品牌 Logo 图标(81个) +export * from './logos' diff --git a/packages/ui/src/components/icons/logos/302ai.tsx b/packages/ui/src/components/icons/logos/302ai.tsx new file mode 100644 index 0000000000..e28700af61 --- /dev/null +++ b/packages/ui/src/components/icons/logos/302ai.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' +const Ai302 = (props: SVGProps) => ( + + + + + + + + + + + + +) +export { Ai302 } +export default Ai302 diff --git a/packages/ui/src/components/icons/logos/aiOnly.tsx b/packages/ui/src/components/icons/logos/aiOnly.tsx new file mode 100644 index 0000000000..867c64643d --- /dev/null +++ b/packages/ui/src/components/icons/logos/aiOnly.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' +const AiOnly = (props: SVGProps) => ( + + + + + + + + + + +) +export { AiOnly } +export default AiOnly diff --git a/packages/ui/src/components/icons/logos/aihubmix.tsx b/packages/ui/src/components/icons/logos/aihubmix.tsx new file mode 100644 index 0000000000..cc21a8140f --- /dev/null +++ b/packages/ui/src/components/icons/logos/aihubmix.tsx @@ -0,0 +1,34 @@ +import type { SVGProps } from 'react' +const Aihubmix = (props: SVGProps) => ( + + + + + + + + + + + + + + + +) +export { Aihubmix } +export default Aihubmix diff --git a/packages/ui/src/components/icons/logos/alayanew.tsx b/packages/ui/src/components/icons/logos/alayanew.tsx new file mode 100644 index 0000000000..ab7446e305 --- /dev/null +++ b/packages/ui/src/components/icons/logos/alayanew.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Alayanew = (props: SVGProps) => ( + + + + + + + + + +) +export { Alayanew } +export default Alayanew diff --git a/packages/ui/src/components/icons/logos/anthropic.tsx b/packages/ui/src/components/icons/logos/anthropic.tsx new file mode 100644 index 0000000000..d85307c55b --- /dev/null +++ b/packages/ui/src/components/icons/logos/anthropic.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from 'react' +const Anthropic = (props: SVGProps) => ( + + + + + + + + + + + +) +export { Anthropic } +export default Anthropic diff --git a/packages/ui/src/components/icons/logos/awsBedrock.tsx b/packages/ui/src/components/icons/logos/awsBedrock.tsx new file mode 100644 index 0000000000..7a1a5ae4ee --- /dev/null +++ b/packages/ui/src/components/icons/logos/awsBedrock.tsx @@ -0,0 +1,48 @@ +import type { SVGProps } from 'react' +const AwsBedrock = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + +) +export { AwsBedrock } +export default AwsBedrock diff --git a/packages/ui/src/components/icons/logos/azureai.tsx b/packages/ui/src/components/icons/logos/azureai.tsx new file mode 100644 index 0000000000..1bd7f3db0b --- /dev/null +++ b/packages/ui/src/components/icons/logos/azureai.tsx @@ -0,0 +1,85 @@ +import type { SVGProps } from 'react' +const Azureai = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) +export { Azureai } +export default Azureai diff --git a/packages/ui/src/components/icons/logos/baichuan.tsx b/packages/ui/src/components/icons/logos/baichuan.tsx new file mode 100644 index 0000000000..e62487ab1c --- /dev/null +++ b/packages/ui/src/components/icons/logos/baichuan.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react' +const Baichuan = (props: SVGProps) => ( + + + + + + + + + +) +export { Baichuan } +export default Baichuan diff --git a/packages/ui/src/components/icons/logos/baiduCloud.tsx b/packages/ui/src/components/icons/logos/baiduCloud.tsx new file mode 100644 index 0000000000..dbc59b5b48 --- /dev/null +++ b/packages/ui/src/components/icons/logos/baiduCloud.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' +const BaiduCloud = (props: SVGProps) => ( + + + + + +) +export { BaiduCloud } +export default BaiduCloud diff --git a/packages/ui/src/components/icons/logos/bailian.tsx b/packages/ui/src/components/icons/logos/bailian.tsx new file mode 100644 index 0000000000..30812b416d --- /dev/null +++ b/packages/ui/src/components/icons/logos/bailian.tsx @@ -0,0 +1,32 @@ +import type { SVGProps } from 'react' +const Bailian = (props: SVGProps) => ( + + + + + + + + + +) +export { Bailian } +export default Bailian diff --git a/packages/ui/src/components/icons/logos/bocha.tsx b/packages/ui/src/components/icons/logos/bocha.tsx new file mode 100644 index 0000000000..f32705c2a9 --- /dev/null +++ b/packages/ui/src/components/icons/logos/bocha.tsx @@ -0,0 +1,32 @@ +import type { SVGProps } from 'react' +const Bocha = (props: SVGProps) => ( + + + + + + +) +export { Bocha } +export default Bocha diff --git a/packages/ui/src/components/icons/logos/burncloud.tsx b/packages/ui/src/components/icons/logos/burncloud.tsx new file mode 100644 index 0000000000..6feae25070 --- /dev/null +++ b/packages/ui/src/components/icons/logos/burncloud.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Burncloud = (props: SVGProps) => ( + + + + + + + + + +) +export { Burncloud } +export default Burncloud diff --git a/packages/ui/src/components/icons/logos/bytedance.tsx b/packages/ui/src/components/icons/logos/bytedance.tsx new file mode 100644 index 0000000000..b405d20bb9 --- /dev/null +++ b/packages/ui/src/components/icons/logos/bytedance.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' +const Bytedance = (props: SVGProps) => ( + + + + + + +) +export { Bytedance } +export default Bytedance diff --git a/packages/ui/src/components/icons/logos/cephalon.tsx b/packages/ui/src/components/icons/logos/cephalon.tsx new file mode 100644 index 0000000000..39a7b92208 --- /dev/null +++ b/packages/ui/src/components/icons/logos/cephalon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from 'react' +const Cephalon = (props: SVGProps) => ( + + + +) +export { Cephalon } +export default Cephalon diff --git a/packages/ui/src/components/icons/logos/cherryin.tsx b/packages/ui/src/components/icons/logos/cherryin.tsx new file mode 100644 index 0000000000..87e0cf38ad --- /dev/null +++ b/packages/ui/src/components/icons/logos/cherryin.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Cherryin = (props: SVGProps) => ( + + + + + + + + + +) +export { Cherryin } +export default Cherryin diff --git a/packages/ui/src/components/icons/logos/cohere.tsx b/packages/ui/src/components/icons/logos/cohere.tsx new file mode 100644 index 0000000000..56d156a51e --- /dev/null +++ b/packages/ui/src/components/icons/logos/cohere.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +const Cohere = (props: SVGProps) => ( + + + + + + + + + + + + +) +export { Cohere } +export default Cohere diff --git a/packages/ui/src/components/icons/logos/dashscope.tsx b/packages/ui/src/components/icons/logos/dashscope.tsx new file mode 100644 index 0000000000..eb78aaf238 --- /dev/null +++ b/packages/ui/src/components/icons/logos/dashscope.tsx @@ -0,0 +1,184 @@ +import type { SVGProps } from 'react' +const Dashscope = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) +export { Dashscope } +export default Dashscope diff --git a/packages/ui/src/components/icons/logos/deepseek.tsx b/packages/ui/src/components/icons/logos/deepseek.tsx new file mode 100644 index 0000000000..f9a7f6aa8b --- /dev/null +++ b/packages/ui/src/components/icons/logos/deepseek.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Deepseek = (props: SVGProps) => ( + + + + + + + + + + +) +export { Deepseek } +export default Deepseek diff --git a/packages/ui/src/components/icons/logos/dmxapi.tsx b/packages/ui/src/components/icons/logos/dmxapi.tsx new file mode 100644 index 0000000000..760e9283f4 --- /dev/null +++ b/packages/ui/src/components/icons/logos/dmxapi.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Dmxapi = (props: SVGProps) => ( + + + + + + + + + +) +export { Dmxapi } +export default Dmxapi diff --git a/packages/ui/src/components/icons/logos/dmxapiLogo.tsx b/packages/ui/src/components/icons/logos/dmxapiLogo.tsx new file mode 100644 index 0000000000..9b03bfc656 --- /dev/null +++ b/packages/ui/src/components/icons/logos/dmxapiLogo.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const DmxapiLogo = (props: SVGProps) => ( + + + + + + + + + +) +export { DmxapiLogo } +export default DmxapiLogo diff --git a/packages/ui/src/components/icons/logos/dmxapiToImg.tsx b/packages/ui/src/components/icons/logos/dmxapiToImg.tsx new file mode 100644 index 0000000000..113524da56 --- /dev/null +++ b/packages/ui/src/components/icons/logos/dmxapiToImg.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from "react"; +const DmxapiToImg = (props: SVGProps) => ( + + + + + + + + + +); +export { DmxapiToImg }; +export default DmxapiToImg; diff --git a/packages/ui/src/components/icons/logos/doc2x.tsx b/packages/ui/src/components/icons/logos/doc2x.tsx new file mode 100644 index 0000000000..70367a8895 --- /dev/null +++ b/packages/ui/src/components/icons/logos/doc2x.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react' +const Doc2x = (props: SVGProps) => ( + + + + +) +export { Doc2x } +export default Doc2x diff --git a/packages/ui/src/components/icons/logos/doubao.tsx b/packages/ui/src/components/icons/logos/doubao.tsx new file mode 100644 index 0000000000..08ef6e57b0 --- /dev/null +++ b/packages/ui/src/components/icons/logos/doubao.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +const Doubao = (props: SVGProps) => ( + + + + + + + + + + + + + +) +export { Doubao } +export default Doubao diff --git a/packages/ui/src/components/icons/logos/exa.tsx b/packages/ui/src/components/icons/logos/exa.tsx new file mode 100644 index 0000000000..28fa3732f3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/exa.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' +const Exa = (props: SVGProps) => ( + + + +) +export { Exa } +export default Exa diff --git a/packages/ui/src/components/icons/logos/fireworks.tsx b/packages/ui/src/components/icons/logos/fireworks.tsx new file mode 100644 index 0000000000..593cd46bab --- /dev/null +++ b/packages/ui/src/components/icons/logos/fireworks.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' +const Fireworks = (props: SVGProps) => ( + + + +) +export { Fireworks } +export default Fireworks diff --git a/packages/ui/src/components/icons/logos/gemini.tsx b/packages/ui/src/components/icons/logos/gemini.tsx new file mode 100644 index 0000000000..9669e151f5 --- /dev/null +++ b/packages/ui/src/components/icons/logos/gemini.tsx @@ -0,0 +1,34 @@ +import type { SVGProps } from 'react' +const Gemini = (props: SVGProps) => ( + + + + + + + + + + + + + + + + +) +export { Gemini } +export default Gemini diff --git a/packages/ui/src/components/icons/logos/giteeAi.tsx b/packages/ui/src/components/icons/logos/giteeAi.tsx new file mode 100644 index 0000000000..983ac42b52 --- /dev/null +++ b/packages/ui/src/components/icons/logos/giteeAi.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from 'react' +const GiteeAi = (props: SVGProps) => ( + + + + + + + + + + +) +export { GiteeAi } +export default GiteeAi diff --git a/packages/ui/src/components/icons/logos/github.tsx b/packages/ui/src/components/icons/logos/github.tsx new file mode 100644 index 0000000000..936904810d --- /dev/null +++ b/packages/ui/src/components/icons/logos/github.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from 'react' +const Github = (props: SVGProps) => ( + + + + + + + + + + +) +export { Github } +export default Github diff --git a/packages/ui/src/components/icons/logos/google.tsx b/packages/ui/src/components/icons/logos/google.tsx new file mode 100644 index 0000000000..7cf1c7bd14 --- /dev/null +++ b/packages/ui/src/components/icons/logos/google.tsx @@ -0,0 +1,306 @@ +import type { SVGProps } from 'react' +const Google = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) +export { Google } +export default Google diff --git a/packages/ui/src/components/icons/logos/gpustack.tsx b/packages/ui/src/components/icons/logos/gpustack.tsx new file mode 100644 index 0000000000..fe65cc2be7 --- /dev/null +++ b/packages/ui/src/components/icons/logos/gpustack.tsx @@ -0,0 +1,31 @@ +import type { SVGProps } from 'react' +const Gpustack = (props: SVGProps) => ( + + + + + + + + + + + + + + + +) +export { Gpustack } +export default Gpustack diff --git a/packages/ui/src/components/icons/logos/graphRag.tsx b/packages/ui/src/components/icons/logos/graphRag.tsx new file mode 100644 index 0000000000..8c3ecb1f1e --- /dev/null +++ b/packages/ui/src/components/icons/logos/graphRag.tsx @@ -0,0 +1,51 @@ +import type { SVGProps } from 'react' +const GraphRag = (props: SVGProps) => ( + + + + + + + + + + + +) +export { GraphRag } +export default GraphRag diff --git a/packages/ui/src/components/icons/logos/grok.tsx b/packages/ui/src/components/icons/logos/grok.tsx new file mode 100644 index 0000000000..ea1092a301 --- /dev/null +++ b/packages/ui/src/components/icons/logos/grok.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from 'react' +const Grok = (props: SVGProps) => ( + + + + + + +) +export { Grok } +export default Grok diff --git a/packages/ui/src/components/icons/logos/groq.tsx b/packages/ui/src/components/icons/logos/groq.tsx new file mode 100644 index 0000000000..31620f68b3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/groq.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' +const Groq = (props: SVGProps) => ( + + + + + + + + + + + +) +export { Groq } +export default Groq diff --git a/packages/ui/src/components/icons/logos/huggingface.tsx b/packages/ui/src/components/icons/logos/huggingface.tsx new file mode 100644 index 0000000000..baa72dab62 --- /dev/null +++ b/packages/ui/src/components/icons/logos/huggingface.tsx @@ -0,0 +1,69 @@ +import type { SVGProps } from 'react' +const Huggingface = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + +) +export { Huggingface } +export default Huggingface diff --git a/packages/ui/src/components/icons/logos/hyperbolic.tsx b/packages/ui/src/components/icons/logos/hyperbolic.tsx new file mode 100644 index 0000000000..00183569b3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/hyperbolic.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Hyperbolic = (props: SVGProps) => ( + + + + + + + + + + +) +export { Hyperbolic } +export default Hyperbolic diff --git a/packages/ui/src/components/icons/logos/index.ts b/packages/ui/src/components/icons/logos/index.ts new file mode 100644 index 0000000000..7a26287ee9 --- /dev/null +++ b/packages/ui/src/components/icons/logos/index.ts @@ -0,0 +1,88 @@ +/** + * Auto-generated icon exports + * Do not edit manually + * + * Generated at: 2025-11-14T10:23:25.580Z + * Total icons: 80 + */ + +export { Ai302 } from './302ai' +export { Aihubmix } from './aihubmix' +export { AiOnly } from './aiOnly' +export { Alayanew } from './alayanew' +export { Anthropic } from './anthropic' +export { AwsBedrock } from './awsBedrock' +export { Azureai } from './azureai' +export { Baichuan } from './baichuan' +export { BaiduCloud } from './baiduCloud' +export { Bailian } from './bailian' +export { Bocha } from './bocha' +export { Burncloud } from './burncloud' +export { Bytedance } from './bytedance' +export { Cephalon } from './cephalon' +export { Cherryin } from './cherryin' +export { Cohere } from './cohere' +export { Dashscope } from './dashscope' +export { Deepseek } from './deepseek' +export { Dmxapi } from './dmxapi' +export { DmxapiToImg } from './dmxapiToImg' +export { Doc2x } from './doc2x' +export { Doubao } from './doubao' +export { Exa } from './exa' +export { Fireworks } from './fireworks' +export { Gemini } from './gemini' +export { GiteeAi } from './giteeAi' +export { Github } from './github' +export { Google } from './google' +export { Gpustack } from './gpustack' +export { GraphRag } from './graphRag' +export { Grok } from './grok' +export { Groq } from './groq' +export { Huggingface } from './huggingface' +export { Hyperbolic } from './hyperbolic' +export { Infini } from './infini' +export { Intel } from './intel' +export { Jimeng } from './jimeng' +export { Jina } from './jina' +export { Lanyun } from './lanyun' +export { Lepton } from './lepton' +export { Lmstudio } from './lmstudio' +export { Longcat } from './longcat' +export { Macos } from './macos' +export { Mcprouter } from './mcprouter' +export { Meta } from './meta' +export { Mineru } from './mineru' +export { Minimax } from './minimax' +export { Mistral } from './mistral' +export { Mixedbread } from './mixedbread' +export { Mixedbread1 } from './mixedbread1' +export { Moonshot } from './moonshot' +export { NeteaseYoudao } from './neteaseYoudao' +export { Newapi } from './newapi' +export { Nomic } from './nomic' +export { Nvidia } from './nvidia' +export { O3 } from './o3' +export { Ocoolai } from './ocoolai' +export { Ollama } from './ollama' +export { Openai } from './openai' +export { Openrouter } from './openrouter' +export { Paddleocr } from './paddleocr' +export { Perplexity } from './perplexity' +export { Ph8 } from './ph8' +export { Ppio } from './ppio' +export { Qiniu } from './qiniu' +export { Searxng } from './searxng' +export { Silicon } from './silicon' +export { Sophnet } from './sophnet' +export { Step } from './step' +export { Tavily } from './tavily' +export { TencentCloudTi } from './tencentCloudTi' +export { TesseractJs } from './tesseractJs' +export { Together } from './together' +export { Tokenflux } from './tokenflux' +export { Vertexai } from './vertexai' +export { Volcengine } from './volcengine' +export { Voyage } from './voyage' +export { Xirang } from './xirang' +export { ZeroOne } from './zeroOne' +export { Zhipu } from './zhipu' diff --git a/packages/ui/src/components/icons/logos/infini.tsx b/packages/ui/src/components/icons/logos/infini.tsx new file mode 100644 index 0000000000..c248c07488 --- /dev/null +++ b/packages/ui/src/components/icons/logos/infini.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from 'react' +const Infini = (props: SVGProps) => ( + + + + + + + + + + + + + + +) +export { Infini } +export default Infini diff --git a/packages/ui/src/components/icons/logos/intel.tsx b/packages/ui/src/components/icons/logos/intel.tsx new file mode 100644 index 0000000000..dd80334ae5 --- /dev/null +++ b/packages/ui/src/components/icons/logos/intel.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' +const Intel = (props: SVGProps) => ( + + + + + + + + +) +export { Intel } +export default Intel diff --git a/packages/ui/src/components/icons/logos/jimeng.tsx b/packages/ui/src/components/icons/logos/jimeng.tsx new file mode 100644 index 0000000000..1b0594eef7 --- /dev/null +++ b/packages/ui/src/components/icons/logos/jimeng.tsx @@ -0,0 +1,171 @@ +import type { SVGProps } from 'react' +const Jimeng = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) +export { Jimeng } +export default Jimeng diff --git a/packages/ui/src/components/icons/logos/jina.tsx b/packages/ui/src/components/icons/logos/jina.tsx new file mode 100644 index 0000000000..fc75b57549 --- /dev/null +++ b/packages/ui/src/components/icons/logos/jina.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react' +const Jina = (props: SVGProps) => ( + + + + +) +export { Jina } +export default Jina diff --git a/packages/ui/src/components/icons/logos/lanyun.tsx b/packages/ui/src/components/icons/logos/lanyun.tsx new file mode 100644 index 0000000000..011ba0ca80 --- /dev/null +++ b/packages/ui/src/components/icons/logos/lanyun.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Lanyun = (props: SVGProps) => ( + + + + + + + + + +) +export { Lanyun } +export default Lanyun diff --git a/packages/ui/src/components/icons/logos/lepton.tsx b/packages/ui/src/components/icons/logos/lepton.tsx new file mode 100644 index 0000000000..d44770d84f --- /dev/null +++ b/packages/ui/src/components/icons/logos/lepton.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Lepton = (props: SVGProps) => ( + + + + + + +) +export { Lepton } +export default Lepton diff --git a/packages/ui/src/components/icons/logos/lmstudio.tsx b/packages/ui/src/components/icons/logos/lmstudio.tsx new file mode 100644 index 0000000000..a8369a1f1b --- /dev/null +++ b/packages/ui/src/components/icons/logos/lmstudio.tsx @@ -0,0 +1,33 @@ +import type { SVGProps } from 'react' +const Lmstudio = (props: SVGProps) => ( + + + + + + + + + + + +) +export { Lmstudio } +export default Lmstudio diff --git a/packages/ui/src/components/icons/logos/longcat.tsx b/packages/ui/src/components/icons/logos/longcat.tsx new file mode 100644 index 0000000000..df9cace079 --- /dev/null +++ b/packages/ui/src/components/icons/logos/longcat.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' +const Longcat = (props: SVGProps) => ( + + + + + + + + + + + +) +export { Longcat } +export default Longcat diff --git a/packages/ui/src/components/icons/logos/macos.tsx b/packages/ui/src/components/icons/logos/macos.tsx new file mode 100644 index 0000000000..bb42902441 --- /dev/null +++ b/packages/ui/src/components/icons/logos/macos.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Macos = (props: SVGProps) => ( + + + + + + + + + + +) +export { Macos } +export default Macos diff --git a/packages/ui/src/components/icons/logos/mcprouter.tsx b/packages/ui/src/components/icons/logos/mcprouter.tsx new file mode 100644 index 0000000000..f32ed0079f --- /dev/null +++ b/packages/ui/src/components/icons/logos/mcprouter.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Mcprouter = (props: SVGProps) => ( + + + + + + + + + +) +export { Mcprouter } +export default Mcprouter diff --git a/packages/ui/src/components/icons/logos/meta.tsx b/packages/ui/src/components/icons/logos/meta.tsx new file mode 100644 index 0000000000..24489bf51c --- /dev/null +++ b/packages/ui/src/components/icons/logos/meta.tsx @@ -0,0 +1,48 @@ +import type { SVGProps } from 'react' +const Meta = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + +) +export { Meta } +export default Meta diff --git a/packages/ui/src/components/icons/logos/mineru.tsx b/packages/ui/src/components/icons/logos/mineru.tsx new file mode 100644 index 0000000000..a88a63a2d3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/mineru.tsx @@ -0,0 +1,67 @@ +import type { SVGProps } from 'react' +const Mineru = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + +) +export { Mineru } +export default Mineru diff --git a/packages/ui/src/components/icons/logos/minimax.tsx b/packages/ui/src/components/icons/logos/minimax.tsx new file mode 100644 index 0000000000..57df2a0087 --- /dev/null +++ b/packages/ui/src/components/icons/logos/minimax.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' +const Minimax = (props: SVGProps) => ( + + + + + + + + + +) +export { Minimax } +export default Minimax diff --git a/packages/ui/src/components/icons/logos/mistral.tsx b/packages/ui/src/components/icons/logos/mistral.tsx new file mode 100644 index 0000000000..09ff68bb05 --- /dev/null +++ b/packages/ui/src/components/icons/logos/mistral.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react' +const Mistral = (props: SVGProps) => ( + + + + + + + + + + + + +) +export { Mistral } +export default Mistral diff --git a/packages/ui/src/components/icons/logos/mixedbread.tsx b/packages/ui/src/components/icons/logos/mixedbread.tsx new file mode 100644 index 0000000000..8a7c2e5da6 --- /dev/null +++ b/packages/ui/src/components/icons/logos/mixedbread.tsx @@ -0,0 +1,278 @@ +import type { SVGProps } from 'react' +const Mixedbread = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) +export { Mixedbread } +export default Mixedbread diff --git a/packages/ui/src/components/icons/logos/mixedbread1.tsx b/packages/ui/src/components/icons/logos/mixedbread1.tsx new file mode 100644 index 0000000000..e21941c3d4 --- /dev/null +++ b/packages/ui/src/components/icons/logos/mixedbread1.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' +const Mixedbread1 = (props: SVGProps) => ( + + + + + + + + + +) +export { Mixedbread1 } +export default Mixedbread1 diff --git a/packages/ui/src/components/icons/logos/moonshot.tsx b/packages/ui/src/components/icons/logos/moonshot.tsx new file mode 100644 index 0000000000..32e564fc9b --- /dev/null +++ b/packages/ui/src/components/icons/logos/moonshot.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' +const Moonshot = (props: SVGProps) => ( + + + + +) +export { Moonshot } +export default Moonshot diff --git a/packages/ui/src/components/icons/logos/neteaseYoudao.tsx b/packages/ui/src/components/icons/logos/neteaseYoudao.tsx new file mode 100644 index 0000000000..e67d07091f --- /dev/null +++ b/packages/ui/src/components/icons/logos/neteaseYoudao.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react' +const NeteaseYoudao = (props: SVGProps) => ( + + + + +) +export { NeteaseYoudao } +export default NeteaseYoudao diff --git a/packages/ui/src/components/icons/logos/newapi.tsx b/packages/ui/src/components/icons/logos/newapi.tsx new file mode 100644 index 0000000000..a940725ae3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/newapi.tsx @@ -0,0 +1,51 @@ +import type { SVGProps } from 'react' +const Newapi = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + +) +export { Newapi } +export default Newapi diff --git a/packages/ui/src/components/icons/logos/nomic.tsx b/packages/ui/src/components/icons/logos/nomic.tsx new file mode 100644 index 0000000000..be0aff7ff3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/nomic.tsx @@ -0,0 +1,36 @@ +import type { SVGProps } from 'react' +const Nomic = (props: SVGProps) => ( + + + + + + + + + + + + + + + + +) +export { Nomic } +export default Nomic diff --git a/packages/ui/src/components/icons/logos/nvidia.tsx b/packages/ui/src/components/icons/logos/nvidia.tsx new file mode 100644 index 0000000000..8ff33e0921 --- /dev/null +++ b/packages/ui/src/components/icons/logos/nvidia.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from 'react' +const Nvidia = (props: SVGProps) => ( + + + +) +export { Nvidia } +export default Nvidia diff --git a/packages/ui/src/components/icons/logos/o3.tsx b/packages/ui/src/components/icons/logos/o3.tsx new file mode 100644 index 0000000000..469a2a5f1a --- /dev/null +++ b/packages/ui/src/components/icons/logos/o3.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const O3 = (props: SVGProps) => ( + + + + + + + + + +) +export { O3 } +export default O3 diff --git a/packages/ui/src/components/icons/logos/ocoolai.tsx b/packages/ui/src/components/icons/logos/ocoolai.tsx new file mode 100644 index 0000000000..4ebe159e1a --- /dev/null +++ b/packages/ui/src/components/icons/logos/ocoolai.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from 'react' +const Ocoolai = (props: SVGProps) => ( + + + +) +export { Ocoolai } +export default Ocoolai diff --git a/packages/ui/src/components/icons/logos/ollama.tsx b/packages/ui/src/components/icons/logos/ollama.tsx new file mode 100644 index 0000000000..26728af641 --- /dev/null +++ b/packages/ui/src/components/icons/logos/ollama.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' +const Ollama = (props: SVGProps) => ( + + + +) +export { Ollama } +export default Ollama diff --git a/packages/ui/src/components/icons/logos/openai.tsx b/packages/ui/src/components/icons/logos/openai.tsx new file mode 100644 index 0000000000..37097d2ce4 --- /dev/null +++ b/packages/ui/src/components/icons/logos/openai.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Openai = (props: SVGProps) => ( + + + + + + + + + + +) +export { Openai } +export default Openai diff --git a/packages/ui/src/components/icons/logos/openrouter.tsx b/packages/ui/src/components/icons/logos/openrouter.tsx new file mode 100644 index 0000000000..6f96006b0b --- /dev/null +++ b/packages/ui/src/components/icons/logos/openrouter.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from 'react' +const Openrouter = (props: SVGProps) => ( + + + + + + + + + + +) +export { Openrouter } +export default Openrouter diff --git a/packages/ui/src/components/icons/logos/paddleocr.tsx b/packages/ui/src/components/icons/logos/paddleocr.tsx new file mode 100644 index 0000000000..b16a0ee59d --- /dev/null +++ b/packages/ui/src/components/icons/logos/paddleocr.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Paddleocr = (props: SVGProps) => ( + + + + + + + + + +) +export { Paddleocr } +export default Paddleocr diff --git a/packages/ui/src/components/icons/logos/perplexity.tsx b/packages/ui/src/components/icons/logos/perplexity.tsx new file mode 100644 index 0000000000..c0119a7063 --- /dev/null +++ b/packages/ui/src/components/icons/logos/perplexity.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' +const Perplexity = (props: SVGProps) => ( + + + +) +export { Perplexity } +export default Perplexity diff --git a/packages/ui/src/components/icons/logos/ph8.tsx b/packages/ui/src/components/icons/logos/ph8.tsx new file mode 100644 index 0000000000..ce5a7ffed1 --- /dev/null +++ b/packages/ui/src/components/icons/logos/ph8.tsx @@ -0,0 +1,46 @@ +import type { SVGProps } from 'react' +const Ph8 = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + +) +export { Ph8 } +export default Ph8 diff --git a/packages/ui/src/components/icons/logos/ppio.tsx b/packages/ui/src/components/icons/logos/ppio.tsx new file mode 100644 index 0000000000..4a31dbd0b0 --- /dev/null +++ b/packages/ui/src/components/icons/logos/ppio.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Ppio = (props: SVGProps) => ( + + + + + + + + + + +) +export { Ppio } +export default Ppio diff --git a/packages/ui/src/components/icons/logos/qiniu.tsx b/packages/ui/src/components/icons/logos/qiniu.tsx new file mode 100644 index 0000000000..7aaba3a892 --- /dev/null +++ b/packages/ui/src/components/icons/logos/qiniu.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from 'react' +const Qiniu = (props: SVGProps) => ( + + + +) +export { Qiniu } +export default Qiniu diff --git a/packages/ui/src/components/icons/logos/searxng.tsx b/packages/ui/src/components/icons/logos/searxng.tsx new file mode 100644 index 0000000000..397335a9c3 --- /dev/null +++ b/packages/ui/src/components/icons/logos/searxng.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Searxng = (props: SVGProps) => ( + + + + + + + + + + +) +export { Searxng } +export default Searxng diff --git a/packages/ui/src/components/icons/logos/silicon.tsx b/packages/ui/src/components/icons/logos/silicon.tsx new file mode 100644 index 0000000000..179c12a970 --- /dev/null +++ b/packages/ui/src/components/icons/logos/silicon.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' +const Silicon = (props: SVGProps) => ( + + + +) +export { Silicon } +export default Silicon diff --git a/packages/ui/src/components/icons/logos/sophnet.tsx b/packages/ui/src/components/icons/logos/sophnet.tsx new file mode 100644 index 0000000000..09189f5dd5 --- /dev/null +++ b/packages/ui/src/components/icons/logos/sophnet.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' +const Sophnet = (props: SVGProps) => ( + + + + + + +) +export { Sophnet } +export default Sophnet diff --git a/packages/ui/src/components/icons/logos/step.tsx b/packages/ui/src/components/icons/logos/step.tsx new file mode 100644 index 0000000000..53cfe4e25a --- /dev/null +++ b/packages/ui/src/components/icons/logos/step.tsx @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +const Step = (props: SVGProps) => ( + + + + + + + + + + + + + + +) +export { Step } +export default Step diff --git a/packages/ui/src/components/icons/logos/tavily.tsx b/packages/ui/src/components/icons/logos/tavily.tsx new file mode 100644 index 0000000000..80620b7cde --- /dev/null +++ b/packages/ui/src/components/icons/logos/tavily.tsx @@ -0,0 +1,38 @@ +import type { SVGProps } from 'react' +const Tavily = (props: SVGProps) => ( + + + + + + + + + + + + + + + +) +export { Tavily } +export default Tavily diff --git a/packages/ui/src/components/icons/logos/tencentCloudTi.tsx b/packages/ui/src/components/icons/logos/tencentCloudTi.tsx new file mode 100644 index 0000000000..abf779e607 --- /dev/null +++ b/packages/ui/src/components/icons/logos/tencentCloudTi.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react' +const TencentCloudTi = (props: SVGProps) => ( + + + + + +) +export { TencentCloudTi } +export default TencentCloudTi diff --git a/packages/ui/src/components/icons/logos/tesseractJs.tsx b/packages/ui/src/components/icons/logos/tesseractJs.tsx new file mode 100644 index 0000000000..40b8546880 --- /dev/null +++ b/packages/ui/src/components/icons/logos/tesseractJs.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const TesseractJs = (props: SVGProps) => ( + + + + + + + + + +) +export { TesseractJs } +export default TesseractJs diff --git a/packages/ui/src/components/icons/logos/together.tsx b/packages/ui/src/components/icons/logos/together.tsx new file mode 100644 index 0000000000..07f1338f40 --- /dev/null +++ b/packages/ui/src/components/icons/logos/together.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react' +const Together = (props: SVGProps) => ( + + + + +) +export { Together } +export default Together diff --git a/packages/ui/src/components/icons/logos/tokenflux.tsx b/packages/ui/src/components/icons/logos/tokenflux.tsx new file mode 100644 index 0000000000..e87afafdd9 --- /dev/null +++ b/packages/ui/src/components/icons/logos/tokenflux.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Tokenflux = (props: SVGProps) => ( + + + + + + + + + +) +export { Tokenflux } +export default Tokenflux diff --git a/packages/ui/src/components/icons/logos/vertexai.tsx b/packages/ui/src/components/icons/logos/vertexai.tsx new file mode 100644 index 0000000000..c550e09aa1 --- /dev/null +++ b/packages/ui/src/components/icons/logos/vertexai.tsx @@ -0,0 +1,50 @@ +import type { SVGProps } from 'react' +const Vertexai = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + +) +export { Vertexai } +export default Vertexai diff --git a/packages/ui/src/components/icons/logos/volcengine.tsx b/packages/ui/src/components/icons/logos/volcengine.tsx new file mode 100644 index 0000000000..2318598a48 --- /dev/null +++ b/packages/ui/src/components/icons/logos/volcengine.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' +const Volcengine = (props: SVGProps) => ( + + + + + + + +) +export { Volcengine } +export default Volcengine diff --git a/packages/ui/src/components/icons/logos/voyage.tsx b/packages/ui/src/components/icons/logos/voyage.tsx new file mode 100644 index 0000000000..159a96db9a --- /dev/null +++ b/packages/ui/src/components/icons/logos/voyage.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' +const Voyage = (props: SVGProps) => ( + + + + + + + + + + + +) +export { Voyage } +export default Voyage diff --git a/packages/ui/src/components/icons/logos/xirang.tsx b/packages/ui/src/components/icons/logos/xirang.tsx new file mode 100644 index 0000000000..7126a2952e --- /dev/null +++ b/packages/ui/src/components/icons/logos/xirang.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react' +const Xirang = (props: SVGProps) => ( + + + +) +export { Xirang } +export default Xirang diff --git a/packages/ui/src/components/icons/logos/zeroOne.tsx b/packages/ui/src/components/icons/logos/zeroOne.tsx new file mode 100644 index 0000000000..c95655d44e --- /dev/null +++ b/packages/ui/src/components/icons/logos/zeroOne.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from 'react' +const ZeroOne = (props: SVGProps) => ( + + + + + + + + + + + + + + +) +export { ZeroOne } +export default ZeroOne diff --git a/packages/ui/src/components/icons/logos/zhipu.tsx b/packages/ui/src/components/icons/logos/zhipu.tsx new file mode 100644 index 0000000000..f88bf587bc --- /dev/null +++ b/packages/ui/src/components/icons/logos/zhipu.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react' +const Zhipu = (props: SVGProps) => ( + + + + +) +export { Zhipu } +export default Zhipu diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts new file mode 100644 index 0000000000..ae7ff6de33 --- /dev/null +++ b/packages/ui/src/components/index.ts @@ -0,0 +1,95 @@ +// Primitive Components +export { Avatar, AvatarGroup, type AvatarProps, EmojiAvatar } from './primitives/Avatar' +export { default as CopyButton } from './primitives/copyButton' +export { default as CustomTag } from './primitives/customTag' +export { default as DividerWithText } from './primitives/dividerWithText' +export { default as EmojiIcon } from './primitives/emojiIcon' +export type { CustomFallbackProps, ErrorBoundaryCustomizedProps } from './primitives/ErrorBoundary' +export { ErrorBoundary } from './primitives/ErrorBoundary' +export { default as IndicatorLight } from './primitives/indicatorLight' +export { default as Spinner } from './primitives/spinner' +export { DescriptionSwitch, Switch } from './primitives/switch' +export { Tooltip, type TooltipProps } from './primitives/tooltip' + +// Composite Components +export { default as Ellipsis } from './composites/Ellipsis' +export { default as ExpandableText } from './composites/ExpandableText' +export { Box, Center, ColFlex, Flex, RowFlex, SpaceBetweenRowFlex } from './composites/Flex' +export { default as HorizontalScrollContainer } from './composites/HorizontalScrollContainer' +export { default as ListItem } from './composites/ListItem' +export { default as MaxContextCount } from './composites/MaxContextCount' +export { default as Scrollbar } from './composites/Scrollbar' +export { default as ThinkingEffect } from './composites/ThinkingEffect' + +// Icon Components +export { FilePngIcon, FileSvgIcon } from './icons/FileIcons' +// export type { LucideIcon, LucideProps } from './icons/Icon' +// export { +// CopyIcon, +// createIcon, +// DeleteIcon, +// EditIcon, +// OcrIcon, +// RefreshIcon, +// ResetIcon, +// ToolIcon, +// UnWrapIcon, +// VisionIcon, +// WebSearchIcon, +// WrapIcon +// } from './icons/Icon' +export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring' +export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon' + +// Brand Logo Icons (彩色品牌 Logo 图标 - 84个) +// 推荐使用 '@cherrystudio/ui/icons' 路径导入 +export * from './icons' + +// /* Selector Components */ +// export { default as Selector } from './primitives/select' +// export { default as SearchableSelector } from './primitives/Selector/SearchableSelector' +// export type { +// MultipleSearchableSelectorProps, +// MultipleSelectorProps, +// SearchableSelectorItem, +// SearchableSelectorProps, +// SelectorItem, +// SelectorProps, +// SingleSearchableSelectorProps, +// SingleSelectorProps +// } from './primitives/Selector/types' + +/* Additional Composite Components */ +// CodeEditor +export { + default as CodeEditor, + type CodeEditorHandles, + type CodeEditorProps, + type CodeMirrorTheme, + getCmThemeByName, + getCmThemeNames +} from './composites/CodeEditor' +// CollapsibleSearchBar +export { default as CollapsibleSearchBar } from './composites/CollapsibleSearchBar' +// DraggableList +export { DraggableList, useDraggableReorder } from './composites/DraggableList' +// EditableNumber +export type { EditableNumberProps } from './composites/EditableNumber' +export { default as EditableNumber } from './composites/EditableNumber' +// Tooltip variants +export { HelpTooltip, type IconTooltipProps, InfoTooltip, WarnTooltip } from './composites/IconTooltips' +// ImageToolButton +export { default as ImageToolButton } from './composites/ImageToolButton' +// Sortable +export { Sortable } from './composites/Sortable' + +/* Shadcn Primitive Components */ +export * from './primitives/button' +export * from './primitives/checkbox' +export * from './primitives/combobox' +export * from './primitives/command' +export * from './primitives/dialog' +export * from './primitives/popover' +export * from './primitives/radioGroup' +export * from './primitives/select' +export * from './primitives/shadcn-io/dropzone' diff --git a/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx b/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx new file mode 100644 index 0000000000..7a9ce03e24 --- /dev/null +++ b/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx @@ -0,0 +1,37 @@ +import React, { memo } from 'react' + +import { cn } from '../../../utils' + +interface EmojiAvatarProps { + children: string + size?: number + fontSize?: number + onClick?: React.MouseEventHandler + className?: string + style?: React.CSSProperties +} + +const EmojiAvatar = ({ children, size = 31, fontSize, onClick, className, style }: EmojiAvatarProps) => ( +
+ {children} +
+) + +EmojiAvatar.displayName = 'EmojiAvatar' + +export default memo(EmojiAvatar) diff --git a/packages/ui/src/components/primitives/Avatar/index.tsx b/packages/ui/src/components/primitives/Avatar/index.tsx new file mode 100644 index 0000000000..a2ad31bd73 --- /dev/null +++ b/packages/ui/src/components/primitives/Avatar/index.tsx @@ -0,0 +1,34 @@ +import type { AvatarProps as HeroUIAvatarProps } from '@heroui/react' +import { Avatar as HeroUIAvatar, AvatarGroup as HeroUIAvatarGroup } from '@heroui/react' + +import { cn } from '../../../utils' +import EmojiAvatar from './EmojiAvatar' + +export interface AvatarProps extends Omit { + size?: 'xs' | 'sm' | 'md' | 'lg' +} + +const Avatar = (props: AvatarProps) => { + const { size, className = '', ...rest } = props + const isExtraSmall = size === 'xs' + + const resolvedSize = isExtraSmall ? undefined : size + const mergedClassName = cn(isExtraSmall && 'w-6 h-6 text-tiny', 'shadow-lg', className) + + return +} + +Avatar.displayName = 'Avatar' + +/** + * @deprecated 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次) + * 计划在未来版本中移除。建议直接使用 HeroUI 的 AvatarGroup 组件。 + * + * This component has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages). + * Planned for removal in future versions. Consider using HeroUI's AvatarGroup component directly. + */ +const AvatarGroup = HeroUIAvatarGroup + +AvatarGroup.displayName = 'AvatarGroup' + +export { Avatar, AvatarGroup, EmojiAvatar } diff --git a/packages/ui/src/components/primitives/ErrorBoundary/index.tsx b/packages/ui/src/components/primitives/ErrorBoundary/index.tsx new file mode 100644 index 0000000000..3df8e3a5a9 --- /dev/null +++ b/packages/ui/src/components/primitives/ErrorBoundary/index.tsx @@ -0,0 +1,94 @@ +// Original path: src/renderer/src/components/ErrorBoundary.tsx +import { AlertTriangle } from 'lucide-react' +import type { ComponentType, ReactNode } from 'react' +import type { FallbackProps } from 'react-error-boundary' +import { ErrorBoundary } from 'react-error-boundary' + +import { Button } from '../button' +import { formatErrorMessage } from './utils' + +interface CustomFallbackProps extends FallbackProps { + onDebugClick?: () => void | Promise + onReloadClick?: () => void | Promise + debugButtonText?: string + reloadButtonText?: string + errorMessage?: string +} + +const DefaultFallback: ComponentType = (props: CustomFallbackProps): ReactNode => { + const { + error, + onDebugClick, + onReloadClick, + debugButtonText = 'Open DevTools', + reloadButtonText = 'Reload', + errorMessage = 'An error occurred' + } = props + + return ( +
+
+
+ +
+

{errorMessage}

+

{formatErrorMessage(error)}

+
+ {onDebugClick && ( + + )} + {onReloadClick && ( + + )} +
+
+
+
+
+ ) +} + +interface ErrorBoundaryCustomizedProps { + children: ReactNode + fallbackComponent?: ComponentType + onDebugClick?: () => void | Promise + onReloadClick?: () => void | Promise + debugButtonText?: string + reloadButtonText?: string + errorMessage?: string +} + +const ErrorBoundaryCustomized = ({ + children, + fallbackComponent, + onDebugClick, + onReloadClick, + debugButtonText, + reloadButtonText, + errorMessage +}: ErrorBoundaryCustomizedProps) => { + const FallbackComponent = fallbackComponent ?? DefaultFallback + + return ( + ( + + )}> + {children} + + ) +} + +export { ErrorBoundaryCustomized as ErrorBoundary } +export type { CustomFallbackProps, ErrorBoundaryCustomizedProps } diff --git a/packages/ui/src/components/primitives/ErrorBoundary/utils.ts b/packages/ui/src/components/primitives/ErrorBoundary/utils.ts new file mode 100644 index 0000000000..57c2ba62f9 --- /dev/null +++ b/packages/ui/src/components/primitives/ErrorBoundary/utils.ts @@ -0,0 +1,8 @@ +// Utility functions for ErrorBoundary component + +export function formatErrorMessage(error: Error): string { + if (error.message) { + return error.message + } + return error.toString() +} diff --git a/packages/ui/src/components/primitives/button.tsx b/packages/ui/src/components/primitives/button.tsx new file mode 100644 index 0000000000..d233b0f35e --- /dev/null +++ b/packages/ui/src/components/primitives/button.tsx @@ -0,0 +1,95 @@ +import { cn } from '@cherrystudio/ui/utils/index' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { Loader } from 'lucide-react' +import * as React from 'react' + +const buttonVariants = cva( + cn( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap', + 'rounded-md text-sm font-medium transition-all', + 'disabled:pointer-events-none disabled:opacity-40', + "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + 'aria-loading:cursor-progress aria-loading:opacity-40', + 'hover:shadow-xs' + ), + { + variants: { + variant: { + default: 'bg-primary hover:bg-primary-hover text-white', + destructive: 'bg-destructive text-white hover:bg-destructive-hover focus-visible:ring-destructive/20', + outline: cn('border border-primary/40 bg-primary/10 text-primary', 'hover:bg-primary/5'), + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:text-primary-hover text-primary', + link: 'text-primary underline-offset-4 hover:underline hover:text-primary-hover' + }, + size: { + default: 'min-h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'min-h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'min-h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-sm': 'size-8', + 'icon-lg': 'size-10' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +) + +function Button({ + className, + variant, + size, + asChild = false, + loading = false, + loadingIcon, + loadingIconClassName, + disabled, + children, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + loading?: boolean + loadingIcon?: React.ReactNode + loadingIconClassName?: string + }) { + const Comp = asChild ? Slot : 'button' + + // Determine spinner size based on button size + const getSpinnerSize = () => { + if (size === 'sm' || size === 'icon-sm') return 14 + if (size === 'lg' || size === 'icon-lg') return 18 + return 16 + } + + // Default loading icon + const defaultLoadingIcon = + + // Use custom icon or default icon + const spinnerElement = loadingIcon ?? defaultLoadingIcon + + return ( + + {/* asChild mode does not support loading because Slot requires a single child element */} + {asChild ? ( + children + ) : ( + <> + {loading && spinnerElement} + {children} + + )} + + ) +} + +export { Button, buttonVariants } diff --git a/packages/ui/src/components/primitives/checkbox.tsx b/packages/ui/src/components/primitives/checkbox.tsx new file mode 100644 index 0000000000..34f374fec4 --- /dev/null +++ b/packages/ui/src/components/primitives/checkbox.tsx @@ -0,0 +1,65 @@ +import { cn } from '@cherrystudio/ui/utils/index' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { cva, type VariantProps } from 'class-variance-authority' +import { CheckIcon } from 'lucide-react' +import * as React from 'react' + +export type CheckedState = CheckboxPrimitive.CheckedState + +const checkboxVariants = cva( + cn( + 'aspect-square shrink-0 rounded-[4px] border transition-all outline-none', + 'border-primary text-primary', + 'hover:bg-primary/10', + 'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary', + 'focus-visible:ring-3 focus-visible:ring-primary/20', + 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'disabled:cursor-not-allowed disabled:border-gray-500/10 disabled:bg-background-subtle', + 'bg-white/10 shadow-xs' + ), + { + variants: { + size: { + sm: 'size-4', + md: 'size-5', + lg: 'size-6' + } + }, + defaultVariants: { + size: 'md' + } + } +) + +const checkboxIconVariants = cva('dark:text-white', { + variants: { + size: { + sm: 'size-3', + md: 'size-3.5', + lg: 'size-4' + } + }, + defaultVariants: { + size: 'md' + } +}) + +function Checkbox({ + className, + size = 'md', + ...props +}: React.ComponentProps & VariantProps) { + return ( + + + + + + ) +} + +export { Checkbox, checkboxVariants } diff --git a/packages/ui/src/components/primitives/combobox.tsx b/packages/ui/src/components/primitives/combobox.tsx new file mode 100644 index 0000000000..15afa8c0a8 --- /dev/null +++ b/packages/ui/src/components/primitives/combobox.tsx @@ -0,0 +1,291 @@ +'use client' + +import { Button } from '@cherrystudio/ui/components/primitives/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from '@cherrystudio/ui/components/primitives/command' +import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover' +import { cn } from '@cherrystudio/ui/utils/index' +import { cva, type VariantProps } from 'class-variance-authority' +import { Check, ChevronDown, X } from 'lucide-react' +import * as React from 'react' + +// ==================== Variants ==================== + +const comboboxTriggerVariants = cva( + cn( + 'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal', + 'bg-zinc-50 dark:bg-zinc-900', + 'text-foreground' + ), + { + variants: { + state: { + default: 'border-border aria-expanded:border-primary aria-expanded:ring-3 aria-expanded:ring-primary/20', + error: 'border border-destructive! aria-expanded:ring-3 aria-expanded:ring-red-600/20', + disabled: 'opacity-50 cursor-not-allowed pointer-events-none' + }, + size: { + sm: 'px-2 text-xs gap-1', + default: 'px-3 gap-2', + lg: 'px-4 gap-2' + } + }, + defaultVariants: { + state: 'default', + size: 'default' + } + } +) + +const comboboxItemVariants = cva( + 'relative flex items-center gap-2 px-2 py-1.5 text-sm rounded-2xs cursor-pointer transition-colors outline-none select-none', + { + variants: { + state: { + default: 'hover:bg-accent data-[selected=true]:bg-accent', + selected: 'bg-success/10 text-success-foreground', + disabled: 'opacity-50 cursor-not-allowed pointer-events-none' + } + }, + defaultVariants: { + state: 'default' + } + } +) + +// ==================== Types ==================== + +export interface ComboboxOption { + value: string + label: string + disabled?: boolean + icon?: React.ReactNode + description?: string + [key: string]: any +} + +export interface ComboboxProps extends Omit, 'state'> { + // Data source + options: ComboboxOption[] + value?: string | string[] + defaultValue?: string | string[] + onChange?: (value: string | string[]) => void + + // Mode + multiple?: boolean + + // Custom rendering + renderOption?: (option: ComboboxOption) => React.ReactNode + renderValue?: (value: string | string[], options: ComboboxOption[]) => React.ReactNode + + // Search + searchable?: boolean + searchPlaceholder?: string + emptyText?: string + onSearch?: (search: string) => void + + // State + error?: boolean + disabled?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + + // Styling + placeholder?: string + className?: string + popoverClassName?: string + width?: string | number + + // Other + name?: string +} + +// ==================== Component ==================== + +export function Combobox({ + options, + value: controlledValue, + defaultValue, + onChange, + multiple = false, + renderOption, + renderValue, + searchable = true, + searchPlaceholder = 'Search...', + emptyText = 'No results found.', + onSearch, + error = false, + disabled = false, + open: controlledOpen, + onOpenChange, + placeholder = 'Please Select', + className, + popoverClassName, + width, + size, + name +}: ComboboxProps) { + // ==================== State ==================== + const [internalOpen, setInternalOpen] = React.useState(false) + const [internalValue, setInternalValue] = React.useState(defaultValue ?? (multiple ? [] : '')) + + const open = controlledOpen ?? internalOpen + const setOpen = onOpenChange ?? setInternalOpen + + const value = controlledValue ?? internalValue + const setValue = (newValue: string | string[]) => { + if (controlledValue === undefined) { + setInternalValue(newValue) + } + onChange?.(newValue) + } + + // ==================== Handlers ==================== + + const handleSelect = (selectedValue: string) => { + if (multiple) { + const currentValues = (value as string[]) || [] + const newValues = currentValues.includes(selectedValue) + ? currentValues.filter((v) => v !== selectedValue) + : [...currentValues, selectedValue] + setValue(newValues) + } else { + setValue(selectedValue === value ? '' : selectedValue) + setOpen(false) + } + } + + const handleRemoveTag = (tagValue: string, e: React.MouseEvent) => { + e.stopPropagation() + if (multiple) { + const currentValues = (value as string[]) || [] + setValue(currentValues.filter((v) => v !== tagValue)) + } + } + + const isSelected = (optionValue: string): boolean => { + if (multiple) { + return ((value as string[]) || []).includes(optionValue) + } + return value === optionValue + } + + // ==================== Render Helpers ==================== + + const renderTriggerContent = () => { + if (renderValue) { + return renderValue(value, options) + } + + if (multiple) { + const selectedValues = (value as string[]) || [] + if (selectedValues.length === 0) { + return {placeholder} + } + + const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value)) + + return ( +
+ {selectedOptions.map((option) => ( + + {option.label} + handleRemoveTag(option.value, e)} + /> + + ))} +
+ ) + } + + const selectedOption = options.find((opt) => opt.value === value) + if (selectedOption) { + return ( +
+ {selectedOption.icon} + {selectedOption.label} +
+ ) + } + + return {placeholder} + } + + const renderOptionContent = (option: ComboboxOption) => { + if (renderOption) { + return renderOption(option) + } + + return ( + <> + {option.icon && {option.icon}} +
+
{option.label}
+ {option.description &&
{option.description}
} +
+ {isSelected(option.value) && } + + ) + } + + // ==================== Render ==================== + + const state = disabled ? 'disabled' : error ? 'error' : 'default' + const triggerWidth = width ? (typeof width === 'number' ? `${width}px` : width) : undefined + + return ( + + + + + + + {searchable && ( + + )} + + {emptyText} + + {options.map((option) => ( + handleSelect(option.value)} + className={cn(comboboxItemVariants({ state: option.disabled ? 'disabled' : 'default' }))}> + {renderOptionContent(option)} + + ))} + + + + + {name && } + + ) +} diff --git a/packages/ui/src/components/primitives/command.tsx b/packages/ui/src/components/primitives/command.tsx new file mode 100644 index 0000000000..2d0515d272 --- /dev/null +++ b/packages/ui/src/components/primitives/command.tsx @@ -0,0 +1,140 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@cherrystudio/ui/components/primitives/dialog' +import { cn } from '@cherrystudio/ui/utils' +import { Command as CommandPrimitive } from 'cmdk' +import { SearchIcon } from 'lucide-react' +import * as React from 'react' + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return +} + +function CommandGroup({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut +} diff --git a/packages/ui/src/components/primitives/copyButton.tsx b/packages/ui/src/components/primitives/copyButton.tsx new file mode 100644 index 0000000000..af62889e43 --- /dev/null +++ b/packages/ui/src/components/primitives/copyButton.tsx @@ -0,0 +1,32 @@ +// Original path: src/renderer/src/components/CopyButton.tsx +import { Copy } from 'lucide-react' +import type { FC } from 'react' + +import { Tooltip } from './tooltip' + +interface CopyButtonProps { + tooltip?: string + label?: string + size?: number + className?: string + [key: string]: any +} + +const CopyButton: FC = ({ tooltip, label, size = 14, className = '', ...props }) => { + const button = ( +
+ + {label && {label}} +
+ ) + + if (tooltip) { + return {button} + } + + return button +} + +export default CopyButton diff --git a/packages/ui/src/components/primitives/customTag.tsx b/packages/ui/src/components/primitives/customTag.tsx new file mode 100644 index 0000000000..425531b0a5 --- /dev/null +++ b/packages/ui/src/components/primitives/customTag.tsx @@ -0,0 +1,88 @@ +// Original path: src/renderer/src/components/Tags/CustomTag.tsx +import { X } from 'lucide-react' +import type { CSSProperties, FC, MouseEventHandler } from 'react' +import { memo, useMemo } from 'react' + +import { Tooltip } from './tooltip' + +export interface CustomTagProps { + icon?: React.ReactNode + children?: React.ReactNode | string + color: string + size?: number + style?: CSSProperties + tooltip?: string + closable?: boolean + onClose?: () => void + onClick?: MouseEventHandler + disabled?: boolean + inactive?: boolean + className?: string +} + +const CustomTag: FC = ({ + children, + icon, + color, + size = 12, + style, + tooltip, + closable = false, + onClose, + onClick, + disabled, + inactive, + className = '' +}) => { + const actualColor = inactive ? '#aaaaaa' : color + + const tagContent = useMemo( + () => ( +
+ {icon && {icon}} + {children} + {closable && ( +
{ + e.stopPropagation() + onClose?.() + }}> + +
+ )} +
+ ), + [actualColor, children, closable, disabled, icon, onClick, onClose, size, style, className] + ) + + return tooltip ? ( + + {tagContent} + + ) : ( + tagContent + ) +} + +export default memo(CustomTag) diff --git a/packages/ui/src/components/primitives/dialog.tsx b/packages/ui/src/components/primitives/dialog.tsx new file mode 100644 index 0000000000..b27e423ae2 --- /dev/null +++ b/packages/ui/src/components/primitives/dialog.tsx @@ -0,0 +1,118 @@ +import { cn } from '@cherrystudio/ui/utils/index' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' +import * as React from 'react' + +function Dialog({ ...props }: React.ComponentProps) { + return +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return +} + +function DialogClose({ ...props }: React.ComponentProps) { + return +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +} diff --git a/packages/ui/src/components/primitives/dividerWithText.tsx b/packages/ui/src/components/primitives/dividerWithText.tsx new file mode 100644 index 0000000000..75c225fe33 --- /dev/null +++ b/packages/ui/src/components/primitives/dividerWithText.tsx @@ -0,0 +1,20 @@ +// Original: src/renderer/src/components/DividerWithText.tsx +import type { CSSProperties } from 'react' +import React from 'react' + +interface DividerWithTextProps { + text: string + style?: CSSProperties + className?: string +} + +const DividerWithText: React.FC = ({ text, style, className = '' }) => { + return ( +
+ {text} +
+
+ ) +} + +export default DividerWithText diff --git a/packages/ui/src/components/primitives/emojiIcon.tsx b/packages/ui/src/components/primitives/emojiIcon.tsx new file mode 100644 index 0000000000..344ef96744 --- /dev/null +++ b/packages/ui/src/components/primitives/emojiIcon.tsx @@ -0,0 +1,34 @@ +// Original path: src/renderer/src/components/EmojiIcon.tsx +import type { FC } from 'react' + +interface EmojiIconProps { + emoji: string + className?: string + size?: number + fontSize?: number +} + +const EmojiIcon: FC = ({ emoji, className = '', size = 26, fontSize = 15 }) => { + return ( +
+
+ {emoji || '⭐️'} +
+ {emoji} +
+ ) +} + +export default EmojiIcon diff --git a/packages/ui/src/components/primitives/indicatorLight.tsx b/packages/ui/src/components/primitives/indicatorLight.tsx new file mode 100644 index 0000000000..0ab947a756 --- /dev/null +++ b/packages/ui/src/components/primitives/indicatorLight.tsx @@ -0,0 +1,37 @@ +// Original: src/renderer/src/components/IndicatorLight.tsx +import React from 'react' + +interface IndicatorLightProps { + color: string + size?: number + shadow?: boolean + style?: React.CSSProperties + animation?: boolean + className?: string +} + +const IndicatorLight: React.FC = ({ + color, + size = 8, + shadow = true, + style, + animation = true, + className = '' +}) => { + const actualColor = color === 'green' ? '#22c55e' : color + + return ( +
+ ) +} + +export default IndicatorLight diff --git a/packages/ui/src/components/primitives/popover.tsx b/packages/ui/src/components/primitives/popover.tsx new file mode 100644 index 0000000000..b52cc7aa4a --- /dev/null +++ b/packages/ui/src/components/primitives/popover.tsx @@ -0,0 +1,41 @@ +'use client' + +import { cn } from '@cherrystudio/ui/utils' +import * as PopoverPrimitive from '@radix-ui/react-popover' +import * as React from 'react' + +function Popover({ ...props }: React.ComponentProps) { + return +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return +} + +export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } diff --git a/packages/ui/src/components/primitives/radioGroup.tsx b/packages/ui/src/components/primitives/radioGroup.tsx new file mode 100644 index 0000000000..0d4b95b6c9 --- /dev/null +++ b/packages/ui/src/components/primitives/radioGroup.tsx @@ -0,0 +1,57 @@ +import { cn } from '@cherrystudio/ui/utils/index' +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' +import { cva, type VariantProps } from 'class-variance-authority' +import { CircleIcon } from 'lucide-react' +import * as React from 'react' + +const radioGroupItemVariants = cva( + cn( + 'aspect-square shrink-0 rounded-full border transition-all outline-none', + 'border-primary text-primary', + 'hover:bg-primary/10', + 'aria-checked:ring-3 aria-checked:ring-primary/20', + 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + 'disabled:cursor-not-allowed disabled:border-gray-500/10 disabled:bg-background-subtle', + 'dark:bg-input/30 shadow-xs' + ), + { + variants: { + size: { + sm: 'size-4', + md: 'size-5', + lg: 'size-6' + } + }, + defaultVariants: { + size: 'md' + } + } +) + +function RadioGroup({ className, ...props }: React.ComponentProps) { + return +} + +function RadioGroupItem({ + className, + size = 'md', + ...props +}: React.ComponentProps & VariantProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem, radioGroupItemVariants } diff --git a/packages/ui/src/components/primitives/select.tsx b/packages/ui/src/components/primitives/select.tsx new file mode 100644 index 0000000000..ec2bac4cba --- /dev/null +++ b/packages/ui/src/components/primitives/select.tsx @@ -0,0 +1,183 @@ +import { cn } from '@cherrystudio/ui/utils/index' +import * as SelectPrimitive from '@radix-ui/react-select' +import { cva, type VariantProps } from 'class-variance-authority' +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react' +import * as React from 'react' + +const selectTriggerVariants = cva( + cn( + 'inline-flex items-center justify-between rounded-2xs border-1 text-sm transition-colors outline-none font-normal', + 'bg-zinc-50 dark:bg-zinc-900', + 'text-foreground' + ), + { + variants: { + state: { + default: 'border-border aria-expanded:border-primary aria-expanded:ring-3 aria-expanded:ring-primary/20', + error: 'border border-destructive! aria-expanded:ring-3 aria-expanded:ring-red-600/20', + disabled: 'opacity-50 cursor-not-allowed pointer-events-none' + }, + size: { + sm: 'px-3 gap-2 h-8', + default: 'px-3 gap-2 h-9' + } + }, + defaultVariants: { + state: 'default', + size: 'default' + } + } +) + +function Select({ ...props }: React.ComponentProps) { + return +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return +} + +function SelectValue({ ...props }: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & + Omit, 'state'> & { + size?: 'sm' | 'default' + }) { + const state = props.disabled ? 'disabled' : props['aria-invalid'] ? 'error' : 'default' + + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = 'popper', + align = 'center', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue +} diff --git a/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx b/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx new file mode 100644 index 0000000000..4892a94244 --- /dev/null +++ b/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx @@ -0,0 +1,178 @@ +'use client' + +import { Button } from '@cherrystudio/ui/components/primitives/button' +import { cn } from '@cherrystudio/ui/utils/index' +import { UploadIcon } from 'lucide-react' +import type { ReactNode } from 'react' +import { createContext, use } from 'react' +import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone' +import { useDropzone } from 'react-dropzone' + +type DropzoneContextType = { + src?: File[] + accept?: DropzoneOptions['accept'] + maxSize?: DropzoneOptions['maxSize'] + minSize?: DropzoneOptions['minSize'] + maxFiles?: DropzoneOptions['maxFiles'] +} + +const renderBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(2)}${units[unitIndex]}` +} + +const DropzoneContext = createContext(undefined) + +export type DropzoneProps = Omit & { + src?: File[] + className?: string + onDrop?: (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => void + children?: ReactNode +} + +export const Dropzone = ({ + accept, + maxFiles = 1, + maxSize, + minSize, + onDrop, + onError, + disabled, + src, + className, + children, + ...props +}: DropzoneProps) => { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept, + maxFiles, + maxSize, + minSize, + onError, + disabled, + onDrop: (acceptedFiles, fileRejections, event) => { + if (fileRejections.length > 0) { + const message = fileRejections.at(0)?.errors.at(0)?.message + onError?.(new Error(message)) + return + } + + onDrop?.(acceptedFiles, fileRejections, event) + }, + ...props + }) + + return ( + + + + ) +} + +const useDropzoneContext = () => { + const context = use(DropzoneContext) + + if (!context) { + throw new Error('useDropzoneContext must be used within a Dropzone') + } + + return context +} + +export type DropzoneContentProps = { + children?: ReactNode + className?: string +} + +const maxLabelItems = 3 + +export const DropzoneContent = ({ children, className }: DropzoneContentProps) => { + const { src } = useDropzoneContext() + + if (!src) { + return null + } + + if (children) { + return children + } + + return ( +
+
+ +
+

+ {src.length > maxLabelItems + ? `${new Intl.ListFormat('en').format( + src.slice(0, maxLabelItems).map((file) => file.name) + )} and ${src.length - maxLabelItems} more` + : new Intl.ListFormat('en').format(src.map((file) => file.name))} +

+

Drag and drop or click to replace

+
+ ) +} + +export type DropzoneEmptyStateProps = { + children?: ReactNode + className?: string +} + +export const DropzoneEmptyState = ({ children, className }: DropzoneEmptyStateProps) => { + const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext() + + if (src) { + return null + } + + if (children) { + return children + } + + let caption = '' + + if (accept) { + caption += 'Accepts ' + caption += new Intl.ListFormat('en').format(Object.keys(accept)) + } + + if (minSize && maxSize) { + caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}` + } else if (minSize) { + caption += ` at least ${renderBytes(minSize)}` + } else if (maxSize) { + caption += ` less than ${renderBytes(maxSize)}` + } + + return ( +
+
+ +
+

Upload {maxFiles === 1 ? 'a file' : 'files'}

+

Drag and drop or click to upload

+ {caption &&

{caption}.

} +
+ ) +} diff --git a/packages/ui/src/components/primitives/spinner.tsx b/packages/ui/src/components/primitives/spinner.tsx new file mode 100644 index 0000000000..c149c2a934 --- /dev/null +++ b/packages/ui/src/components/primitives/spinner.tsx @@ -0,0 +1,37 @@ +// Original: src/renderer/src/components/Spinner.tsx +import { motion } from 'framer-motion' +import { Search } from 'lucide-react' + +interface Props { + text: React.ReactNode + className?: string +} + +// Define variants for the spinner animation +const spinnerVariants = { + defaultColor: { + color: '#2a2a2a' + }, + dimmed: { + color: '#8C9296' + } +} + +export default function Spinner({ text, className = '' }: Props) { + return ( + + + {text} + + ) +} diff --git a/packages/ui/src/components/primitives/switch.tsx b/packages/ui/src/components/primitives/switch.tsx new file mode 100644 index 0000000000..51d9721603 --- /dev/null +++ b/packages/ui/src/components/primitives/switch.tsx @@ -0,0 +1,54 @@ +import type { SwitchProps } from '@heroui/react' +import { cn, Spinner, Switch } from '@heroui/react' + +// Enhanced Switch component with loading state support +interface CustomSwitchProps extends SwitchProps { + isLoading?: boolean +} + +/** + * A customized Switch component based on HeroUI Switch + * @see https://www.heroui.com/docs/components/switch#api + * @param isLoading When true, displays a loading spinner in the switch thumb + */ +const CustomizedSwitch = ({ isLoading, children, ref, thumbIcon, ...props }: CustomSwitchProps) => { + const finalThumbIcon = isLoading ? : thumbIcon + + return ( + + {children} + + ) +} + +const DescriptionSwitch = ({ children, ...props }: CustomSwitchProps) => { + return ( + + {children} + + ) +} + +CustomizedSwitch.displayName = 'Switch' + +export { DescriptionSwitch, CustomizedSwitch as Switch } +export type { CustomSwitchProps as SwitchProps } diff --git a/packages/ui/src/components/primitives/tooltip.tsx b/packages/ui/src/components/primitives/tooltip.tsx new file mode 100644 index 0000000000..9d98386994 --- /dev/null +++ b/packages/ui/src/components/primitives/tooltip.tsx @@ -0,0 +1,34 @@ +import type { TooltipProps as HeroUITooltipProps } from '@heroui/react' +import { cn, Tooltip as HeroUITooltip } from '@heroui/react' + +export interface TooltipProps extends HeroUITooltipProps {} + +/** + * Tooltip wrapper that applies consistent styling and arrow display. + * Differences from raw HeroUI Tooltip: + * 1. Defaults showArrow={true} + * 2. Merges a default max-w-60 class into the content slot, capping width at 240px. + * All other HeroUI Tooltip props/behaviors remain unchanged. + * + * @see https://www.heroui.com/docs/components/tooltip + */ +export const Tooltip = ({ + children, + classNames, + showArrow, + ...rest +}: Omit & { + classNames?: TooltipProps['classNames'] & { placeholder?: string } +}) => { + return ( + +
{children}
+
+ ) +} diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts new file mode 100644 index 0000000000..31e4e7ab2d --- /dev/null +++ b/packages/ui/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useDndReorder' +export * from './useDndState' diff --git a/src/renderer/src/components/dnd/useDndReorder.ts b/packages/ui/src/hooks/useDndReorder.ts similarity index 97% rename from src/renderer/src/components/dnd/useDndReorder.ts rename to packages/ui/src/hooks/useDndReorder.ts index 60beaf925a..0651a1ae6a 100644 --- a/src/renderer/src/components/dnd/useDndReorder.ts +++ b/packages/ui/src/hooks/useDndReorder.ts @@ -1,4 +1,5 @@ -import { Key, useCallback, useMemo } from 'react' +import type { Key } from 'react' +import { useCallback, useMemo } from 'react' interface UseDndReorderParams { /** 原始的、完整的数据列表 */ diff --git a/src/renderer/src/components/dnd/useDndState.ts b/packages/ui/src/hooks/useDndState.ts similarity index 100% rename from src/renderer/src/components/dnd/useDndState.ts rename to packages/ui/src/hooks/useDndState.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000000..0f9f049d9c --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,5 @@ +// 主入口文件 - 导出所有公共API +export * from './components' +export * from './hooks' +// export * from './types' +export * from './utils' diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css new file mode 100644 index 0000000000..27b213900f --- /dev/null +++ b/packages/ui/src/styles/index.css @@ -0,0 +1,24 @@ +/** + * CherryStudio Styles - npm Package Entry Point + * + * Purpose: Export CSS variables only, without generating utility classes or overriding user themes + * Use Cases: + * - npm users who want to use design system variables without overriding Tailwind's default theme + * - Can be used via var(--cs-primary) and similar patterns + * - Does not generate utility classes like bg-primary (user's own bg-primary won't be affected) + * + * Example: + * ```css + * @import '@cherrystudio/ui/styles/index.css'; + * + * .my-button { + * background: var(--cs-primary); // Use CS brand color + * padding: var(--cs-size-md); // Use CS spacing + * } + * ``` + * + * For complete theme override (generating bg-primary classes), import: + * @import '@cherrystudio/ui/styles/theme.css'; + */ + +@import './tokens.css'; diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css new file mode 100644 index 0000000000..6308a054b4 --- /dev/null +++ b/packages/ui/src/styles/theme.css @@ -0,0 +1,447 @@ +/** + * Generated from Design Tokens + * + * ⚠️ DO NOT EDIT DIRECTLY! + * This file is auto-generated from tokens/ directory. + * To make changes, edit files in tokens/ and run: npm run tokens:build + * + * Generated on: 2025-11-07T08:56:09.444Z + */ + +@theme { + /* ==================== */ + /* Primitive Colors */ + /* ==================== */ + --color-neutral-50: oklch(0.98 0 0); + --color-neutral-100: oklch(0.97 0 0); + --color-neutral-200: oklch(0.92 0 0); + --color-neutral-300: oklch(0.87 0 0); + --color-neutral-400: oklch(0.72 0 0); + --color-neutral-500: oklch(0.55 0 0); + --color-neutral-600: oklch(0.44 0.027 257); + --color-neutral-700: oklch(0.37 0 0); + --color-neutral-800: oklch(0.27 0 0); + --color-neutral-900: oklch(0.2 0 0); + --color-neutral-950: oklch(0.15 0 0); + --color-stone-50: oklch(0.99 0.001 106); + --color-stone-100: oklch(0.97 0.001 106); + --color-stone-200: oklch(0.92 0.003 49); + --color-stone-300: oklch(0.87 0.004 56); + --color-stone-400: oklch(0.72 0.008 56); + --color-stone-500: oklch(0.56 0.011 58); + --color-stone-600: oklch(0.44 0.009 73); + --color-stone-700: oklch(0.37 0.008 68); + --color-stone-800: oklch(0.27 0.006 34); + --color-stone-900: oklch(0.22 0.006 56); + --color-stone-950: oklch(0.15 0.004 50); + --color-zinc-50: oklch(0.98 0 0); + --color-zinc-100: oklch(0.97 0.001 286); + --color-zinc-200: oklch(0.92 0.004 286); + --color-zinc-300: oklch(0.87 0.006 286); + --color-zinc-400: oklch(0.71 0.013 286); + --color-zinc-500: oklch(0.55 0.014 286); + --color-zinc-600: oklch(0.44 0.014 286); + --color-zinc-700: oklch(0.37 0.011 286); + --color-zinc-800: oklch(0.27 0.006 286); + --color-zinc-900: oklch(0.21 0.006 286); + --color-zinc-950: oklch(0.14 0.004 286); + --color-slate-50: oklch(0.98 0.003 248); + --color-slate-100: oklch(0.97 0.007 248); + --color-slate-200: oklch(0.93 0.013 255); + --color-slate-300: oklch(0.87 0.02 253); + --color-slate-400: oklch(0.71 0.035 257); + --color-slate-500: oklch(0.56 0.04 257); + --color-slate-600: oklch(0.45 0.037 257); + --color-slate-700: oklch(0.38 0.039 257); + --color-slate-800: oklch(0.28 0.036 260); + --color-slate-900: oklch(0.21 0.039 266); + --color-slate-950: oklch(0.13 0.042 265); + --color-gray-50: oklch(0.98 0.002 248); + --color-gray-100: oklch(0.97 0.003 265); + --color-gray-200: oklch(0.93 0.006 265); + --color-gray-300: oklch(0.87 0.009 258); + --color-gray-400: oklch(0.71 0.02 261); + --color-gray-500: oklch(0.55 0.023 264); + --color-gray-600: oklch(0.44 0 0); + --color-gray-700: oklch(0.38 0.031 260); + --color-gray-800: oklch(0.28 0.03 257); + --color-gray-900: oklch(0.21 0.032 265); + --color-gray-950: oklch(0.13 0.027 262); + --color-red-50: oklch(0.97 0.014 17); + --color-red-100: oklch(0.93 0.031 18); + --color-red-200: oklch(0.88 0.062 18); + --color-red-300: oklch(0.81 0.103 20); + --color-red-400: oklch(0.71 0.166 22); + --color-red-500: oklch(0.64 0.208 25); + --color-red-600: oklch(0.58 0.214 27); + --color-red-700: oklch(0.51 0.192 28); + --color-red-800: oklch(0.44 0.16 27); + --color-red-900: oklch(0.4 0.135 26); + --color-red-950: oklch(0.25 0.087 26); + --color-orange-50: oklch(0.98 0.018 73); + --color-orange-100: oklch(0.96 0.036 75); + --color-orange-200: oklch(0.9 0.074 70); + --color-orange-300: oklch(0.84 0.118 67); + --color-orange-400: oklch(0.76 0.159 56); + --color-orange-500: oklch(0.71 0.186 48); + --color-orange-600: oklch(0.65 0.192 42); + --color-orange-700: oklch(0.55 0.173 38); + --color-orange-800: oklch(0.47 0.144 37); + --color-orange-900: oklch(0.41 0.118 38); + --color-orange-950: oklch(0.27 0.078 36); + --color-amber-50: oklch(0.99 0.022 95); + --color-amber-100: oklch(0.96 0.057 96); + --color-amber-200: oklch(0.93 0.114 96); + --color-amber-300: oklch(0.88 0.153 92); + --color-amber-400: oklch(0.83 0.164 84); + --color-amber-500: oklch(0.77 0.165 71); + --color-amber-600: oklch(0.67 0.159 58); + --color-amber-700: oklch(0.55 0.145 49); + --color-amber-800: oklch(0.47 0.124 47); + --color-amber-900: oklch(0.41 0.104 46); + --color-amber-950: oklch(0.28 0.074 46); + --color-yellow-50: oklch(0.99 0.028 103); + --color-yellow-100: oklch(0.97 0.07 103); + --color-yellow-200: oklch(0.95 0.124 102); + --color-yellow-300: oklch(0.9 0.164 98); + --color-yellow-400: oklch(0.86 0.173 92); + --color-yellow-500: oklch(0.79 0.16 85); + --color-yellow-600: oklch(0.68 0.141 77); + --color-yellow-700: oklch(0.55 0.121 65); + --color-yellow-800: oklch(0.48 0.104 62); + --color-yellow-900: oklch(0.42 0.091 57); + --color-yellow-950: oklch(0.28 0.063 54); + --color-lime-50: oklch(0.99 0.032 121); + --color-lime-100: oklch(0.97 0.067 123); + --color-lime-200: oklch(0.94 0.119 124); + --color-lime-300: oklch(0.9 0.18 127); + --color-lime-400: oklch(0.85 0.209 129); + --color-lime-500: oklch(0.76 0.204 131); + --color-lime-600: oklch(0.65 0.177 132); + --color-lime-700: oklch(0.53 0.139 132); + --color-lime-800: oklch(0.46 0.114 131); + --color-lime-900: oklch(0.4 0.095 131); + --color-lime-950: oklch(0.27 0.068 132); + --color-green-50: oklch(0.98 0.016 156); + --color-green-100: oklch(0.96 0.041 157); + --color-green-200: oklch(0.92 0.081 156); + --color-green-300: oklch(0.87 0.137 155); + --color-green-400: oklch(0.8 0.182 152); + --color-green-500: oklch(0.72 0.192 149); + --color-green-600: oklch(0.62 0.169 149); + --color-green-700: oklch(0.52 0.137 150); + --color-green-800: oklch(0.45 0.107 151); + --color-green-900: oklch(0.39 0.089 153); + --color-green-950: oklch(0.27 0.063 153); + --color-emerald-50: oklch(0.98 0.02 166); + --color-emerald-100: oklch(0.95 0.051 163); + --color-emerald-200: oklch(0.9 0.092 164); + --color-emerald-300: oklch(0.85 0.13 165); + --color-emerald-400: oklch(0.77 0.152 163); + --color-emerald-500: oklch(0.69 0.148 162); + --color-emerald-600: oklch(0.59 0.127 163); + --color-emerald-700: oklch(0.5 0.104 166); + --color-emerald-800: oklch(0.43 0.087 167); + --color-emerald-900: oklch(0.37 0.072 169); + --color-emerald-950: oklch(0.26 0.048 173); + --color-teal-50: oklch(0.99 0.013 181); + --color-teal-100: oklch(0.95 0.051 181); + --color-teal-200: oklch(0.91 0.094 180); + --color-teal-300: oklch(0.85 0.125 182); + --color-teal-400: oklch(0.78 0.133 181); + --color-teal-500: oklch(0.7 0.123 182); + --color-teal-600: oklch(0.61 0.105 185); + --color-teal-700: oklch(0.51 0.086 186); + --color-teal-800: oklch(0.44 0.071 188); + --color-teal-900: oklch(0.39 0.059 189); + --color-teal-950: oklch(0.28 0.045 193); + --color-cyan-50: oklch(0.98 0.02 201); + --color-cyan-100: oklch(0.95 0.046 203); + --color-cyan-200: oklch(0.92 0.077 205); + --color-cyan-300: oklch(0.86 0.115 207); + --color-cyan-400: oklch(0.8 0.134 212); + --color-cyan-500: oklch(0.71 0.126 216); + --color-cyan-600: oklch(0.6 0.11 222); + --color-cyan-700: oklch(0.52 0.093 223); + --color-cyan-800: oklch(0.45 0.077 224); + --color-cyan-900: oklch(0.4 0.067 227); + --color-cyan-950: oklch(0.3 0.054 230); + --color-sky-50: oklch(0.98 0.013 237); + --color-sky-100: oklch(0.95 0.024 237); + --color-sky-200: oklch(0.9 0.056 232); + --color-sky-300: oklch(0.83 0.1 230); + --color-sky-400: oklch(0.76 0.137 232); + --color-sky-500: oklch(0.68 0.148 238); + --color-sky-600: oklch(0.59 0.137 241); + --color-sky-700: oklch(0.5 0.118 242); + --color-sky-800: oklch(0.44 0.099 241); + --color-sky-900: oklch(0.39 0.084 241); + --color-sky-950: oklch(0.29 0.063 243); + --color-blue-50: oklch(0.97 0.014 255); + --color-blue-100: oklch(0.93 0.03 255); + --color-blue-200: oklch(0.88 0.058 254); + --color-blue-300: oklch(0.8 0.098 252); + --color-blue-400: oklch(0.72 0.143 254); + --color-blue-500: oklch(0.63 0.186 260); + --color-blue-600: oklch(0.54 0.215 263); + --color-blue-700: oklch(0.49 0.215 264); + --color-blue-800: oklch(0.42 0.181 266); + --color-blue-900: oklch(0.38 0.136 265); + --color-blue-950: oklch(0.28 0.087 268); + --color-indigo-50: oklch(0.97 0.016 272); + --color-indigo-100: oklch(0.93 0.033 272); + --color-indigo-200: oklch(0.87 0.061 274); + --color-indigo-300: oklch(0.79 0.104 275); + --color-indigo-400: oklch(0.68 0.156 277); + --color-indigo-500: oklch(0.59 0.204 277); + --color-indigo-600: oklch(0.51 0.228 277); + --color-indigo-700: oklch(0.46 0.213 278); + --color-indigo-800: oklch(0.4 0.177 278); + --color-indigo-900: oklch(0.36 0.133 279); + --color-indigo-950: oklch(0.26 0.086 282); + --color-violet-50: oklch(0.97 0.014 294); + --color-violet-100: oklch(0.94 0.031 294); + --color-violet-200: oklch(0.9 0.053 294); + --color-violet-300: oklch(0.81 0.102 294); + --color-violet-400: oklch(0.71 0.161 293); + --color-violet-500: oklch(0.6 0.221 292); + --color-violet-600: oklch(0.54 0.245 293); + --color-violet-700: oklch(0.49 0.242 292); + --color-violet-800: oklch(0.43 0.209 292); + --color-violet-900: oklch(0.38 0.178 294); + --color-violet-950: oklch(0.28 0.142 291); + --color-purple-50: oklch(0.98 0.014 308); + --color-purple-100: oklch(0.94 0.036 307); + --color-purple-200: oklch(0.91 0.059 307); + --color-purple-300: oklch(0.83 0.108 306); + --color-purple-400: oklch(0.72 0.178 305); + --color-purple-500: oklch(0.63 0.233 304); + --color-purple-600: oklch(0.56 0.251 302); + --color-purple-700: oklch(0.49 0.237 302); + --color-purple-800: oklch(0.44 0.196 304); + --color-purple-900: oklch(0.38 0.167 305); + --color-purple-950: oklch(0.29 0.144 303); + --color-fuchsia-50: oklch(0.98 0.016 320); + --color-fuchsia-100: oklch(0.95 0.04 319); + --color-fuchsia-200: oklch(0.91 0.07 319); + --color-fuchsia-300: oklch(0.83 0.132 321); + --color-fuchsia-400: oklch(0.75 0.203 322); + --color-fuchsia-500: oklch(0.67 0.257 322); + --color-fuchsia-600: oklch(0.59 0.256 323); + --color-fuchsia-700: oklch(0.52 0.226 324); + --color-fuchsia-800: oklch(0.45 0.192 324); + --color-fuchsia-900: oklch(0.4 0.161 326); + --color-fuchsia-950: oklch(0.29 0.13 326); + --color-pink-50: oklch(0.97 0.014 343); + --color-pink-100: oklch(0.95 0.026 342); + --color-pink-200: oklch(0.9 0.058 343); + --color-pink-300: oklch(0.83 0.108 346); + --color-pink-400: oklch(0.72 0.177 350); + --color-pink-500: oklch(0.65 0.213 354); + --color-pink-600: oklch(0.59 0.217 360); + --color-pink-700: oklch(0.53 0.2 4); + --color-pink-800: oklch(0.46 0.168 4); + --color-pink-900: oklch(0.4 0.143 3); + --color-pink-950: oklch(0.28 0.105 4); + --color-rose-50: oklch(0.97 0.017 13); + --color-rose-100: oklch(0.94 0.028 13); + --color-rose-200: oklch(0.89 0.056 10); + --color-rose-300: oklch(0.81 0.105 12); + --color-rose-400: oklch(0.72 0.172 13); + --color-rose-500: oklch(0.64 0.216 17); + --color-rose-600: oklch(0.59 0.222 18); + --color-rose-700: oklch(0.52 0.199 17); + --color-rose-800: oklch(0.46 0.173 13); + --color-rose-900: oklch(0.41 0.148 11); + --color-rose-950: oklch(0.27 0.102 12); + --color-brand-50: oklch(0.98 0.015 152); + --color-brand-100: oklch(0.96 0.034 151); + --color-brand-200: oklch(0.91 0.073 151); + --color-brand-300: oklch(0.85 0.13 149); + --color-brand-400: oklch(0.81 0.173 148); + --color-brand-500: oklch(0.77 0.208 146); + --color-brand-600: oklch(0.67 0.192 146); + --color-brand-700: oklch(0.56 0.156 146); + --color-brand-800: oklch(0.43 0.117 146); + --color-brand-900: oklch(0.3 0.075 147); + --color-brand-950: oklch(0.22 0.051 148); + + /* ==================== */ + /* Semantic Colors */ + /* ==================== */ + --color-primary: oklch(0.77 0.208 146); + --color-primary-hover: oklch(0.85 0.13 149); + --color-destructive: oklch(0.64 0.208 25); + --color-destructive-hover: oklch(0.71 0.166 22); + --color-background: oklch(0.98 0 0); + --color-background-subtle: oklch(0 0 0 / 0.02); + --color-foreground: oklch(0 0 0 / 0.9); + --color-foreground-secondary: oklch(0 0 0 / 0.6); + --color-foreground-muted: oklch(0 0 0 / 0.4); + --color-card: oklch(1 0 0); + --color-popover: oklch(1 0 0); + --color-border: oklch(0 0 0 / 0.1); + --color-border-hover: oklch(0 0 0 / 0.2); + --color-border-active: oklch(0 0 0 / 0.3); + --color-ring: color-mix(in srgb, oklch(0.77 0.208 146) 40%, transparent); + --color-secondary: oklch(0 0 0 / 0.05); + --color-secondary-hover: oklch(0 0 0 / 0.85); + --color-secondary-active: oklch(0 0 0 / 0.7); + --color-muted: oklch(0 0 0 / 0.05); + --color-accent: oklch(0 0 0 / 0.05); + --color-ghost-hover: oklch(0 0 0 / 0.05); + --color-ghost-active: oklch(0 0 0 / 0.1); + --color-sidebar: oklch(1 0 0); + --color-sidebar-accent: oklch(0 0 0 / 0.05); + + /* ==================== */ + /* Status Colors */ + /* ==================== */ + --color-error-base: oklch(0.64 0.208 25); + --color-error-text: oklch(0.44 0.16 27); + --color-error-bg: oklch(0.97 0.014 17); + --color-error-text-hover: oklch(0.51 0.192 28); + --color-error-bg-hover: oklch(0.93 0.031 18); + --color-error-border: oklch(0.88 0.062 18); + --color-error-border-hover: oklch(0.81 0.103 20); + --color-error-active: oklch(0.58 0.214 27); + --color-success-base: oklch(0.72 0.192 149); + --color-success-text-hover: oklch(0.52 0.137 150); + --color-success-bg: oklch(0.98 0.016 156); + --color-success-bg-hover: oklch(0.92 0.081 156); + --color-warning-base: oklch(0.83 0.164 84); + --color-warning-text-hover: oklch(0.55 0.145 49); + --color-warning-bg: oklch(0.99 0.022 95); + --color-warning-bg-hover: oklch(0.96 0.057 96); + --color-warning-active: oklch(0.67 0.159 58); + + /* ==================== */ + /* Spacing */ + /* ==================== */ + --spacing-5xs: 0.25rem; + --spacing-4xs: 0.5rem; + --spacing-3xs: 0.75rem; + --spacing-2xs: 1rem; + --spacing-xs: 1.5rem; + --spacing-sm: 2rem; + --spacing-md: 2.5rem; + --spacing-lg: 3rem; + --spacing-xl: 3.5rem; + --spacing-2xl: 4rem; + --spacing-3xl: 4.5rem; + --spacing-4xl: 5rem; + --spacing-5xl: 5.5rem; + --spacing-6xl: 6rem; + --spacing-7xl: 6.5rem; + --spacing-8xl: 7rem; + + /* ==================== */ + /* Radius */ + /* ==================== */ + --radius-4xs: 0.25rem; /* 4px */ + --radius-3xs: 0.5rem; /* 8px */ + --radius-2xs: 0.75rem; /* 12px */ + --radius-xs: 1rem; /* 16px */ + --radius-sm: 1.5rem; /* 24px */ + --radius-md: 2rem; /* 32px */ + --radius-lg: 2.5rem; /* 40px */ + --radius-xl: 3rem; /* 48px */ + --radius-2xl: 3.5rem; /* 56px */ + --radius-3xl: 4rem; /* 64px */ + --radius-round: 999px; /* 完全圆角 */ + + /* ==================== */ + /* Typography */ + /* ==================== */ + --font-family-heading: Inter; + --font-family-body: Inter; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-bold: 700; + --font-size-body-xs: 0.75rem; + --font-size-body-sm: 0.875rem; + --font-size-body-md: 1rem; + --font-size-body-lg: 1.125rem; + --font-size-heading-xs: 1.25rem; + --font-size-heading-sm: 1.5rem; + --font-size-heading-md: 2rem; + --font-size-heading-lg: 2.5rem; + --font-size-heading-xl: 3rem; + --font-size-heading-2xl: 3.75rem; + --line-height-body-xs: 1.25rem; + --line-height-body-sm: 1.5rem; + --line-height-body-md: 1.5rem; + --line-height-body-lg: 1.75rem; + --line-height-heading-xs: 2rem; + --line-height-heading-sm: 2.5rem; + --line-height-heading-md: 3rem; + --line-height-heading-lg: 3.75rem; + --line-height-heading-xl: 5rem; + --paragraph-spacing-body-xs: 0.75rem; + --paragraph-spacing-body-sm: 0.875rem; + --paragraph-spacing-body-md: 1rem; + --paragraph-spacing-body-lg: 1.125rem; + --paragraph-spacing-heading-xs: 1.25rem; + --paragraph-spacing-heading-sm: 1.5rem; + --paragraph-spacing-heading-md: 2rem; + --paragraph-spacing-heading-lg: 2.5rem; + --paragraph-spacing-heading-xl: 3rem; + --paragraph-spacing-heading-2xl: 3.75rem; +} + +/* ==================== */ +/* Dark Mode */ +/* ==================== */ +@layer theme { + .dark { + --color-background: oklch(0.21 0.006 286); + --color-background-subtle: oklch(1 0 0 / 0.02); + --color-foreground: oklch(1 0 0 / 0.9); + --color-foreground-secondary: oklch(1 0 0 / 0.6); + --color-foreground-muted: oklch(1 0 0 / 0.4); + --color-card: oklch(0 0 0); + --color-popover: oklch(0 0 0); + --color-border: oklch(1 0 0 / 0.1); + --color-border-hover: oklch(1 0 0 / 0.2); + --color-border-active: oklch(1 0 0 / 0.3); + --color-ring: oklch(0.76 0.204 131 / 0.4); + --color-secondary: oklch(1 0 0 / 0.1); + --color-secondary-hover: oklch(1 0 0 / 0.2); + --color-secondary-active: oklch(1 0 0 / 0.25); + --color-muted: oklch(1 0 0 / 0.1); + --color-accent: oklch(1 0 0 / 0.1); + --color-ghost-hover: oklch(1 0 0 / 0.1); + --color-ghost-active: oklch(1 0 0 / 0.15); + --color-sidebar: oklch(0 0 0); + --color-sidebar-accent: oklch(1 0 0 / 0.1); + --color-error-base: oklch(0.71 0.166 22); + --color-error-text: oklch(0.93 0.031 18); + --color-error-bg: oklch(0.4 0.135 26); + --color-error-text-hover: oklch(0.88 0.062 18); + --color-error-bg-hover: oklch(0.44 0.16 27); + --color-error-border: oklch(0.51 0.192 28); + --color-error-border-hover: oklch(0.58 0.214 27); + --color-error-active: oklch(0.81 0.103 20); + --color-success-base: oklch(0.8 0.182 152); + --color-success-text-hover: oklch(0.92 0.081 156); + --color-success-bg: oklch(0.39 0.089 153); + --color-success-bg-hover: oklch(0.45 0.107 151); + --color-warning-base: oklch(0.83 0.164 84); + --color-warning-text-hover: oklch(0.93 0.114 96); + --color-warning-bg: oklch(0.41 0.104 46); + --color-warning-bg-hover: oklch(0.47 0.124 47); + --color-warning-active: oklch(0.67 0.159 58); + } +} + +/* ==================== */ +/* Base Styles */ +/* ==================== */ +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css new file mode 100644 index 0000000000..b4ab133cea --- /dev/null +++ b/packages/ui/src/styles/tokens.css @@ -0,0 +1,5 @@ +/** + * CherryStudio Design Tokens + */ + +@import './tokens/index.css'; diff --git a/packages/ui/src/styles/tokens/colors/primitive.css b/packages/ui/src/styles/tokens/colors/primitive.css new file mode 100644 index 0000000000..acad5c343e --- /dev/null +++ b/packages/ui/src/styles/tokens/colors/primitive.css @@ -0,0 +1,309 @@ +/** + * Primitive Colors - Light Mode + * 基础色板 - 所有原始颜色定义 + */ + +:root { + /* Neutral */ + --cs-neutral-50: oklch(0.98 0 0); + --cs-neutral-100: oklch(0.97 0 0); + --cs-neutral-200: oklch(0.92 0 0); + --cs-neutral-300: oklch(0.87 0 0); + --cs-neutral-400: oklch(0.72 0 0); + --cs-neutral-500: oklch(0.55 0 0); + --cs-neutral-600: oklch(0.44 0.027 257); + --cs-neutral-700: oklch(0.37 0 0); + --cs-neutral-800: oklch(0.27 0 0); + --cs-neutral-900: oklch(0.2 0 0); + --cs-neutral-950: oklch(0.15 0 0); + + /* Stone */ + --cs-stone-50: oklch(0.99 0.001 106); + --cs-stone-100: oklch(0.97 0.001 106); + --cs-stone-200: oklch(0.92 0.003 49); + --cs-stone-300: oklch(0.87 0.004 56); + --cs-stone-400: oklch(0.72 0.008 56); + --cs-stone-500: oklch(0.56 0.011 58); + --cs-stone-600: oklch(0.44 0.009 73); + --cs-stone-700: oklch(0.37 0.008 68); + --cs-stone-800: oklch(0.27 0.006 34); + --cs-stone-900: oklch(0.22 0.006 56); + --cs-stone-950: oklch(0.15 0.004 50); + + /* Zinc */ + --cs-zinc-50: oklch(0.98 0 0); + --cs-zinc-100: oklch(0.97 0.001 286); + --cs-zinc-200: oklch(0.92 0.004 286); + --cs-zinc-300: oklch(0.87 0.006 286); + --cs-zinc-400: oklch(0.71 0.013 286); + --cs-zinc-500: oklch(0.55 0.014 286); + --cs-zinc-600: oklch(0.44 0.014 286); + --cs-zinc-700: oklch(0.37 0.011 286); + --cs-zinc-800: oklch(0.27 0.006 286); + --cs-zinc-900: oklch(0.21 0.006 286); + --cs-zinc-950: oklch(0.14 0.004 286); + + /* Slate */ + --cs-slate-50: oklch(0.98 0.003 248); + --cs-slate-100: oklch(0.97 0.007 248); + --cs-slate-200: oklch(0.93 0.013 255); + --cs-slate-300: oklch(0.87 0.02 253); + --cs-slate-400: oklch(0.71 0.035 257); + --cs-slate-500: oklch(0.56 0.04 257); + --cs-slate-600: oklch(0.45 0.037 257); + --cs-slate-700: oklch(0.38 0.039 257); + --cs-slate-800: oklch(0.28 0.036 260); + --cs-slate-900: oklch(0.21 0.039 266); + --cs-slate-950: oklch(0.13 0.042 265); + + /* Gray */ + --cs-gray-50: oklch(0.98 0.002 248); + --cs-gray-100: oklch(0.97 0.003 265); + --cs-gray-200: oklch(0.93 0.006 265); + --cs-gray-300: oklch(0.87 0.009 258); + --cs-gray-400: oklch(0.71 0.02 261); + --cs-gray-500: oklch(0.55 0.023 264); + --cs-gray-600: oklch(0.44 0 0); + --cs-gray-700: oklch(0.38 0.031 260); + --cs-gray-800: oklch(0.28 0.03 257); + --cs-gray-900: oklch(0.21 0.032 265); + --cs-gray-950: oklch(0.13 0.027 262); + + /* Red */ + --cs-red-50: oklch(0.97 0.014 17); + --cs-red-100: oklch(0.93 0.031 18); + --cs-red-200: oklch(0.88 0.062 18); + --cs-red-300: oklch(0.81 0.103 20); + --cs-red-400: oklch(0.71 0.166 22); + --cs-red-500: oklch(0.64 0.208 25); + --cs-red-600: oklch(0.58 0.214 27); + --cs-red-700: oklch(0.51 0.192 28); + --cs-red-800: oklch(0.44 0.16 27); + --cs-red-900: oklch(0.4 0.135 26); + --cs-red-950: oklch(0.25 0.087 26); + + /* Orange */ + --cs-orange-50: oklch(0.98 0.018 73); + --cs-orange-100: oklch(0.96 0.036 75); + --cs-orange-200: oklch(0.9 0.074 70); + --cs-orange-300: oklch(0.84 0.118 67); + --cs-orange-400: oklch(0.76 0.159 56); + --cs-orange-500: oklch(0.71 0.186 48); + --cs-orange-600: oklch(0.65 0.192 42); + --cs-orange-700: oklch(0.55 0.173 38); + --cs-orange-800: oklch(0.47 0.144 37); + --cs-orange-900: oklch(0.41 0.118 38); + --cs-orange-950: oklch(0.27 0.078 36); + + /* Amber */ + --cs-amber-50: oklch(0.99 0.022 95); + --cs-amber-100: oklch(0.96 0.057 96); + --cs-amber-200: oklch(0.93 0.114 96); + --cs-amber-300: oklch(0.88 0.153 92); + --cs-amber-400: oklch(0.83 0.164 84); + --cs-amber-500: oklch(0.77 0.165 71); + --cs-amber-600: oklch(0.67 0.159 58); + --cs-amber-700: oklch(0.55 0.145 49); + --cs-amber-800: oklch(0.47 0.124 47); + --cs-amber-900: oklch(0.41 0.104 46); + --cs-amber-950: oklch(0.28 0.074 46); + + /* Yellow */ + --cs-yellow-50: oklch(0.99 0.028 103); + --cs-yellow-100: oklch(0.97 0.07 103); + --cs-yellow-200: oklch(0.95 0.124 102); + --cs-yellow-300: oklch(0.9 0.164 98); + --cs-yellow-400: oklch(0.86 0.173 92); + --cs-yellow-500: oklch(0.79 0.16 85); + --cs-yellow-600: oklch(0.68 0.141 77); + --cs-yellow-700: oklch(0.55 0.121 65); + --cs-yellow-800: oklch(0.48 0.104 62); + --cs-yellow-900: oklch(0.42 0.091 57); + --cs-yellow-950: oklch(0.28 0.063 54); + + /* Lime (品牌主色) */ + --cs-lime-50: oklch(0.99 0.032 121); + --cs-lime-100: oklch(0.97 0.067 123); + --cs-lime-200: oklch(0.94 0.119 124); + --cs-lime-300: oklch(0.9 0.18 127); + --cs-lime-400: oklch(0.85 0.209 129); + --cs-lime-500: oklch(0.76 0.204 131); + --cs-lime-600: oklch(0.65 0.177 132); + --cs-lime-700: oklch(0.53 0.139 132); + --cs-lime-800: oklch(0.46 0.114 131); + --cs-lime-900: oklch(0.4 0.095 131); + --cs-lime-950: oklch(0.27 0.068 132); + + /* Green */ + --cs-green-50: oklch(0.98 0.016 156); + --cs-green-100: oklch(0.96 0.041 157); + --cs-green-200: oklch(0.92 0.081 156); + --cs-green-300: oklch(0.87 0.137 155); + --cs-green-400: oklch(0.8 0.182 152); + --cs-green-500: oklch(0.72 0.192 149); + --cs-green-600: oklch(0.62 0.169 149); + --cs-green-700: oklch(0.52 0.137 150); + --cs-green-800: oklch(0.45 0.107 151); + --cs-green-900: oklch(0.39 0.089 153); + --cs-green-950: oklch(0.27 0.063 153); + + /* Emerald */ + --cs-emerald-50: oklch(0.98 0.02 166); + --cs-emerald-100: oklch(0.95 0.051 163); + --cs-emerald-200: oklch(0.9 0.092 164); + --cs-emerald-300: oklch(0.85 0.13 165); + --cs-emerald-400: oklch(0.77 0.152 163); + --cs-emerald-500: oklch(0.69 0.148 162); + --cs-emerald-600: oklch(0.59 0.127 163); + --cs-emerald-700: oklch(0.5 0.104 166); + --cs-emerald-800: oklch(0.43 0.087 167); + --cs-emerald-900: oklch(0.37 0.072 169); + --cs-emerald-950: oklch(0.26 0.048 173); + + /* Teal */ + --cs-teal-50: oklch(0.99 0.013 181); + --cs-teal-100: oklch(0.95 0.051 181); + --cs-teal-200: oklch(0.91 0.094 180); + --cs-teal-300: oklch(0.85 0.125 182); + --cs-teal-400: oklch(0.78 0.133 181); + --cs-teal-500: oklch(0.7 0.123 182); + --cs-teal-600: oklch(0.61 0.105 185); + --cs-teal-700: oklch(0.51 0.086 186); + --cs-teal-800: oklch(0.44 0.071 188); + --cs-teal-900: oklch(0.39 0.059 189); + --cs-teal-950: oklch(0.28 0.045 193); + + /* Cyan */ + --cs-cyan-50: oklch(0.98 0.02 201); + --cs-cyan-100: oklch(0.95 0.046 203); + --cs-cyan-200: oklch(0.92 0.077 205); + --cs-cyan-300: oklch(0.86 0.115 207); + --cs-cyan-400: oklch(0.8 0.134 212); + --cs-cyan-500: oklch(0.71 0.126 216); + --cs-cyan-600: oklch(0.6 0.11 222); + --cs-cyan-700: oklch(0.52 0.093 223); + --cs-cyan-800: oklch(0.45 0.077 224); + --cs-cyan-900: oklch(0.4 0.067 227); + --cs-cyan-950: oklch(0.3 0.054 230); + + /* Sky */ + --cs-sky-50: oklch(0.98 0.013 237); + --cs-sky-100: oklch(0.95 0.024 237); + --cs-sky-200: oklch(0.9 0.056 232); + --cs-sky-300: oklch(0.83 0.1 230); + --cs-sky-400: oklch(0.76 0.137 232); + --cs-sky-500: oklch(0.68 0.148 238); + --cs-sky-600: oklch(0.59 0.137 241); + --cs-sky-700: oklch(0.5 0.118 242); + --cs-sky-800: oklch(0.44 0.099 241); + --cs-sky-900: oklch(0.39 0.084 241); + --cs-sky-950: oklch(0.29 0.063 243); + + /* Blue */ + --cs-blue-50: oklch(0.97 0.014 255); + --cs-blue-100: oklch(0.93 0.03 255); + --cs-blue-200: oklch(0.88 0.058 254); + --cs-blue-300: oklch(0.8 0.098 252); + --cs-blue-400: oklch(0.72 0.143 254); + --cs-blue-500: oklch(0.63 0.186 260); + --cs-blue-600: oklch(0.54 0.215 263); + --cs-blue-700: oklch(0.49 0.215 264); + --cs-blue-800: oklch(0.42 0.181 266); + --cs-blue-900: oklch(0.38 0.136 265); + --cs-blue-950: oklch(0.28 0.087 268); + + /* Indigo */ + --cs-indigo-50: oklch(0.97 0.016 272); + --cs-indigo-100: oklch(0.93 0.033 272); + --cs-indigo-200: oklch(0.87 0.061 274); + --cs-indigo-300: oklch(0.79 0.104 275); + --cs-indigo-400: oklch(0.68 0.156 277); + --cs-indigo-500: oklch(0.59 0.204 277); + --cs-indigo-600: oklch(0.51 0.228 277); + --cs-indigo-700: oklch(0.46 0.213 278); + --cs-indigo-800: oklch(0.4 0.177 278); + --cs-indigo-900: oklch(0.36 0.133 279); + --cs-indigo-950: oklch(0.26 0.086 282); + + /* Violet */ + --cs-violet-50: oklch(0.97 0.014 294); + --cs-violet-100: oklch(0.94 0.031 294); + --cs-violet-200: oklch(0.9 0.053 294); + --cs-violet-300: oklch(0.81 0.102 294); + --cs-violet-400: oklch(0.71 0.161 293); + --cs-violet-500: oklch(0.6 0.221 292); + --cs-violet-600: oklch(0.54 0.245 293); + --cs-violet-700: oklch(0.49 0.242 292); + --cs-violet-800: oklch(0.43 0.209 292); + --cs-violet-900: oklch(0.38 0.178 294); + --cs-violet-950: oklch(0.28 0.142 291); + + /* Purple */ + --cs-purple-50: oklch(0.98 0.014 308); + --cs-purple-100: oklch(0.94 0.036 307); + --cs-purple-200: oklch(0.91 0.059 307); + --cs-purple-300: oklch(0.83 0.108 306); + --cs-purple-400: oklch(0.72 0.178 305); + --cs-purple-500: oklch(0.63 0.233 304); + --cs-purple-600: oklch(0.56 0.251 302); + --cs-purple-700: oklch(0.49 0.237 302); + --cs-purple-800: oklch(0.44 0.196 304); + --cs-purple-900: oklch(0.38 0.167 305); + --cs-purple-950: oklch(0.29 0.144 303); + + /* Fuchsia */ + --cs-fuchsia-50: oklch(0.98 0.016 320); + --cs-fuchsia-100: oklch(0.95 0.04 319); + --cs-fuchsia-200: oklch(0.91 0.07 319); + --cs-fuchsia-300: oklch(0.83 0.132 321); + --cs-fuchsia-400: oklch(0.75 0.203 322); + --cs-fuchsia-500: oklch(0.67 0.257 322); + --cs-fuchsia-600: oklch(0.59 0.256 323); + --cs-fuchsia-700: oklch(0.52 0.226 324); + --cs-fuchsia-800: oklch(0.45 0.192 324); + --cs-fuchsia-900: oklch(0.4 0.161 326); + --cs-fuchsia-950: oklch(0.29 0.13 326); + + /* Pink */ + --cs-pink-50: oklch(0.97 0.014 343); + --cs-pink-100: oklch(0.95 0.026 342); + --cs-pink-200: oklch(0.9 0.058 343); + --cs-pink-300: oklch(0.83 0.108 346); + --cs-pink-400: oklch(0.72 0.177 350); + --cs-pink-500: oklch(0.65 0.213 354); + --cs-pink-600: oklch(0.59 0.217 360); + --cs-pink-700: oklch(0.53 0.2 4); + --cs-pink-800: oklch(0.46 0.168 4); + --cs-pink-900: oklch(0.4 0.143 3); + --cs-pink-950: oklch(0.28 0.105 4); + + /* Rose */ + --cs-rose-50: oklch(0.97 0.017 13); + --cs-rose-100: oklch(0.94 0.028 13); + --cs-rose-200: oklch(0.89 0.056 10); + --cs-rose-300: oklch(0.81 0.105 12); + --cs-rose-400: oklch(0.72 0.172 13); + --cs-rose-500: oklch(0.64 0.216 17); + --cs-rose-600: oklch(0.59 0.222 18); + --cs-rose-700: oklch(0.52 0.199 17); + --cs-rose-800: oklch(0.46 0.173 13); + --cs-rose-900: oklch(0.41 0.148 11); + --cs-rose-950: oklch(0.27 0.102 12); + + /* Black & White */ + --cs-black: oklch(0 0 0); + --cs-white: oklch(1 0 0); + + /* Brand (Cherry Studio 品牌专属色) */ + --cs-brand-50: oklch(0.98 0.015 152); + --cs-brand-100: oklch(0.96 0.034 151); + --cs-brand-200: oklch(0.91 0.073 151); + --cs-brand-300: oklch(0.85 0.13 149); + --cs-brand-400: oklch(0.81 0.173 148); + --cs-brand-500: oklch(0.77 0.208 146); + --cs-brand-600: oklch(0.67 0.192 146); + --cs-brand-700: oklch(0.56 0.156 146); + --cs-brand-800: oklch(0.43 0.117 146); + --cs-brand-900: oklch(0.3 0.075 147); + --cs-brand-950: oklch(0.22 0.051 148); +} diff --git a/packages/ui/src/styles/tokens/colors/semantic.css b/packages/ui/src/styles/tokens/colors/semantic.css new file mode 100644 index 0000000000..ff04ae0f85 --- /dev/null +++ b/packages/ui/src/styles/tokens/colors/semantic.css @@ -0,0 +1,81 @@ +/** + * Semantic Colors - Light Mode + * 语义化颜色 - 基于 Primitive Colors 的语义化映射 + */ + +:root { + /* Brand Colors */ + --cs-primary: var(--cs-brand-500); + --cs-primary-hover: var(--cs-brand-300); + --cs-destructive: var(--cs-red-500); + --cs-destructive-hover: var(--cs-red-400); + --cs-success: var(--cs-green-500); + --cs-warning: var(--cs-amber-500); + + /* Background & Foreground */ + --cs-background: var(--cs-zinc-50); + --cs-background-subtle: oklch(0 0 0 / 0.02); + --cs-foreground: oklch(0 0 0 / 0.9); + --cs-foreground-secondary: oklch(0 0 0 / 0.6); + --cs-foreground-muted: oklch(0 0 0 / 0.4); + + /* Card & Popover */ + --cs-card: var(--cs-white); + --cs-popover: var(--cs-white); + + /* Border */ + --cs-border: oklch(0 0 0 / 0.1); + --cs-border-hover: oklch(0 0 0 / 0.2); + --cs-border-active: oklch(0 0 0 / 0.3); + + /* Ring (Focus) */ + --cs-ring: color-mix(in srgb, var(--cs-primary) 40%, transparent); + + /* UI Element Colors */ + --cs-secondary: oklch(0 0 0 / 0.05); /* Secondary Button Background */ + --cs-secondary-hover: oklch(0 0 0 / 0.85); + --cs-secondary-active: oklch(0 0 0 / 0.7); + --cs-muted: oklch(0 0 0 / 0.05); /* Muted/Subtle Background */ + --cs-accent: oklch(0 0 0 / 0.05); /* Accent Background */ + --cs-ghost-hover: oklch(0 0 0 / 0.05); /* Ghost Button Hover */ + --cs-ghost-active: oklch(0 0 0 / 0.1); /* Ghost Button Active */ + + /* Sidebar */ + --cs-sidebar: var(--cs-white); + --cs-sidebar-accent: oklch(0 0 0 / 0.05); +} + +/* Dark Mode */ +.dark { + /* Background & Foreground */ + --cs-background: var(--cs-zinc-900); + --cs-background-subtle: oklch(1 0 0 / 0.02); + --cs-foreground: oklch(1 0 0 / 0.9); + --cs-foreground-secondary: oklch(1 0 0 / 0.6); + --cs-foreground-muted: oklch(1 0 0 / 0.4); + + /* Card & Popover */ + --cs-card: var(--cs-black); + --cs-popover: var(--cs-black); + + /* Border */ + --cs-border: oklch(1 0 0 / 0.1); + --cs-border-hover: oklch(1 0 0 / 0.2); + --cs-border-active: oklch(1 0 0 / 0.3); + + /* Ring (Focus) - 保持不变 */ + --cs-ring: oklch(0.76 0.204 131 / 0.4); + + /* UI Element Colors - Dark Mode */ + --cs-secondary: oklch(1 0 0 / 0.1); /* Secondary Button Background */ + --cs-secondary-hover: oklch(1 0 0 / 0.2); + --cs-secondary-active: oklch(1 0 0 / 0.25); + --cs-muted: oklch(1 0 0 / 0.1); /* Muted/Subtle Background */ + --cs-accent: oklch(1 0 0 / 0.1); /* Accent Background */ + --cs-ghost-hover: oklch(1 0 0 / 0.1); /* Ghost Button Hover */ + --cs-ghost-active: oklch(1 0 0 / 0.15); /* Ghost Button Active */ + + /* Sidebar */ + --cs-sidebar: var(--cs-black); + --cs-sidebar-accent: oklch(1 0 0 / 0.1); +} diff --git a/packages/ui/src/styles/tokens/colors/status.css b/packages/ui/src/styles/tokens/colors/status.css new file mode 100644 index 0000000000..6d7072af88 --- /dev/null +++ b/packages/ui/src/styles/tokens/colors/status.css @@ -0,0 +1,55 @@ +/** + * Status Colors - Light Mode & Dark Mode + * 状态颜色 - Error, Success, Warning + */ + +:root { + /* Status Colors - Error */ + --cs-error-base: var(--cs-red-500); /* oklch(0.64 0.208 25) */ + --cs-error-text: var(--cs-red-800); /* oklch(0.44 0.161 27) */ + --cs-error-bg: var(--cs-red-50); /* oklch(0.97 0.013 17) */ + --cs-error-text-hover: var(--cs-red-700); /* oklch(0.51 0.19 28) */ + --cs-error-bg-hover: var(--cs-red-100); /* oklch(0.94 0.031 18) */ + --cs-error-border: var(--cs-red-200); /* oklch(0.88 0.059 18) */ + --cs-error-border-hover: var(--cs-red-300); /* oklch(0.81 0.103 20) */ + --cs-error-active: var(--cs-red-600); /* oklch(0.58 0.215 27) */ + + /* Status Colors - Success */ + --cs-success-base: var(--cs-green-500); /* oklch(0.72 0.192 150) */ + --cs-success-text-hover: var(--cs-green-700); /* oklch(0.53 0.137 150) */ + --cs-success-bg: var(--cs-green-50); /* oklch(0.98 0.018 156) */ + --cs-success-bg-hover: var(--cs-green-200); /* oklch(0.93 0.081 156) */ + + /* Status Colors - Warning */ + --cs-warning-base: var(--cs-amber-400); /* oklch(0.84 0.164 84) */ + --cs-warning-text-hover: var(--cs-amber-700); /* oklch(0.56 0.146 49) */ + --cs-warning-bg: var(--cs-amber-50); /* oklch(0.99 0.021 95) */ + --cs-warning-bg-hover: var(--cs-amber-100); /* oklch(0.96 0.058 96) */ + --cs-warning-active: var(--cs-amber-600); /* oklch(0.67 0.157 58) */ +} + +/* Dark Mode */ +.dark { + /* Status Colors - Error (Dark Mode) */ + --cs-error-base: var(--cs-red-400); /* oklch(0.71 0.166 22) */ + --cs-error-text: var(--cs-red-100); /* oklch(0.94 0.031 18) */ + --cs-error-bg: var(--cs-red-900); /* oklch(0.4 0.133 26) */ + --cs-error-text-hover: var(--cs-red-200); /* oklch(0.88 0.059 18) */ + --cs-error-bg-hover: var(--cs-red-800); /* oklch(0.44 0.161 27) */ + --cs-error-border: var(--cs-red-700); /* oklch(0.51 0.19 28) */ + --cs-error-border-hover: var(--cs-red-600); /* oklch(0.58 0.215 27) */ + --cs-error-active: var(--cs-red-300); /* oklch(0.81 0.103 20) */ + + /* Status Colors - Success (Dark Mode) */ + --cs-success-base: var(--cs-green-400); /* oklch(0.8 0.182 152) */ + --cs-success-text-hover: var(--cs-green-200); /* oklch(0.93 0.081 156) */ + --cs-success-bg: var(--cs-green-900); /* oklch(0.39 0.09 153) */ + --cs-success-bg-hover: var(--cs-green-800); /* oklch(0.45 0.108 151) */ + + /* Status Colors - Warning (Dark Mode) */ + --cs-warning-base: var(--cs-amber-400); /* oklch(0.84 0.164 84) */ + --cs-warning-text-hover: var(--cs-amber-200); /* oklch(0.92 0.115 96) */ + --cs-warning-bg: var(--cs-amber-900); /* oklch(0.41 0.105 46) */ + --cs-warning-bg-hover: var(--cs-amber-800); /* oklch(0.47 0.125 46) */ + --cs-warning-active: var(--cs-amber-600); /* oklch(0.67 0.157 58) */ +} diff --git a/packages/ui/src/styles/tokens/index.css b/packages/ui/src/styles/tokens/index.css new file mode 100644 index 0000000000..83d23a8acc --- /dev/null +++ b/packages/ui/src/styles/tokens/index.css @@ -0,0 +1,23 @@ +/** + * CherryStudio Design Tokens + * + * Design tokens based on Figma design system + * - All variables use --cs-* prefix (CherryStudio) + * - Layered architecture: Primitive → Semantic + * - Properly separated Light/Dark Mode + * + * File structure: + * - colors/primitive.css: Base color palette + * - colors/semantic.css: Semantic color mappings + * - colors/status.css: Status colors (Error, Success, Warning) + * - spacing.css: Spacing and sizing + * - radius.css: Border radius + * - typography.css: Typography system + */ + +@import './colors/primitive.css'; +@import './colors/semantic.css'; +@import './colors/status.css'; +@import './spacing.css'; +@import './radius.css'; +@import './typography.css'; diff --git a/packages/ui/src/styles/tokens/radius.css b/packages/ui/src/styles/tokens/radius.css new file mode 100644 index 0000000000..4683f7b667 --- /dev/null +++ b/packages/ui/src/styles/tokens/radius.css @@ -0,0 +1,18 @@ +/** + * Border Radius + * 圆角半径 + */ + +:root { + --cs-radius-4xs: 0.25rem; /* 4px */ + --cs-radius-3xs: 0.5rem; /* 8px */ + --cs-radius-2xs: 0.75rem; /* 12px */ + --cs-radius-xs: 1rem; /* 16px */ + --cs-radius-sm: 1.5rem; /* 24px */ + --cs-radius-md: 2rem; /* 32px */ + --cs-radius-lg: 2.5rem; /* 40px */ + --cs-radius-xl: 3rem; /* 48px */ + --cs-radius-2xl: 3.5rem; /* 56px */ + --cs-radius-3xl: 4rem; /* 64px */ + --cs-radius-round: 999px; /* 完全圆角,保持 px */ +} diff --git a/packages/ui/src/styles/tokens/spacing.css b/packages/ui/src/styles/tokens/spacing.css new file mode 100644 index 0000000000..6d5d8d60fb --- /dev/null +++ b/packages/ui/src/styles/tokens/spacing.css @@ -0,0 +1,23 @@ +/** + * Spacing & Sizing + * 间距与尺寸 + */ + +:root { + --cs-size-5xs: 0.25rem; /* 4px */ + --cs-size-4xs: 0.5rem; /* 8px */ + --cs-size-3xs: 0.75rem; /* 12px */ + --cs-size-2xs: 1rem; /* 16px */ + --cs-size-xs: 1.5rem; /* 24px */ + --cs-size-sm: 2rem; /* 32px */ + --cs-size-md: 2.5rem; /* 40px */ + --cs-size-lg: 3rem; /* 48px */ + --cs-size-xl: 3.5rem; /* 56px */ + --cs-size-2xl: 4rem; /* 64px */ + --cs-size-3xl: 4.5rem; /* 72px */ + --cs-size-4xl: 5rem; /* 80px */ + --cs-size-5xl: 5.5rem; /* 88px */ + --cs-size-6xl: 6rem; /* 96px */ + --cs-size-7xl: 6.5rem; /* 104px */ + --cs-size-8xl: 7rem; /* 112px */ +} diff --git a/packages/ui/src/styles/tokens/typography.css b/packages/ui/src/styles/tokens/typography.css new file mode 100644 index 0000000000..a0c25db941 --- /dev/null +++ b/packages/ui/src/styles/tokens/typography.css @@ -0,0 +1,54 @@ +/** + * Typography + * 排版系统 - 字体、字号、行高、段落间距 + */ + +:root { + /* Font Families */ + --cs-font-family-heading: Inter; + --cs-font-family-body: Inter; + + /* Font Weights (修正单位错误) */ + --cs-font-weight-regular: 400; + --cs-font-weight-medium: 500; + --cs-font-weight-bold: 700; + + /* Font Sizes - Body */ + --cs-font-size-body-xs: 0.75rem; /* 12px */ + --cs-font-size-body-sm: 0.875rem; /* 14px */ + --cs-font-size-body-md: 1rem; /* 16px */ + --cs-font-size-body-lg: 1.125rem; /* 18px */ + + /* Font Sizes - Heading */ + --cs-font-size-heading-xs: 1.25rem; /* 20px */ + --cs-font-size-heading-sm: 1.5rem; /* 24px */ + --cs-font-size-heading-md: 2rem; /* 32px */ + --cs-font-size-heading-lg: 2.5rem; /* 40px */ + --cs-font-size-heading-xl: 3rem; /* 48px */ + --cs-font-size-heading-2xl: 3.75rem; /* 60px */ + + /* Line Heights - Body */ + --cs-line-height-body-xs: 1.25rem; /* 20px */ + --cs-line-height-body-sm: 1.5rem; /* 24px */ + --cs-line-height-body-md: 1.5rem; /* 24px */ + --cs-line-height-body-lg: 1.75rem; /* 28px */ + + /* Line Heights - Heading */ + --cs-line-height-heading-xs: 2rem; /* 32px */ + --cs-line-height-heading-sm: 2.5rem; /* 40px */ + --cs-line-height-heading-md: 3rem; /* 48px */ + --cs-line-height-heading-lg: 3.75rem; /* 60px */ + --cs-line-height-heading-xl: 5rem; /* 80px */ + + /* Paragraph Spacing */ + --cs-paragraph-spacing-body-xs: 0.75rem; /* 12px */ + --cs-paragraph-spacing-body-sm: 0.875rem; /* 14px */ + --cs-paragraph-spacing-body-md: 1rem; /* 16px */ + --cs-paragraph-spacing-body-lg: 1.125rem; /* 18px */ + --cs-paragraph-spacing-heading-xs: 1.25rem; /* 20px */ + --cs-paragraph-spacing-heading-sm: 1.5rem; /* 24px */ + --cs-paragraph-spacing-heading-md: 2rem; /* 32px */ + --cs-paragraph-spacing-heading-lg: 2.5rem; /* 40px */ + --cs-paragraph-spacing-heading-xl: 3rem; /* 48px */ + --cs-paragraph-spacing-heading-2xl: 3.75rem; /* 60px */ +} diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts new file mode 100644 index 0000000000..6b44ed9856 --- /dev/null +++ b/packages/ui/src/types/index.ts @@ -0,0 +1,15 @@ +/** + * Makes specified properties required while keeping others as is + * @template T - The object type to modify + * @template K - Keys of T that should be required + * @example + * type User = { + * name?: string; + * age?: number; + * } + * + * type UserWithName = RequireSome + * // Result: { name: string; age?: number; } + */ +// The type is copied from src/renderer/src/types/index.ts. +export type RequireSome = Omit & Required> diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts new file mode 100644 index 0000000000..8a273ad834 --- /dev/null +++ b/packages/ui/src/utils/index.ts @@ -0,0 +1,10 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** + * Merge class names with tailwind-merge + * This utility combines clsx and tailwind-merge for optimal class name handling + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/ui/stories/README.md b/packages/ui/stories/README.md new file mode 100644 index 0000000000..286c2307b4 --- /dev/null +++ b/packages/ui/stories/README.md @@ -0,0 +1,41 @@ +# Stories 文档 + +这里存放所有组件的 Storybook stories 文件,与源码分离以保持项目结构清晰。 + +## 目录结构 + +``` +stories/ +├── components/ +│ ├── base/ # 基础组件 stories +│ ├── display/ # 展示组件 stories +│ ├── interactive/ # 交互组件 stories +│ ├── icons/ # 图标组件 stories +│ ├── layout/ # 布局组件 stories +│ └── composite/ # 复合组件 stories +└── README.md # 本说明文件 +``` + +## 命名约定 + +- 文件名格式:`ComponentName.stories.tsx` +- Story 标题格式:`分类/组件名`,如 `Base/CustomTag` +- 导入路径:使用相对路径导入源码组件,如 `../../../src/components/base/ComponentName` + +## 编写指南 + +每个 stories 文件应该包含: + +1. **Default** - 基本用法示例 +2. **Variants** - 不同变体/状态 +3. **Interactive** - 交互行为演示(如果适用) +4. **Use Cases** - 实际使用场景 + +## 启动 Storybook + +```bash +cd packages/ui +yarn storybook +``` + +访问 http://localhost:6006 查看组件文档。 diff --git a/packages/ui/stories/components/composites/CodeEditor.stories.tsx b/packages/ui/stories/components/composites/CodeEditor.stories.tsx new file mode 100644 index 0000000000..c767d85db5 --- /dev/null +++ b/packages/ui/stories/components/composites/CodeEditor.stories.tsx @@ -0,0 +1,195 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { action } from 'storybook/actions' + +import { CodeEditor, getCmThemeByName, getCmThemeNames } from '../../../src/components' +import type { LanguageConfig } from '../../../src/components/composites/CodeEditor/types' + +// 示例语言配置 - 为 Storybook 提供更丰富的语言支持演示 +const exampleLanguageConfig: LanguageConfig = { + JavaScript: { + type: 'programming', + extensions: ['.js', '.mjs', '.cjs'], + aliases: ['js', 'node'] + }, + TypeScript: { + type: 'programming', + extensions: ['.ts'], + aliases: ['ts'] + }, + Python: { + type: 'programming', + extensions: ['.py'], + aliases: ['python3', 'py'] + }, + JSON: { + type: 'data', + extensions: ['.json'] + }, + Markdown: { + type: 'prose', + extensions: ['.md', '.markdown'], + aliases: ['md'] + }, + HTML: { + type: 'markup', + extensions: ['.html', '.htm'] + }, + CSS: { + type: 'markup', + extensions: ['.css'] + }, + 'Graphviz (DOT)': { + type: 'data', + extensions: ['.dot', '.gv'], + aliases: ['dot', 'graphviz'] + }, + Mermaid: { + type: 'markup', + extensions: ['.mmd', '.mermaid'], + aliases: ['mmd'] + } +} + +const meta: Meta = { + title: 'Components/Composites/CodeEditor', + component: CodeEditor, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + argTypes: { + language: { + control: 'select', + options: ['typescript', 'javascript', 'json', 'markdown', 'python', 'dot', 'mmd', 'go', 'rust', 'php'] + }, + theme: { + control: 'select', + options: getCmThemeNames() + }, + fontSize: { control: { type: 'range', min: 12, max: 22, step: 1 } }, + editable: { control: 'boolean' }, + readOnly: { control: 'boolean' }, + expanded: { control: 'boolean' }, + wrapped: { control: 'boolean' }, + height: { control: 'text' }, + maxHeight: { control: 'text' }, + minHeight: { control: 'text' }, + languageConfig: { + control: false, + description: 'Optional language configuration. If not provided, uses built-in defaults.' + } + } +} + +export default meta +type Story = StoryObj + +// 基础示例(非流式) +export const Default: Story = { + args: { + language: 'typescript', + theme: 'light', + value: `function greet(name: string) {\n return 'Hello ' + name\n}`, + fontSize: 16, + editable: true, + readOnly: false, + expanded: true, + wrapped: true + }, + render: (args) => ( +
+ +
+ ) +} + +// JSON + Lint(非流式) +export const JSONLint: Story = { + args: { + language: 'json', + theme: 'light', + value: `{\n "valid": true,\n "missingComma": true\n "another": 123\n}`, + wrapped: true + }, + render: (args) => ( +
+ +
+ ) +} + +// 保存快捷键(Mod/Ctrl + S 触发 onSave) +export const SaveShortcut: Story = { + args: { + language: 'markdown', + theme: 'light', + value: `# Press Mod/Ctrl + S to fire onSave`, + wrapped: true + }, + render: (args) => ( +
+ +

使用 Mod/Ctrl + S 触发保存事件。

+
+ ) +} + +// 使用默认语言配置(展示组件的独立性) +export const DefaultLanguageConfig: Story = { + args: { + language: 'javascript', + theme: 'light', + value: `// 这个示例使用内置的默认语言配置 +function fibonacci(n) { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +console.log(fibonacci(10));`, + wrapped: true + }, + render: (args) => ( +
+ +

此示例未传入 languageConfig,使用组件内置的默认语言配置。

+
+ ) +} diff --git a/packages/ui/stories/components/composites/Ellipsis.stories.tsx b/packages/ui/stories/components/composites/Ellipsis.stories.tsx new file mode 100644 index 0000000000..c276826b96 --- /dev/null +++ b/packages/ui/stories/components/composites/Ellipsis.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Ellipsis } from '../../../src/components' + +const meta = { + title: 'Components/Composites/Ellipsis', + component: Ellipsis, + parameters: { + layout: 'centered', + docs: { + description: { + component: '一个用于显示省略文本的组件,支持单行和多行省略功能。' + } + } + }, + tags: ['autodocs'], + argTypes: { + maxLine: { + control: { type: 'number' }, + description: '最大显示行数,默认为1。设置为1时为单行省略,大于1时为多行省略。' + }, + className: { + control: { type: 'text' }, + description: '自定义 CSS 类名' + }, + children: { + control: { type: 'text' }, + description: '要显示的文本内容' + } + }, + args: { + children: '这是一段很长的文本内容,用于演示省略功能的效果。当文本超出容器宽度或高度时,会自动显示省略号。' + } +} satisfies Meta + +export default meta +type Story = StoryObj + +// 默认单行省略 +export const Default: Story = { + args: { + maxLine: 1 + }, + render: (args) => ( +
+ +
+ ) +} + +// 多行省略 +export const MultiLine: Story = { + args: { + maxLine: 3, + children: + '这是一段很长的文本内容,用于演示多行省略功能的效果。当文本内容超过指定的最大行数时,会在最后一行的末尾显示省略号。这个功能特别适用于显示文章摘要、商品描述等需要限制显示行数的场景。' + }, + render: (args) => ( +
+ +
+ ) +} + +// 不同的最大行数 +export const DifferentMaxLines: Story = { + render: () => ( +
+
+

单行省略 (maxLine = 1)

+
+ 这是一段很长的文本内容,用于演示单行省略功能的效果。 +
+
+ +
+

两行省略 (maxLine = 2)

+
+ + 这是一段很长的文本内容,用于演示两行省略功能的效果。当文本内容超过两行时,会在第二行的末尾显示省略号。 + +
+
+ +
+

三行省略 (maxLine = 3)

+
+ + 这是一段很长的文本内容,用于演示三行省略功能的效果。当文本内容超过三行时,会在第三行的末尾显示省略号。这个功能特别适用于显示文章摘要、商品描述等需要限制显示行数的场景。 + +
+
+
+ ) +} + +// 短文本(不需要省略) +export const ShortText: Story = { + args: { + maxLine: 2, + children: '这是一段短文本。' + }, + render: (args) => ( +
+ +
+ ) +} + +// 自定义样式 +export const CustomStyle: Story = { + args: { + maxLine: 2, + className: 'text-blue-600 font-medium text-lg', + children: '这是一段带有自定义样式的长文本内容,用于演示如何自定义省略文本的样式。' + }, + render: (args) => ( +
+ +
+ ) +} + +// 不同容器宽度的响应式展示 +export const ResponsiveWidth: Story = { + render: () => ( +
+
+

窄容器 (200px)

+
+ 这是一段在窄容器中显示的文本内容,用于演示在不同宽度下的省略效果。 +
+
+ +
+

中等容器 (300px)

+
+ 这是一段在中等宽度容器中显示的文本内容,用于演示在不同宽度下的省略效果。 +
+
+ +
+

宽容器 (400px)

+
+ 这是一段在宽容器中显示的文本内容,用于演示在不同宽度下的省略效果。 +
+
+
+ ) +} + +// 包含HTML内容 +export const WithHTMLContent: Story = { + args: { + maxLine: 2 + }, + render: (args) => ( +
+ + 这是红色文本加粗文本 + 以及 + 斜体文本 + 组合的长文本内容,用于演示包含HTML元素的省略效果。 + +
+ ) +} diff --git a/packages/ui/stories/components/composites/ExpandableText.stories.tsx b/packages/ui/stories/components/composites/ExpandableText.stories.tsx new file mode 100644 index 0000000000..67d8adb6cf --- /dev/null +++ b/packages/ui/stories/components/composites/ExpandableText.stories.tsx @@ -0,0 +1,201 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { ExpandableText } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Composites/ExpandableText', + component: ExpandableText, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + text: { + control: 'text', + description: '要显示的文本内容' + }, + expandText: { + control: 'text', + description: '展开按钮文本', + defaultValue: 'Expand' + }, + collapseText: { + control: 'text', + description: '收起按钮文本', + defaultValue: 'Collapse' + }, + lineClamp: { + control: { type: 'range', min: 1, max: 5, step: 1 }, + description: '收起时显示的行数', + defaultValue: 1 + }, + className: { + control: 'text', + description: '自定义类名' + } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +const longText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.' + +const chineseText = + '这是一段很长的中文文本内容,用于测试文本展开和收起功能。当文本内容超过指定的行数限制时,会显示省略号,用户可以点击展开按钮查看完整内容,也可以点击收起按钮将文本重新收起。这个组件在显示长文本内容时非常有用。' + +// 基础用法 +export const Default: Story = { + args: { + text: longText, + expandText: 'Expand', + collapseText: 'Collapse' + } +} + +// 单行省略 +export const SingleLine: Story = { + args: { + text: longText, + lineClamp: 1 + } +} + +// 多行省略 +export const MultiLine: Story = { + render: (args) => ( +
+
+

显示 2 行

+ +
+
+

显示 3 行

+ +
+
+

显示 4 行

+ +
+
+ ) +} + +// 中文文本 +export const ChineseText: Story = { + args: { + text: chineseText, + expandText: '展开', + collapseText: '收起', + lineClamp: 2 + } +} + +// 短文本(不需要展开) +export const ShortText: Story = { + args: { + text: 'This is a short text.', + lineClamp: 1 + } +} + +// 自定义按钮文本 +export const CustomButtonText: Story = { + args: { + text: longText, + expandText: 'Show More', + collapseText: 'Show Less', + lineClamp: 2 + } +} + +// 不同语言示例 +export const Multilingual: Story = { + render: (args) => ( +
+
+

English

+ +
+
+

中文

+ +
+
+

日本語

+ +
+
+ ) +} + +// 在卡片中使用 +export const InCard: Story = { + render: (args) => ( +
+

Article Title

+

Published on December 1, 2024

+ +
+ ) +} + +// 列表项中使用 +export const InList: Story = { + render: (args) => ( +
+ {[ + { + title: 'First Item', + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.' + }, + { + title: 'Second Item', + text: 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.' + }, + { + title: 'Third Item', + text: 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.' + } + ].map((item, index) => ( +
+

{item.title}

+ +
+ ))} +
+ ) +} + +// 自定义样式 +export const CustomStyle: Story = { + args: { + text: longText, + lineClamp: 2, + className: 'bg-blue-50 p-4 rounded-lg', + style: { fontStyle: 'italic' } + } +} diff --git a/packages/ui/stories/components/composites/HorizontalScrollContainer.stories.tsx b/packages/ui/stories/components/composites/HorizontalScrollContainer.stories.tsx new file mode 100644 index 0000000000..321f5d3379 --- /dev/null +++ b/packages/ui/stories/components/composites/HorizontalScrollContainer.stories.tsx @@ -0,0 +1,215 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' + +import { HorizontalScrollContainer } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Composites/HorizontalScrollContainer', + component: HorizontalScrollContainer, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + scrollDistance: { control: { type: 'range', min: 50, max: 500, step: 50 } }, + gap: { control: 'text' }, + expandable: { control: 'boolean' } + } +} + +export default meta +type Story = StoryObj + +// Default example +export const Default: Story = { + args: { + children: ( +
+ {Array.from({ length: 20 }, (_, i) => ( +
+ Item {i + 1} +
+ ))} +
+ ), + scrollDistance: 200 + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// With Tags +export const WithTags: Story = { + args: { + children: ( + <> + {[ + 'React', + 'TypeScript', + 'JavaScript', + 'HTML', + 'CSS', + 'Node.js', + 'Express', + 'MongoDB', + 'PostgreSQL', + 'Docker', + 'Kubernetes', + 'AWS', + 'Azure', + 'GraphQL', + 'REST API' + ].map((tag) => ( + + {tag} + + ))} + + ), + gap: '8px' + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// Expandable +export const Expandable: Story = { + args: { + expandable: true, + children: ( + <> + {['Frontend', 'Backend', 'DevOps', 'Mobile', 'Desktop', 'Web', 'Cloud', 'Database', 'Security', 'Testing'].map( + (category) => ( +
+ {category} +
+ ) + )} + + ), + gap: '10px' + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// With Cards +export const WithCards: Story = { + args: { + scrollDistance: 300, + gap: '16px', + children: ( + <> + {Array.from({ length: 10 }, (_, i) => ( +
+

Card {i + 1}

+

This is a sample card content for demonstration purposes.

+
+ ))} + + ) + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// Interactive Example +export const Interactive: Story = { + render: function InteractiveExample() { + const [items, setItems] = useState([ + 'Apple', + 'Banana', + 'Cherry', + 'Date', + 'Elderberry', + 'Fig', + 'Grape', + 'Honeydew', + 'Kiwi', + 'Lemon', + 'Mango', + 'Orange' + ]) + + return ( +
+ + {items.map((item) => ( +
alert(`Clicked: ${item}`)}> + {item} +
+ ))} +
+ +
+ ) + } +} + +// Different Gaps +export const DifferentGaps: Story = { + render: () => ( +
+
+

Small Gap (4px)

+ + {Array.from({ length: 15 }, (_, i) => ( + + Item {i + 1} + + ))} + +
+ +
+

Medium Gap (12px)

+ + {Array.from({ length: 15 }, (_, i) => ( + + Item {i + 1} + + ))} + +
+ +
+

Large Gap (20px)

+ + {Array.from({ length: 15 }, (_, i) => ( + + Item {i + 1} + + ))} + +
+
+ ) +} diff --git a/packages/ui/stories/components/composites/ListItem.stories.tsx b/packages/ui/stories/components/composites/ListItem.stories.tsx new file mode 100644 index 0000000000..fa2fad13b5 --- /dev/null +++ b/packages/ui/stories/components/composites/ListItem.stories.tsx @@ -0,0 +1,326 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { + ChevronRight, + Edit, + File, + Folder, + Heart, + Mail, + MoreHorizontal, + Phone, + Settings, + Star, + Trash2, + User +} from 'lucide-react' +import { action } from 'storybook/actions' + +import { Button, ListItem } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Composites/ListItem', + component: ListItem, + parameters: { + layout: 'centered', + docs: { + description: { + component: '一个通用的列表项组件,支持图标、标题、副标题、激活状态和右侧内容等功能。' + } + } + }, + tags: ['autodocs'], + argTypes: { + active: { + control: { type: 'boolean' }, + description: '是否处于激活状态,激活时会显示高亮样式' + }, + icon: { + control: { type: 'text' }, + description: '左侧图标,可以是任何 React 节点' + }, + title: { + control: { type: 'text' }, + description: '标题内容,必填字段,可以是文本或 React 节点' + }, + subtitle: { + control: { type: 'text' }, + description: '副标题内容,显示在标题下方' + }, + titleStyle: { + control: { type: 'object' }, + description: '标题的自定义样式对象' + }, + onClick: { + action: 'clicked', + description: '点击事件处理函数' + }, + rightContent: { + control: { type: 'text' }, + description: '右侧内容,可以是任何 React 节点' + }, + className: { + control: { type: 'text' }, + description: '自定义 CSS 类名' + } + }, + args: { + title: '列表项标题', + onClick: action('clicked') + } +} satisfies Meta + +export default meta +type Story = StoryObj + +// 默认样式 +export const Default: Story = { + args: { + title: '默认列表项' + }, + render: (args: any) => ( +
+ +
+ ) +} + +// 带图标 +export const WithIcon: Story = { + args: { + icon: , + title: '带图标的列表项', + subtitle: '这是一个副标题' + }, + render: (args: any) => ( +
+ +
+ ) +} + +// 激活状态 +export const Active: Story = { + args: { + icon: , + title: '激活状态的列表项', + subtitle: '当前选中项', + active: true + }, + render: (args: any) => ( +
+ +
+ ) +} + +// 带右侧内容 +export const WithRightContent: Story = { + args: { + icon: , + title: '带右侧内容的列表项', + subtitle: '右侧有附加信息', + rightContent: + }, + render: (args: any) => ( +
+ +
+ ) +} + +// 多种图标类型 +export const DifferentIcons: Story = { + render: () => ( +
+ } + title="文件项" + subtitle="文档文件" + onClick={action('file-clicked')} + /> + } + title="文件夹项" + subtitle="目录文件夹" + onClick={action('folder-clicked')} + /> + } + title="用户项" + subtitle="用户信息" + onClick={action('user-clicked')} + /> + } + title="设置项" + subtitle="系统设置" + onClick={action('settings-clicked')} + /> +
+ ) +} + +// 不同长度的标题和副标题 +export const DifferentContentLength: Story = { + render: () => ( +
+ } title="短标题" subtitle="短副标题" /> + } + title="这是一个比较长的标题,可能会被截断" + subtitle="这也是一个比较长的副标题,用于测试文本溢出效果" + /> + } + title="超级长的标题内容用于测试文本省略功能,当标题过长时会自动截断并显示省略号" + subtitle="超级长的副标题内容用于测试文本省略功能,当副标题过长时也会自动截断" + /> +
+ ) +} + +// 不同的右侧内容类型 +export const DifferentRightContent: Story = { + render: () => ( +
+ } + title="带箭头" + subtitle="导航类型" + rightContent={} + /> + } + title="带按钮" + subtitle="操作类型" + rightContent={ + + } + /> + } + title="带文本" + subtitle="信息显示" + rightContent={在线} + /> + } + title="带多个操作" + subtitle="复合操作" + rightContent={ +
+ + +
+ } + /> +
+ ) +} + +// 激活状态对比 +export const ActiveComparison: Story = { + render: () => ( +
+

普通状态

+ } + title="普通列表项" + subtitle="未激活状态" + rightContent={} + /> + +

激活状态

+ } + title="激活列表项" + subtitle="当前选中状态" + active={true} + rightContent={} + /> +
+ ) +} + +// 自定义标题样式 +export const CustomTitleStyle: Story = { + render: () => ( +
+ } + title="红色标题" + subtitle="自定义颜色" + titleStyle={{ color: '#ef4444', fontWeight: 'bold' }} + /> + } + title="大号标题" + subtitle="自定义大小" + titleStyle={{ fontSize: '16px', fontWeight: '600' }} + /> + } + title="斜体标题" + subtitle="自定义样式" + titleStyle={{ fontStyle: 'italic', color: '#6366f1' }} + /> +
+ ) +} + +// 无副标题 +export const WithoutSubtitle: Story = { + render: () => ( +
+ } title="只有标题的列表项" /> + } + title="另一个只有标题的项" + rightContent={} + /> +
+ ) +} + +// 无图标 +export const WithoutIcon: Story = { + render: () => ( +
+ + 标签} + /> +
+ ) +} + +// 完整功能展示 +export const FullFeatures: Story = { + render: () => ( +
+ } + title="完整功能展示" + subtitle="包含所有功能的列表项" + titleStyle={{ fontWeight: '600' }} + active={true} + rightContent={ +
+ NEW + +
+ } + onClick={action('full-features-clicked')} + className="hover:shadow-sm transition-shadow" + /> +
+ ) +} diff --git a/packages/ui/stories/components/composites/MaxContextCount.stories.tsx b/packages/ui/stories/components/composites/MaxContextCount.stories.tsx new file mode 100644 index 0000000000..e1bf06c11d --- /dev/null +++ b/packages/ui/stories/components/composites/MaxContextCount.stories.tsx @@ -0,0 +1,300 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { MaxContextCount } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Composites/MaxContextCount', + component: MaxContextCount, + parameters: { + layout: 'centered', + docs: { + description: { + component: + '⚠️ **已废弃** - 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。此组件与业务逻辑耦合,不适合通用 UI 库。\n\n一个用于显示最大上下文数量的组件。当数量达到100时显示无限符号,否则显示具体数字。' + } + } + }, + tags: ['autodocs', 'deprecated'], + argTypes: { + maxContext: { + control: { type: 'number', min: 0, max: 100, step: 1 }, + description: '最大上下文数量。当值为100时显示无限符号(∞),其他值显示具体数字。' + }, + size: { + control: { type: 'number', min: 8, max: 48, step: 2 }, + description: '图标大小,默认为14像素' + }, + className: { + control: { type: 'text' }, + description: '自定义 CSS 类名' + }, + style: { + control: { type: 'object' }, + description: '自定义样式对象' + } + }, + args: { + maxContext: 10, + size: 14 + } +} satisfies Meta + +export default meta +type Story = StoryObj + +// 默认数字显示 +export const Default: Story = { + args: { + maxContext: 10 + }, + render: (args) => ( +
+ 最大上下文: + +
+ ) +} + +// 无限符号显示 +export const InfinitySymbol: Story = { + args: { + maxContext: 100 + }, + render: (args) => ( +
+ 最大上下文: + +
+ ) +} + +// 不同的数值范围 +export const DifferentValues: Story = { + render: () => ( +
+
+
+
小数值
+
+ 上下文: + +
+
+ +
+
中等数值
+
+ 上下文: + +
+
+ +
+
大数值
+
+ 上下文: + +
+
+ +
+
无限
+
+ 上下文: + +
+
+
+
+ ) +} + +// 不同大小 +export const DifferentSizes: Story = { + render: () => ( +
+
+
+ 小号 (12px): + +
+
+ +
+
+ 默认 (14px): + +
+
+ +
+
+ 中号 (18px): + +
+
+ +
+
+ 大号 (24px): + +
+
+
+ ) +} + +// 无限符号不同大小对比 +export const InfinityDifferentSizes: Story = { + render: () => ( +
+

无限符号不同大小对比

+
+
+ 12px: + +
+
+ 16px: + +
+
+ 20px: + +
+
+ 28px: + +
+
+
+ ) +} + +// 自定义样式 +export const CustomStyles: Story = { + render: () => ( +
+
+ 红色数字: + +
+ +
+ 蓝色无限符号: + +
+ +
+ 带背景: + +
+ +
+ 带边框: + +
+
+ ) +} + +// 在实际使用场景中的展示 +export const InRealScenarios: Story = { + render: () => ( +
+
+
+ AI 模型配置 +
+
+
+ 模型: + GPT-4 +
+
+ 温度: + 0.7 +
+
+ 最大上下文: + +
+
+
+ +
+
+ 对话设置 +
+
+
+ 记忆长度: + +
+
+ 历史消息: + +
+
+
+
+ ) +} + +// 边界值测试 +export const EdgeCases: Story = { + render: () => ( +
+

边界值测试

+
+
+
零值
+ +
+ +
+
临界值 99
+ +
+ +
+
临界值 100
+ +
+
+
+ ) +} + +// 深色主题下的表现 +export const DarkTheme: Story = { + parameters: { + backgrounds: { default: 'dark' } + }, + render: () => ( +
+

深色主题下的表现

+
+
+ 普通数字: + +
+
+ 无限符号: + +
+
+ 自定义颜色: + +
+
+
+ ) +} diff --git a/packages/ui/stories/components/composites/Scrollbar.stories.tsx b/packages/ui/stories/components/composites/Scrollbar.stories.tsx new file mode 100644 index 0000000000..57f8868b65 --- /dev/null +++ b/packages/ui/stories/components/composites/Scrollbar.stories.tsx @@ -0,0 +1,258 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { Scrollbar } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Composites/Scrollbar', + component: Scrollbar, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'] +} + +export default meta +type Story = StoryObj + +// Default example +export const Default: Story = { + args: { + children: ( +
+ {Array.from({ length: 50 }, (_, i) => ( +

+ Line {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. +

+ ))} +
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// With Cards +export const WithCards: Story = { + args: { + children: ( +
+ {Array.from({ length: 20 }, (_, i) => ( +
+

Card {i + 1}

+

+ This is a sample card with some content to demonstrate scrolling behavior. +

+
+ ))} +
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// Horizontal Layout +export const HorizontalContent: Story = { + args: { + children: ( +
+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ Column {i + 1} +
+ ))} +
+ {Array.from({ length: 30 }, (_, i) => ( +

+ Row {i + 1}: Additional content to enable vertical scrolling +

+ ))} +
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// Interactive List +export const InteractiveList: Story = { + render: () => { + const handleScroll = () => { + console.log('Scrolling...') + } + + return ( +
+ +
+ {Array.from({ length: 30 }, (_, i) => ( +
alert(`Clicked item ${i + 1}`)}> + Interactive Item {i + 1} +
+ ))} +
+
+
+ ) + } +} + +// Code Block +export const CodeBlock: Story = { + args: { + children: ( +
+        {`function calculateTotal(items) {
+  let total = 0;
+
+  for (const item of items) {
+    if (item.price && item.quantity) {
+      total += item.price * item.quantity;
+    }
+  }
+
+  return total;
+}
+
+const items = [
+  { name: 'Apple', price: 0.5, quantity: 10 },
+  { name: 'Banana', price: 0.3, quantity: 15 },
+  { name: 'Orange', price: 0.6, quantity: 8 },
+  { name: 'Grape', price: 2.0, quantity: 3 },
+  { name: 'Watermelon', price: 5.0, quantity: 1 }
+];
+
+const totalCost = calculateTotal(items);
+console.log('Total cost:', totalCost);
+
+// More code to demonstrate scrolling
+class ShoppingCart {
+  constructor() {
+    this.items = [];
+  }
+
+  addItem(item) {
+    this.items.push(item);
+  }
+
+  removeItem(name) {
+    this.items = this.items.filter(item => item.name !== name);
+  }
+
+  getTotal() {
+    return calculateTotal(this.items);
+  }
+
+  checkout() {
+    const total = this.getTotal();
+    if (total > 0) {
+      console.log('Processing payment...');
+      return true;
+    }
+    return false;
+  }
+}`}
+      
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} + +// Long Article +export const LongArticle: Story = { + args: { + children: ( +
+

The Art of Scrolling

+ +

+ Scrolling is a fundamental interaction pattern in user interfaces. It allows users to navigate through content + that exceeds the visible viewport, making it possible to present large amounts of information in a limited + space. +

+ +

History of Scrolling

+

+ The concept of scrolling dates back to the early days of computing, when terminal displays could only show a + limited number of lines. As content grew beyond what could fit on a single screen, the need for scrolling + became apparent. +

+ +

Types of Scrolling

+
    +
  • Vertical Scrolling - The most common type
  • +
  • Horizontal Scrolling - Often used for timelines and galleries
  • +
  • Infinite Scrolling - Continuously loads new content
  • +
  • Parallax Scrolling - Creates depth through different scroll speeds
  • +
+ +

Best Practices

+

When implementing scrolling in your applications, consider the following best practices:

+ +
    +
  1. Always provide visual feedback for scrollable areas
  2. +
  3. Ensure scroll performance is smooth and responsive
  4. +
  5. Consider keyboard navigation for accessibility
  6. +
  7. Use appropriate scroll indicators
  8. +
  9. Test on various devices and screen sizes
  10. +
+ +

+ Modern web technologies have made it easier than ever to implement sophisticated scrolling behaviors. CSS + properties like scroll-behavior and overscroll-behavior provide fine-grained control over the scrolling + experience. +

+ +

Performance Considerations

+

+ Scrolling performance is crucial for user experience. Poor scrolling performance can make an application feel + sluggish and unresponsive. Key factors affecting scroll performance include: +

+ +
    +
  • DOM complexity and size
  • +
  • CSS animations and transforms
  • +
  • JavaScript event handlers
  • +
  • Image loading and rendering
  • +
+ +

+ To optimize scrolling performance, consider using techniques like virtual scrolling for large lists, + debouncing scroll event handlers, and leveraging CSS transforms for animations. +

+
+ ) + }, + decorators: [ + (Story) => ( +
+ +
+ ) + ] +} diff --git a/packages/ui/stories/components/composites/Sortable.stories.tsx b/packages/ui/stories/components/composites/Sortable.stories.tsx new file mode 100644 index 0000000000..e3a1744ca8 --- /dev/null +++ b/packages/ui/stories/components/composites/Sortable.stories.tsx @@ -0,0 +1,185 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { clsx } from 'clsx' +import { useMemo, useState } from 'react' + +import { Sortable } from '../../../src/components' +import { useDndReorder } from '../../../src/hooks' + +type ExampleItem = { id: number; label: string } + +const initialItems: ExampleItem[] = Array.from({ length: 18 }).map((_, i) => ({ + id: i + 1, + label: `Item ${i + 1}` +})) + +const meta: Meta = { + title: 'Components/Composites/Sortable', + component: Sortable, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'A basic drag-and-drop sorting component that supports vertical/horizontal lists and grid layout. Each demo includes a search box to filter items, and useDndReorder ensures drags in the filtered view correctly update the original list order.' + } + } + }, + tags: ['autodocs'], + argTypes: { + gap: { control: 'text', description: 'CSS gap value, e.g., 8px, 0.5rem, 12px' }, + useDragOverlay: { control: 'boolean' }, + showGhost: { control: 'boolean' } + }, + args: { + gap: '8px', + useDragOverlay: true, + showGhost: false + } +} + +export default meta +type Story = StoryObj + +function useExampleData() { + const [originalList, setOriginalList] = useState(initialItems) + const [query, setQuery] = useState('') + + const filteredList = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return originalList + return originalList.filter((x) => x.label.toLowerCase().includes(q)) + }, [query, originalList]) + + const { onSortEnd } = useDndReorder({ + originalList, + filteredList, + onUpdate: setOriginalList, + itemKey: 'id' + }) + + return { originalList, setOriginalList, query, setQuery, filteredList, onSortEnd } +} + +function ItemCard({ item, dragging }: { item: ExampleItem; dragging: boolean }) { + return ( +
+
{item.label}
+
+ ) +} + +export const Vertical: Story = { + render: (args) => +} + +export const Horizontal: Story = { + render: (args) => +} + +export const Grid: Story = { + render: (args) => +} + +function VerticalDemo(args: any) { + const { query, setQuery, filteredList, onSortEnd } = useExampleData() + + return ( +
+ setQuery(e.target.value)} + placeholder="Search (fuzzy match label)" + className="w-full rounded-md border px-3 py-2 text-sm" + /> + +
+ + items={filteredList} + itemKey="id" + onSortEnd={onSortEnd} + layout="list" + horizontal={false} + gap={args.gap as string} + useDragOverlay={args.useDragOverlay as boolean} + showGhost={args.showGhost as boolean} + renderItem={(item, { dragging }) => ( +
+ +
+ )} + /> +
+ +

+ Dragging within a filtered view correctly updates the original order (handled by useDndReorder). +

+
+ ) +} + +function HorizontalDemo(args: any) { + const { query, setQuery, filteredList, onSortEnd } = useExampleData() + + return ( +
+ setQuery(e.target.value)} + placeholder="Search (fuzzy match label)" + className="w-full rounded-md border px-3 py-2 text-sm" + /> + +
+ + items={filteredList} + itemKey="id" + onSortEnd={onSortEnd} + layout="list" + horizontal + gap={args.gap as string} + useDragOverlay={args.useDragOverlay as boolean} + showGhost={args.showGhost as boolean} + renderItem={(item, { dragging }) => ( +
+ +
+ )} + /> +
+ +

Horizontal dragging with overflow scrolling.

+
+ ) +} + +function GridDemo(args: any) { + const { query, setQuery, filteredList, onSortEnd } = useExampleData() + + return ( +
+ setQuery(e.target.value)} + placeholder="Search (fuzzy match label)" + className="w-full rounded-md border px-3 py-2 text-sm" + /> + + + items={filteredList} + itemKey="id" + onSortEnd={onSortEnd} + layout="grid" + gap={(args.gap as string) ?? '12px'} + useDragOverlay={args.useDragOverlay as boolean} + showGhost={args.showGhost as boolean} + renderItem={(item, { dragging }) => } + /> + +

Responsive grid layout with drag-and-drop sorting.

+
+ ) +} diff --git a/packages/ui/stories/components/composites/ThinkingEffect.stories.tsx b/packages/ui/stories/components/composites/ThinkingEffect.stories.tsx new file mode 100644 index 0000000000..a27927f936 --- /dev/null +++ b/packages/ui/stories/components/composites/ThinkingEffect.stories.tsx @@ -0,0 +1,400 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '../../../src/components' +import { ThinkingEffect } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Composites/ThinkingEffect', + component: ThinkingEffect, + parameters: { + layout: 'centered', + docs: { + description: { + component: + '⚠️ **已废弃** - 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。此组件是 AI 思考特效,可能需要保留在主项目中而不是 UI 库。\n\n一个用于显示AI思考过程的动画组件,包含灯泡动画、思考内容滚动展示和展开收缩功能。' + } + } + }, + tags: ['autodocs', 'deprecated'], + argTypes: { + isThinking: { + control: { type: 'boolean' }, + description: '是否正在思考,控制动画状态和内容显示' + }, + thinkingTimeText: { + control: { type: 'text' }, + description: '思考时间文本,显示在组件顶部' + }, + content: { + control: { type: 'text' }, + description: '思考内容,多行文本用换行符分隔,最后一行在思考时会被过滤' + }, + expanded: { + control: { type: 'boolean' }, + description: '是否展开状态,影响组件的显示样式' + }, + className: { + control: { type: 'text' }, + description: '自定义 CSS 类名' + } + }, + args: { + isThinking: true, + thinkingTimeText: '思考中...', + content: `正在分析问题\n寻找最佳解决方案\n整理思路和逻辑\n准备回答`, + expanded: false + } +} satisfies Meta + +export default meta +type Story = StoryObj + +// 默认思考状态 +export const Default: Story = { + args: { + isThinking: true, + thinkingTimeText: '思考中 2s', + content: `正在分析用户的问题\n查找相关信息\n整理回答思路`, + expanded: false + }, + render: (args) => ( +
+ +
+ ) +} + +// 非思考状态(静止) +export const NotThinking: Story = { + args: { + isThinking: false, + thinkingTimeText: '思考完成', + content: `已完成思考\n找到最佳答案\n准备响应`, + expanded: false + }, + render: (args) => ( +
+ +
+ ) +} + +// 展开状态 +export const Expanded: Story = { + args: { + isThinking: false, + thinkingTimeText: '思考用时 5s', + content: `第一步:理解问题本质\n第二步:分析可能的解决方案\n第三步:评估各方案的优缺点\n第四步:选择最优方案\n第五步:构建详细回答`, + expanded: true + }, + render: (args) => ( +
+ +
+ ) +} + +// 交互式演示 +export const Interactive: Story = { + render: function Render() { + const [isThinking, setIsThinking] = useState(false) + const [expanded, setExpanded] = useState(false) + const [thinkingTime, setThinkingTime] = useState(0) + + const thinkingSteps = useMemo(() => { + return [ + '开始分析问题...', + '查找相关资料和信息', + '对比不同的解决方案', + '评估方案的可行性', + '选择最佳解决路径', + '构建完整的回答框架', + '检查逻辑的连贯性', + '优化回答的表达方式' + ] + }, []) + + const [content, setContent] = useState('') + + useEffect(() => { + let interval: NodeJS.Timeout + if (isThinking) { + setThinkingTime(0) + setContent(thinkingSteps[0]) + + interval = setInterval(() => { + setThinkingTime((prev) => { + const newTime = prev + 1 + const stepIndex = Math.min(Math.floor(newTime / 2), thinkingSteps.length - 1) + const currentSteps = thinkingSteps.slice(0, stepIndex + 1) + setContent(currentSteps.join('\n')) + return newTime + }) + }, 1000) + } + + return () => { + if (interval) clearInterval(interval) + } + }, [isThinking, thinkingSteps]) + + const handleStartThinking = () => { + setIsThinking(true) + setExpanded(false) + } + + const handleStopThinking = () => { + setIsThinking(false) + } + + const handleToggleExpanded = () => { + setExpanded(!expanded) + } + + return ( +
+
+ + + +
+ + +
+ ) + } +} + +// 不同内容长度 +export const DifferentContentLength: Story = { + render: () => ( +
+
+

短内容

+ +
+ +
+

中等长度内容

+ +
+ +
+

长内容

+ +
+
+ ) +} + +// 不同的思考时间文本 +export const DifferentThinkingTime: Story = { + render: () => ( +
+ + + + + + + + + 思考完成 +
+ } + content={`成功找到解决方案\n可以开始回答`} + expanded={false} + /> +
+ ) +} + +// 空内容状态 +export const EmptyContent: Story = { + render: () => ( +
+
+

无内容 - 思考中

+ +
+ +
+

无内容 - 停止思考

+ +
+
+ ) +} + +// 实时内容更新演示 +export const RealTimeUpdate: Story = { + render: function Render() { + const [content, setContent] = useState('') + const [isThinking, setIsThinking] = useState(false) + const [step, setStep] = useState(0) + + const steps = useMemo(() => { + return [ + '开始分析问题的复杂性...', + '识别关键信息和要求', + '搜索相关的知识点', + '整理可能的解决思路', + '评估不同方案的优缺点', + '选择最优的解决方案', + '构建详细的回答框架', + '检查逻辑的连贯性', + '优化表达的清晰度', + '完成最终答案的准备' + ] + }, []) + + useEffect(() => { + if (isThinking && step < steps.length) { + const timer = setTimeout(() => { + const newContent = steps.slice(0, step + 1).join('\n') + setContent(newContent) + setStep((prev) => prev + 1) + }, 1500) + + return () => clearTimeout(timer) + } else if (step >= steps.length) { + setIsThinking(false) + } + + return undefined + }, [isThinking, step, steps]) + + const handleStart = () => { + setIsThinking(true) + setStep(0) + setContent('') + } + + const handleReset = () => { + setIsThinking(false) + setStep(0) + setContent('') + } + + return ( +
+
+ + +
+ + +
+ ) + } +} + +// 自定义样式 +export const CustomStyles: Story = { + render: () => ( +
+
+

自定义边框和背景

+ +
+ +
+

圆角和阴影

+ +
+
+ ) +} + +// 错误和边界情况 +export const EdgeCases: Story = { + render: () => ( +
+
+

单行内容

+ +
+ +
+

超长单行

+ +
+ +
+

特殊字符

+ +
+
+ ) +} diff --git a/packages/ui/stories/components/icons/FileIcons.stories.tsx b/packages/ui/stories/components/icons/FileIcons.stories.tsx new file mode 100644 index 0000000000..e638448049 --- /dev/null +++ b/packages/ui/stories/components/icons/FileIcons.stories.tsx @@ -0,0 +1,270 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { FilePngIcon, FileSvgIcon } from '../../../src/components/icons/FileIcons' + +// Create a dummy component for the story +const FileIconsShowcase = () =>
+ +const meta: Meta = { + title: 'Components/Icons/FileIcons', + component: FileIconsShowcase, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + size: { + description: '图标大小', + control: { type: 'text' }, + defaultValue: '1.1em' + } + } +} + +export default meta +type Story = StoryObj + +// Basic File Icons +export const BasicFileIcons: Story = { + render: () => ( +
+
+

文件类型图标 (默认尺寸: 1.1em)

+
+
+ + SVG 文件 +
+
+ + PNG 文件 +
+
+
+
+ ) +} + +// Different Sizes +export const DifferentSizes: Story = { + render: () => ( +
+
+

不同尺寸的 SVG 图标

+
+
+ + 16px +
+
+ + 24px +
+
+ + 32px +
+
+ + 48px +
+
+ + 64px +
+
+
+ +
+

不同尺寸的 PNG 图标

+
+
+ + 16px +
+
+ + 24px +
+
+ + 32px +
+
+ + 48px +
+
+ + 64px +
+
+
+
+ ) +} + +// Custom Colors +export const CustomColors: Story = { + render: () => ( +
+
+

自定义颜色 - SVG 图标

+
+
+ + 蓝色 +
+
+ + 绿色 +
+
+ + 橙色 +
+
+ + 红色 +
+
+ + 紫色 +
+
+
+ +
+

自定义颜色 - PNG 图标

+
+
+ + 蓝色 +
+
+ + 绿色 +
+
+ + 橙色 +
+
+ + 红色 +
+
+ + 紫色 +
+
+
+
+ ) +} + +// In File List Context +export const InFileListContext: Story = { + render: () => ( +
+

文件列表中的使用示例

+ +
+
+
+ + illustration.svg + 45 KB +
+ +
+ + screenshot.png + 1.2 MB +
+ +
+ + logo.svg + 12 KB +
+ +
+ + background.png + 2.8 MB +
+
+
+
+ ) +} + +// File Type Grid +export const FileTypeGrid: Story = { + render: () => ( +
+

文件类型网格展示

+ +
+
+ + SVG + 矢量图形 +
+ +
+ + PNG + 位图图像 +
+ +
+ + SVG + 已处理 +
+ +
+ + PNG + 错误状态 +
+
+
+ ) +} + +// Interactive Example +export const InteractiveExample: Story = { + render: () => { + const fileTypes = [ + { icon: FileSvgIcon, name: 'Vector Graphics', ext: 'SVG', color: '#3B82F6' }, + { icon: FilePngIcon, name: 'Raster Image', ext: 'PNG', color: '#10B981' } + ] + + return ( +
+

交互式文件类型选择器

+ +
+ {fileTypes.map((fileType, index) => { + const IconComponent = fileType.icon + return ( + + ) + })} +
+
+ ) + } +} diff --git a/packages/ui/stories/components/icons/Logos.stories.tsx b/packages/ui/stories/components/icons/Logos.stories.tsx new file mode 100644 index 0000000000..5d3747bcfa --- /dev/null +++ b/packages/ui/stories/components/icons/Logos.stories.tsx @@ -0,0 +1,237 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { + Ai302, + Aihubmix, + AiOnly, + Alayanew, + Anthropic, + AwsBedrock, + Azureai, + Baichuan, + BaiduCloud, + Bailian, + Bocha, + Burncloud, + Bytedance, + Cephalon, + Cherryin, + Cohere, + Dashscope, + Deepseek, + Dmxapi, + DmxapiToImg, + Doc2x, + Doubao, + Exa, + Fireworks, + Gemini, + GiteeAi, + Github, + Google, + Gpustack, + GraphRag, + Grok, + Groq, + Huggingface, + Hyperbolic, + Infini, + Intel, + Jimeng, + Jina, + Lanyun, + Lepton, + Lmstudio, + Longcat, + Macos, + Mcprouter, + Meta as MetaLogo, + Mineru, + Minimax, + Mistral, + Mixedbread, + Mixedbread1, + Moonshot, + NeteaseYoudao, + Newapi, + Nomic, + Nvidia, + O3, + Ocoolai, + Ollama, + Openai, + Openrouter, + Paddleocr, + Perplexity, + Ph8, + Ppio, + Qiniu, + Searxng, + Silicon, + Sophnet, + Step, + Tavily, + TencentCloudTi, + TesseractJs, + Together, + Tokenflux, + Vertexai, + Volcengine, + Voyage, + Xirang, + ZeroOne, + Zhipu +} from '../../../src/components/icons/logos' + +// Logo 列表,包含组件和名称 +const logos = [ + { Component: Ai302, name: 'Ai302' }, + { Component: Aihubmix, name: 'Aihubmix' }, + { Component: AiOnly, name: 'AiOnly' }, + { Component: Alayanew, name: 'Alayanew' }, + { Component: Anthropic, name: 'Anthropic' }, + { Component: AwsBedrock, name: 'AwsBedrock' }, + { Component: Azureai, name: 'Azureai' }, + { Component: Baichuan, name: 'Baichuan' }, + { Component: BaiduCloud, name: 'BaiduCloud' }, + { Component: Bailian, name: 'Bailian' }, + { Component: Bocha, name: 'Bocha' }, + { Component: Burncloud, name: 'Burncloud' }, + { Component: Bytedance, name: 'Bytedance' }, + { Component: Cephalon, name: 'Cephalon' }, + { Component: Cherryin, name: 'Cherryin' }, + { Component: Cohere, name: 'Cohere' }, + { Component: Dashscope, name: 'Dashscope' }, + { Component: Deepseek, name: 'Deepseek' }, + { Component: Dmxapi, name: 'Dmxapi' }, + { Component: DmxapiToImg, name: 'DmxapiToImg' }, + { Component: Doc2x, name: 'Doc2x' }, + { Component: Doubao, name: 'Doubao' }, + { Component: Exa, name: 'Exa' }, + { Component: Fireworks, name: 'Fireworks' }, + { Component: Gemini, name: 'Gemini' }, + { Component: GiteeAi, name: 'GiteeAi' }, + { Component: Github, name: 'Github' }, + { Component: Google, name: 'Google' }, + { Component: Gpustack, name: 'Gpustack' }, + { Component: GraphRag, name: 'GraphRag' }, + { Component: Grok, name: 'Grok' }, + { Component: Groq, name: 'Groq' }, + { Component: Huggingface, name: 'Huggingface' }, + { Component: Hyperbolic, name: 'Hyperbolic' }, + { Component: Infini, name: 'Infini' }, + { Component: Intel, name: 'Intel' }, + { Component: Jimeng, name: 'Jimeng' }, + { Component: Jina, name: 'Jina' }, + { Component: Lanyun, name: 'Lanyun' }, + { Component: Lepton, name: 'Lepton' }, + { Component: Lmstudio, name: 'Lmstudio' }, + { Component: Longcat, name: 'Longcat' }, + { Component: Macos, name: 'Macos' }, + { Component: Mcprouter, name: 'Mcprouter' }, + { Component: MetaLogo, name: 'Meta' }, + { Component: Mineru, name: 'Mineru' }, + { Component: Minimax, name: 'Minimax' }, + { Component: Mistral, name: 'Mistral' }, + { Component: Mixedbread, name: 'Mixedbread' }, + { Component: Mixedbread1, name: 'Mixedbread1' }, + { Component: Moonshot, name: 'Moonshot' }, + { Component: NeteaseYoudao, name: 'NeteaseYoudao' }, + { Component: Newapi, name: 'Newapi' }, + { Component: Nomic, name: 'Nomic' }, + { Component: Nvidia, name: 'Nvidia' }, + { Component: O3, name: 'O3' }, + { Component: Ocoolai, name: 'Ocoolai' }, + { Component: Ollama, name: 'Ollama' }, + { Component: Openai, name: 'Openai' }, + { Component: Openrouter, name: 'Openrouter' }, + { Component: Paddleocr, name: 'Paddleocr' }, + { Component: Perplexity, name: 'Perplexity' }, + { Component: Ph8, name: 'Ph8' }, + { Component: Ppio, name: 'Ppio' }, + { Component: Qiniu, name: 'Qiniu' }, + { Component: Searxng, name: 'Searxng' }, + { Component: Silicon, name: 'Silicon' }, + { Component: Sophnet, name: 'Sophnet' }, + { Component: Step, name: 'Step' }, + { Component: Tavily, name: 'Tavily' }, + { Component: TencentCloudTi, name: 'TencentCloudTi' }, + { Component: TesseractJs, name: 'TesseractJs' }, + { Component: Together, name: 'Together' }, + { Component: Tokenflux, name: 'Tokenflux' }, + { Component: Vertexai, name: 'Vertexai' }, + { Component: Volcengine, name: 'Volcengine' }, + { Component: Voyage, name: 'Voyage' }, + { Component: Xirang, name: 'Xirang' }, + { Component: ZeroOne, name: 'ZeroOne' }, + { Component: Zhipu, name: 'Zhipu' } +] + +interface LogosShowcaseProps { + fontSize?: number +} + +const LogosShowcase = ({ fontSize = 32 }: LogosShowcaseProps) => { + return ( +
+ {logos.map(({ Component, name }) => ( +
+
+ +
+

{name}

+
+ ))} +
+ ) +} + +const meta: Meta = { + title: 'Components/Icons/Logos', + component: LogosShowcase, + parameters: { + layout: 'fullscreen' + }, + tags: ['autodocs'], + argTypes: { + fontSize: { + control: { type: 'number', min: 16, max: 64, step: 4 }, + description: 'Logo 大小(通过 fontSize 控制,因为图标使用 1em 单位)', + defaultValue: 32 + } + } +} + +export default meta +type Story = StoryObj + +/** + * 展示所有 81 个品牌 Logo 图标 + * + * 这些图标使用 SVGR 的 `icon: true` 选项生成,具有以下特点: + * - 使用 `width="1em"` 和 `height="1em"`,响应父元素的 `fontSize` + * - 保留所有原始 SVG 属性(颜色、渐变、clipPath 等) + * - 支持标准的 SVG props(className, style, onClick 等) + * + * ## 使用示例 + * + * ```tsx + * import { Anthropic } from '@cherrystudio/ui/icons' + * + * // 通过 fontSize 控制大小 + *
+ * + *
+ * + * // 通过 className 控制(Tailwind) + * + * + * // 使用标准 SVG props + * + * ``` + */ +export const AllLogos: Story = { + args: { + fontSize: 32 + } +} diff --git a/packages/ui/stories/components/icons/SvgSpinners180Ring.stories.tsx b/packages/ui/stories/components/icons/SvgSpinners180Ring.stories.tsx new file mode 100644 index 0000000000..8a13af9297 --- /dev/null +++ b/packages/ui/stories/components/icons/SvgSpinners180Ring.stories.tsx @@ -0,0 +1,339 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import SvgSpinners180Ring from '../../../src/components/icons/SvgSpinners180Ring' + +const meta: Meta = { + title: 'Components/Icons/SvgSpinners180Ring', + component: SvgSpinners180Ring, + parameters: { + layout: 'centered', + docs: { + description: { + component: + '⚠️ **已废弃** - 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。' + } + } + }, + tags: ['autodocs', 'deprecated'], + argTypes: { + size: { + description: '加载图标大小', + control: { type: 'text' }, + defaultValue: '1em' + }, + className: { + description: '自定义 CSS 类名', + control: { type: 'text' } + } + } +} + +export default meta +type Story = StoryObj + +// Basic Spinner +export const BasicSpinner: Story = { + render: () => ( +
+
+

基础加载动画

+
+ + 默认尺寸 (1em) +
+
+
+ ) +} + +// Different Sizes +export const DifferentSizes: Story = { + render: () => ( +
+
+

不同尺寸的加载动画

+
+
+ + 12px +
+
+ + 16px +
+
+ + 20px +
+
+ + 24px +
+
+ + 32px +
+
+ + 48px +
+
+
+
+ ) +} + +// Different Colors +export const DifferentColors: Story = { + render: () => ( +
+
+

不同颜色的加载动画

+
+
+ + 蓝色 +
+
+ + 绿色 +
+
+ + 橙色 +
+
+ + 红色 +
+
+ + 紫色 +
+
+ + 灰色 +
+
+
+
+ ) +} + +// Loading States in Buttons +export const LoadingStatesInButtons: Story = { + render: () => ( +
+
+

按钮中的加载状态

+
+ + + + + + + +
+
+
+ ) +} + +// Loading Cards +export const LoadingCards: Story = { + render: () => ( +
+
+

加载状态卡片

+
+
+
+ +
+

AI 模型响应中

+

正在生成回复...

+
+
+
+ +
+
+ +
+

文件上传中

+

75% 完成

+
+
+
+ +
+
+ +
+

数据同步中

+

请稍候...

+
+
+
+ +
+
+ +
+

模型训练中

+

预计2分钟

+
+
+
+
+
+
+ ) +} + +// Inline Loading States +export const InlineLoadingStates: Story = { + render: () => ( +
+
+

行内加载状态

+
+
+ + 正在检查网络连接... +
+ +
+ + 正在保存更改... +
+ +
+ + 正在验证凭据... +
+ +
+
+ + 系统正在处理您的请求,请稍候... +
+
+
+
+
+ ) +} + +// Loading States with Different Speeds +export const LoadingStatesWithDifferentSpeeds: Story = { + render: () => ( +
+
+

不同速度的加载动画

+
+
+ + 慢速 (2s) +
+
+ + 默认速度 +
+
+ + 快速 (0.5s) +
+
+
+
+ ) +} + +// Full Page Loading +export const FullPageLoading: Story = { + render: () => ( +
+
+

全屏加载示例

+
+
+ +

页面加载中,请稍候...

+
+ + {/* 模拟页面内容 */} +
+
+
+
+
+
+
+
+
+
+
+ ) +} + +// Interactive Loading Demo +export const InteractiveLoadingDemo: Story = { + render: () => { + const loadingStates = [ + { text: '发送消息', color: 'text-blue-500', bgColor: 'bg-blue-500' }, + { text: '上传文件', color: 'text-green-500', bgColor: 'bg-green-500' }, + { text: '生成内容', color: 'text-purple-500', bgColor: 'bg-purple-500' }, + { text: '搜索结果', color: 'text-orange-500', bgColor: 'bg-orange-500' } + ] + + return ( +
+

交互式加载演示

+ +
+ {loadingStates.map((state, index) => ( + + ))} +
+ +

点击按钮查看加载状态的交互效果

+
+ ) + } +} diff --git a/packages/ui/stories/components/icons/ToolsCallingIcon.stories.tsx b/packages/ui/stories/components/icons/ToolsCallingIcon.stories.tsx new file mode 100644 index 0000000000..55536e90ba --- /dev/null +++ b/packages/ui/stories/components/icons/ToolsCallingIcon.stories.tsx @@ -0,0 +1,374 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import ToolsCallingIcon from '../../../src/components/icons/ToolsCallingIcon' + +const meta: Meta = { + title: 'Components/Icons/ToolsCallingIcon', + component: ToolsCallingIcon, + parameters: { + layout: 'centered', + docs: { + description: { + component: + '⚠️ **已废弃** - 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。建议直接使用 lucide-react 的 Wrench 图标。' + } + } + }, + tags: ['autodocs', 'deprecated'], + argTypes: { + className: { + description: '容器的自定义 CSS 类名', + control: { type: 'text' } + }, + iconClassName: { + description: '图标的自定义 CSS 类名', + control: { type: 'text' } + } + } +} + +export default meta +type Story = StoryObj + +// Basic Tools Calling Icon +export const BasicToolsCallingIcon: Story = { + render: () => ( +
+
+

基础工具调用图标

+
+ +
+

悬停图标查看工具提示,显示"函数调用"文本

+
+
+ ) +} + +// Different Sizes +export const DifferentSizes: Story = { + render: () => ( +
+
+

不同尺寸的工具调用图标

+
+
+ + 小号 +
+
+ + 默认 +
+
+ + 中号 +
+
+ + 大号 +
+
+ + 特大号 +
+
+
+
+ ) +} + +// Different Colors +export const DifferentColors: Story = { + render: () => ( +
+
+

不同颜色的工具调用图标

+
+
+ + 默认绿色 +
+
+ + 蓝色 +
+
+ + 橙色 +
+
+ + 红色 +
+
+ + 紫色 +
+
+ + 灰色 +
+
+
+
+ ) +} + +// Model Features Context +export const ModelFeaturesContext: Story = { + render: () => ( +
+

在模型功能标识中的使用

+ +
+
+
+

GPT-4 Turbo

+ +
+

支持函数调用的先进模型,可以调用外部工具和API

+
+ 函数调用 + 多模态 +
+
+ +
+
+

Claude 3.5 Sonnet

+ +
+

Anthropic的高性能模型,具备强大的工具使用能力

+
+ 函数调用 + 推理 +
+
+ +
+
+

Llama 3.1 8B

+ {/* 不支持函数调用 */} +
+

Meta的开源模型,适用于基础对话任务

+
+ 文本生成 +
+
+
+
+ ) +} + +// Chat Message Context +export const ChatMessageContext: Story = { + render: () => ( +
+

在聊天消息中的使用

+ +
+
+
+ + 调用工具: weather_api +
+

正在获取北京的天气信息...

+
+ +
+
+ + 调用工具: search_web +
+

正在搜索最新的AI新闻...

+
+ +
+
+ + 调用工具: code_interpreter +
+

正在执行Python代码计算结果...

+
+
+
+ ) +} + +// Tool Availability Indicator +export const ToolAvailabilityIndicator: Story = { + render: () => ( +
+

工具可用性指示器

+ +
+
+

可用工具

+
+ +
+
+
+ + 天气查询 +
+ 可用 +
+ +
+
+ + 网络搜索 +
+ 可用 +
+ +
+
+ + 代码执行 +
+ 不可用 +
+ +
+
+ + 图像生成 +
+ 限制使用 +
+
+
+
+ ) +} + +// Interactive Tool Selection +export const InteractiveToolSelection: Story = { + render: () => { + const tools = [ + { name: '天气查询', description: '获取实时天气信息', available: true }, + { name: '网络搜索', description: '搜索最新信息', available: true }, + { name: '代码执行', description: '运行Python代码', available: false }, + { name: '图像分析', description: '分析和描述图像', available: true }, + { name: '数据可视化', description: '创建图表和图形', available: false } + ] + + return ( +
+

交互式工具选择器

+ +
+ {tools.map((tool, index) => ( + + ))} +
+
+ ) + } +} + +// Loading Tool Calls +export const LoadingToolCalls: Story = { + render: () => ( +
+

工具调用加载状态

+ +
+
+
+ + 正在调用工具... +
+
+

weather_api(city="北京")

+
+ +
+
+ + 工具调用完成 + +
+

已获取北京天气信息:晴天,温度 22°C

+
+ +
+
+ + 工具调用失败 + +
+

API密钥无效,请检查配置

+
+
+
+ ) +} + +// Settings Panel +export const SettingsPanel: Story = { + render: () => ( +
+

设置面板中的使用

+ +
+
+ +

函数调用设置

+
+ +
+
+
+
启用函数调用
+
允许AI模型调用外部工具
+
+ +
+ +
+
+
自动确认调用
+
自动执行安全的工具调用
+
+ +
+ +
+
+
显示调用详情
+
在聊天中显示工具调用过程
+
+ +
+
+
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/Button.stories.tsx b/packages/ui/stories/components/primitives/Button.stories.tsx new file mode 100644 index 0000000000..9c07f1e22f --- /dev/null +++ b/packages/ui/stories/components/primitives/Button.stories.tsx @@ -0,0 +1,345 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ChevronRight, Loader2, Mail } from 'lucide-react' + +import { Button } from '../../../src/components/primitives/button' + +const meta: Meta = { + title: 'Components/Primitives/Button', + component: Button, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Displays a button or a component that looks like a button. Based on shadcn/ui.' + } + } + }, + tags: ['autodocs'], + argTypes: { + variant: { + control: { type: 'select' }, + options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'], + description: 'The visual style variant of the button' + }, + size: { + control: { type: 'select' }, + options: ['default', 'sm', 'lg', 'icon', 'icon-sm', 'icon-lg'], + description: 'The size of the button' + }, + disabled: { + control: { type: 'boolean' }, + description: 'Whether the button is disabled' + }, + asChild: { + control: { type: 'boolean' }, + description: 'Render as a child element' + }, + className: { + control: { type: 'text' }, + description: 'Additional CSS classes' + } + } +} + +export default meta +type Story = StoryObj + +// Default +export const Default: Story = { + args: { + children: 'Button' + } +} + +// Variants +export const Secondary: Story = { + args: { + variant: 'secondary', + children: 'Secondary' + } +} + +export const Destructive: Story = { + args: { + variant: 'destructive', + children: 'Destructive' + } +} + +export const Outline: Story = { + args: { + variant: 'outline', + children: 'Outline' + } +} + +export const Ghost: Story = { + args: { + variant: 'ghost', + children: 'Ghost' + } +} + +export const Link: Story = { + args: { + variant: 'link', + children: 'Link' + } +} + +// All Variants +export const AllVariants: Story = { + render: () => ( +
+ + + + + + +
+ ) +} + +// Sizes +export const Small: Story = { + args: { + size: 'sm', + children: 'Small' + } +} + +export const Large: Story = { + args: { + size: 'lg', + children: 'Large' + } +} + +export const AllSizes: Story = { + render: () => ( +
+ + + +
+ ) +} + +// Icon Buttons +export const IconButton: Story = { + render: () => ( + + ) +} + +export const AllIconSizes: Story = { + render: () => ( +
+ + + +
+ ) +} + +// With Icon +export const WithIcon: Story = { + render: () => ( +
+ + + +
+ ) +} + +// Loading +export const Loading: Story = { + render: () => ( +
+ + +
+ ) +} + +// Rounded +export const Rounded: Story = { + render: () => ( +
+ + + +
+ ) +} + +// States +export const Disabled: Story = { + args: { + disabled: true, + children: 'Disabled' + } +} + +export const AllStates: Story = { + render: () => ( +
+ + + + +
+ ) +} + +// Full Width +export const FullWidth: Story = { + render: () => ( +
+ +
+ ) +} + +// As Child - Composition Pattern +// Note: asChild uses Radix UI's Slot component to merge Button's props +// with a single child element. The child must support prop spreading. +// Warning: asChild does NOT support loading prop (Slot requires single child) +export const AsChild: Story = { + render: () => ( +
+
+

Using asChild to render as an anchor tag:

+ +
+
+

Using asChild with link variant:

+ +
+
+

+ Note: The{' '} + asChild prop does not work with{' '} + loading because Radix Slot + requires a single child element. +

+
+
+ ) +} + +// Real World Examples +export const RealWorldExamples: Story = { + render: () => ( +
+ {/* Action Buttons */} +
+

Action Buttons

+
+ + + +
+
+ + {/* Icon Buttons */} +
+

Icon Buttons

+
+ + + +
+
+ + {/* Loading States */} +
+

Loading States

+
+ + +
+
+ + {/* With Icons */} +
+

Buttons with Icons

+
+ + +
+
+ + {/* Rounded Variants */} +
+

Rounded Buttons

+
+ + + +
+
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/Checkbox.stories.tsx b/packages/ui/stories/components/primitives/Checkbox.stories.tsx new file mode 100644 index 0000000000..3705fabeaa --- /dev/null +++ b/packages/ui/stories/components/primitives/Checkbox.stories.tsx @@ -0,0 +1,533 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Bell, Check, FileText, Mail, Shield, Star } from 'lucide-react' +import { useState } from 'react' + +import { Checkbox, type CheckedState } from '../../../src/components/primitives/checkbox' + +const meta: Meta = { + title: 'Components/Primitives/Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A checkbox component based on Radix UI, allowing users to select multiple options. Supports three sizes (sm, md, lg) as defined in the Figma design system.' + } + } + }, + tags: ['autodocs'], + argTypes: { + disabled: { + control: { type: 'boolean' }, + description: 'Whether the checkbox is disabled' + }, + defaultChecked: { + control: { type: 'boolean' }, + description: 'Default checked state' + }, + checked: { + control: { type: 'boolean' }, + description: 'Checked state in controlled mode' + } + } +} + +export default meta +type Story = StoryObj + +// Default +export const Default: Story = { + render: () => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// With Default Checked +export const WithDefaultChecked: Story = { + render: () => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// Disabled +export const Disabled: Story = { + render: () => ( +
+
+ + +
+
+ + +
+
+ ) +} + +// Controlled +export const Controlled: Story = { + render: function ControlledExample() { + const [checked, setChecked] = useState(false) + + return ( +
+
+ + +
+
Current state: {checked ? 'Checked' : 'Unchecked'}
+
+ ) + } +} + +// Sizes +export const Sizes: Story = { + render: () => ( +
+
+

Small (sm)

+
+
+ + +
+
+ + +
+
+
+ +
+

Medium (md) - Default

+
+
+ + +
+
+ + +
+
+
+ +
+

Large (lg)

+
+
+ + +
+
+ + +
+
+
+
+ ) +} + +// All States +export const AllStates: Story = { + render: function AllStatesExample() { + const [normalChecked, setNormalChecked] = useState(false) + const [checkedState, setCheckedState] = useState(true) + + return ( +
+ {/* Normal State (Unchecked) */} +
+

Normal State (Unchecked)

+
+ + +
+
+ + {/* Checked State */} +
+

Checked State

+
+ + +
+
+ + {/* Disabled State (Unchecked) */} +
+

Disabled State (Unchecked)

+
+ + +
+
+ + {/* Disabled State (Checked) */} +
+

Disabled State (Checked)

+
+ + +
+
+ + {/* Error State */} +
+

Error State

+
+ + +
+

This field is required

+
+
+ ) + } +} + +// Real World Examples +export const RealWorldExamples: Story = { + render: function RealWorldExample() { + const [settings, setSettings] = useState({ + emailNotifications: true, + pushNotifications: false, + smsNotifications: false, + newsletter: true + }) + + const [features, setFeatures] = useState({ + analytics: true, + backup: false, + security: true, + api: false + }) + + return ( +
+ {/* Notification Settings */} +
+

Notification Preferences

+
+
+ setSettings({ ...settings, emailNotifications: !!checked })} + /> + +
+
+ setSettings({ ...settings, pushNotifications: !!checked })} + /> + +
+
+ setSettings({ ...settings, smsNotifications: !!checked })} + /> + +
+
+ setSettings({ ...settings, newsletter: !!checked })} + /> + +
+
+
+ + {/* Feature Toggles */} +
+

Feature Toggles

+
+
+ setFeatures({ ...features, analytics: !!checked })} + className="mt-1" + /> + +
+
+ setFeatures({ ...features, backup: !!checked })} + className="mt-1" + /> + +
+
+ setFeatures({ ...features, security: !!checked })} + className="mt-1" + /> + +
+
+ setFeatures({ ...features, api: !!checked })} + className="mt-1" + /> + +
+
+
+ + {/* Required Agreement */} +
+

+ Terms and Conditions * +

+
+ + +
+

You must accept the terms and conditions to continue

+
+
+ ) + } +} + +// Size Comparison +export const SizeComparison: Story = { + render: () => ( +
+
+

Unchecked

+
+
+ + sm +
+
+ + md +
+
+ + lg +
+
+
+ +
+

Checked

+
+
+ + sm +
+
+ + md +
+
+ + lg +
+
+
+
+ ) +} + +// Form Example +export const FormExample: Story = { + render: function FormExample() { + const [formData, setFormData] = useState({ + terms: false, + privacy: false, + marketing: false + }) + + const [submitted, setSubmitted] = useState(false) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setSubmitted(true) + } + + return ( +
+

Account Registration

+ +
+
+ setFormData({ ...formData, terms: !!checked })} + aria-invalid={submitted && !formData.terms} + className="mt-0.5" + /> + +
+ {submitted && !formData.terms &&

This field is required

} + +
+ setFormData({ ...formData, privacy: !!checked })} + aria-invalid={submitted && !formData.privacy} + className="mt-0.5" + /> + +
+ {submitted && !formData.privacy &&

This field is required

} + +
+ setFormData({ ...formData, marketing: !!checked })} + className="mt-0.5" + /> + +
+
+ + + + {submitted && formData.terms && formData.privacy && ( +

Registration successful!

+ )} +
+ ) + } +} diff --git a/packages/ui/stories/components/primitives/Combobox.stories.tsx b/packages/ui/stories/components/primitives/Combobox.stories.tsx new file mode 100644 index 0000000000..8d273898aa --- /dev/null +++ b/packages/ui/stories/components/primitives/Combobox.stories.tsx @@ -0,0 +1,421 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ChevronDown, User } from 'lucide-react' +import { useState } from 'react' + +import { Combobox } from '../../../src/components/primitives/combobox' + +const meta: Meta = { + title: 'Components/Primitives/Combobox', + component: Combobox, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'A combobox component with search, single/multiple selection support. Based on shadcn/ui.' + } + } + }, + tags: ['autodocs'], + argTypes: { + size: { + control: { type: 'select' }, + options: ['sm', 'default', 'lg'], + description: 'The size of the combobox' + }, + error: { + control: { type: 'boolean' }, + description: 'Whether the combobox is in error state' + }, + disabled: { + control: { type: 'boolean' }, + description: 'Whether the combobox is disabled' + }, + multiple: { + control: { type: 'boolean' }, + description: 'Enable multiple selection' + }, + searchable: { + control: { type: 'boolean' }, + description: 'Enable search functionality' + } + } +} + +export default meta +type Story = StoryObj + +// Mock data - 根据设计稿中的用户选择场景 +const userOptions = [ + { + value: 'rachel-meyers', + label: 'Rachel Meyers', + description: '@rachel', + icon: ( +
+ RM +
+ ) + }, + { + value: 'john-doe', + label: 'John Doe', + description: '@john', + icon: ( +
+ JD +
+ ) + }, + { + value: 'jane-smith', + label: 'Jane Smith', + description: '@jane', + icon: ( +
+ JS +
+ ) + }, + { + value: 'alex-chen', + label: 'Alex Chen', + description: '@alex', + icon: ( +
+ AC +
+ ) + } +] + +// 简单选项数据 +const simpleOptions = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' } +] + +// 带图标的简单选项 +const iconOptions = [ + { + value: 'user1', + label: '@rachel', + icon: + }, + { + value: 'user2', + label: '@john', + icon: + }, + { + value: 'user3', + label: '@jane', + icon: + } +] + +// ==================== Stories ==================== + +// Default - 占位符状态 +export const Default: Story = { + args: { + options: simpleOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 带头像和描述 - 对应设计稿顶部的用户选择器 +export const WithAvatarAndDescription: Story = { + args: { + options: userOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 已选中状态 - 对应设计稿中有值的状态 +export const WithSelectedValue: Story = { + args: { + options: userOptions, + defaultValue: 'rachel-meyers', + placeholder: 'Please Select', + width: 280 + } +} + +// 带简单图标 - 对应设计稿中间部分 +export const WithSimpleIcon: Story = { + args: { + options: iconOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 多选模式 - 对应设计稿底部的标签形式 +export const MultipleSelection: Story = { + args: { + multiple: true, + options: userOptions, + placeholder: 'Please Select', + width: 280 + } +} + +// 多选已选中状态 +export const MultipleWithSelectedValues: Story = { + args: { + multiple: true, + options: userOptions, + defaultValue: ['rachel-meyers', 'john-doe'], + placeholder: 'Please Select', + width: 280 + } +} + +// 所有状态展示 - 对应设计稿的三列(Normal, Focus, Error) +export const AllStates: Story = { + render: function AllStatesExample() { + const [normalValue, setNormalValue] = useState('') + const [selectedValue, setSelectedValue] = useState('rachel-meyers') + const [errorValue, setErrorValue] = useState('') + + return ( +
+ {/* Normal State - 默认灰色边框 */} +
+

Normal State

+ setNormalValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+ + {/* Selected State - 绿色边框 (focus 时) */} +
+

Selected State

+ setSelectedValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+ + {/* Error State - 红色边框 */} +
+

Error State

+ setErrorValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+ + {/* Disabled State */} +
+

Disabled State

+ setSelectedValue(val as string)} + placeholder="Please Select" + width={280} + /> +
+
+ ) + } +} + +// 所有尺寸 +export const AllSizes: Story = { + render: function AllSizesExample() { + const [value, setValue] = useState('') + return ( +
+
+

Small

+ setValue(val as string)} + width={280} + /> +
+
+

Default

+ setValue(val as string)} + width={280} + /> +
+
+

Large

+ setValue(val as string)} + width={280} + /> +
+
+ ) + } +} + +// 多选不同状态组合 - 对应设计稿底部区域 +export const MultipleStates: Story = { + render: function MultipleStatesExample() { + const [normalValue, setNormalValue] = useState([]) + const [selectedValue, setSelectedValue] = useState(['rachel-meyers', 'john-doe']) + const [errorValue, setErrorValue] = useState(['rachel-meyers']) + + return ( +
+ {/* Multiple - Normal */} +
+

Multiple - Normal (Empty)

+ setNormalValue(val as string[])} + placeholder="Please Select" + width={280} + /> +
+ + {/* Multiple - With Values */} +
+

Multiple - With Selected Values

+ setSelectedValue(val as string[])} + placeholder="Please Select" + width={280} + /> +
+ + {/* Multiple - Error */} +
+

Multiple - Error State

+ setErrorValue(val as string[])} + placeholder="Please Select" + width={280} + /> +
+
+ ) + } +} + +// 禁用选项 +export const WithDisabledOptions: Story = { + args: { + options: [...userOptions.slice(0, 2), { ...userOptions[2], disabled: true }, ...userOptions.slice(3)], + placeholder: 'Please Select', + width: 280 + } +} + +// 无搜索模式 +export const WithoutSearch: Story = { + args: { + searchable: false, + options: simpleOptions, + width: 280 + } +} + +// 实际使用场景 - 综合展示 +export const RealWorldExamples: Story = { + render: function RealWorldExample() { + const [assignee, setAssignee] = useState('') + const [members, setMembers] = useState([]) + const [status, setStatus] = useState('') + + const statusOptions = [ + { value: 'pending', label: 'Pending', description: 'Waiting for review' }, + { value: 'in-progress', label: 'In Progress', description: 'Currently working' }, + { value: 'completed', label: 'Completed', description: 'Task finished' } + ] + + return ( +
+ {/* 分配任务给单个用户 */} +
+

Assign Task

+ setAssignee(val as string)} + placeholder="Select assignee..." + width={280} + /> +
+ + {/* 添加多个成员 */} +
+

Add Team Members

+ setMembers(val as string[])} + placeholder="Select members..." + width={280} + /> +
+ + {/* 选择状态 */} +
+

Task Status

+ setStatus(val as string)} + placeholder="Select status..." + width={280} + /> +
+ + {/* 错误提示场景 */} +
+

Required Field (Error)

+ {}} + placeholder="This field is required" + width={280} + /> +

Please select at least one option

+
+
+ ) + } +} diff --git a/packages/ui/stories/components/primitives/CopyButton.stories.tsx b/packages/ui/stories/components/primitives/CopyButton.stories.tsx new file mode 100644 index 0000000000..afe82aedab --- /dev/null +++ b/packages/ui/stories/components/primitives/CopyButton.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { CopyButton } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/CopyButton', + component: CopyButton, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + tooltip: { + control: 'text', + description: '悬停时显示的提示文字' + }, + label: { + control: 'text', + description: '复制按钮的标签文字' + }, + size: { + control: { type: 'range', min: 10, max: 30, step: 1 }, + description: '图标和文字的大小' + }, + className: { + control: 'text', + description: '自定义 CSS 类名' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {} +} + +export const WithTooltip: Story = { + args: { + tooltip: '点击复制' + } +} + +export const WithLabel: Story = { + args: { + label: '复制' + } +} + +export const WithTooltipAndLabel: Story = { + args: { + tooltip: '点击复制内容到剪贴板', + label: '复制内容' + } +} + +export const SmallSize: Story = { + args: { + size: 12, + label: '小尺寸', + tooltip: '小尺寸复制按钮' + } +} + +export const LargeSize: Story = { + args: { + size: 20, + label: '大尺寸', + tooltip: '大尺寸复制按钮' + } +} + +export const CustomStyle: Story = { + args: { + label: '自定义样式', + tooltip: '自定义样式的复制按钮', + className: 'bg-blue-50 dark:bg-blue-900/20 p-2 rounded-lg border-2 border-blue-200 dark:border-blue-700' + } +} + +export const OnlyIcon: Story = { + args: { + tooltip: '仅图标模式', + size: 16 + } +} + +export const Interactive: Story = { + args: { + tooltip: '可交互的复制按钮', + label: '点击复制' + }, + render: (args) => ( +
+
+

不同状态的复制按钮:

+
+
+ alert('已复制!')} /> +
+
+ +
+
+
+
+ ) +} + +export const MultipleButtons: Story = { + render: () => ( +
+

多个复制按钮组合:

+
+ + + + +
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/CustomTag.stories.tsx b/packages/ui/stories/components/primitives/CustomTag.stories.tsx new file mode 100644 index 0000000000..3e5a3ea494 --- /dev/null +++ b/packages/ui/stories/components/primitives/CustomTag.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { AlertTriangleIcon, StarIcon } from 'lucide-react' +import { action } from 'storybook/actions' + +import { CustomTag } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/CustomTag', + component: CustomTag, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + color: { control: 'color' }, + size: { control: { type: 'range', min: 8, max: 24, step: 1 } }, + disabled: { control: 'boolean' }, + inactive: { control: 'boolean' }, + closable: { control: 'boolean' }, + onClose: { action: 'closed' }, + onClick: { action: 'clicked' } + } +} + +export default meta +type Story = StoryObj + +// 基础示例 +export const Default: Story = { + args: { + children: '默认标签', + color: '#1890ff' + } +} + +// 带图标 +export const WithIcon: Story = { + args: { + children: '带图标', + color: '#52c41a', + icon: + } +} + +// 可关闭 +export const Closable: Story = { + args: { + children: '可关闭标签', + color: '#fa8c16', + closable: true, + onClose: action('tag-closed') + } +} + +// 不同尺寸 +export const Sizes: Story = { + render: () => ( +
+ + 小号 + + + 中号 + + + 大号 + +
+ ) +} + +// 不同状态 +export const States: Story = { + render: () => ( +
+
+ 正常 + + 禁用 + + + 未激活 + +
+
+ + 可点击 + + + 带提示 + +
+
+ ) +} + +// 实际使用场景 +export const UseCases: Story = { + render: () => ( +
+
+

技能标签:

+
+ React + TypeScript + Tailwind +
+
+ +
+

状态标签:

+
+ }> + 进行中 + + + 待处理 + +
+
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/DividerWithText.stories.tsx b/packages/ui/stories/components/primitives/DividerWithText.stories.tsx new file mode 100644 index 0000000000..9b62cda1b7 --- /dev/null +++ b/packages/ui/stories/components/primitives/DividerWithText.stories.tsx @@ -0,0 +1,220 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { DividerWithText } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/DividerWithText', + component: DividerWithText, + parameters: { + layout: 'padded' + }, + tags: ['autodocs'], + argTypes: { + text: { + control: 'text', + description: '分割线上显示的文字' + }, + style: { + control: false, + description: '自定义样式对象' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + text: '分割线' + } +} + +export const ShortText: Story = { + args: { + text: '或' + } +} + +export const LongText: Story = { + args: { + text: '这是一个较长的分割线文字' + } +} + +export const EnglishText: Story = { + args: { + text: 'OR' + } +} + +export const WithNumbers: Story = { + args: { + text: '步骤 1' + } +} + +export const WithSymbols: Story = { + args: { + text: '• • •' + } +} + +export const CustomStyle: Story = { + args: { + text: '自定义样式', + style: { + marginTop: '16px', + marginBottom: '16px' + } + } +} + +export const MultipleUsage: Story = { + render: () => ( +
+
+

登录表单示例

+
+
+ + +
+
+ + +
+ + + + + + +
+
+
+ ) +} + +export const InSections: Story = { + render: () => ( +
+
+

文章内容

+

这是文章的第一段内容。在这里我们可以看到一些基本信息和介绍性的内容。

+ + + +

文章的正文部分开始了。这里包含了详细的内容和分析。

+

更多的内容段落,提供深入的见解和分析。

+ + + +

最后是总结部分,概括了文章的主要观点和结论。

+
+
+ ) +} + +export const WithSteps: Story = { + render: () => ( +
+

安装步骤

+ +
+

下载安装包到本地

+
+ + + +
+

解压缩文件到指定目录

+
+ + + +
+

运行安装程序

+
+ + + +
+

安装完成!

+
+
+ ) +} + +export const DifferentSizes: Story = { + render: () => ( +
+

不同样式的分割线

+ +
+ + + + + + + + + +
+
+ ) +} + +export const Timeline: Story = { + render: () => ( +
+

项目时间线

+ +
+
+

项目启动

+

确定项目需求和目标

+
+ + + +
+

开发阶段

+

功能开发和测试

+
+ + + +
+

测试阶段

+

全面测试和优化

+
+ + + +
+

发布上线

+

正式发布产品

+
+
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/EmojiAvatar.stories.tsx b/packages/ui/stories/components/primitives/EmojiAvatar.stories.tsx new file mode 100644 index 0000000000..c9eb542269 --- /dev/null +++ b/packages/ui/stories/components/primitives/EmojiAvatar.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { EmojiAvatar } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/EmojiAvatar', + component: EmojiAvatar, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + children: { + control: 'text', + description: 'Emoji 字符', + defaultValue: '😊' + }, + size: { + control: { type: 'range', min: 20, max: 100, step: 1 }, + description: '头像尺寸', + defaultValue: 31 + }, + fontSize: { + control: { type: 'range', min: 10, max: 50, step: 1 }, + description: '字体大小(默认为 size * 0.5)' + }, + className: { + control: 'text', + description: '自定义类名' + } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +// 基础用法 +export const Default: Story = { + args: { + children: '😊', + size: 40 + } +} + +// 不同尺寸展示 +export const Sizes: Story = { + render: (args) => ( +
+ + + + + + +
+ ) +} + +// 各种 Emoji +export const VariousEmojis: Story = { + render: (args) => ( +
+ {[ + '😀', + '😎', + '🥳', + '🤔', + '😴', + '🤯', + '❤️', + '🔥', + '✨', + '🎉', + '🎯', + '🚀', + '🌟', + '🌈', + '☀️', + '🌸', + '🍕', + '🎨', + '📚', + '💡', + '🔧', + '🎮', + '🎵', + '🏆' + ].map((emoji) => ( + + ))} +
+ ) +} + +// 自定义字体大小 +export const CustomFontSize: Story = { + render: (args) => ( +
+
+ +

字体: 15px

+
+
+ +

字体: 25px (默认)

+
+
+ +

字体: 35px

+
+
+ ) +} + +// 点击交互 +export const Interactive: Story = { + args: { + children: '👆', + size: 50, + onClick: () => alert('Emoji clicked!') + } +} + +// 自定义样式 +export const CustomStyles: Story = { + render: (args) => ( +
+ + + + +
+ ) +} + +// 组合使用 +export const WithLabels: Story = { + render: (args) => ( +
+ {[ + { emoji: '😊', label: 'Happy' }, + { emoji: '😢', label: 'Sad' }, + { emoji: '😡', label: 'Angry' }, + { emoji: '😴', label: 'Tired' } + ].map(({ emoji, label }) => ( +
+ + {label} +
+ ))} +
+ ) +} + +// 网格展示 +export const Grid: Story = { + render: (args) => ( +
+

选择你的心情

+
+ {[ + '😊', + '😂', + '😍', + '🤔', + '😎', + '😴', + '😭', + '😡', + '🤗', + '😏', + '😅', + '😌', + '🙄', + '😮', + '😐', + '😯', + '😪', + '😫', + '🥱', + '😤', + '😢', + '😥', + '😰', + '🤯' + ].map((emoji) => ( + console.log(`Selected: ${emoji}`)} + /> + ))} +
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/EmojiIcon.stories.tsx b/packages/ui/stories/components/primitives/EmojiIcon.stories.tsx new file mode 100644 index 0000000000..7108e71349 --- /dev/null +++ b/packages/ui/stories/components/primitives/EmojiIcon.stories.tsx @@ -0,0 +1,310 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { EmojiIcon } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/EmojiIcon', + component: EmojiIcon, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + emoji: { + control: 'text', + description: '要显示的 emoji 字符' + }, + className: { + control: 'text', + description: '自定义 CSS 类名' + }, + size: { + control: { type: 'range', min: 16, max: 80, step: 2 }, + description: '图标容器的大小(像素)' + }, + fontSize: { + control: { type: 'range', min: 8, max: 40, step: 1 }, + description: 'emoji 的字体大小(像素)' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {} +} + +export const Star: Story = { + args: { + emoji: '⭐️' + } +} + +export const Heart: Story = { + args: { + emoji: '❤️' + } +} + +export const Smile: Story = { + args: { + emoji: '😊' + } +} + +export const Fire: Story = { + args: { + emoji: '🔥' + } +} + +export const Rocket: Story = { + args: { + emoji: '🚀' + } +} + +export const SmallSize: Story = { + args: { + emoji: '🎯', + size: 20, + fontSize: 12 + } +} + +export const LargeSize: Story = { + args: { + emoji: '🌟', + size: 60, + fontSize: 30 + } +} + +export const CustomStyle: Story = { + args: { + emoji: '💎', + size: 40, + fontSize: 20, + className: 'border-2 border-blue-300 dark:border-blue-600 shadow-lg' + } +} + +export const EmojiCollection: Story = { + render: () => ( +
+
+

表情符号集合

+
+ {[ + '😀', + '😃', + '😄', + '😁', + '😊', + '😍', + '🤔', + '😎', + '🤗', + '😴', + '🙄', + '😇', + '❤️', + '💙', + '💚', + '💛', + '🧡', + '💜', + '⭐', + '🌟', + '✨', + '🔥', + '💎', + '🎯', + '🚀', + '⚡', + '🌈', + '🎉', + '🎊', + '🏆' + ].map((emoji, index) => ( + + ))} +
+
+
+ ) +} + +export const SizeComparison: Story = { + render: () => ( +
+

不同尺寸对比

+
+
+ +

小 (20px)

+
+
+ +

中 (30px)

+
+
+ +

大 (40px)

+
+
+ +

特大 (60px)

+
+
+
+ ) +} + +export const InUserInterface: Story = { + render: () => ( +
+

界面应用示例

+ + {/* 用户头像 */} +
+

用户头像

+
+ +
+

用户名

+

user@example.com

+
+
+
+ + {/* 状态指示器 */} +
+

状态指示器

+
+
+ + 任务已完成 +
+
+ + 进行中 +
+
+ + 任务失败 +
+
+
+ + {/* 导航菜单 */} +
+

导航菜单

+
+
+ + 首页 +
+
+ + 数据统计 +
+
+ + 设置 +
+
+
+
+ ) +} + +export const CategoryIcons: Story = { + render: () => ( +
+

分类图标

+ +
+
+

工作相关

+
+
+ + 商务 +
+
+ + 分析 +
+
+ + 开发 +
+
+
+ +
+

生活相关

+
+
+ + 美食 +
+
+ + 旅行 +
+
+ + 音乐 +
+
+
+
+
+ ) +} + +export const AnimatedExample: Story = { + render: () => ( +
+

交互示例

+
+ {['🎉', '🎊', '✨', '🌟', '⭐'].map((emoji, index) => ( +
alert(`点击了 ${emoji}`)}> + +
+ ))} +
+

点击上面的图标试试

+
+ ) +} + +export const BlurEffect: Story = { + render: () => ( +
+

模糊效果展示

+

EmojiIcon 组件具有独特的模糊背景效果,让 emoji 看起来更有层次感

+
+
+ +

夜晚模式

+
+
+ +

白天模式

+
+
+ +

彩虹效果

+
+
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/ErrorBoundary.stories.tsx b/packages/ui/stories/components/primitives/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..62ae1f4312 --- /dev/null +++ b/packages/ui/stories/components/primitives/ErrorBoundary.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' + +import type { CustomFallbackProps } from '../../../src/components' +import { Button } from '../../../src/components' +import { ErrorBoundary } from '../../../src/components' + +// 错误组件 - 用于触发错误 +const ThrowErrorComponent = ({ shouldThrow = false, errorMessage = '这是一个模拟错误' }) => { + if (shouldThrow) { + throw new Error(errorMessage) + } + return
组件正常运行
+} + +// 异步错误组件 +const AsyncErrorComponent = () => { + const [error, setError] = useState(false) + + const handleAsyncError = () => { + setTimeout(() => { + setError(true) + }, 1000) + } + + if (error) { + throw new Error('异步操作失败') + } + + return ( +
+

这是一个可以触发异步错误的组件

+ +
+ ) +} + +const meta: Meta = { + title: 'Components/Primitives/ErrorBoundary', + component: ErrorBoundary, + parameters: { + layout: 'padded' + }, + tags: ['autodocs'], + argTypes: { + children: { + control: false, + description: '被错误边界包裹的子组件' + }, + fallbackComponent: { + control: false, + description: '自定义错误回退组件' + }, + onDebugClick: { + control: false, + description: '调试按钮点击回调' + }, + onReloadClick: { + control: false, + description: '重新加载按钮点击回调' + }, + debugButtonText: { + control: 'text', + description: '调试按钮文字' + }, + reloadButtonText: { + control: 'text', + description: '重新加载按钮文字' + }, + errorMessage: { + control: 'text', + description: '错误消息标题' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + ) +} + +export const CustomErrorMessage: Story = { + render: () => ( + + + + ) +} + +export const WithDebugButton: Story = { + render: () => ( + alert('打开调试工具')} debugButtonText="打开调试"> + + + ) +} + +export const WithReloadButton: Story = { + render: () => ( + window.location.reload()} reloadButtonText="重新加载页面"> + + + ) +} + +export const WithBothButtons: Story = { + render: () => ( + alert('打开开发者工具')} + onReloadClick={() => alert('重新加载应用')} + debugButtonText="调试" + reloadButtonText="重载" + errorMessage="应用程序遇到错误"> + + + ) +} + +export const NoError: Story = { + render: () => ( + + + + ) +} + +export const InteractiveDemo: Story = { + render: function InteractiveDemo() { + const [shouldThrow, setShouldThrow] = useState(false) + const [errorMessage, setErrorMessage] = useState('用户触发的错误') + + return ( +
+
+ + setErrorMessage(e.target.value)} + placeholder="自定义错误消息" + className="px-3 py-1 border border-gray-300 rounded text-sm" + /> +
+ + console.log('Debug clicked')} + onReloadClick={() => setShouldThrow(false)} + debugButtonText="控制台调试" + reloadButtonText="重置组件" + errorMessage="交互式错误演示"> + + +
+ ) + } +} + +export const CustomFallback: Story = { + render: () => { + const CustomFallbackComponent = ({ error, onDebugClick, onReloadClick }: CustomFallbackProps) => ( +
+
+

😵 哎呀!

+

看起来出了点小问题...

+

{error?.message}

+
+ {onDebugClick && ( + + )} + {onReloadClick && ( + + )} +
+
+
+ ) + + return ( + alert('自定义调试')} + onReloadClick={() => alert('自定义重载')}> + + + ) + } +} + +export const NestedErrorBoundaries: Story = { + render: () => ( +
+

嵌套错误边界

+ + +
+

外层容器

+

这个容器有自己的错误边界

+ + +
+
内层容器
+ +
+
+
+
+
+ ) +} + +export const MultipleComponents: Story = { + render: () => ( +
+

多个组件保护

+ + window.location.reload()} reloadButtonText="刷新页面"> +
+ + + + +
+
+
+ ) +} + +export const AsyncError: Story = { + render: () => ( + window.location.reload()} + reloadButtonText="重新加载" + errorMessage="异步操作失败"> + + + ) +} diff --git a/packages/ui/stories/components/primitives/IndicatorLight.stories.tsx b/packages/ui/stories/components/primitives/IndicatorLight.stories.tsx new file mode 100644 index 0000000000..ab80ea61cb --- /dev/null +++ b/packages/ui/stories/components/primitives/IndicatorLight.stories.tsx @@ -0,0 +1,344 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { IndicatorLight } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/IndicatorLight', + component: IndicatorLight, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + color: { + control: 'color', + description: '指示灯的颜色(支持预设颜色名称或十六进制值)' + }, + size: { + control: { type: 'range', min: 4, max: 32, step: 2 }, + description: '指示灯的大小(像素)' + }, + shadow: { + control: 'boolean', + description: '是否显示发光阴影效果' + }, + style: { + control: false, + description: '自定义样式对象' + }, + animation: { + control: 'boolean', + description: '是否启用脉冲动画' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + color: 'green' + } +} + +export const Red: Story = { + args: { + color: '#ef4444' + } +} + +export const Blue: Story = { + args: { + color: '#3b82f6' + } +} + +export const Yellow: Story = { + args: { + color: '#eab308' + } +} + +export const Purple: Story = { + args: { + color: '#a855f7' + } +} + +export const Orange: Story = { + args: { + color: '#f97316' + } +} + +export const WithoutShadow: Story = { + args: { + color: 'green', + shadow: false + } +} + +export const WithoutAnimation: Story = { + args: { + color: '#3b82f6', + animation: false + } +} + +export const SmallSize: Story = { + args: { + color: '#ef4444', + size: 6 + } +} + +export const LargeSize: Story = { + args: { + color: '#22c55e', + size: 24 + } +} + +export const CustomStyle: Story = { + args: { + color: '#8b5cf6', + size: 16, + style: { + border: '2px solid #8b5cf6', + opacity: 0.8 + }, + className: 'ring-2 ring-purple-200 dark:ring-purple-800' + } +} + +export const StatusColors: Story = { + render: () => ( +
+

状态指示颜色

+
+
+ + 在线/成功 +
+
+ + 离线/错误 +
+
+ + 警告/等待 +
+
+ + 信息/处理中 +
+
+ + 禁用/未知 +
+
+ + 特殊状态 +
+
+
+ ) +} + +export const SizeComparison: Story = { + render: () => ( +
+

不同尺寸对比

+
+
+ +

小 (6px)

+
+
+ +

默认 (8px)

+
+
+ +

中 (12px)

+
+
+ +

大 (16px)

+
+
+ +

特大 (24px)

+
+
+
+ ) +} + +export const UserStatusList: Story = { + render: () => ( +
+

用户状态列表

+
+
+ +
+

张三

+

在线 - 5分钟前活跃

+
+
+
+ +
+

李四

+

离开 - 30分钟前活跃

+
+
+
+ +
+

王五

+

离线 - 2小时前活跃

+
+
+
+ +
+

赵六

+

忙碌 - 正在通话中

+
+
+
+
+ ) +} + +export const ServiceStatus: Story = { + render: () => ( +
+

服务状态监控

+
+
+
+

Web 服务器

+ +
+

+ 响应时间: 120ms +
+ 正常运行时间: 99.9% +

+
+
+
+

数据库

+ +
+

+ 响应时间: 250ms +
+ 正常运行时间: 98.5% +

+
+
+
+

API 网关

+ +
+

+ 响应时间: 89ms +
+ 正常运行时间: 99.8% +

+
+
+
+

缓存服务

+ +
+

+ 响应时间: 超时 +
+ 正常运行时间: 85.2% +

+
+
+
+ ) +} + +export const AnimationComparison: Story = { + render: () => ( +
+

动画效果对比

+
+
+ +

有动画

+
+
+ +

无动画

+
+
+
+ ) +} + +export const NotificationDot: Story = { + render: () => ( +
+

通知红点示例

+
+
+
📧
+
+ +
+
+
+
🔔
+
+ +
+
+
+
💬
+
+ +
+
+
+
+ ) +} + +export const CustomColors: Story = { + render: () => ( +
+

自定义颜色

+
+ {[ + '#ff6b6b', + '#4ecdc4', + '#45b7d1', + '#f9ca24', + '#6c5ce7', + '#fd79a8', + '#00b894', + '#e17055', + '#74b9ff', + '#fd79a8', + '#00cec9', + '#fdcb6e' + ].map((color, index) => ( +
+ +

{color}

+
+ ))} +
+
+ ) +} diff --git a/packages/ui/stories/components/primitives/RadioGroup.stories.tsx b/packages/ui/stories/components/primitives/RadioGroup.stories.tsx new file mode 100644 index 0000000000..b3c64f2549 --- /dev/null +++ b/packages/ui/stories/components/primitives/RadioGroup.stories.tsx @@ -0,0 +1,534 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Bell, Check, Moon, Palette, Sun } from 'lucide-react' +import { useState } from 'react' + +import { RadioGroup, RadioGroupItem } from '../../../src/components/primitives/radioGroup' + +const meta: Meta = { + title: 'Components/Primitives/RadioGroup', + component: RadioGroup, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A radio group component based on Radix UI, allowing users to select a single option from a set. Supports three sizes (sm, md, lg) as defined in the Figma design system.' + } + } + }, + tags: ['autodocs'], + argTypes: { + disabled: { + control: { type: 'boolean' }, + description: 'Whether the radio group is disabled' + }, + defaultValue: { + control: { type: 'text' }, + description: 'Default selected value' + }, + value: { + control: { type: 'text' }, + description: 'Value in controlled mode' + }, + orientation: { + control: { type: 'select' }, + options: ['horizontal', 'vertical'], + description: 'The orientation of the radio group' + } + } +} + +export default meta +type Story = StoryObj + +// Default +export const Default: Story = { + render: () => ( + +
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// With Default Value +export const WithDefaultValue: Story = { + render: () => ( + +
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// Horizontal Layout +export const HorizontalLayout: Story = { + render: () => ( + +
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// Disabled +export const Disabled: Story = { + render: () => ( + +
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// Disabled Items +export const DisabledItems: Story = { + render: () => ( + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// With Descriptions +export const WithDescriptions: Story = { + render: () => ( + +
+ + +
+
+ + +
+
+ + +
+
+ ) +} + +// Controlled +export const Controlled: Story = { + render: function ControlledExample() { + const [value, setValue] = useState('option1') + + return ( +
+ +
+ + +
+
+ + +
+
+ + +
+
+
Current value: {value}
+
+ ) + } +} + +// Sizes +export const Sizes: Story = { + render: () => ( +
+
+

Small (sm)

+ +
+ + +
+
+ + +
+
+
+ +
+

Medium (md) - Default

+ +
+ + +
+
+ + +
+
+
+ +
+

Large (lg)

+ +
+ + +
+
+ + +
+
+
+
+ ) +} + +// All States +export const AllStates: Story = { + render: function AllStatesExample() { + const [normalValue, setNormalValue] = useState('') + const [selectedValue, setSelectedValue] = useState('option2') + + return ( +
+ {/* Normal State */} +
+

Normal State

+ +
+ + +
+
+
+ + {/* Selected State */} +
+

Selected State

+ +
+ + +
+
+
+ + {/* Disabled State */} +
+

Disabled State

+ +
+ + +
+
+
+ + {/* Error State */} +
+

Error State

+ +
+ + +
+
+

Please select an option

+
+
+ ) + } +} + +// Real World Examples +export const RealWorldExamples: Story = { + render: function RealWorldExample() { + const [theme, setTheme] = useState('light') + const [notifications, setNotifications] = useState('all') + const [visibility, setVisibility] = useState('public') + + return ( +
+ {/* Theme Selection */} +
+

Theme Preference

+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Notification Settings */} +
+

Notification Settings

+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Visibility Settings */} +
+

Profile Visibility

+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Required Field Example */} +
+

+ Payment Method * +

+ +
+ + +
+
+ + +
+
+ + +
+
+

Please select a payment method

+
+
+ ) + } +} + +// Card Style +export const CardStyle: Story = { + render: () => ( + + + + + + + + ) +} diff --git a/packages/ui/stories/components/primitives/Select.stories.tsx b/packages/ui/stories/components/primitives/Select.stories.tsx new file mode 100644 index 0000000000..72424a233b --- /dev/null +++ b/packages/ui/stories/components/primitives/Select.stories.tsx @@ -0,0 +1,440 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Globe, Palette, User } from 'lucide-react' +import { useState } from 'react' + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue +} from '../../../src/components/primitives/select' + +const meta: Meta = { + title: 'Components/Primitives/Select', + component: Select, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A dropdown select component based on Radix UI, with support for groups, separators, and custom content.' + } + } + }, + tags: ['autodocs'], + argTypes: { + disabled: { + control: { type: 'boolean' }, + description: 'Whether the select is disabled' + }, + defaultValue: { + control: { type: 'text' }, + description: 'Default selected value' + }, + value: { + control: { type: 'text' }, + description: 'Value in controlled mode' + } + } +} + +export default meta +type Story = StoryObj + +// Default +export const Default: Story = { + render: () => ( + + ) +} + +// With Default Value +export const WithDefaultValue: Story = { + render: () => ( + + ) +} + +// With Icons +export const WithIcons: Story = { + render: () => ( + + ) +} + +// With Groups +export const WithGroups: Story = { + render: () => ( + + ) +} + +// Sizes +export const Sizes: Story = { + render: () => ( +
+
+

Small

+ +
+ +
+

Default

+ +
+
+ ) +} + +// Disabled +export const Disabled: Story = { + render: () => ( + + ) +} + +// Disabled Items +export const DisabledItems: Story = { + render: () => ( + + ) +} + +// Controlled +export const Controlled: Story = { + render: function ControlledExample() { + const [value, setValue] = useState('option1') + + return ( +
+ +
Current value: {value}
+
+ ) + } +} + +// All States +export const AllStates: Story = { + render: function AllStatesExample() { + const [normalValue, setNormalValue] = useState('') + const [selectedValue, setSelectedValue] = useState('option2') + + return ( +
+ {/* Normal State */} +
+

Normal State

+ +
+ + {/* Selected State */} +
+

Selected State

+ +
+ + {/* Disabled State */} +
+

Disabled State

+ +
+ + {/* Error State */} +
+

Error State

+ +

Please select an option

+
+
+ ) + } +} + +// Real World Examples +export const RealWorldExamples: Story = { + render: function RealWorldExample() { + const [language, setLanguage] = useState('zh-CN') + const [theme, setTheme] = useState('system') + const [timezone, setTimezone] = useState('') + + return ( +
+ {/* Language Selection */} +
+

Language Settings

+ +
+ + {/* Theme Selection */} +
+

Theme Settings

+ +
+ + {/* Timezone Selection (with groups) */} +
+

Timezone Settings

+ +
+ + {/* Required Field Example */} +
+

User Role (Required)

+ +

Please select a user role

+
+
+ ) + } +} + +// Long List +export const LongList: Story = { + render: () => ( + + ) +} diff --git a/packages/ui/stories/components/primitives/Spinner.stories.tsx b/packages/ui/stories/components/primitives/Spinner.stories.tsx new file mode 100644 index 0000000000..633c4f1f1a --- /dev/null +++ b/packages/ui/stories/components/primitives/Spinner.stories.tsx @@ -0,0 +1,343 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' + +import { Button } from '../../../src/components' +import { Spinner } from '../../../src/components' + +const meta: Meta = { + title: 'Components/Primitives/Spinner', + component: Spinner, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + text: { + control: false, + description: '加载文字或React节点' + } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + text: '加载中...' + } +} + +export const ShortText: Story = { + args: { + text: '搜索' + } +} + +export const LongText: Story = { + args: { + text: '正在处理您的请求,请稍候' + } +} + +export const WithReactNode: Story = { + args: { + text: ( + + 加载 数据 中... + + ) + } +} + +export const CustomStyle: Story = { + args: { + text: '自定义样式', + className: 'bg-blue-50 dark:bg-blue-900/20 px-4 py-2 rounded-lg border border-blue-200 dark:border-blue-700' + } +} + +export const LoadingStates: Story = { + render: () => ( +
+

不同加载状态

+
+
+

文件操作

+
+ + + +
+
+ +
+

数据处理

+
+ + + +
+
+ +
+

网络请求

+
+ + + +
+
+
+
+ ) +} + +export const InteractiveDemo: Story = { + render: function InteractiveDemo() { + const [isLoading, setIsLoading] = useState(false) + const [loadingText, setLoadingText] = useState('处理中...') + + const handleStartLoading = () => { + setIsLoading(true) + setTimeout(() => { + setIsLoading(false) + }, 3000) + } + + return ( +
+
+ + setLoadingText(e.target.value)} + placeholder="自定义加载文字" + className="px-3 py-1 border border-gray-300 rounded text-sm" + disabled={isLoading} + /> +
+ + {isLoading && ( +
+ +
+ )} +
+ ) + } +} + +export const InComponents: Story = { + render: () => ( +
+

组件中的应用

+ +
+ {/* 搜索框 */} +
+

搜索框

+
+ +
+ +
+
+
+ + {/* 按钮加载状态 */} +
+

按钮加载状态

+
+ + +
+
+ + {/* 卡片加载 */} +
+

卡片加载

+
+ +
+
+ + {/* 列表加载 */} +
+

列表加载

+
+
+

已加载的项目 1

+
+
+

已加载的项目 2

+
+
+ +
+
+
+
+
+ ) +} + +export const DifferentSizes: Story = { + render: () => ( +
+

不同场景的尺寸

+
+
+ 小尺寸: + +
+
+ 默认: + +
+
+ 大尺寸: + +
+
+
+ ) +} + +export const ColorVariations: Story = { + render: () => ( +
+

颜色变化

+
+
+ + + + + + +
+
+
+ ) +} + +export const BackgroundVariations: Story = { + render: () => ( +
+

背景变化

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} + +export const LoadingSequence: Story = { + render: function LoadingSequence() { + const [step, setStep] = useState(0) + const steps = ['准备中...', '连接服务器...', '验证身份...', '加载数据...', '处理结果...', '完成!'] + + const nextStep = () => { + setStep((prev) => (prev + 1) % steps.length) + } + + const currentStep = steps[step] + const isComplete = step === steps.length - 1 + + return ( +
+ + +
+ {isComplete ? ( +
✅ {currentStep}
+ ) : ( + + )} +
+ +
+ 步骤 {step + 1} / {steps.length} +
+
+ ) + } +} + +export const RealWorldUsage: Story = { + render: () => ( +
+

真实场景应用

+ +
+ {/* 表单提交 */} +
+

表单提交

+
+ + +
+ +
+
+
+ + {/* 文件上传 */} +
+

文件上传

+
+ +
+
+
+
+
+ + {/* 数据获取 */} +
+

数据获取

+
+
+
+
+ +
+
+
+ + {/* 页面切换 */} +
+

页面切换

+
+
+ +
+
+
+
+
+ ) +} diff --git a/packages/ui/stories/tailwind.css b/packages/ui/stories/tailwind.css new file mode 100644 index 0000000000..8142a488b1 --- /dev/null +++ b/packages/ui/stories/tailwind.css @@ -0,0 +1,133 @@ +/* Storybook 专用的 Tailwind CSS 配置 */ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@import '../src/styles/theme.css'; + +/* 扫描组件文件 */ +@source '../src/components/**/*.{js,ts,jsx,tsx}'; + +/* 扫描 stories 文件 */ +@source './components/**/*.{js,ts,jsx,tsx}'; + +/* Dark mode support */ +@custom-variant dark (&:is(.dark *)); + + +:root { + --icon: #00000099; + + /* Shadcn Variables - 只保留 @theme inline 中使用的变量 */ + --primary-foreground: oklch(0.985 0 0); + --card-foreground: oklch(0.145 0 0); + --popover-foreground: oklch(0.145 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive-foreground: oklch(0.577 0.245 27.325); + --input: oklch(0.922 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --icon: #ffffff99; + + /* Shadcn Dark Mode Variables - 只保留 @theme inline 中使用的变量 */ + --primary-foreground: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover-foreground: oklch(0.985 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive-foreground: oklch(0.637 0.237 25.331); + --input: oklch(0.269 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +/* 应用特定的变量和动画(不与 UI 库冲突) */ +@theme inline { + /* Icon 颜色 - 应用特定变量 */ + --color-icon: var(--icon); + + /* Shadcn Tailwind Mappings - 只映射 UI 库缺少的变量 */ + /* 注意:--color-primary, --color-background 等核心变量由 UI 库的 theme.css 提供 */ + + /* Foreground colors - UI 库缺少的 */ + --color-primary-foreground: var(--primary-foreground); + --color-card-foreground: var(--card-foreground); + --color-popover-foreground: var(--popover-foreground); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-accent-foreground: var(--accent-foreground); + --color-destructive-foreground: var(--destructive-foreground); + + /* Input - UI 库缺少的 */ + --color-input: var(--input); + + /* Chart colors - UI 库缺少的 */ + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + /* Sidebar extensions - UI 库缺少的 */ + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* 跑马灯动画 - 应用特定 */ + --animate-marquee: marquee var(--duration) infinite linear; + --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + + @keyframes marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-100% - var(--gap))); + } + } + @keyframes marquee-vertical { + from { + transform: translateY(0); + } + to { + transform: translateY(calc(-100% - var(--gap))); + } + } +} + +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + +:root { + background-color: unset; +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000000..79c5b286fb --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "declaration": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2021"], + "module": "ESNext", + "moduleResolution": "bundler", + "noFallthroughCasesInSwitch": true, + "outDir": "./dist", + "paths": { + "@cherrystudio/ui/*": ["./src/*"] + }, + "resolveJsonModule": true, + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" + }, + "exclude": ["node_modules", "dist", "**/*.test.*", "**/__tests__/**"], + "include": ["src/**/*", "stories/components/**/*"] +} diff --git a/packages/ui/tsdown.config.ts b/packages/ui/tsdown.config.ts new file mode 100644 index 0000000000..5ec6a3dc93 --- /dev/null +++ b/packages/ui/tsdown.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'components/index': 'src/components/index.ts', + 'hooks/index': 'src/hooks/index.ts', + 'utils/index': 'src/utils/index.ts' + }, + outDir: 'dist', + format: ['esm', 'cjs'], + clean: true, + dts: true, + tsconfig: 'tsconfig.json', + // 将 HeroUI、Tailwind 和其他 peer dependencies 标记为外部依赖 + external: [ + 'react', + 'react-dom', + '@heroui/react', + '@heroui/theme', + 'framer-motion', + 'tailwindcss', + // 保留 styled-components 作为外部依赖(迁移期间) + 'styled-components' + ] +}) diff --git a/resources/database/drizzle/0002_wealthy_naoko.sql b/resources/database/drizzle/0002_wealthy_naoko.sql new file mode 100644 index 0000000000..c369ccf61f --- /dev/null +++ b/resources/database/drizzle/0002_wealthy_naoko.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions` ADD `slash_commands` text; \ No newline at end of file diff --git a/resources/database/drizzle/meta/0002_snapshot.json b/resources/database/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000000..ef5eefcb65 --- /dev/null +++ b/resources/database/drizzle/meta/0002_snapshot.json @@ -0,0 +1,346 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8", + "prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87", + "tables": { + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_messages": { + "name": "session_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migrations": { + "name": "migrations", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slash_commands": { + "name": "slash_commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/resources/database/drizzle/meta/_journal.json b/resources/database/drizzle/meta/_journal.json index 8648e01703..ac026637aa 100644 --- a/resources/database/drizzle/meta/_journal.json +++ b/resources/database/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1758187378775, "tag": "0001_woozy_captain_flint", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1762526423527, + "tag": "0002_wealthy_naoko", + "breakpoints": true } ] } diff --git a/resources/js/bridge.js b/resources/js/bridge.js deleted file mode 100644 index f6c0021a63..0000000000 --- a/resources/js/bridge.js +++ /dev/null @@ -1,36 +0,0 @@ -;(() => { - let messageId = 0 - const pendingCalls = new Map() - - function api(method, ...args) { - const id = messageId++ - return new Promise((resolve, reject) => { - pendingCalls.set(id, { resolve, reject }) - window.parent.postMessage({ id, type: 'api-call', method, args }, '*') - }) - } - - window.addEventListener('message', (event) => { - if (event.data.type === 'api-response') { - const { id, result, error } = event.data - const pendingCall = pendingCalls.get(id) - if (pendingCall) { - if (error) { - pendingCall.reject(new Error(error)) - } else { - pendingCall.resolve(result) - } - pendingCalls.delete(id) - } - } - }) - - window.api = new Proxy( - {}, - { - get: (target, prop) => { - return (...args) => api(prop, ...args) - } - } - ) -})() diff --git a/resources/js/utils.js b/resources/js/utils.js deleted file mode 100644 index 36981ac44f..0000000000 --- a/resources/js/utils.js +++ /dev/null @@ -1,5 +0,0 @@ -export function getQueryParam(paramName) { - const url = new URL(window.location.href) - const params = new URLSearchParams(url.search) - return params.get(paramName) -} diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index 1467a4cde4..33ee18d732 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -7,7 +7,7 @@ const { downloadWithRedirects } = require('./download') // Base URL for downloading bun binaries const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download' -const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version +const DEFAULT_BUN_VERSION = '1.3.1' // Default fallback version // Mapping of platform+arch to binary package name const BUN_PACKAGES = { diff --git a/resources/scripts/install-uv.js b/resources/scripts/install-uv.js index 3dc8b3e477..c3d34efc33 100644 --- a/resources/scripts/install-uv.js +++ b/resources/scripts/install-uv.js @@ -7,28 +7,29 @@ const { downloadWithRedirects } = require('./download') // Base URL for downloading uv binaries const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download' -const DEFAULT_UV_VERSION = '0.7.13' +const DEFAULT_UV_VERSION = '0.9.5' // Mapping of platform+arch to binary package name const UV_PACKAGES = { - 'darwin-arm64': 'uv-aarch64-apple-darwin.zip', - 'darwin-x64': 'uv-x86_64-apple-darwin.zip', + 'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz', + 'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz', 'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip', 'win32-ia32': 'uv-i686-pc-windows-msvc.zip', 'win32-x64': 'uv-x86_64-pc-windows-msvc.zip', - 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip', - 'linux-ia32': 'uv-i686-unknown-linux-gnu.zip', - 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip', - 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip', - 'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip', - 'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip', - 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip', + 'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz', + 'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz', + 'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz', + 'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz', + 'linux-riscv64': 'uv-riscv64gc-unknown-linux-gnu.tar.gz', + 'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz', + 'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz', + 'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz', // MUSL variants - 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip', - 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip', - 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip', - 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip', - 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip' + 'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz', + 'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz', + 'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz', + 'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz', + 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz' } /** @@ -56,6 +57,7 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is const downloadUrl = `${UV_RELEASE_BASE_URL}/${version}/${packageName}` const tempdir = os.tmpdir() const tempFilename = path.join(tempdir, packageName) + const isTarGz = packageName.endsWith('.tar.gz') try { console.log(`Downloading uv ${version} for ${platformKey}...`) @@ -65,34 +67,58 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is console.log(`Extracting ${packageName} to ${binDir}...`) - const zip = new StreamZip.async({ file: tempFilename }) + if (isTarGz) { + // Use tar command to extract tar.gz files (macOS and Linux) + const tempExtractDir = path.join(tempdir, `uv-extract-${Date.now()}`) + fs.mkdirSync(tempExtractDir, { recursive: true }) - // Get all entries in the zip file - const entries = await zip.entries() + execSync(`tar -xzf "${tempFilename}" -C "${tempExtractDir}"`, { stdio: 'inherit' }) - // Extract files directly to binDir, flattening the directory structure - for (const entry of Object.values(entries)) { - if (!entry.isDirectory) { - // Get just the filename without path - const filename = path.basename(entry.name) - const outputPath = path.join(binDir, filename) - - console.log(`Extracting ${entry.name} -> ${filename}`) - await zip.extract(entry.name, outputPath) - // Make executable files executable on Unix-like systems - if (platform !== 'win32') { - try { + // Find all files in the extracted directory and move them to binDir + const findAndMoveFiles = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + findAndMoveFiles(fullPath) + } else { + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) + fs.copyFileSync(fullPath, outputPath) + console.log(`Extracted ${entry.name} -> ${outputPath}`) + // Make executable on Unix-like systems fs.chmodSync(outputPath, 0o755) - } catch (chmodError) { - console.error(`Warning: Failed to set executable permissions on ${filename}`) - return 102 } } - console.log(`Extracted ${entry.name} -> ${outputPath}`) } + + findAndMoveFiles(tempExtractDir) + + // Clean up temporary extraction directory + fs.rmSync(tempExtractDir, { recursive: true }) + } else { + // Use StreamZip for zip files (Windows) + const zip = new StreamZip.async({ file: tempFilename }) + + // Get all entries in the zip file + const entries = await zip.entries() + + // Extract files directly to binDir, flattening the directory structure + for (const entry of Object.values(entries)) { + if (!entry.isDirectory) { + // Get just the filename without path + const filename = path.basename(entry.name) + const outputPath = path.join(binDir, filename) + + console.log(`Extracting ${entry.name} -> ${filename}`) + await zip.extract(entry.name, outputPath) + console.log(`Extracted ${entry.name} -> ${outputPath}`) + } + } + + await zip.close() } - await zip.close() fs.unlinkSync(tempFilename) console.log(`Successfully installed uv ${version} for ${platform}-${arch}`) return 0 diff --git a/resources/scripts/ipService.js b/resources/scripts/ipService.js deleted file mode 100644 index 8e997659a7..0000000000 --- a/resources/scripts/ipService.js +++ /dev/null @@ -1,88 +0,0 @@ -const https = require('https') -const { loggerService } = require('@logger') - -const logger = loggerService.withContext('IpService') - -/** - * 获取用户的IP地址所在国家 - * @returns {Promise} 返回国家代码,默认为'CN' - */ -async function getIpCountry() { - return new Promise((resolve) => { - // 添加超时控制 - const timeout = setTimeout(() => { - logger.info('IP Address Check Timeout, default to China Mirror') - resolve('CN') - }, 5000) - - const options = { - hostname: 'ipinfo.io', - path: '/json', - method: 'GET', - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.9' - } - } - - const req = https.request(options, (res) => { - clearTimeout(timeout) - let data = '' - - res.on('data', (chunk) => { - data += chunk - }) - - res.on('end', () => { - try { - const parsed = JSON.parse(data) - const country = parsed.country || 'CN' - logger.info(`Detected user IP address country: ${country}`) - resolve(country) - } catch (error) { - logger.error('Failed to parse IP address information:', error.message) - resolve('CN') - } - }) - }) - - req.on('error', (error) => { - clearTimeout(timeout) - logger.error('Failed to get IP address information:', error.message) - resolve('CN') - }) - - req.end() - }) -} - -/** - * 检查用户是否在中国 - * @returns {Promise} 如果用户在中国返回true,否则返回false - */ -async function isUserInChina() { - const country = await getIpCountry() - return country.toLowerCase() === 'cn' -} - -/** - * 根据用户位置获取适合的npm镜像URL - * @returns {Promise} 返回npm镜像URL - */ -async function getNpmRegistryUrl() { - const inChina = await isUserInChina() - if (inChina) { - logger.info('User in China, using Taobao npm mirror') - return 'https://registry.npmmirror.com' - } else { - logger.info('User not in China, using default npm mirror') - return 'https://registry.npmjs.org' - } -} - -module.exports = { - getIpCountry, - isUserInChina, - getNpmRegistryUrl -} diff --git a/scripts/auto-translate-i18n.ts b/scripts/auto-translate-i18n.ts index 71650f6618..7a1bea6f35 100644 --- a/scripts/auto-translate-i18n.ts +++ b/scripts/auto-translate-i18n.ts @@ -18,8 +18,10 @@ import { sortedObjectByKeys } from './sort' // ========== SCRIPT CONFIGURATION AREA - MODIFY SETTINGS HERE ========== const SCRIPT_CONFIG = { // 🔧 Concurrency Control Configuration - MAX_CONCURRENT_TRANSLATIONS: 5, // Max concurrent requests (Make sure the concurrency level does not exceed your provider's limits.) - TRANSLATION_DELAY_MS: 100, // Delay between requests to avoid rate limiting (Recommended: 100-500ms, Range: 0-5000ms) + MAX_CONCURRENT_TRANSLATIONS: process.env.TRANSLATION_MAX_CONCURRENT_REQUESTS + ? parseInt(process.env.TRANSLATION_MAX_CONCURRENT_REQUESTS) + : 5, // Max concurrent requests (Make sure the concurrency level does not exceed your provider's limits.) + TRANSLATION_DELAY_MS: process.env.TRANSLATION_DELAY_MS ? parseInt(process.env.TRANSLATION_DELAY_MS) : 500, // Delay between requests to avoid rate limiting (Recommended: 100-500ms, Range: 0-5000ms) // 🔑 API Configuration API_KEY: process.env.TRANSLATION_API_KEY || '', // API key from environment variable diff --git a/scripts/update-app-upgrade-config.ts b/scripts/update-app-upgrade-config.ts new file mode 100644 index 0000000000..4fcfa647f7 --- /dev/null +++ b/scripts/update-app-upgrade-config.ts @@ -0,0 +1,532 @@ +import fs from 'fs/promises' +import path from 'path' +import semver from 'semver' + +type UpgradeChannel = 'latest' | 'rc' | 'beta' +type UpdateMirror = 'github' | 'gitcode' + +const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta'] +const MIRRORS: UpdateMirror[] = ['github', 'gitcode'] +const GITHUB_REPO = 'CherryHQ/cherry-studio' +const GITCODE_REPO = 'CherryHQ/cherry-studio' +const DEFAULT_FEED_TEMPLATES: Record = { + github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`, + gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}` +} +const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com' + +interface CliOptions { + tag?: string + configPath?: string + segmentsPath?: string + dryRun?: boolean + skipReleaseChecks?: boolean + isPrerelease?: boolean +} + +interface ChannelTemplateConfig { + feedTemplates?: Partial> +} + +interface SegmentMatchRule { + range?: string + exact?: string[] + excludeExact?: string[] +} + +interface SegmentDefinition { + id: string + type: 'legacy' | 'breaking' | 'latest' + match: SegmentMatchRule + lockedVersion?: string + minCompatibleVersion: string + description: string + channelTemplates?: Partial> +} + +interface SegmentMetadataFile { + segments: SegmentDefinition[] +} + +interface ChannelConfig { + version: string + feedUrls: Record +} + +interface VersionMetadata { + segmentId: string + segmentType?: string +} + +interface VersionEntry { + metadata?: VersionMetadata + minCompatibleVersion: string + description: string + channels: Record +} + +interface UpgradeConfigFile { + lastUpdated: string + versions: Record +} + +interface ReleaseInfo { + tag: string + version: string + channel: UpgradeChannel +} + +interface UpdateVersionsResult { + versions: Record + updated: boolean +} + +const ROOT_DIR = path.resolve(__dirname, '..') +const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json') +const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json') + +async function main() { + const options = parseArgs() + const releaseTag = resolveTag(options) + const normalizedVersion = normalizeVersion(releaseTag) + const releaseChannel = detectChannel(normalizedVersion) + if (!releaseChannel) { + console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`) + return + } + + // Validate version format matches prerelease status + if (options.isPrerelease !== undefined) { + const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc' + + if (options.isPrerelease && !hasPrereleaseSuffix) { + console.warn( + `[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.` + ) + return + } + + if (!options.isPrerelease && hasPrereleaseSuffix) { + console.warn( + `[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.` + ) + return + } + } + + const [config, segmentFile] = await Promise.all([ + readJson(options.configPath ?? DEFAULT_CONFIG_PATH), + readJson(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH) + ]) + + const segment = pickSegment(segmentFile.segments, normalizedVersion) + if (!segment) { + throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`) + } + + if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) { + throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`) + } + + const releaseInfo: ReleaseInfo = { + tag: formatTag(releaseTag), + version: normalizedVersion, + channel: releaseChannel + } + + const { versions: updatedVersions, updated } = await updateVersions( + config.versions, + segment, + releaseInfo, + Boolean(options.skipReleaseChecks) + ) + + if (!updated) { + throw new Error( + `[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.` + ) + } + + const updatedConfig: UpgradeConfigFile = { + ...config, + lastUpdated: new Date().toISOString(), + versions: updatedVersions + } + + const output = JSON.stringify(updatedConfig, null, 2) + '\n' + + if (options.dryRun) { + console.log('Dry run enabled. Generated configuration:\n') + console.log(output) + return + } + + await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8') + console.log( + `✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}` + ) +} + +function parseArgs(): CliOptions { + const args = process.argv.slice(2) + const options: CliOptions = {} + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] + if (arg === '--tag') { + options.tag = args[i + 1] + i += 1 + } else if (arg === '--config') { + options.configPath = args[i + 1] + i += 1 + } else if (arg === '--segments') { + options.segmentsPath = args[i + 1] + i += 1 + } else if (arg === '--dry-run') { + options.dryRun = true + } else if (arg === '--skip-release-checks') { + options.skipReleaseChecks = true + } else if (arg === '--is-prerelease') { + options.isPrerelease = args[i + 1] === 'true' + i += 1 + } else if (arg === '--help') { + printHelp() + process.exit(0) + } else { + console.warn(`Ignoring unknown argument "${arg}"`) + } + } + + if (options.skipReleaseChecks && !options.dryRun) { + throw new Error('--skip-release-checks can only be used together with --dry-run') + } + + return options +} + +function printHelp() { + console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options] + +Options: + --tag Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG. + --config Path to app-upgrade-config.json. + --segments Path to app-upgrade-segments.json. + --is-prerelease Whether this is a prerelease (validates version format). + --dry-run Print the result without writing to disk. + --skip-release-checks Skip release page availability checks (only valid with --dry-run). + --help Show this help message.`) +} + +function resolveTag(options: CliOptions): string { + const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME + const tag = options.tag ?? envTag + + if (!tag) { + throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.') + } + + return tag +} + +function normalizeVersion(tag: string): string { + const cleaned = semver.clean(tag, { loose: true }) + if (!cleaned) { + throw new Error(`Tag "${tag}" is not a valid semantic version`) + } + + const valid = semver.valid(cleaned, { loose: true }) + if (!valid) { + throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`) + } + + return valid +} + +function detectChannel(version: string): UpgradeChannel | null { + const parsed = semver.parse(version, { loose: true, includePrerelease: true }) + if (!parsed) { + return null + } + + if (parsed.prerelease.length === 0) { + return 'latest' + } + + const label = String(parsed.prerelease[0]).toLowerCase() + if (label === 'beta') { + return 'beta' + } + if (label === 'rc') { + return 'rc' + } + + return null +} + +async function readJson(filePath: string): Promise { + const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath) + const data = await fs.readFile(absolute, 'utf-8') + return JSON.parse(data) as T +} + +function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null { + for (const segment of segments) { + if (matchesSegment(segment.match, version)) { + return segment + } + } + return null +} + +function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean { + if (matchRule.exact && matchRule.exact.includes(version)) { + return true + } + + if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) { + return false + } + + if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) { + return false + } + + if (matchRule.exact) { + return matchRule.exact.includes(version) + } + + return Boolean(matchRule.range) +} + +function formatTag(tag: string): string { + if (tag.startsWith('refs/tags/')) { + return tag.replace('refs/tags/', '') + } + return tag +} + +async function updateVersions( + versions: Record, + segment: SegmentDefinition, + releaseInfo: ReleaseInfo, + skipReleaseValidation: boolean +): Promise { + const versionsCopy: Record = { ...versions } + const existingKey = findVersionKeyBySegment(versionsCopy, segment.id) + const targetKey = resolveVersionKey(existingKey, segment, releaseInfo) + const shouldRename = existingKey && existingKey !== targetKey + + let entry: VersionEntry + if (existingKey) { + entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } } + } else { + entry = createEmptyVersionEntry() + } + + entry.channels = ensureChannelSlots(entry.channels) + + const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation) + if (!channelUpdated) { + return { versions, updated: false } + } + + if (shouldRename && existingKey) { + delete versionsCopy[existingKey] + } + + entry.metadata = { + segmentId: segment.id, + segmentType: segment.type + } + entry.minCompatibleVersion = segment.minCompatibleVersion + entry.description = segment.description + + versionsCopy[targetKey] = entry + return { + versions: sortVersionMap(versionsCopy), + updated: true + } +} + +function findVersionKeyBySegment(versions: Record, segmentId: string): string | null { + for (const [key, value] of Object.entries(versions)) { + if (value.metadata?.segmentId === segmentId) { + return key + } + } + return null +} + +function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string { + if (segment.lockedVersion) { + return segment.lockedVersion + } + + if (releaseInfo.channel === 'latest') { + return releaseInfo.version + } + + if (existingKey) { + return existingKey + } + + const baseVersion = getBaseVersion(releaseInfo.version) + return baseVersion ?? releaseInfo.version +} + +function getBaseVersion(version: string): string | null { + const parsed = semver.parse(version, { loose: true, includePrerelease: true }) + if (!parsed) { + return null + } + return `${parsed.major}.${parsed.minor}.${parsed.patch}` +} + +function createEmptyVersionEntry(): VersionEntry { + return { + minCompatibleVersion: '', + description: '', + channels: { + latest: null, + rc: null, + beta: null + } + } +} + +function ensureChannelSlots( + channels: Record +): Record { + return CHANNELS.reduce( + (acc, channel) => { + acc[channel] = channels[channel] ?? null + return acc + }, + {} as Record + ) +} + +async function applyChannelUpdate( + entry: VersionEntry, + segment: SegmentDefinition, + releaseInfo: ReleaseInfo, + skipReleaseValidation: boolean +): Promise { + if (!CHANNELS.includes(releaseInfo.channel)) { + throw new Error(`Unsupported channel "${releaseInfo.channel}"`) + } + + const feedUrls = buildFeedUrls(segment, releaseInfo) + + if (skipReleaseValidation) { + console.warn( + `[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).` + ) + } else { + const availability = await ensureReleaseAvailability(releaseInfo) + if (!availability.github) { + return false + } + if (releaseInfo.channel === 'latest' && !availability.gitcode) { + console.warn( + `[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.` + ) + feedUrls.gitcode = GITCODE_LATEST_FALLBACK + } + } + + entry.channels[releaseInfo.channel] = { + version: releaseInfo.version, + feedUrls + } + + return true +} + +function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record { + return MIRRORS.reduce( + (acc, mirror) => { + const template = resolveFeedTemplate(segment, releaseInfo, mirror) + acc[mirror] = applyTemplate(template, releaseInfo) + return acc + }, + {} as Record + ) +} + +function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string { + if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') { + return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github + } + + return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror] +} + +function applyTemplate(template: string, releaseInfo: ReleaseInfo): string { + return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version) +} + +function sortVersionMap(versions: Record): Record { + const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b)) + return sorted.reduce( + (acc, [version, entry]) => { + acc[version] = entry + return acc + }, + {} as Record + ) +} + +interface ReleaseAvailability { + github: boolean + gitcode: boolean +} + +async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise { + const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github'] + const availability: ReleaseAvailability = { + github: false, + gitcode: releaseInfo.channel === 'latest' ? false : true + } + + for (const mirror of mirrorsToCheck) { + const url = getReleasePageUrl(mirror, releaseInfo.tag) + try { + const response = await fetch(url, { + method: mirror === 'github' ? 'HEAD' : 'GET', + redirect: 'follow' + }) + + if (response.ok) { + availability[mirror] = true + } else { + console.warn( + `[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).` + ) + availability[mirror] = false + } + } catch (error) { + console.warn( + `[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`, + error + ) + availability[mirror] = false + } + } + + return availability +} + +function getReleasePageUrl(mirror: UpdateMirror, tag: string): string { + if (mirror === 'github') { + return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}` + } + // Use latest.yml download URL for GitCode to check if release exists + // Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability + return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml` +} + +main().catch((error) => { + console.error('❌ Failed to update app-upgrade-config:', error) + process.exit(1) +}) diff --git a/src/main/apiServer/config.ts b/src/main/apiServer/config.ts index c962726d24..60b1986be9 100644 --- a/src/main/apiServer/config.ts +++ b/src/main/apiServer/config.ts @@ -1,4 +1,4 @@ -import { ApiServerConfig } from '@types' +import type { ApiServerConfig } from '@types' import { v4 as uuidv4 } from 'uuid' import { loggerService } from '../services/LoggerService' diff --git a/src/main/apiServer/middleware/auth.ts b/src/main/apiServer/middleware/auth.ts index 93548c4a50..bf44e4eb37 100644 --- a/src/main/apiServer/middleware/auth.ts +++ b/src/main/apiServer/middleware/auth.ts @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { NextFunction, Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { config } from '../config' diff --git a/src/main/apiServer/middleware/error.ts b/src/main/apiServer/middleware/error.ts index 401a8cad84..03c2d5617e 100644 --- a/src/main/apiServer/middleware/error.ts +++ b/src/main/apiServer/middleware/error.ts @@ -1,4 +1,4 @@ -import { NextFunction, Request, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { loggerService } from '../../services/LoggerService' diff --git a/src/main/apiServer/middleware/openapi.ts b/src/main/apiServer/middleware/openapi.ts index da3cfe0c4c..ff01005bd9 100644 --- a/src/main/apiServer/middleware/openapi.ts +++ b/src/main/apiServer/middleware/openapi.ts @@ -1,4 +1,4 @@ -import { Express } from 'express' +import type { Express } from 'express' import swaggerJSDoc from 'swagger-jsdoc' import swaggerUi from 'swagger-ui-express' @@ -171,7 +171,7 @@ const swaggerOptions: swaggerJSDoc.Options = { } ] }, - apis: ['./src/main/apiServer/routes/*.ts', './src/main/apiServer/app.ts'] + apis: ['./src/main/apiServer/routes/**/*.ts', './src/main/apiServer/app.ts'] } export function setupOpenAPIDocumentation(app: Express) { diff --git a/src/main/apiServer/routes/agents/handlers/agents.ts b/src/main/apiServer/routes/agents/handlers/agents.ts index d6f31a555d..0b21cb1d68 100644 --- a/src/main/apiServer/routes/agents/handlers/agents.ts +++ b/src/main/apiServer/routes/agents/handlers/agents.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { AgentModelValidationError, agentService, sessionService } from '@main/services/agents' -import { ListAgentsResponse, type ReplaceAgentRequest, type UpdateAgentRequest } from '@types' -import { Request, Response } from 'express' +import type { ListAgentsResponse, ReplaceAgentRequest, UpdateAgentRequest } from '@types' +import type { Request, Response } from 'express' import type { ValidationRequest } from '../validators/zodValidator' diff --git a/src/main/apiServer/routes/agents/handlers/messages.ts b/src/main/apiServer/routes/agents/handlers/messages.ts index e18fadc0e0..1b547abba8 100644 --- a/src/main/apiServer/routes/agents/handlers/messages.ts +++ b/src/main/apiServer/routes/agents/handlers/messages.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts' import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController' import { agentService, sessionMessageService, sessionService } from '@main/services/agents' -import { Request, Response } from 'express' +import type { Request, Response } from 'express' const logger = loggerService.withContext('ApiServerMessagesHandlers') diff --git a/src/main/apiServer/routes/agents/handlers/sessions.ts b/src/main/apiServer/routes/agents/handlers/sessions.ts index 72875dab8a..2f42fe203f 100644 --- a/src/main/apiServer/routes/agents/handlers/sessions.ts +++ b/src/main/apiServer/routes/agents/handlers/sessions.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { AgentModelValidationError, sessionMessageService, sessionService } from '@main/services/agents' -import { ListAgentSessionsResponse, type ReplaceSessionRequest, UpdateSessionResponse } from '@types' -import { Request, Response } from 'express' +import type { ListAgentSessionsResponse, ReplaceSessionRequest, UpdateSessionResponse } from '@types' +import type { Request, Response } from 'express' import type { ValidationRequest } from '../validators/zodValidator' diff --git a/src/main/apiServer/routes/agents/middleware/common.ts b/src/main/apiServer/routes/agents/middleware/common.ts index d45f197e4a..7ca5db8baf 100644 --- a/src/main/apiServer/routes/agents/middleware/common.ts +++ b/src/main/apiServer/routes/agents/middleware/common.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express' +import type { Request, Response } from 'express' import { agentService } from '../../../../services/agents' import { loggerService } from '../../../../services/LoggerService' diff --git a/src/main/apiServer/routes/agents/validators/zodValidator.ts b/src/main/apiServer/routes/agents/validators/zodValidator.ts index 1a0e83786a..971c1445ed 100644 --- a/src/main/apiServer/routes/agents/validators/zodValidator.ts +++ b/src/main/apiServer/routes/agents/validators/zodValidator.ts @@ -1,5 +1,6 @@ -import { NextFunction, Request, Response } from 'express' -import { ZodError, ZodType } from 'zod' +import type { NextFunction, Request, Response } from 'express' +import type { ZodType } from 'zod' +import { ZodError } from 'zod' export interface ValidationRequest extends Request { validatedBody?: any diff --git a/src/main/apiServer/routes/chat.ts b/src/main/apiServer/routes/chat.ts index baf29aae08..3dd58b9654 100644 --- a/src/main/apiServer/routes/chat.ts +++ b/src/main/apiServer/routes/chat.ts @@ -1,5 +1,6 @@ -import { ChatCompletionCreateParams } from '@cherrystudio/openai/resources' -import express, { Request, Response } from 'express' +import type { ChatCompletionCreateParams } from '@cherrystudio/openai/resources' +import type { Request, Response } from 'express' +import express from 'express' import { loggerService } from '../../services/LoggerService' import { diff --git a/src/main/apiServer/routes/mcp.ts b/src/main/apiServer/routes/mcp.ts index 6474194712..90626af158 100644 --- a/src/main/apiServer/routes/mcp.ts +++ b/src/main/apiServer/routes/mcp.ts @@ -1,4 +1,5 @@ -import express, { Request, Response } from 'express' +import type { Request, Response } from 'express' +import express from 'express' import { loggerService } from '../../services/LoggerService' import { mcpApiService } from '../services/mcp' diff --git a/src/main/apiServer/routes/messages.ts b/src/main/apiServer/routes/messages.ts index 3b7c338199..02ce0544e8 100644 --- a/src/main/apiServer/routes/messages.ts +++ b/src/main/apiServer/routes/messages.ts @@ -1,7 +1,8 @@ -import { MessageCreateParams } from '@anthropic-ai/sdk/resources' +import type { MessageCreateParams } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' -import { Provider } from '@types' -import express, { Request, Response } from 'express' +import type { Provider } from '@types' +import type { Request, Response } from 'express' +import express from 'express' import { messagesService } from '../services/messages' import { getProviderById, validateModelId } from '../utils' diff --git a/src/main/apiServer/routes/models.ts b/src/main/apiServer/routes/models.ts index d926a23523..5f414057fa 100644 --- a/src/main/apiServer/routes/models.ts +++ b/src/main/apiServer/routes/models.ts @@ -1,7 +1,9 @@ -import { ApiModelsFilterSchema, ApiModelsResponse } from '@types' -import express, { Request, Response } from 'express' +import { loggerService } from '@logger' +import type { ApiModelsResponse } from '@types' +import { ApiModelsFilterSchema } from '@types' +import type { Request, Response } from 'express' +import express from 'express' -import { loggerService } from '../../services/LoggerService' import { modelsService } from '../services/models' const logger = loggerService.withContext('ApiServerModelsRoutes') diff --git a/src/main/apiServer/server.ts b/src/main/apiServer/server.ts index 3cb81f4124..9b15e56da0 100644 --- a/src/main/apiServer/server.ts +++ b/src/main/apiServer/server.ts @@ -1,8 +1,10 @@ import { createServer } from 'node:http' import { loggerService } from '@logger' +import { IpcChannel } from '@shared/IpcChannel' import { agentService } from '../services/agents' +import { windowService } from '../services/WindowService' import { app } from './app' import { config } from './config' @@ -43,6 +45,13 @@ export class ApiServer { return new Promise((resolve, reject) => { this.server!.listen(port, host, () => { logger.info('API server started', { host, port }) + + // Notify renderer that API server is ready + const mainWindow = windowService.getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IpcChannel.ApiServer_Ready) + } + resolve() }) diff --git a/src/main/apiServer/services/chat-completion.ts b/src/main/apiServer/services/chat-completion.ts index 63eed3ed8b..49627336b0 100644 --- a/src/main/apiServer/services/chat-completion.ts +++ b/src/main/apiServer/services/chat-completion.ts @@ -1,9 +1,9 @@ import OpenAI from '@cherrystudio/openai' -import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources' -import { Provider } from '@types' +import type { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources' +import { loggerService } from '@logger' +import type { Provider } from '@types' -import { loggerService } from '../../services/LoggerService' -import { ModelValidationError, validateModelId } from '../utils' +import { type ModelValidationError, validateModelId } from '../utils' const logger = loggerService.withContext('ChatCompletionService') diff --git a/src/main/apiServer/services/mcp.ts b/src/main/apiServer/services/mcp.ts index c03a90f930..d75fadee6c 100644 --- a/src/main/apiServer/services/mcp.ts +++ b/src/main/apiServer/services/mcp.ts @@ -1,16 +1,12 @@ import mcpService from '@main/services/MCPService' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp' -import { - isJSONRPCRequest, - JSONRPCMessage, - JSONRPCMessageSchema, - MessageExtraInfo -} from '@modelcontextprotocol/sdk/types' -import { MCPServer } from '@types' +import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types' +import { isJSONRPCRequest, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types' +import type { MCPServer } from '@types' import { randomUUID } from 'crypto' import { EventEmitter } from 'events' -import { Request, Response } from 'express' -import { IncomingMessage, ServerResponse } from 'http' +import type { Request, Response } from 'express' +import type { IncomingMessage, ServerResponse } from 'http' import { loggerService } from '../../services/LoggerService' import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp' diff --git a/src/main/apiServer/services/messages.ts b/src/main/apiServer/services/messages.ts index edce9a9528..8b46deaa8f 100644 --- a/src/main/apiServer/services/messages.ts +++ b/src/main/apiServer/services/messages.ts @@ -1,10 +1,10 @@ -import Anthropic from '@anthropic-ai/sdk' -import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources' +import type Anthropic from '@anthropic-ai/sdk' +import type { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources' import { loggerService } from '@logger' import anthropicService from '@main/services/AnthropicService' import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic' -import { Provider } from '@types' -import { Response } from 'express' +import type { Provider } from '@types' +import type { Response } from 'express' const logger = loggerService.withContext('MessagesService') const EXCLUDED_FORWARD_HEADERS: ReadonlySet = new Set([ diff --git a/src/main/apiServer/services/models.ts b/src/main/apiServer/services/models.ts index 660686ef45..a32d6d37dc 100644 --- a/src/main/apiServer/services/models.ts +++ b/src/main/apiServer/services/models.ts @@ -1,6 +1,6 @@ import { isEmpty } from 'lodash' -import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' +import type { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels' import { loggerService } from '../../services/LoggerService' import { getAvailableProviders, diff --git a/src/main/apiServer/utils/index.ts b/src/main/apiServer/utils/index.ts index 7fb0c3511f..525ffef57f 100644 --- a/src/main/apiServer/utils/index.ts +++ b/src/main/apiServer/utils/index.ts @@ -1,7 +1,7 @@ -import { CacheService } from '@main/services/CacheService' -import { loggerService } from '@main/services/LoggerService' +import { cacheService } from '@data/CacheService' +import { loggerService } from '@logger' import { reduxService } from '@main/services/ReduxService' -import { ApiModel, Model, Provider } from '@types' +import type { ApiModel, Model, Provider } from '@types' const logger = loggerService.withContext('ApiServerUtils') @@ -12,7 +12,7 @@ const PROVIDERS_CACHE_TTL = 10 * 1000 // 10 seconds export async function getAvailableProviders(): Promise { try { // Try to get from cache first (faster) - const cachedSupportedProviders = CacheService.get(PROVIDERS_CACHE_KEY) + const cachedSupportedProviders = cacheService.get(PROVIDERS_CACHE_KEY) if (cachedSupportedProviders && cachedSupportedProviders.length > 0) { logger.debug('Providers resolved from cache', { count: cachedSupportedProviders.length @@ -33,7 +33,7 @@ export async function getAvailableProviders(): Promise { ) // Cache the filtered results - CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL) + cacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL) logger.info('Providers filtered', { supported: supportedProviders.length, diff --git a/src/main/apiServer/utils/mcp.ts b/src/main/apiServer/utils/mcp.ts index 40a9006528..ff3cca11ee 100644 --- a/src/main/apiServer/utils/mcp.ts +++ b/src/main/apiServer/utils/mcp.ts @@ -1,11 +1,11 @@ -import { CacheService } from '@main/services/CacheService' +import { cacheService } from '@data/CacheService' +import { loggerService } from '@logger' import mcpService from '@main/services/MCPService' +import { reduxService } from '@main/services/ReduxService' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js' -import { MCPServer } from '@types' - -import { loggerService } from '../../services/LoggerService' -import { reduxService } from '../../services/ReduxService' +import type { ListToolsResult } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import type { MCPServer } from '@types' const logger = loggerService.withContext('MCPApiService') @@ -50,7 +50,7 @@ export async function getMCPServersFromRedux(): Promise { logger.debug('Getting servers from Redux store') // Try to get from cache first (faster) - const cachedServers = CacheService.get(MCP_SERVERS_CACHE_KEY) + const cachedServers = cacheService.get(MCP_SERVERS_CACHE_KEY) if (cachedServers) { logger.debug('MCP servers resolved from cache', { count: cachedServers.length }) return cachedServers @@ -61,7 +61,7 @@ export async function getMCPServersFromRedux(): Promise { const serverList = servers || [] // Cache the results - CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL) + cacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL) logger.debug('Fetched servers from Redux store', { count: serverList.length }) return serverList diff --git a/src/main/data/CacheService.ts b/src/main/data/CacheService.ts new file mode 100644 index 0000000000..d7571205d2 --- /dev/null +++ b/src/main/data/CacheService.ts @@ -0,0 +1,173 @@ +/** + * @fileoverview CacheService - Infrastructure component for multi-tier caching + * + * NAMING NOTE: + * This component is named "CacheService" for management consistency, but it is + * actually an infrastructure component (cache manager) rather than a business service. + * + * True Nature: Cache Manager / Infrastructure Utility + * - Provides low-level caching primitives (memory/shared/persist tiers) + * - Manages TTL, expiration, and cross-window synchronization via IPC + * - Contains zero business logic - purely technical functionality + * - Acts as a utility for other services (PreferenceService, business services) + * + * The "Service" suffix is kept for consistency with existing codebase conventions, + * but developers should understand this is infrastructure, not business logic. + * + * @see {@link CacheService} For implementation details + */ + +import { loggerService } from '@logger' +import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' +import { IpcChannel } from '@shared/IpcChannel' +import { BrowserWindow, ipcMain } from 'electron' + +const logger = loggerService.withContext('CacheService') + +/** + * Main process cache service + * + * Features: + * - Main process internal cache with TTL support + * - IPC handlers for cross-window cache synchronization + * - Broadcast mechanism for shared cache sync + * - Minimal storage (persist cache interface reserved for future) + * + * Responsibilities: + * 1. Provide cache for Main process services + * 2. Relay cache sync messages between renderer windows + * 3. Reserve persist cache interface (not implemented yet) + */ +export class CacheService { + private static instance: CacheService + private initialized = false + + // Main process cache + private cache = new Map() + + private constructor() { + // Private constructor for singleton pattern + } + + public async initialize(): Promise { + if (this.initialized) { + logger.warn('CacheService already initialized') + return + } + + this.setupIpcHandlers() + logger.info('CacheService initialized') + } + + /** + * Get singleton instance + */ + public static getInstance(): CacheService { + if (!CacheService.instance) { + CacheService.instance = new CacheService() + } + return CacheService.instance + } + + // ============ Main Process Cache (Internal) ============ + + /** + * Get value from main process cache + */ + get(key: string): T | undefined { + const entry = this.cache.get(key) + if (!entry) return undefined + + // Check TTL (lazy cleanup) + if (entry.expireAt && Date.now() > entry.expireAt) { + this.cache.delete(key) + return undefined + } + + return entry.value as T + } + + /** + * Set value in main process cache + */ + set(key: string, value: T, ttl?: number): void { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + + this.cache.set(key, entry) + } + + /** + * Check if key exists in main process cache + */ + has(key: string): boolean { + const entry = this.cache.get(key) + if (!entry) return false + + // Check TTL + if (entry.expireAt && Date.now() > entry.expireAt) { + this.cache.delete(key) + return false + } + + return true + } + + /** + * Delete from main process cache + */ + delete(key: string): boolean { + return this.cache.delete(key) + } + + // ============ Persist Cache Interface (Reserved) ============ + + // TODO: Implement persist cache in future + + // ============ IPC Handlers for Cache Synchronization ============ + + /** + * Broadcast sync message to all renderer windows + */ + private broadcastSync(message: CacheSyncMessage, senderWindowId?: number): void { + const windows = BrowserWindow.getAllWindows() + for (const window of windows) { + if (!window.isDestroyed() && window.id !== senderWindowId) { + window.webContents.send(IpcChannel.Cache_Sync, message) + } + } + } + + /** + * Setup IPC handlers for cache synchronization + */ + private setupIpcHandlers(): void { + // Handle cache sync broadcast from renderer + ipcMain.on(IpcChannel.Cache_Sync, (event, message: CacheSyncMessage) => { + const senderWindowId = BrowserWindow.fromWebContents(event.sender)?.id + this.broadcastSync(message, senderWindowId) + logger.verbose(`Broadcasted cache sync: ${message.type}:${message.key}`) + }) + + logger.debug('Cache sync IPC handlers registered') + } + + /** + * Cleanup resources + */ + public cleanup(): void { + // Clear cache + this.cache.clear() + + // Remove IPC handlers + ipcMain.removeAllListeners(IpcChannel.Cache_Sync) + + logger.debug('CacheService cleanup completed') + } +} + +// Export singleton instance for main process use +export const cacheService = CacheService.getInstance() +export default cacheService diff --git a/src/main/data/DataApiService.ts b/src/main/data/DataApiService.ts new file mode 100644 index 0000000000..7218364218 --- /dev/null +++ b/src/main/data/DataApiService.ts @@ -0,0 +1,153 @@ +/** + * @fileoverview DataApiService - API coordination and orchestration (Main Process) + * + * NAMING NOTE: + * This component is named "DataApiService" for management consistency, but it is + * actually a coordinator/orchestrator rather than a business service. + * + * True Nature: API Coordinator / Orchestrator + * - Initializes and coordinates the Data API framework + * - Wires together ApiServer (routing) and IpcAdapter (IPC communication) + * - Manages lifecycle (startup/shutdown) of API infrastructure + * - Contains zero business logic - purely infrastructure plumbing + * + * Architecture: + * DataApiService → coordinates → ApiServer + IpcAdapter + * ApiServer → routes requests → Handlers → Services → DB + * IpcAdapter → bridges → IPC ↔ ApiServer + * + * The "Service" suffix is kept for consistency with existing codebase conventions, + * but developers should understand this is a coordinator, not a business service. + * + * @see {@link ApiServer} For request routing logic + * @see {@link IpcAdapter} For IPC communication bridge + */ + +import { loggerService } from '@logger' + +import { ApiServer, IpcAdapter } from './api' +import { apiHandlers } from './api/handlers' + +const logger = loggerService.withContext('DataApiService') + +/** + * Data API service for Electron environment + * Coordinates the API server and IPC adapter + */ +class DataApiService { + private static instance: DataApiService + private initialized = false + private apiServer: ApiServer + private ipcAdapter: IpcAdapter + + private constructor() { + // Initialize ApiServer with handlers + this.apiServer = ApiServer.initialize(apiHandlers) + this.ipcAdapter = new IpcAdapter(this.apiServer) + } + + /** + * Get singleton instance + */ + public static getInstance(): DataApiService { + if (!DataApiService.instance) { + DataApiService.instance = new DataApiService() + } + return DataApiService.instance + } + + /** + * Initialize the Data API system + */ + public async initialize(): Promise { + if (this.initialized) { + logger.warn('DataApiService already initialized') + return + } + + try { + logger.info('Initializing Data API system...') + + // API handlers are already registered during ApiServer initialization + logger.debug('API handlers initialized with type-safe routing') + + // Setup IPC handlers + this.ipcAdapter.setupHandlers() + + this.initialized = true + logger.info('Data API system initialized successfully') + + // Log system info + this.logSystemInfo() + } catch (error) { + logger.error('Failed to initialize Data API system', error as Error) + throw error + } + } + + /** + * Log system information for debugging + */ + private logSystemInfo(): void { + const systemInfo = this.apiServer.getSystemInfo() + + logger.info('Data API system ready', { + server: systemInfo.server, + version: systemInfo.version, + handlers: systemInfo.handlers, + middlewares: systemInfo.middlewares + }) + } + + /** + * Get system status and statistics + */ + public getSystemStatus() { + if (!this.initialized) { + return { + initialized: false, + error: 'DataApiService not initialized' + } + } + + const systemInfo = this.apiServer.getSystemInfo() + + return { + initialized: true, + ipcInitialized: this.ipcAdapter.isInitialized(), + ...systemInfo + } + } + + /** + * Get API server instance (for advanced usage) + */ + public getApiServer(): ApiServer { + return this.apiServer + } + + /** + * Shutdown the Data API system + */ + public async shutdown(): Promise { + if (!this.initialized) { + return + } + + try { + logger.info('Shutting down Data API system...') + + // Remove IPC handlers + this.ipcAdapter.removeHandlers() + + this.initialized = false + logger.info('Data API system shutdown complete') + } catch (error) { + logger.error('Error during Data API shutdown', error as Error) + throw error + } + } +} + +// Export singleton instance +export const dataApiService = DataApiService.getInstance() diff --git a/src/main/data/PreferenceService.ts b/src/main/data/PreferenceService.ts new file mode 100644 index 0000000000..ca3d8af9a8 --- /dev/null +++ b/src/main/data/PreferenceService.ts @@ -0,0 +1,611 @@ +import { dbService } from '@data/db/DbService' +import { loggerService } from '@logger' +import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' +import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes' +import { IpcChannel } from '@shared/IpcChannel' +import { and, eq } from 'drizzle-orm' +import { BrowserWindow, ipcMain } from 'electron' + +import { preferenceTable } from './db/schemas/preference' + +const logger = loggerService.withContext('PreferenceService') + +/** + * Custom observer pattern implementation for preference change notifications + * Replaces EventEmitter to avoid listener limits and improve performance + * Optimized for memory efficiency and this binding safety + */ +class PreferenceNotifier { + private subscriptions = new Map void>>() + + /** + * Subscribe to preference changes for a specific key + * Uses arrow function to ensure proper this binding + * @param key - The preference key to watch + * @param callback - Function to call when the preference changes + * @param metadata - Optional metadata for debugging (unused but kept for API compatibility) + * @returns Unsubscribe function + */ + subscribe = (key: string, callback: (key: string, newValue: any, oldValue?: any) => void): (() => void) => { + if (!this.subscriptions.has(key)) { + this.subscriptions.set(key, new Set()) + } + + const keySubscriptions = this.subscriptions.get(key)! + keySubscriptions.add(callback) + + logger.debug(`Added subscription for ${key}, total for this key: ${keySubscriptions.size}`) + + // Return unsubscriber with proper this binding + return () => { + const currentKeySubscriptions = this.subscriptions.get(key) + if (currentKeySubscriptions) { + currentKeySubscriptions.delete(callback) + if (currentKeySubscriptions.size === 0) { + this.subscriptions.delete(key) + logger.debug(`Removed last subscription for ${key}, cleaned up key`) + } else { + logger.debug(`Removed subscription for ${key}, remaining: ${currentKeySubscriptions.size}`) + } + } + } + } + + /** + * Notify all subscribers of a preference change + * Uses arrow function to ensure proper this binding + * @param key - The preference key that changed + * @param newValue - The new value + * @param oldValue - The previous value + */ + notify = (key: string, newValue: any, oldValue?: any): void => { + const keySubscriptions = this.subscriptions.get(key) + if (keySubscriptions && keySubscriptions.size > 0) { + logger.debug(`Notifying ${keySubscriptions.size} subscribers for preference ${key}`) + keySubscriptions.forEach((callback) => { + try { + callback(key, newValue, oldValue) + } catch (error) { + logger.error(`Error in preference subscription callback for ${key}:`, error as Error) + } + }) + } + } + + /** + * Get the total number of subscriptions across all keys + */ + getTotalSubscriptionCount = (): number => { + let total = 0 + for (const keySubscriptions of this.subscriptions.values()) { + total += keySubscriptions.size + } + return total + } + + /** + * Get the number of subscriptions for a specific key + */ + getKeySubscriptionCount = (key: string): number => { + return this.subscriptions.get(key)?.size || 0 + } + + /** + * Get all subscribed keys + */ + getSubscribedKeys = (): string[] => { + return Array.from(this.subscriptions.keys()) + } + + /** + * Remove all subscriptions for cleanup + */ + removeAllSubscriptions = (): void => { + const totalCount = this.getTotalSubscriptionCount() + this.subscriptions.clear() + logger.debug(`Removed all ${totalCount} preference subscriptions`) + } + + /** + * Get subscription statistics for debugging + */ + getSubscriptionStats = (): Record => { + const stats: Record = {} + for (const [key, keySubscriptions] of this.subscriptions.entries()) { + stats[key] = keySubscriptions.size + } + return stats + } +} + +type MultiPreferencesResultType = { [P in K]: PreferenceDefaultScopeType[P] | undefined } + +const DefaultScope = 'default' +/** + * PreferenceService manages preference data storage and synchronization across multiple windows + * + * Features: + * - Memory-cached preferences for high performance + * - SQLite database persistence using Drizzle ORM + * - Multi-window subscription and synchronization + * - Main process change notification support + * - Type-safe preference operations + * - Batch operations support + * - Unified change notification broadcasting + */ +export class PreferenceService { + private static instance: PreferenceService + private subscriptions = new Map>() // windowId -> Set + private cache: PreferenceDefaultScopeType = DefaultPreferences.default + private initialized = false + + private static isIpcHandlerRegistered = false + + // Custom notifier for main process change notifications + private notifier = new PreferenceNotifier() + + private constructor() { + this.setupWindowCleanup() + } + + /** + * Get the singleton instance of PreferenceService + */ + public static getInstance(): PreferenceService { + if (!PreferenceService.instance) { + PreferenceService.instance = new PreferenceService() + } + return PreferenceService.instance + } + + /** + * Initialize preference cache from database + * Should be called once at application startup + */ + public async initialize(): Promise { + if (this.initialized) { + return + } + + try { + const db = dbService.getDb() + const results = await db.select().from(preferenceTable).where(eq(preferenceTable.scope, DefaultScope)) + + // Update cache with database values, keeping defaults for missing keys + for (const result of results) { + const key = result.key + if (key in this.cache) { + this.cache[key] = result.value + } + } + + this.initialized = true + logger.info(`Preference cache initialized with ${results.length} values`) + } catch (error) { + logger.error('Failed to initialize preference cache:', error as Error) + // Keep default values on initialization failure + this.initialized = false + } + } + + /** + * Get a single preference value from memory cache + * Fast synchronous access - no database queries after initialization + */ + public get(key: K): PreferenceDefaultScopeType[K] { + if (!this.initialized) { + logger.warn(`Preference cache not initialized, returning default for ${key}`) + return DefaultPreferences.default[key] + } + + return this.cache[key] ?? DefaultPreferences.default[key] + } + + /** + * Set a single preference value + * Updates both database and memory cache, then broadcasts changes to all listeners + * Optimized to skip database writes and notifications when value hasn't changed + */ + public async set(key: K, value: PreferenceDefaultScopeType[K]): Promise { + try { + if (!(key in this.cache)) { + throw new Error(`Preference ${key} not found in cache`) + } + + const oldValue = this.cache[key] // Save old value for notification + + // Performance optimization: skip update if value hasn't changed + if (this.isEqual(oldValue, value)) { + logger.debug(`Preference ${key} value unchanged, skipping database write and notification`) + return + } + + await dbService + .getDb() + .update(preferenceTable) + .set({ + value: value as any + }) + .where(and(eq(preferenceTable.scope, DefaultScope), eq(preferenceTable.key, key))) + + // Update memory cache immediately + this.cache[key] = value + + // Unified notification to both main and renderer processes + await this.notifyChange(key, value, oldValue) + + logger.debug(`Preference ${key} updated successfully`) + } catch (error) { + logger.error(`Failed to set preference ${key}:`, error as Error) + throw error + } + } + + /** + * Get multiple preferences at once from memory cache + * Fast synchronous access - no database queries + */ + public getMultiple(keys: K[]): MultiPreferencesResultType { + if (!this.initialized) { + logger.warn('Preference cache not initialized, returning defaults for multiple keys') + const output: MultiPreferencesResultType = {} as MultiPreferencesResultType + for (const key of keys) { + if (key in DefaultPreferences.default) { + output[key] = DefaultPreferences.default[key] + } else { + output[key] = undefined as MultiPreferencesResultType[K] + } + } + return output + } + + const output: MultiPreferencesResultType = {} as MultiPreferencesResultType + for (const key of keys) { + if (key in this.cache) { + output[key] = this.cache[key] + } else { + output[key] = undefined + } + } + + return output + } + + /** + * Set multiple preferences at once + * Updates both database and memory cache in a transaction, then broadcasts changes + * Optimized to skip unchanged values and reduce database operations + */ + public async setMultiple(updates: Partial): Promise { + try { + // Performance optimization: filter out unchanged values + const actualUpdates: Record = {} + const oldValues: Record = {} + let skippedCount = 0 + + for (const [key, value] of Object.entries(updates)) { + if (!(key in this.cache) || value === undefined || value === null) { + throw new Error(`Preference ${key} not found in cache or value is undefined or null`) + } + + const oldValue = this.cache[key] + + // Only include keys that actually changed + if (!this.isEqual(oldValue, value)) { + actualUpdates[key] = value + oldValues[key] = oldValue + } else { + skippedCount++ + } + } + + // Early return if no values actually changed + if (Object.keys(actualUpdates).length === 0) { + logger.debug(`All ${Object.keys(updates).length} preference values unchanged, skipping batch update`) + return + } + + // Only update items that actually changed + await dbService.getDb().transaction(async (tx) => { + for (const [key, value] of Object.entries(actualUpdates)) { + await tx + .update(preferenceTable) + .set({ + value + }) + .where(and(eq(preferenceTable.scope, DefaultScope), eq(preferenceTable.key, key))) + } + }) + + // Update memory cache for changed keys only + for (const [key, value] of Object.entries(actualUpdates)) { + if (key in this.cache) { + this.cache[key] = value + } + } + + // Unified batch notification for changed values only + const changePromises = Object.entries(actualUpdates).map(([key, value]) => + this.notifyChange(key, value, oldValues[key]) + ) + await Promise.all(changePromises) + + logger.debug( + `Updated ${Object.keys(actualUpdates).length}/${Object.keys(updates).length} preferences successfully (${skippedCount} unchanged)` + ) + } catch (error) { + logger.error('Failed to set multiple preferences:', error as Error) + throw error + } + } + + /** + * Subscribe a window to preference changes + * Window will receive notifications for specified keys + */ + public subscribeForWindow(windowId: number, keys: string[]): void { + if (!this.subscriptions.has(windowId)) { + this.subscriptions.set(windowId, new Set()) + } + + const windowKeys = this.subscriptions.get(windowId)! + keys.forEach((key) => windowKeys.add(key)) + + logger.verbose(`Window ${windowId} subscribed to ${keys.length} preference keys: ${keys.join(', ')}`) + } + + /** + * Unsubscribe a window from preference changes + */ + public unsubscribeForWindow(windowId: number): void { + this.subscriptions.delete(windowId) + logger.verbose( + `Window ${windowId} unsubscribed from preference changes: ${Array.from(this.subscriptions.keys()).join(', ')}` + ) + } + + /** + * Subscribe to preference changes in main process + * Returns unsubscribe function for cleanup + */ + public subscribeChange( + key: K, + callback: (newValue: PreferenceDefaultScopeType[K], oldValue?: PreferenceDefaultScopeType[K]) => void + ): () => void { + const listener = (changedKey: string, newValue: any, oldValue: any) => { + if (changedKey === key) { + callback(newValue as PreferenceDefaultScopeType[K], oldValue as PreferenceDefaultScopeType[K]) + } + } + + return this.notifier.subscribe(key, listener) + } + + /** + * Subscribe to multiple preference changes in main process + * Returns unsubscribe function for cleanup + */ + public subscribeMultipleChanges( + keys: PreferenceKeyType[], + callback: (key: PreferenceKeyType, newValue: any, oldValue: any) => void + ): () => void { + const listener = (changedKey: string, newValue: any, oldValue: any) => { + if (keys.includes(changedKey as PreferenceKeyType)) { + callback(changedKey as PreferenceKeyType, newValue, oldValue) + } + } + + // Subscribe to all keys and collect unsubscribe functions + const unsubscribeFunctions = keys.map((key) => this.notifier.subscribe(key, listener)) + + // Return a function that unsubscribes from all keys + return () => { + unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()) + } + } + + /** + * Remove all main process listeners for cleanup + */ + public removeAllChangeListeners(): void { + this.notifier.removeAllSubscriptions() + logger.debug('Removed all main process preference listeners') + } + + /** + * Get main process listener count for debugging + */ + public getChangeListenerCount(): number { + return this.notifier.getTotalSubscriptionCount() + } + + /** + * Get subscription count for a specific preference key + */ + public getKeyListenerCount(key: PreferenceKeyType): number { + return this.notifier.getKeySubscriptionCount(key) + } + + /** + * Get all subscribed preference keys + */ + public getSubscribedKeys(): string[] { + return this.notifier.getSubscribedKeys() + } + + /** + * Get detailed subscription statistics for debugging + */ + public getSubscriptionStats(): Record { + return this.notifier.getSubscriptionStats() + } + + /** + * Unified notification method for both main and renderer processes + * Broadcasts preference changes to main process listeners and subscribed renderer windows + */ + private async notifyChange(key: string, value: any, oldValue?: any): Promise { + // 1. Notify main process listeners + this.notifier.notify(key, value, oldValue) + + // 2. Notify renderer process windows + const affectedWindows: number[] = [] + + for (const [windowId, subscribedKeys] of this.subscriptions.entries()) { + if (subscribedKeys.has(key)) { + affectedWindows.push(windowId) + } + } + + if (affectedWindows.length === 0) { + logger.debug(`Preference ${key} changed, notified main listeners only`) + return + } + + // Send to all affected renderer windows + for (const windowId of affectedWindows) { + try { + const window = BrowserWindow.fromId(windowId) + if (window && !window.isDestroyed()) { + window.webContents.send(IpcChannel.Preference_Changed, key, value, DefaultScope) + } else { + // Clean up invalid window subscription + this.subscriptions.delete(windowId) + } + } catch (error) { + logger.error(`Failed to notify window ${windowId}:`, error as Error) + this.subscriptions.delete(windowId) + } + } + + logger.debug(`Preference ${key} changed, notified main listeners and ${affectedWindows.length} renderer windows`) + } + + /** + * Setup automatic cleanup of closed window subscriptions + */ + private setupWindowCleanup(): void { + // This will be called when windows are closed + const cleanup = () => { + const validWindowIds = BrowserWindow.getAllWindows() + .filter((w) => !w.isDestroyed()) + .map((w) => w.id) + + const subscribedWindowIds = Array.from(this.subscriptions.keys()) + const invalidWindowIds = subscribedWindowIds.filter((id) => !validWindowIds.includes(id)) + + invalidWindowIds.forEach((id) => this.subscriptions.delete(id)) + + if (invalidWindowIds.length > 0) { + logger.debug(`Cleaned up ${invalidWindowIds.length} invalid window subscriptions`) + } + } + + // Run cleanup periodically (every 30 seconds) + setInterval(cleanup, 30000) + } + + /** + * Get all preferences from memory cache + * Returns complete preference object for bulk operations + */ + public getAll(): PreferenceDefaultScopeType { + if (!this.initialized) { + logger.warn('Preference cache not initialized, returning defaults') + return DefaultPreferences.default + } + + return { ...this.cache } + } + + /** + * Get all current subscriptions (for debugging) + */ + public getSubscriptions(): Map> { + return new Map(this.subscriptions) + } + + /** + * Deep equality check for preference values + * Handles primitives, arrays, and plain objects + */ + private isEqual(a: any, b: any): boolean { + // Handle strict equality (primitives, same reference) + if (a === b) return true + + // Handle null/undefined + if (a == null || b == null) return a === b + + // Handle different types + if (typeof a !== typeof b) return false + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + return a.every((item, index) => this.isEqual(item, b[index])) + } + + // Handle objects (plain objects only) + if (typeof a === 'object' && typeof b === 'object') { + // Check if both are plain objects + if (Object.getPrototypeOf(a) !== Object.prototype || Object.getPrototypeOf(b) !== Object.prototype) { + return false + } + + const keysA = Object.keys(a) + const keysB = Object.keys(b) + + if (keysA.length !== keysB.length) return false + + return keysA.every((key) => keysB.includes(key) && this.isEqual(a[key], b[key])) + } + + return false + } + + /** + * Register IPC handlers for preference operations + * Provides communication interface between main and renderer processes + */ + public static registerIpcHandler(): void { + if (this.isIpcHandlerRegistered) return + + const instance = PreferenceService.getInstance() + + ipcMain.handle(IpcChannel.Preference_Get, (_, key: PreferenceKeyType) => { + return instance.get(key) + }) + + ipcMain.handle( + IpcChannel.Preference_Set, + async (_, key: PreferenceKeyType, value: PreferenceDefaultScopeType[PreferenceKeyType]) => { + await instance.set(key, value) + } + ) + + ipcMain.handle(IpcChannel.Preference_GetMultiple, (_, keys: PreferenceKeyType[]) => { + return instance.getMultiple(keys) + }) + + ipcMain.handle(IpcChannel.Preference_SetMultiple, async (_, updates: Partial) => { + await instance.setMultiple(updates) + }) + + ipcMain.handle(IpcChannel.Preference_GetAll, () => { + return instance.getAll() + }) + + ipcMain.handle(IpcChannel.Preference_Subscribe, async (event, keys: string[]) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id + if (windowId) { + instance.subscribeForWindow(windowId, keys) + } + }) + + this.isIpcHandlerRegistered = true + logger.info('PreferenceService IPC handlers registered') + } +} + +// Export singleton instance +export const preferenceService = PreferenceService.getInstance() diff --git a/src/main/data/README.md b/src/main/data/README.md new file mode 100644 index 0000000000..30167d6537 --- /dev/null +++ b/src/main/data/README.md @@ -0,0 +1,386 @@ +# Main Data Layer + +This directory contains the main process data management system, providing unified data access for the entire application. + +## Directory Structure + +``` +src/main/data/ +├── api/ # Data API framework (interface layer) +│ ├── core/ # Core API infrastructure +│ │ ├── ApiServer.ts # Request routing and handler execution +│ │ ├── MiddlewareEngine.ts # Request/response middleware +│ │ └── adapters/ # Communication adapters (IPC) +│ ├── handlers/ # API endpoint implementations +│ │ └── index.ts # Thin handlers: param extraction, DTO conversion +│ └── index.ts # API framework exports +│ +├── services/ # Business logic layer +│ ├── base/ # Service base classes and interfaces +│ │ └── IBaseService.ts # Service interface definitions +│ └── TestService.ts # Test service (placeholder for real services) +│ # Future business services: +│ # - TopicService.ts # Topic business logic +│ # - MessageService.ts # Message business logic +│ # - FileService.ts # File business logic +│ +├── repositories/ # Data access layer (selective usage) +│ # Repository pattern used selectively for complex domains +│ # Future repositories: +│ # - TopicRepository.ts # Complex: Topic data access +│ # - MessageRepository.ts # Complex: Message data access +│ +├── db/ # Database layer +│ ├── schemas/ # Drizzle table definitions +│ │ ├── preference.ts # Preference configuration table +│ │ ├── appState.ts # Application state table +│ │ └── columnHelpers.ts # Reusable column definitions +│ ├── seeding/ # Database initialization +│ └── DbService.ts # Database connection and management +│ +├── migrate/ # Data migration system +│ └── dataRefactor/ # v2 data refactoring migration tools +│ +├── CacheService.ts # Infrastructure: Cache management +├── DataApiService.ts # Infrastructure: API coordination +└── PreferenceService.ts # System service: User preferences +``` + +## Core Components + +### Naming Note + +Three components at the root of `data/` use the "Service" suffix but serve different purposes: + +#### CacheService (Infrastructure Component) +- **True Nature**: Cache Manager / Infrastructure Utility +- **Purpose**: Multi-tier caching system (memory/shared/persist) +- **Features**: TTL support, IPC synchronization, cross-window broadcasting +- **Characteristics**: Zero business logic, purely technical functionality +- **Note**: Named "Service" for management consistency, but is actually infrastructure + +#### DataApiService (Coordinator Component) +- **True Nature**: API Coordinator (Main) / API Client (Renderer) +- **Main Process Purpose**: Coordinates ApiServer and IpcAdapter initialization +- **Renderer Purpose**: HTTP-like client for IPC communication +- **Characteristics**: Zero business logic, purely coordination/communication plumbing +- **Note**: Named "Service" for management consistency, but is actually coordinator/client + +#### PreferenceService (System Service) +- **True Nature**: System-level Data Access Service +- **Purpose**: User configuration management with caching and multi-window sync +- **Features**: SQLite persistence, full memory cache, cross-window synchronization +- **Characteristics**: Minimal business logic (validation, defaults), primarily data access +- **Note**: Hybrid between data access and infrastructure, "Service" naming is acceptable + +**Key Takeaway**: Despite all being named "Service", these are infrastructure/coordination components, not business services. The "Service" suffix is kept for consistency with existing codebase conventions. + +## Architecture Layers + +### API Framework Layer (`api/`) + +The API framework provides the interface layer for data access: + +#### API Server (`api/core/ApiServer.ts`) +- Request routing and handler execution +- Middleware pipeline processing +- Type-safe endpoint definitions + +#### Handlers (`api/handlers/`) +- **Purpose**: Thin API endpoint implementations +- **Responsibilities**: + - HTTP-like parameter extraction from requests + - DTO/domain model conversion + - Delegating to business services + - Transforming responses for IPC +- **Anti-pattern**: Do NOT put business logic in handlers +- **Currently**: Contains test handlers (production handlers pending) +- **Type Safety**: Must implement all endpoints defined in `@shared/data/api` + +### Business Logic Layer (`services/`) + +Business services implement domain logic and workflows: + +#### When to Create a Service +- Contains business rules and validation +- Orchestrates multiple repositories or data sources +- Implements complex workflows +- Manages transactions across multiple operations + +#### Service Pattern + +Just an example for understanding. + +```typescript +// services/TopicService.ts +export class TopicService { + constructor( + private topicRepo: TopicRepository, // Use repository for complex data access + private cacheService: CacheService // Use infrastructure utilities + ) {} + + async createTopicWithMessage(data: CreateTopicDto) { + // Business validation + this.validateTopicData(data) + + // Transaction coordination + return await DbService.transaction(async (tx) => { + const topic = await this.topicRepo.create(data.topic, tx) + const message = await this.messageRepo.create(data.message, tx) + return { topic, message } + }) + } +} +``` + +#### Current Services +- `TestService`: Placeholder service for testing API framework +- More business services will be added as needed (TopicService, MessageService, etc.) + +### Data Access Layer (`repositories/`) + +Repositories handle database operations with a **selective usage pattern**: + +#### When to Use Repository Pattern +Use repositories for **complex domains** that meet multiple criteria: +- ✅ Complex queries (joins, subqueries, aggregations) +- ✅ GB-scale data requiring optimization and pagination +- ✅ Complex transactions involving multiple tables +- ✅ Reusable data access patterns across services +- ✅ High testing requirements (mock data access in tests) + +#### When to Use Direct Drizzle in Services +Skip repository layer for **simple domains**: +- ✅ Simple CRUD operations +- ✅ Small datasets (< 100MB) +- ✅ Domain-specific queries with no reuse potential +- ✅ Fast development is priority + +#### Repository Pattern + +Just an example for understanding. + +```typescript +// repositories/TopicRepository.ts +export class TopicRepository { + async findById(id: string, tx?: Transaction): Promise { + const db = tx || DbService.db + return await db.select() + .from(topicTable) + .where(eq(topicTable.id, id)) + .limit(1) + } + + async findByIdWithMessages( + topicId: string, + pagination: PaginationOptions + ): Promise { + // Complex join query with pagination + // Handles GB-scale data efficiently + } +} +``` + +#### Direct Drizzle Pattern (Simple Services) +```typescript +// services/SimpleService.ts +export class SimpleService extends BaseService { + async getItem(id: string) { + // Direct Drizzle query for simple operations + return await this.database + .select() + .from(itemTable) + .where(eq(itemTable.id, id)) + } +} +``` + +#### Planned Repositories +- **TopicRepository**: Complex topic data access with message relationships +- **MessageRepository**: GB-scale message queries with pagination +- **FileRepository**: File reference counting and cleanup logic + +**Decision Principle**: Use the simplest approach that solves the problem. Add repository abstraction only when complexity demands it. + +## Database Layer + +### DbService +- SQLite database connection management +- Automatic migrations and seeding +- Drizzle ORM integration + +### Schemas (`db/schemas/`) +- Table definitions using Drizzle ORM +- Follow naming convention: `{entity}Table` exports +- Use `crudTimestamps` helper for timestamp fields + +### Current Tables +- `preference`: User configuration storage +- `appState`: Application state persistence + +## Usage Examples + +### Accessing Services +```typescript +// Get service instances +import { cacheService } from '@/data/CacheService' +import { preferenceService } from '@/data/PreferenceService' +import { dataApiService } from '@/data/DataApiService' + +// Services are singletons, initialized at app startup +``` + +### Adding New API Endpoints +1. Define endpoint in `@shared/data/api/apiSchemas.ts` +2. Implement handler in `api/handlers/index.ts` (thin layer, delegate to service) +3. Create business service in `services/` for domain logic +4. Create repository in `repositories/` if domain is complex (optional) +5. Add database schema in `db/schemas/` if required + +### Adding Database Tables +1. Create schema in `db/schemas/{tableName}.ts` +2. Generate migration: `yarn run migrations:generate` +3. Add seeding data in `db/seeding/` if needed +4. Decide: Repository pattern or direct Drizzle? + - Complex domain → Create repository in `repositories/` + - Simple domain → Use direct Drizzle in service +5. Create business service in `services/` +6. Implement API handler in `api/handlers/` + +### Creating a New Business Service + +**For complex domains (with repository)**: +```typescript +// 1. Create repository: repositories/ExampleRepository.ts +export class ExampleRepository { + async findById(id: string, tx?: Transaction) { /* ... */ } + async create(data: CreateDto, tx?: Transaction) { /* ... */ } +} + +// 2. Create service: services/ExampleService.ts +export class ExampleService { + constructor(private exampleRepo: ExampleRepository) {} + + async createExample(data: CreateDto) { + // Business validation + this.validate(data) + + // Use repository + return await this.exampleRepo.create(data) + } +} + +// 3. Create handler: api/handlers/example.ts +import { ExampleService } from '../../services/ExampleService' + +export const exampleHandlers = { + 'POST /examples': async ({ body }) => { + return await ExampleService.getInstance().createExample(body) + } +} +``` + +**For simple domains (direct Drizzle)**: +```typescript +// 1. Create service: services/SimpleService.ts +export class SimpleService extends BaseService { + async getItem(id: string) { + // Direct database access + return await this.database + .select() + .from(itemTable) + .where(eq(itemTable.id, id)) + } +} + +// 2. Create handler: api/handlers/simple.ts +export const simpleHandlers = { + 'GET /items/:id': async ({ params }) => { + return await SimpleService.getInstance().getItem(params.id) + } +} +``` + +## Data Flow + +### Complete Request Flow + +``` +┌─────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ React Component → useDataApi Hook │ +└────────────────┬────────────────────────────────────┘ + │ IPC Request +┌────────────────▼────────────────────────────────────┐ +│ Infrastructure Layer │ +│ DataApiService (coordinator) │ +│ ↓ │ +│ ApiServer (routing) → MiddlewareEngine │ +└────────────────┬────────────────────────────────────┘ + │ +┌────────────────▼────────────────────────────────────┐ +│ API Layer (api/handlers/) │ +│ Handler: Thin layer │ +│ - Extract parameters │ +│ - Call business service │ +│ - Transform response │ +└────────────────┬────────────────────────────────────┘ + │ +┌────────────────▼────────────────────────────────────┐ +│ Business Logic Layer (services/) │ +│ Service: Domain logic │ +│ - Business validation │ +│ - Transaction coordination │ +│ - Call repository or direct DB │ +└────────────────┬────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + │ │ +┌─────▼─────────┐ ┌──────▼──────────────────────────┐ +│ repositories/ │ │ Direct Drizzle │ +│ (Complex) │ │ (Simple domains) │ +│ - Repository │ │ - Service uses DbService.db │ +│ - Query logic │ │ - Inline queries │ +└─────┬─────────┘ └──────┬──────────────────────────┘ + │ │ + └──────────┬─────────┘ + │ +┌────────────────▼────────────────────────────────────┐ +│ Database Layer (db/) │ +│ DbService → SQLite (Drizzle ORM) │ +└─────────────────────────────────────────────────────┘ +``` + +### Architecture Principles + +1. **Separation of Concerns** + - Handlers: Request/response transformation only + - Services: Business logic and orchestration + - Repositories: Data access (when complexity demands it) + +2. **Dependency Flow** (top to bottom only) + - Handlers depend on Services + - Services depend on Repositories (or DbService directly) + - Repositories depend on DbService + - **Never**: Services depend on Handlers + - **Never**: Repositories contain business logic + +3. **Selective Repository Usage** + - Use Repository: Complex domains (Topic, Message, File) + - Direct Drizzle: Simple domains (Agent, Session, Translate) + - Decision based on: query complexity, data volume, testing needs + +## Development Guidelines + +- All services use singleton pattern +- Database operations must be type-safe (Drizzle) +- API endpoints require complete type definitions +- Services should handle errors gracefully +- Use existing logging system (`@logger`) + +## Integration Points + +- **IPC Communication**: All services expose IPC handlers for renderer communication +- **Type Safety**: Shared types in `@shared/data` ensure end-to-end type safety +- **Error Handling**: Standardized error codes and handling across all services +- **Logging**: Comprehensive logging for debugging and monitoring \ No newline at end of file diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts new file mode 100644 index 0000000000..038f7cc1b8 --- /dev/null +++ b/src/main/data/api/core/ApiServer.ts @@ -0,0 +1,264 @@ +import { loggerService } from '@logger' +import type { ApiImplementation } from '@shared/data/api/apiSchemas' +import type { DataRequest, DataResponse, HttpMethod, RequestContext } from '@shared/data/api/apiTypes' +import { DataApiErrorFactory, ErrorCode } from '@shared/data/api/errorCodes' + +import { MiddlewareEngine } from './MiddlewareEngine' + +// Handler function type +type HandlerFunction = (params: { params?: Record; query?: any; body?: any }) => Promise + +const logger = loggerService.withContext('DataApiServer') + +/** + * Core API Server - Transport agnostic request processor + * Now uses direct handler mapping for type-safe routing + */ +export class ApiServer { + private static instance: ApiServer + private middlewareEngine: MiddlewareEngine + private handlers: ApiImplementation + + private constructor(handlers: ApiImplementation) { + this.middlewareEngine = new MiddlewareEngine() + this.handlers = handlers + } + + /** + * Initialize singleton instance with handlers + */ + public static initialize(handlers: ApiImplementation): ApiServer { + if (ApiServer.instance) { + throw new Error('ApiServer already initialized') + } + ApiServer.instance = new ApiServer(handlers) + return ApiServer.instance + } + + /** + * Get singleton instance + */ + public static getInstance(): ApiServer { + if (!ApiServer.instance) { + throw new Error('ApiServer not initialized. Call initialize() first.') + } + return ApiServer.instance + } + + /** + * Register middleware + */ + use(middleware: any): void { + this.middlewareEngine.use(middleware) + } + + /** + * Main request handler - direct handler lookup + */ + async handleRequest(request: DataRequest): Promise { + const { method, path } = request + const startTime = Date.now() + + logger.debug(`Processing request: ${method} ${path}`) + + try { + // Find handler + const handlerMatch = this.findHandler(path, method as HttpMethod) + + if (!handlerMatch) { + throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, `Handler not found: ${method} ${path}`) + } + + // Create request context + const requestContext = this.createRequestContext(request, path, method as HttpMethod) + + // Execute middleware chain + await this.middlewareEngine.executeMiddlewares(requestContext) + + // Execute handler if middleware didn't set error + if (!requestContext.response.error) { + await this.executeHandler(requestContext, handlerMatch) + } + + // Set timing metadata + requestContext.response.metadata = { + ...requestContext.response.metadata, + duration: Date.now() - startTime, + timestamp: Date.now() + } + + return requestContext.response + } catch (error) { + logger.error(`Request handling failed: ${method} ${path}`, error as Error) + + const apiError = DataApiErrorFactory.create(ErrorCode.INTERNAL_SERVER_ERROR, (error as Error).message) + + return { + id: request.id, + status: apiError.status, + error: apiError, + metadata: { + duration: Date.now() - startTime, + timestamp: Date.now() + } + } + } + } + + /** + * Handle batch requests + */ + async handleBatchRequest(batchRequest: DataRequest): Promise { + const requests = batchRequest.body?.requests || [] + + if (!Array.isArray(requests)) { + throw DataApiErrorFactory.create(ErrorCode.VALIDATION_ERROR, 'Batch request body must contain requests array') + } + + logger.debug(`Processing batch request with ${requests.length} requests`) + + // Use the batch handler from our handlers + const batchHandler = this.handlers['/batch']?.POST + if (!batchHandler) { + throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, 'Batch handler not found') + } + + const result = await batchHandler({ body: batchRequest.body }) + + return { + id: batchRequest.id, + status: 200, + data: result, + metadata: { + duration: 0, + timestamp: Date.now() + } + } + } + + /** + * Find handler for given path and method + */ + private findHandler( + path: string, + method: HttpMethod + ): { handler: HandlerFunction; params: Record } | null { + // Direct lookup first + const directHandler = (this.handlers as any)[path]?.[method] + if (directHandler) { + return { handler: directHandler, params: {} } + } + + // Pattern matching for parameterized paths + for (const [pattern, methods] of Object.entries(this.handlers)) { + if (pattern.includes(':') && (methods as any)[method]) { + const params = this.extractPathParams(pattern, path) + if (params !== null) { + return { handler: (methods as any)[method], params } + } + } + } + + return null + } + + /** + * Extract path parameters from URL + */ + private extractPathParams(pattern: string, path: string): Record | null { + const patternParts = pattern.split('/') + const pathParts = path.split('/') + + if (patternParts.length !== pathParts.length) { + return null + } + + const params: Record = {} + + for (let i = 0; i < patternParts.length; i++) { + if (patternParts[i].startsWith(':')) { + const paramName = patternParts[i].slice(1) + params[paramName] = pathParts[i] + } else if (patternParts[i] !== pathParts[i]) { + return null + } + } + + return params + } + + /** + * Create request context + */ + private createRequestContext(request: DataRequest, path: string, method: HttpMethod): RequestContext { + const response: DataResponse = { + id: request.id, + status: 200 + } + + return { + request, + response, + path, + method, + data: new Map() + } + } + + /** + * Execute handler function + */ + private async executeHandler( + context: RequestContext, + handlerMatch: { handler: HandlerFunction; params: Record } + ): Promise { + const { request, response } = context + const { handler, params } = handlerMatch + + try { + // Prepare handler parameters + const handlerParams = { + params, + query: request.params, // URL query parameters + body: request.body + } + + // Execute handler + const result = await handler(handlerParams) + + // Set response data + if (result !== undefined) { + response.data = result + } + + if (!response.status) { + response.status = 200 + } + } catch (error) { + logger.error('Handler execution failed', error as Error) + throw error + } + } + + /** + * Get system information + */ + getSystemInfo() { + const handlerPaths = Object.keys(this.handlers) + const handlerCount = handlerPaths.reduce((count, path) => { + return count + Object.keys((this.handlers as any)[path]).length + }, 0) + + const middlewares = this.middlewareEngine.getMiddlewares() + + return { + server: 'DataApiServer', + version: '2.0', + handlers: { + paths: handlerPaths, + total: handlerCount + }, + middlewares: middlewares + } + } +} diff --git a/src/main/data/api/core/MiddlewareEngine.ts b/src/main/data/api/core/MiddlewareEngine.ts new file mode 100644 index 0000000000..1f6bf1915d --- /dev/null +++ b/src/main/data/api/core/MiddlewareEngine.ts @@ -0,0 +1,177 @@ +import { loggerService } from '@logger' +import type { DataRequest, DataResponse, Middleware, RequestContext } from '@shared/data/api/apiTypes' +import { toDataApiError } from '@shared/data/api/errorCodes' + +const logger = loggerService.withContext('MiddlewareEngine') + +/** + * Middleware engine for executing middleware chains + * Extracted from ResponseService to support reusability + */ +export class MiddlewareEngine { + private middlewares = new Map() + private middlewareOrder: string[] = [] + + constructor() { + this.setupDefaultMiddlewares() + } + + /** + * Register middleware + */ + use(middleware: Middleware): void { + this.middlewares.set(middleware.name, middleware) + + // Insert based on priority + const priority = middleware.priority || 50 + let insertIndex = 0 + + for (let i = 0; i < this.middlewareOrder.length; i++) { + const existingMiddleware = this.middlewares.get(this.middlewareOrder[i]) + const existingPriority = existingMiddleware?.priority || 50 + + if (priority < existingPriority) { + insertIndex = i + break + } + insertIndex = i + 1 + } + + this.middlewareOrder.splice(insertIndex, 0, middleware.name) + + logger.debug(`Registered middleware: ${middleware.name} (priority: ${priority})`) + } + + /** + * Execute middleware chain + */ + async executeMiddlewares(context: RequestContext, middlewareNames: string[] = this.middlewareOrder): Promise { + let index = 0 + + const next = async (): Promise => { + if (index >= middlewareNames.length) { + return + } + + const middlewareName = middlewareNames[index++] + const middleware = this.middlewares.get(middlewareName) + + if (!middleware) { + logger.warn(`Middleware not found: ${middlewareName}`) + return next() + } + + await middleware.execute(context.request, context.response, next) + } + + await next() + } + + /** + * Setup default middlewares + */ + private setupDefaultMiddlewares(): void { + // Error handling middleware (should be first) + this.use({ + name: 'error-handler', + priority: 0, + execute: async (req: DataRequest, res: DataResponse, next: () => Promise) => { + try { + await next() + } catch (error) { + logger.error(`Request error: ${req.method} ${req.path}`, error as Error) + + const apiError = toDataApiError(error, `${req.method} ${req.path}`) + res.error = apiError + res.status = apiError.status + } + } + }) + + // Request logging middleware + this.use({ + name: 'request-logger', + priority: 10, + execute: async (req: DataRequest, res: DataResponse, next: () => Promise) => { + const startTime = Date.now() + + logger.debug(`Incoming request: ${req.method} ${req.path}`, { + id: req.id, + params: req.params, + body: req.body + }) + + await next() + + const duration = Date.now() - startTime + res.metadata = { + ...res.metadata, + duration, + timestamp: Date.now() + } + + logger.debug(`Request completed: ${req.method} ${req.path}`, { + id: req.id, + status: res.status, + duration + }) + } + }) + + // Response formatting middleware (should be last) + this.use({ + name: 'response-formatter', + priority: 100, + execute: async (_req: DataRequest, res: DataResponse, next: () => Promise) => { + await next() + + // Ensure response always has basic structure + if (!res.status) { + res.status = 200 + } + + if (!res.metadata) { + res.metadata = { + duration: 0, + timestamp: Date.now() + } + } + } + }) + } + + /** + * Get all middleware names in execution order + */ + getMiddlewares(): string[] { + return [...this.middlewareOrder] + } + + /** + * Get middleware by name + */ + getMiddleware(name: string): Middleware | undefined { + return this.middlewares.get(name) + } + + /** + * Remove middleware + */ + removeMiddleware(name: string): void { + this.middlewares.delete(name) + const index = this.middlewareOrder.indexOf(name) + if (index > -1) { + this.middlewareOrder.splice(index, 1) + } + logger.debug(`Removed middleware: ${name}`) + } + + /** + * Clear all middlewares + */ + clear(): void { + this.middlewares.clear() + this.middlewareOrder = [] + logger.debug('Cleared all middlewares') + } +} diff --git a/src/main/data/api/core/adapters/IpcAdapter.ts b/src/main/data/api/core/adapters/IpcAdapter.ts new file mode 100644 index 0000000000..7d17264388 --- /dev/null +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -0,0 +1,152 @@ +import { loggerService } from '@logger' +import type { DataRequest, DataResponse } from '@shared/data/api/apiTypes' +import { toDataApiError } from '@shared/data/api/errorCodes' +import { IpcChannel } from '@shared/IpcChannel' +import { ipcMain } from 'electron' + +import type { ApiServer } from '../ApiServer' + +const logger = loggerService.withContext('DataApiIpcAdapter') + +/** + * IPC Adapter for Electron environment + * Handles IPC communication and forwards requests to ApiServer + */ +export class IpcAdapter { + private initialized = false + + constructor(private apiServer: ApiServer) {} + + /** + * Setup IPC handlers + */ + setupHandlers(): void { + if (this.initialized) { + logger.warn('IPC handlers already initialized') + return + } + + logger.debug('Setting up IPC handlers...') + + // Main data request handler + ipcMain.handle(IpcChannel.DataApi_Request, async (_event, request: DataRequest): Promise => { + try { + logger.debug(`Handling data request: ${request.method} ${request.path}`, { + id: request.id, + params: request.params + }) + + const response = await this.apiServer.handleRequest(request) + + return response + } catch (error) { + logger.error(`Data request failed: ${request.method} ${request.path}`, error as Error) + + const apiError = toDataApiError(error, `${request.method} ${request.path}`) + const errorResponse: DataResponse = { + id: request.id, + status: apiError.status, + error: apiError, + metadata: { + duration: 0, + timestamp: Date.now() + } + } + + return errorResponse + } + }) + + // Batch request handler + ipcMain.handle(IpcChannel.DataApi_Batch, async (_event, batchRequest: DataRequest): Promise => { + try { + logger.debug('Handling batch request', { requestCount: batchRequest.body?.requests?.length }) + + const response = await this.apiServer.handleBatchRequest(batchRequest) + return response + } catch (error) { + logger.error('Batch request failed', error as Error) + + const apiError = toDataApiError(error, 'batch request') + return { + id: batchRequest.id, + status: apiError.status, + error: apiError, + metadata: { + duration: 0, + timestamp: Date.now() + } + } + } + }) + + // Transaction handler (placeholder) + ipcMain.handle( + IpcChannel.DataApi_Transaction, + async (_event, transactionRequest: DataRequest): Promise => { + try { + logger.debug('Handling transaction request') + + // TODO: Implement transaction support + throw new Error('Transaction support not yet implemented') + } catch (error) { + logger.error('Transaction request failed', error as Error) + + const apiError = toDataApiError(error, 'transaction request') + return { + id: transactionRequest.id, + status: apiError.status, + error: apiError, + metadata: { + duration: 0, + timestamp: Date.now() + } + } + } + } + ) + + // Subscription handlers (placeholder for future real-time features) + ipcMain.handle(IpcChannel.DataApi_Subscribe, async (_event, path: string) => { + logger.debug(`Data subscription request: ${path}`) + // TODO: Implement real-time subscriptions + return { success: true, subscriptionId: `sub_${Date.now()}` } + }) + + ipcMain.handle(IpcChannel.DataApi_Unsubscribe, async (_event, subscriptionId: string) => { + logger.debug(`Data unsubscription request: ${subscriptionId}`) + // TODO: Implement real-time subscriptions + return { success: true } + }) + + this.initialized = true + logger.debug('IPC handlers setup complete') + } + + /** + * Remove IPC handlers + */ + removeHandlers(): void { + if (!this.initialized) { + return + } + + logger.debug('Removing IPC handlers...') + + ipcMain.removeHandler(IpcChannel.DataApi_Request) + ipcMain.removeHandler(IpcChannel.DataApi_Batch) + ipcMain.removeHandler(IpcChannel.DataApi_Transaction) + ipcMain.removeHandler(IpcChannel.DataApi_Subscribe) + ipcMain.removeHandler(IpcChannel.DataApi_Unsubscribe) + + this.initialized = false + logger.debug('IPC handlers removed') + } + + /** + * Check if handlers are initialized + */ + isInitialized(): boolean { + return this.initialized + } +} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts new file mode 100644 index 0000000000..817a882be8 --- /dev/null +++ b/src/main/data/api/handlers/index.ts @@ -0,0 +1,210 @@ +/** + * Complete API handler implementation + * + * This file implements ALL endpoints defined in ApiSchemas. + * TypeScript will error if any endpoint is missing. + */ + +import { TestService } from '@data/services/TestService' +import type { ApiImplementation } from '@shared/data/api/apiSchemas' + +// Service instances +const testService = TestService.getInstance() + +/** + * Complete API handlers implementation + * Must implement every path+method combination from ApiSchemas + */ +export const apiHandlers: ApiImplementation = { + '/test/items': { + GET: async ({ query }) => { + return await testService.getItems({ + page: (query as any)?.page, + limit: (query as any)?.limit, + search: (query as any)?.search, + type: (query as any)?.type, + status: (query as any)?.status + }) + }, + + POST: async ({ body }) => { + return await testService.createItem({ + title: body.title, + description: body.description, + type: body.type, + status: body.status, + priority: body.priority, + tags: body.tags, + metadata: body.metadata + }) + } + }, + + '/test/items/:id': { + GET: async ({ params }) => { + const item = await testService.getItemById(params.id) + if (!item) { + throw new Error(`Test item not found: ${params.id}`) + } + return item + }, + + PUT: async ({ params, body }) => { + const item = await testService.updateItem(params.id, { + title: body.title, + description: body.description, + type: body.type, + status: body.status, + priority: body.priority, + tags: body.tags, + metadata: body.metadata + }) + if (!item) { + throw new Error(`Test item not found: ${params.id}`) + } + return item + }, + + DELETE: async ({ params }) => { + const deleted = await testService.deleteItem(params.id) + if (!deleted) { + throw new Error(`Test item not found: ${params.id}`) + } + return undefined + } + }, + + '/test/search': { + GET: async ({ query }) => { + return await testService.searchItems(query.query, { + page: query.page, + limit: query.limit, + filters: { + type: query.type, + status: query.status + } + }) + } + }, + + '/test/stats': { + GET: async () => { + return await testService.getStats() + } + }, + + '/test/bulk': { + POST: async ({ body }) => { + return await testService.bulkOperation(body.operation, body.data) + } + }, + + '/test/error': { + POST: async ({ body }) => { + return await testService.simulateError(body.errorType) + } + }, + + '/test/slow': { + POST: async ({ body }) => { + const delay = body.delay + await new Promise((resolve) => setTimeout(resolve, delay)) + return { + message: `Slow response completed after ${delay}ms`, + delay, + timestamp: new Date().toISOString() + } + } + }, + + '/test/reset': { + POST: async () => { + await testService.resetData() + return { + message: 'Test data reset successfully', + timestamp: new Date().toISOString() + } + } + }, + + '/test/config': { + GET: async () => { + return { + environment: 'test', + version: '1.0.0', + debug: true, + features: { + bulkOperations: true, + search: true, + statistics: true + } + } + }, + + PUT: async ({ body }) => { + return { + ...body, + updated: true, + timestamp: new Date().toISOString() + } + } + }, + + '/test/status': { + GET: async () => { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + version: '1.0.0', + uptime: Math.floor(process.uptime()), + environment: 'test' + } + } + }, + + '/test/performance': { + GET: async () => { + const memUsage = process.memoryUsage() + return { + requestsPerSecond: Math.floor(Math.random() * 100) + 50, + averageLatency: Math.floor(Math.random() * 200) + 50, + memoryUsage: memUsage.heapUsed / 1024 / 1024, // MB + cpuUsage: Math.random() * 100, + uptime: Math.floor(process.uptime()) + } + } + }, + + '/batch': { + POST: async ({ body }) => { + // Mock batch implementation - can be enhanced with actual batch processing + const { requests } = body + + const results = requests.map(() => ({ + status: 200, + data: { processed: true, timestamp: new Date().toISOString() } + })) + + return { + results, + metadata: { + duration: Math.floor(Math.random() * 500) + 100, + successCount: requests.length, + errorCount: 0 + } + } + } + }, + + '/transaction': { + POST: async ({ body }) => { + // Mock transaction implementation - can be enhanced with actual transaction support + const { operations } = body + + return operations.map(() => ({ + status: 200, + data: { executed: true, timestamp: new Date().toISOString() } + })) + } + } +} diff --git a/src/main/data/api/index.ts b/src/main/data/api/index.ts new file mode 100644 index 0000000000..1bd4d3b7a5 --- /dev/null +++ b/src/main/data/api/index.ts @@ -0,0 +1,32 @@ +/** + * API Module - Unified entry point + * + * This module exports all necessary components for the Data API system + * Designed to be portable and reusable in different environments + */ + +// Core components +export { ApiServer } from './core/ApiServer' +export { MiddlewareEngine } from './core/MiddlewareEngine' + +// Adapters +export { IpcAdapter } from './core/adapters/IpcAdapter' +// export { HttpAdapter } from './core/adapters/HttpAdapter' // Future implementation + +// Handlers (new type-safe system) +export { apiHandlers } from './handlers' + +// Services (still used by handlers) +export { TestService } from '@data/services/TestService' + +// Re-export types for convenience +export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api' +export type { + DataRequest, + DataResponse, + Middleware, + PaginatedResponse, + PaginationParams, + RequestContext, + ServiceOptions +} from '@shared/data/api/apiTypes' diff --git a/src/main/data/db/DbService.ts b/src/main/data/db/DbService.ts new file mode 100644 index 0000000000..6f136a58a6 --- /dev/null +++ b/src/main/data/db/DbService.ts @@ -0,0 +1,70 @@ +import { loggerService } from '@logger' +import { drizzle } from 'drizzle-orm/libsql' +import { migrate } from 'drizzle-orm/libsql/migrator' +import { app } from 'electron' +import path from 'path' +import { pathToFileURL } from 'url' + +import Seeding from './seeding' +import type { DbType } from './types' + +const logger = loggerService.withContext('DbService') + +const DB_NAME = 'cherrystudio.sqlite' +const MIGRATIONS_BASE_PATH = 'migrations/sqlite-drizzle' + +class DbService { + private static instance: DbService + private db: DbType + + private constructor() { + this.db = drizzle({ + connection: { url: pathToFileURL(path.join(app.getPath('userData'), DB_NAME)).href }, + casing: 'snake_case' + }) + } + + public static getInstance(): DbService { + if (!DbService.instance) { + DbService.instance = new DbService() + } + return DbService.instance + } + + public async migrateDb() { + const migrationsFolder = this.getMigrationsFolder() + await migrate(this.db, { migrationsFolder }) + } + + public getDb(): DbType { + return this.db + } + + public async migrateSeed(seedName: keyof typeof Seeding): Promise { + try { + const Seed = Seeding[seedName] + await new Seed().migrate(this.db) + return true + } catch (error) { + logger.error('migration seeding failed', error as Error) + return false + } + } + + /** + * Get the migrations folder based on the app's packaging status + * @returns The path to the migrations folder + */ + private getMigrationsFolder() { + if (app.isPackaged) { + //see electron-builder.yml, extraResources from/to + return path.join(process.resourcesPath, MIGRATIONS_BASE_PATH) + } else { + // in dev/preview, __dirname maybe /out/main + return path.join(__dirname, '../../', MIGRATIONS_BASE_PATH) + } + } +} + +// Export a singleton instance +export const dbService = DbService.getInstance() diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md new file mode 100644 index 0000000000..8bc38b01c4 --- /dev/null +++ b/src/main/data/db/README.md @@ -0,0 +1,2 @@ +- All the database table names use **singular** form, snake_casing +- Export table names use `xxxxTable` diff --git a/src/main/data/db/schemas/appState.ts b/src/main/data/db/schemas/appState.ts new file mode 100644 index 0000000000..c64ccd95d0 --- /dev/null +++ b/src/main/data/db/schemas/appState.ts @@ -0,0 +1,10 @@ +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps } from './columnHelpers' + +export const appStateTable = sqliteTable('app_state', { + key: text().primaryKey(), + value: text({ mode: 'json' }).notNull(), // JSON field, drizzle handles serialization automatically + description: text(), // Optional description field + ...createUpdateTimestamps +}) diff --git a/src/main/data/db/schemas/columnHelpers.ts b/src/main/data/db/schemas/columnHelpers.ts new file mode 100644 index 0000000000..7623afd0ed --- /dev/null +++ b/src/main/data/db/schemas/columnHelpers.ts @@ -0,0 +1,16 @@ +import { integer } from 'drizzle-orm/sqlite-core' + +const createTimestamp = () => { + return Date.now() +} + +export const createUpdateTimestamps = { + createdAt: integer().$defaultFn(createTimestamp), + updatedAt: integer().$defaultFn(createTimestamp).$onUpdateFn(createTimestamp) +} + +export const createUpdateDeleteTimestamps = { + createdAt: integer().$defaultFn(createTimestamp), + updatedAt: integer().$defaultFn(createTimestamp).$onUpdateFn(createTimestamp), + deletedAt: integer() +} diff --git a/src/main/data/db/schemas/preference.ts b/src/main/data/db/schemas/preference.ts new file mode 100644 index 0000000000..f41cf175c4 --- /dev/null +++ b/src/main/data/db/schemas/preference.ts @@ -0,0 +1,14 @@ +import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps } from './columnHelpers' + +export const preferenceTable = sqliteTable( + 'preference', + { + scope: text().notNull(), // scope is reserved for future use, now only 'default' is supported + key: text().notNull(), + value: text({ mode: 'json' }), + ...createUpdateTimestamps + }, + (t) => [index('scope_name_idx').on(t.scope, t.key)] +) diff --git a/src/main/data/db/seeding/index.ts b/src/main/data/db/seeding/index.ts new file mode 100644 index 0000000000..cfa16f5709 --- /dev/null +++ b/src/main/data/db/seeding/index.ts @@ -0,0 +1,7 @@ +import PreferenceSeeding from './preferenceSeeding' + +const seedingList = { + preference: PreferenceSeeding +} + +export default seedingList diff --git a/src/main/data/db/seeding/preferenceSeeding.ts b/src/main/data/db/seeding/preferenceSeeding.ts new file mode 100644 index 0000000000..c9052807e3 --- /dev/null +++ b/src/main/data/db/seeding/preferenceSeeding.ts @@ -0,0 +1,47 @@ +import { preferenceTable } from '@data/db/schemas/preference' +import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' + +import type { DbType, ISeed } from '../types' + +class PreferenceSeed implements ISeed { + async migrate(db: DbType): Promise { + const preferences = await db.select().from(preferenceTable) + + // Convert existing preferences to a Map for quick lookup + const existingPrefs = new Map(preferences.map((p) => [`${p.scope}.${p.key}`, p])) + + // Collect all new preferences to insert + const newPreferences: Array<{ + scope: string + key: string + value: unknown + }> = [] + + // Process each scope in defaultPreferences + for (const [scope, scopeData] of Object.entries(DefaultPreferences)) { + // Process each key-value pair in the scope + for (const [key, value] of Object.entries(scopeData)) { + const prefKey = `${scope}.${key}` + + // Skip if this preference already exists + if (existingPrefs.has(prefKey)) { + continue + } + + // Add to new preferences array + newPreferences.push({ + scope, + key, + value + }) + } + } + + // If there are new preferences to insert, do it in a transaction + if (newPreferences.length > 0) { + await db.insert(preferenceTable).values(newPreferences) + } + } +} + +export default PreferenceSeed diff --git a/src/main/data/db/types.d.ts b/src/main/data/db/types.d.ts new file mode 100644 index 0000000000..07fb0008dd --- /dev/null +++ b/src/main/data/db/types.d.ts @@ -0,0 +1,7 @@ +import type { LibSQLDatabase } from 'drizzle-orm/libsql' + +export type DbType = LibSQLDatabase + +export interface ISeed { + migrate(db: DbType): Promise +} diff --git a/src/main/data/migrate/dataRefactor/DataRefactorMigrateService.ts b/src/main/data/migrate/dataRefactor/DataRefactorMigrateService.ts new file mode 100644 index 0000000000..eb748b7667 --- /dev/null +++ b/src/main/data/migrate/dataRefactor/DataRefactorMigrateService.ts @@ -0,0 +1,983 @@ +import { dbService } from '@data/db/DbService' +import { appStateTable } from '@data/db/schemas/appState' +import { loggerService } from '@logger' +import { isDev } from '@main/constant' +import BackupManager from '@main/services/BackupManager' +import { IpcChannel } from '@shared/IpcChannel' +import { eq } from 'drizzle-orm' +import { app, BrowserWindow, ipcMain } from 'electron' +import { app as electronApp } from 'electron' +import { join } from 'path' + +import { PreferencesMigrator } from './migrators/PreferencesMigrator' + +const logger = loggerService.withContext('DataRefactorMigrateService') + +const DATA_REFACTOR_MIGRATION_STATUS = 'data_refactor_migration_status' + +// Data refactor migration status interface +interface DataRefactorMigrationStatus { + completed: boolean + completedAt?: number + version?: string +} + +type MigrationStage = + | 'introduction' // Introduction phase - user can cancel + | 'backup_required' // Backup required - show backup requirement + | 'backup_progress' // Backup in progress - user is backing up + | 'backup_confirmed' // Backup confirmed - ready to migrate + | 'migration' // Migration in progress - cannot cancel + | 'completed' // Completed - restart app + | 'error' // Error - recovery options + +interface MigrationProgress { + stage: MigrationStage + progress: number + total: number + message: string + error?: string +} + +interface MigrationResult { + success: boolean + error?: string + migratedCount: number +} + +export class DataRefactorMigrateService { + private static instance: DataRefactorMigrateService | null = null + private migrateWindow: BrowserWindow | null = null + private testWindows: BrowserWindow[] = [] + private backupManager: BackupManager + private db = dbService.getDb() + private currentProgress: MigrationProgress = { + stage: 'introduction', + progress: 0, + total: 100, + message: 'Ready to start data migration' + } + private isMigrating: boolean = false + private reduxData: any = null // Cache for Redux persist data + + constructor() { + this.backupManager = new BackupManager() + } + + /** + * Get backup manager instance for integration with existing backup system + */ + public getBackupManager(): BackupManager { + return this.backupManager + } + + /** + * Get cached Redux persist data for migration + */ + public getReduxData(): any { + return this.reduxData + } + + /** + * Set Redux persist data from renderer process + */ + public setReduxData(data: any): void { + this.reduxData = data + logger.info('Redux data cached for migration', { + dataKeys: data ? Object.keys(data) : [], + hasData: !!data + }) + } + + /** + * Register migration-specific IPC handlers + * This creates an isolated IPC environment only for migration operations + */ + public registerMigrationIpcHandlers(): void { + logger.info('Registering migration-specific IPC handlers') + + // Only register the minimal IPC handlers needed for migration + ipcMain.handle(IpcChannel.DataMigrate_CheckNeeded, async () => { + try { + return await this.isMigrated() + } catch (error) { + logger.error('IPC handler error: checkMigrationNeeded', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_ProceedToBackup, async () => { + try { + await this.proceedToBackup() + return true + } catch (error) { + logger.error('IPC handler error: proceedToBackup', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_StartMigration, async () => { + try { + await this.startMigrationProcess() + return true + } catch (error) { + logger.error('IPC handler error: startMigrationProcess', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_RetryMigration, async () => { + try { + await this.retryMigration() + return true + } catch (error) { + logger.error('IPC handler error: retryMigration', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_GetProgress, () => { + try { + return this.getCurrentProgress() + } catch (error) { + logger.error('IPC handler error: getCurrentProgress', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_Cancel, async () => { + try { + return await this.cancelMigration() + } catch (error) { + logger.error('IPC handler error: cancelMigration', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_BackupCompleted, async () => { + try { + await this.notifyBackupCompleted() + return true + } catch (error) { + logger.error('IPC handler error: notifyBackupCompleted', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_ShowBackupDialog, async () => { + try { + logger.info('Opening backup dialog for migration') + + // Update progress to indicate backup dialog is opening + // await this.updateProgress('backup_progress', 10, 'Opening backup dialog...') + + // Instead of performing backup automatically, let's open the file dialog + // and let the user choose where to save the backup + const { dialog } = await import('electron') + const result = await dialog.showSaveDialog({ + title: 'Save Migration Backup', + defaultPath: `cherry-studio-migration-backup-${new Date().toISOString().split('T')[0]}.zip`, + filters: [ + { name: 'Backup Files', extensions: ['zip'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + + if (!result.canceled && result.filePath) { + logger.info('User selected backup location', { filePath: result.filePath }) + await this.updateProgress('backup_progress', 10, 'Creating backup file...') + + // Perform the actual backup to the selected location + const backupResult = await this.performBackupToFile(result.filePath) + + if (backupResult.success) { + await this.updateProgress('backup_progress', 100, 'Backup created successfully!') + // Wait a moment to show the success message, then transition to confirmed state + setTimeout(async () => { + await this.updateProgress( + 'backup_confirmed', + 100, + 'Backup completed! Ready to start migration. Click "Start Migration" to continue.' + ) + }, 1000) + } else { + await this.updateProgress('backup_required', 0, `Backup failed: ${backupResult.error}`) + } + + return backupResult + } else { + logger.info('User cancelled backup dialog') + await this.updateProgress('backup_required', 0, 'Backup cancelled. Please create a backup to continue.') + return { success: false, error: 'Backup cancelled by user' } + } + } catch (error) { + logger.error('IPC handler error: showBackupDialog', error as Error) + await this.updateProgress('backup_required', 0, 'Backup process failed') + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_StartFlow, async () => { + try { + return await this.startMigrationFlow() + } catch (error) { + logger.error('IPC handler error: startMigrationFlow', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_RestartApp, async () => { + try { + await this.restartApplication() + return true + } catch (error) { + logger.error('IPC handler error: restartApplication', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_CloseWindow, () => { + try { + this.closeMigrateWindow() + return true + } catch (error) { + logger.error('IPC handler error: closeMigrateWindow', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_SendReduxData, (_event, data) => { + try { + this.setReduxData(data) + return { success: true } + } catch (error) { + logger.error('IPC handler error: sendReduxData', error as Error) + throw error + } + }) + + ipcMain.handle(IpcChannel.DataMigrate_GetReduxData, () => { + try { + return this.getReduxData() + } catch (error) { + logger.error('IPC handler error: getReduxData', error as Error) + throw error + } + }) + + logger.info('Migration IPC handlers registered successfully') + } + + /** + * Remove migration-specific IPC handlers + * Clean up when migration is complete or cancelled + */ + public unregisterMigrationIpcHandlers(): void { + logger.info('Unregistering migration-specific IPC handlers') + + try { + ipcMain.removeAllListeners(IpcChannel.DataMigrate_CheckNeeded) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_GetProgress) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_Cancel) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_BackupCompleted) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_ShowBackupDialog) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_StartFlow) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_ProceedToBackup) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_StartMigration) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_RetryMigration) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_RestartApp) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_CloseWindow) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_SendReduxData) + ipcMain.removeAllListeners(IpcChannel.DataMigrate_GetReduxData) + + logger.info('Migration IPC handlers unregistered successfully') + } catch (error) { + logger.warn('Error unregistering migration IPC handlers', error as Error) + } + } + + public static getInstance(): DataRefactorMigrateService { + if (!DataRefactorMigrateService.instance) { + DataRefactorMigrateService.instance = new DataRefactorMigrateService() + } + return DataRefactorMigrateService.instance + } + + /** + * Convenient static method to open test window + */ + public static openTestWindow(): BrowserWindow { + const instance = DataRefactorMigrateService.getInstance() + return instance.createTestWindow() + } + + /** + * Check if migration is needed + */ + async isMigrated(): Promise { + try { + const isMigrated = await this.isMigrationCompleted() + if (isMigrated) { + logger.info('Data Refactor Migration already completed') + return true + } + + logger.info('Data Refactor Migration is needed') + return false + } catch (error) { + logger.error('Failed to check migration status', error as Error) + return false + } + } + + /** + * Check if migration is already completed + */ + private async isMigrationCompleted(): Promise { + try { + logger.debug('Checking migration completion status in database') + + // First check if the database is available + if (!this.db) { + logger.warn('Database not initialized, assuming migration not completed') + return false + } + + const result = await this.db + .select() + .from(appStateTable) + .where(eq(appStateTable.key, DATA_REFACTOR_MIGRATION_STATUS)) + .limit(1) + + logger.debug('Migration status query result', { resultCount: result.length }) + + if (result.length === 0) { + logger.info('No migration status record found, migration needed') + return false + } + + const status = result[0].value as DataRefactorMigrationStatus + const isCompleted = status.completed === true + + logger.info('Migration status found', { + completed: isCompleted, + completedAt: status.completedAt, + version: status.version + }) + + return isCompleted + } catch (error) { + logger.error('Failed to check migration state - treating as not completed', error as Error) + // In case of database errors, assume migration is needed to be safe + return false + } + } + + /** + * Mark migration as completed + */ + private async markMigrationCompleted(): Promise { + try { + const migrationStatus: DataRefactorMigrationStatus = { + completed: true, + completedAt: Date.now(), + version: electronApp.getVersion() + } + + await this.db + .insert(appStateTable) + .values({ + key: DATA_REFACTOR_MIGRATION_STATUS, + value: migrationStatus, // drizzle handles JSON serialization automatically + description: 'Data refactoring migration status from legacy format (ElectronStore + Redux persist) to SQLite', + createdAt: Date.now(), + updatedAt: Date.now() + }) + .onConflictDoUpdate({ + target: appStateTable.key, + set: { + value: migrationStatus, + updatedAt: Date.now() + } + }) + + logger.info('Migration marked as completed in app_state table', { + version: migrationStatus.version, + completedAt: migrationStatus.completedAt + }) + } catch (error) { + logger.error('Failed to mark migration as completed', error as Error) + throw error + } + } + + /** + * Create and show migration window + */ + private createMigrateWindow(): BrowserWindow { + if (this.migrateWindow && !this.migrateWindow.isDestroyed()) { + this.migrateWindow.show() + return this.migrateWindow + } + + // Register migration-specific IPC handlers before creating window + this.registerMigrationIpcHandlers() + + this.migrateWindow = new BrowserWindow({ + width: 640, + height: 480, + resizable: false, + maximizable: false, + minimizable: false, + show: false, + frame: false, + autoHideMenuBar: true, + webPreferences: { + preload: join(__dirname, '../preload/simplest.js'), + sandbox: false, + webSecurity: false, + contextIsolation: true + } + }) + + // Load the migration window + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + this.migrateWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/dataRefactorMigrate.html') + } else { + this.migrateWindow.loadFile(join(__dirname, '../renderer/dataRefactorMigrate.html')) + } + + this.migrateWindow.once('ready-to-show', () => { + this.migrateWindow?.show() + }) + + this.migrateWindow.on('closed', () => { + this.migrateWindow = null + // Clean up IPC handlers when window is closed + this.unregisterMigrationIpcHandlers() + }) + + logger.info('Migration window created') + return this.migrateWindow + } + + /** + * Show migration window and initialize introduction stage + */ + async runMigration(): Promise { + if (this.isMigrating) { + logger.warn('Migration already in progress') + this.migrateWindow?.show() + return + } + + this.isMigrating = true + logger.info('Showing migration window') + + // Initialize introduction stage + await this.updateProgress('introduction', 0, 'Welcome to Cherry Studio data migration') + + // Create migration window + const window = this.createMigrateWindow() + + // Wait for window to be ready + await new Promise((resolve) => { + if (window.webContents.isLoading()) { + window.webContents.once('did-finish-load', () => resolve()) + } else { + resolve() + } + }) + } + + /** + * Start migration flow - simply ensure we're in introduction stage + * This is called when user first opens the migration window + */ + async startMigrationFlow(): Promise { + if (!this.isMigrating) { + logger.warn('Migration not started, cannot execute flow.') + return + } + + logger.info('Confirming introduction stage for migration flow') + await this.updateProgress('introduction', 0, 'Ready to begin migration process. Please read the information below.') + } + + /** + * Proceed from introduction to backup requirement stage + * This is called when user clicks "Next" in introduction + */ + async proceedToBackup(): Promise { + if (!this.isMigrating) { + logger.warn('Migration not started, cannot proceed to backup.') + return + } + + logger.info('Proceeding from introduction to backup stage') + await this.updateProgress('backup_required', 0, 'Data backup is required before migration can proceed') + } + + /** + * Start the actual migration process + * This is called when user confirms backup and clicks "Start Migration" + */ + async startMigrationProcess(): Promise { + if (!this.isMigrating) { + logger.warn('Migration not started, cannot start migration process.') + return + } + + logger.info('Starting actual migration process') + try { + await this.executeMigrationFlow() + } catch (error) { + logger.error('Migration process failed', error as Error) + // error is already handled in executeMigrationFlow + } + } + + /** + * Execute the actual migration process + * Called after user has confirmed backup completion + */ + private async executeMigrationFlow(): Promise { + try { + // Start migration + await this.updateProgress('migration', 0, 'Starting data migration...') + const migrationResult = await this.executeMigration() + + if (!migrationResult.success) { + throw new Error(migrationResult.error || 'Migration failed') + } + + await this.updateProgress( + 'migration', + 100, + `Migration completed: ${migrationResult.migratedCount} items migrated` + ) + + // Mark as completed + await this.markMigrationCompleted() + + await this.updateProgress('completed', 100, 'Migration completed successfully! Click restart to continue.') + } catch (error) { + logger.error('Migration flow failed', error as Error) + const errorMessage = error instanceof Error ? error.message : String(error) + await this.updateProgress( + 'error', + 0, + 'Migration failed. You can close this window and try again, or continue using the previous version.', + errorMessage + ) + + throw error + } + } + + /** + * Perform backup to a specific file location + */ + private async performBackupToFile(filePath: string): Promise<{ success: boolean; error?: string }> { + try { + logger.info('Performing backup to file', { filePath }) + + // Get backup data from the current application state + const backupData = await this.getBackupData() + + // Extract directory and filename from the full path + const path = await import('path') + const destinationDir = path.dirname(filePath) + const fileName = path.basename(filePath) + + // Use the existing backup manager to create a backup + const backupPath = await this.backupManager.backup( + null as any, // IpcMainInvokeEvent - we're calling directly so pass null + fileName, + backupData, + destinationDir, + false // Don't skip backup files - full backup for migration safety + ) + + if (backupPath) { + logger.info('Backup created successfully', { path: backupPath }) + return { success: true } + } else { + return { + success: false, + error: 'Backup process did not return a file path' + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error('Backup failed during migration:', error as Error) + return { + success: false, + error: errorMessage + } + } + } + + /** + * Get backup data from the current application + * This creates a minimal backup with essential system information + */ + private async getBackupData(): Promise { + try { + const fs = await import('fs-extra') + const path = await import('path') + + // Gather basic system information + const data = { + backup: { + timestamp: new Date().toISOString(), + version: electronApp.getVersion(), + type: 'pre-migration-backup', + note: 'This is a safety backup created before data migration' + }, + system: { + platform: process.platform, + arch: process.arch, + nodeVersion: process.version + }, + // Include basic configuration files if they exist + configs: {} as Record + } + + // Try to read some basic configuration files (non-critical if they fail) + try { + const { getDataPath } = await import('@main/utils') + const dataPath = getDataPath() + + // Check if there are any config files we should backup + const configFiles = ['config.json', 'settings.json', 'preferences.json'] + for (const configFile of configFiles) { + const configPath = path.join(dataPath, configFile) + if (await fs.pathExists(configPath)) { + try { + const configContent = await fs.readJson(configPath) + data.configs[configFile] = configContent + } catch (err) { + logger.warn(`Could not read config file ${configFile}`, err as Error) + } + } + } + } catch (err) { + logger.warn('Could not access data directory for config backup', err as Error) + } + + return JSON.stringify(data, null, 2) + } catch (error) { + logger.error('Failed to get backup data:', error as Error) + throw error + } + } + + /** + * Notify that backup has been completed (called from IPC handler) + */ + public async notifyBackupCompleted(): Promise { + logger.info('Backup completed by user') + await this.updateProgress( + 'backup_confirmed', + 100, + 'Backup completed! Ready to start migration. Click "Start Migration" to continue.' + ) + } + + /** + * Execute the actual migration + */ + private async executeMigration(): Promise { + try { + logger.info('Executing migration') + + // Create preferences migrator with reference to this service for Redux data access + const preferencesMigrator = new PreferencesMigrator(this) + + // Execute preferences migration with progress updates + const result = await preferencesMigrator.migrate((progress, message) => { + this.updateProgress('migration', progress, message) + }) + + logger.info('Migration execution completed', result) + + return { + success: result.success, + migratedCount: result.migratedCount, + error: result.errors.length > 0 ? result.errors.map((e) => e.error).join('; ') : undefined + } + } catch (error) { + logger.error('Migration execution failed', error as Error) + return { + success: false, + error: error instanceof Error ? error.message : String(error), + migratedCount: 0 + } + } + } + + /** + * Update migration progress and broadcast to window + */ + private async updateProgress( + stage: MigrationStage, + progress: number, + message: string, + error?: string + ): Promise { + this.currentProgress = { + stage, + progress, + total: 100, + message, + error + } + + if (this.migrateWindow && !this.migrateWindow.isDestroyed()) { + this.migrateWindow.webContents.send(IpcChannel.DataMigrateProgress, this.currentProgress) + } + + logger.debug('Progress updated', this.currentProgress) + } + + /** + * Get current migration progress + */ + getCurrentProgress(): MigrationProgress { + return this.currentProgress + } + + /** + * Cancel migration process + * Only allowed during introduction and backup phases + */ + async cancelMigration(): Promise { + if (!this.isMigrating) { + return + } + + const currentStage = this.currentProgress.stage + if (currentStage === 'migration') { + logger.warn('Cannot cancel migration during migration process') + return + } + + logger.info('Cancelling migration process') + this.isMigrating = false + this.closeMigrateWindow() + } + + /** + * Retry migration after error + */ + async retryMigration(): Promise { + logger.info('Retrying migration process') + await this.updateProgress( + 'introduction', + 0, + 'Ready to restart migration process. Please read the information below.' + ) + } + + /** + * Close migration window + */ + private closeMigrateWindow(): void { + if (this.migrateWindow && !this.migrateWindow.isDestroyed()) { + this.migrateWindow.close() + this.migrateWindow = null + } + + this.isMigrating = false + // Clean up migration-specific IPC handlers + this.unregisterMigrationIpcHandlers() + } + + /** + * Restart the application after successful migration + */ + private async restartApplication(): Promise { + try { + logger.info('Preparing to restart application after migration completion') + + // Ensure migration status is properly saved before restart + await this.verifyMigrationStatus() + + // Give some time for database operations to complete + await new Promise((resolve) => setTimeout(resolve, 500)) + + logger.info('Restarting application now') + + // In development mode, relaunch might not work properly + if (process.env.NODE_ENV === 'development' || !app.isPackaged) { + logger.warn('Development mode detected - showing restart instruction instead of auto-restart') + + const { dialog } = await import('electron') + await dialog.showMessageBox({ + type: 'info', + title: 'Migration Complete - Restart Required', + message: + 'Data migration completed successfully!\n\nSince you are in development mode, please manually restart the application to continue.', + buttons: ['Close App'], + defaultId: 0 + }) + + // Clean up migration window and handlers after showing dialog + this.closeMigrateWindow() + app.quit() + } else { + // Production mode - clean up first, then relaunch + this.closeMigrateWindow() + app.relaunch() + app.exit(0) + } + } catch (error) { + logger.error('Failed to restart application', error as Error) + // Update UI to show restart failure and provide manual restart instruction + await this.updateProgress( + 'error', + 0, + 'Application restart failed. Please manually restart the application to complete migration.', + error instanceof Error ? error.message : String(error) + ) + // Fallback: just close migration window and let user manually restart + this.closeMigrateWindow() + } + } + + /** + * Verify that migration status is properly saved + */ + private async verifyMigrationStatus(): Promise { + try { + const isCompleted = await this.isMigrationCompleted() + if (isCompleted) { + logger.info('Migration status verified as completed') + } else { + logger.warn('Migration status not found as completed, attempting to mark again') + await this.markMigrationCompleted() + + // Double-check + const recheck = await this.isMigrationCompleted() + if (recheck) { + logger.info('Migration status successfully marked as completed on retry') + } else { + logger.error('Failed to mark migration as completed even on retry') + } + } + } catch (error) { + logger.error('Failed to verify migration status', error as Error) + // Don't throw - still allow restart + } + } + + /** + * Create and show test window for testing PreferenceService and usePreference functionality + */ + public createTestWindow(): BrowserWindow { + const windowNumber = this.testWindows.length + 1 + + const testWindow = new BrowserWindow({ + width: 1000, + height: 700, + minWidth: 800, + minHeight: 600, + resizable: true, + maximizable: true, + minimizable: true, + show: false, + frame: true, + autoHideMenuBar: true, + title: `Data Refactor Test Window #${windowNumber} - PreferenceService Testing`, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false, + contextIsolation: true + } + }) + + // Add to test windows array + this.testWindows.push(testWindow) + + // Load the test window + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + testWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/dataRefactorTest.html') + // Open DevTools in development mode for easier testing + testWindow.webContents.openDevTools() + } else { + testWindow.loadFile(join(__dirname, '../renderer/dataRefactorTest.html')) + } + + testWindow.once('ready-to-show', () => { + testWindow?.show() + testWindow?.focus() + }) + + testWindow.on('closed', () => { + // Remove from test windows array when closed + const index = this.testWindows.indexOf(testWindow) + if (index > -1) { + this.testWindows.splice(index, 1) + } + }) + + logger.info(`Test window #${windowNumber} created for PreferenceService testing`) + return testWindow + } + + /** + * Get test window instance (first one) + */ + public getTestWindow(): BrowserWindow | null { + return this.testWindows.length > 0 ? this.testWindows[0] : null + } + + /** + * Get all test windows + */ + public getTestWindows(): BrowserWindow[] { + return this.testWindows.filter((window) => !window.isDestroyed()) + } + + /** + * Close all test windows + */ + public closeTestWindows(): void { + this.testWindows.forEach((window) => { + if (!window.isDestroyed()) { + window.close() + } + }) + this.testWindows = [] + logger.info('All test windows closed') + } + + /** + * Close a specific test window + */ + public closeTestWindow(window?: BrowserWindow): void { + if (window) { + if (!window.isDestroyed()) { + window.close() + } + } else { + // Close first window if no specific window provided + const firstWindow = this.getTestWindow() + if (firstWindow && !firstWindow.isDestroyed()) { + firstWindow.close() + } + } + } + + /** + * Check if any test windows are open + */ + public isTestWindowOpen(): boolean { + return this.testWindows.some((window) => !window.isDestroyed()) + } +} + +// Export singleton instance +export const dataRefactorMigrateService = DataRefactorMigrateService.getInstance() diff --git a/src/main/data/migrate/dataRefactor/migrators/PreferencesMappings.ts b/src/main/data/migrate/dataRefactor/migrators/PreferencesMappings.ts new file mode 100644 index 0000000000..b3cfb07c6c --- /dev/null +++ b/src/main/data/migrate/dataRefactor/migrators/PreferencesMappings.ts @@ -0,0 +1,755 @@ +/** + * Auto-generated preference mappings from classification.json + * Generated at: 2025-09-02T06:27:50.213Z + * + * This file contains pure mapping relationships without default values. + * Default values are managed in packages/shared/data/preferences.ts + * + * === AUTO-GENERATED CONTENT START === + */ + +/** + * ElectronStore映射关系 - 简单一层结构 + * + * ElectronStore没有嵌套,originalKey直接对应configManager.get(key) + */ +export const ELECTRON_STORE_MAPPINGS = [ + { + originalKey: 'ZoomFactor', + targetKey: 'app.zoom_factor' + } +] as const + +/** + * Redux Store映射关系 - 按category分组,支持嵌套路径 + * + * Redux Store可能有children结构,originalKey可能包含嵌套路径: + * - 直接字段: "theme" -> reduxData.settings.theme + * - 嵌套字段: "codeEditor.enabled" -> reduxData.settings.codeEditor.enabled + * - 多层嵌套: "exportMenuOptions.docx" -> reduxData.settings.exportMenuOptions.docx + */ +export const REDUX_STORE_MAPPINGS = { + settings: [ + { + originalKey: 'autoCheckUpdate', + targetKey: 'app.dist.auto_update.enabled' + }, + { + originalKey: 'clickTrayToShowQuickAssistant', + targetKey: 'feature.quick_assistant.click_tray_to_show' + }, + { + originalKey: 'disableHardwareAcceleration', + targetKey: 'app.disable_hardware_acceleration' + }, + { + originalKey: 'enableDataCollection', + targetKey: 'app.privacy.data_collection.enabled' + }, + { + originalKey: 'enableDeveloperMode', + targetKey: 'app.developer_mode.enabled' + }, + { + originalKey: 'enableQuickAssistant', + targetKey: 'feature.quick_assistant.enabled' + }, + { + originalKey: 'language', + targetKey: 'app.language' + }, + { + originalKey: 'launchToTray', + targetKey: 'app.tray.on_launch' + }, + { + originalKey: 'testChannel', + targetKey: 'app.dist.test_plan.channel' + }, + { + originalKey: 'testPlan', + targetKey: 'app.dist.test_plan.enabled' + }, + { + originalKey: 'theme', + targetKey: 'ui.theme_mode' + }, + { + originalKey: 'tray', + targetKey: 'app.tray.enabled' + }, + { + originalKey: 'trayOnClose', + targetKey: 'app.tray.on_close' + }, + { + originalKey: 'showAssistants', + targetKey: 'assistant.tab.show' + }, + { + originalKey: 'showTopics', + targetKey: 'topic.tab.show' + }, + { + originalKey: 'assistantsTabSortType', + targetKey: 'assistant.tab.sort_type' + }, + { + originalKey: 'sendMessageShortcut', + targetKey: 'chat.input.send_message_shortcut' + }, + { + originalKey: 'targetLanguage', + targetKey: 'feature.translate.target_language' + }, + { + originalKey: 'proxyMode', + targetKey: 'app.proxy.mode' + }, + { + originalKey: 'proxyUrl', + targetKey: 'app.proxy.url' + }, + { + originalKey: 'proxyBypassRules', + targetKey: 'app.proxy.bypass_rules' + }, + { + originalKey: 'userName', + targetKey: 'app.user.name' + }, + { + originalKey: 'userId', + targetKey: 'app.user.id' + }, + { + originalKey: 'showPrompt', + targetKey: 'chat.message.show_prompt' + }, + { + originalKey: 'showMessageDivider', + targetKey: 'chat.message.show_divider' + }, + { + originalKey: 'messageFont', + targetKey: 'chat.message.font' + }, + { + originalKey: 'showInputEstimatedTokens', + targetKey: 'chat.input.show_estimated_tokens' + }, + { + originalKey: 'launchOnBoot', + targetKey: 'app.launch_on_boot' + }, + { + originalKey: 'userTheme.colorPrimary', + targetKey: 'ui.theme_user.color_primary' + }, + { + originalKey: 'windowStyle', + targetKey: 'ui.window_style' + }, + { + originalKey: 'fontSize', + targetKey: 'chat.message.font_size' + }, + { + originalKey: 'topicPosition', + targetKey: 'topic.position' + }, + { + originalKey: 'showTopicTime', + targetKey: 'topic.tab.show_time' + }, + { + originalKey: 'pinTopicsToTop', + targetKey: 'topic.tab.pin_to_top' + }, + { + originalKey: 'assistantIconType', + targetKey: 'assistant.icon_type' + }, + { + originalKey: 'pasteLongTextAsFile', + targetKey: 'chat.input.paste_long_text_as_file' + }, + { + originalKey: 'pasteLongTextThreshold', + targetKey: 'chat.input.paste_long_text_threshold' + }, + { + originalKey: 'clickAssistantToShowTopic', + targetKey: 'assistant.click_to_show_topic' + }, + { + originalKey: 'codeExecution.enabled', + targetKey: 'chat.code.execution.enabled' + }, + { + originalKey: 'codeExecution.timeoutMinutes', + targetKey: 'chat.code.execution.timeout_minutes' + }, + { + originalKey: 'codeEditor.enabled', + targetKey: 'chat.code.editor.enabled' + }, + { + originalKey: 'codeEditor.themeLight', + targetKey: 'chat.code.editor.theme_light' + }, + { + originalKey: 'codeEditor.themeDark', + targetKey: 'chat.code.editor.theme_dark' + }, + { + originalKey: 'codeEditor.highlightActiveLine', + targetKey: 'chat.code.editor.highlight_active_line' + }, + { + originalKey: 'codeEditor.foldGutter', + targetKey: 'chat.code.editor.fold_gutter' + }, + { + originalKey: 'codeEditor.autocompletion', + targetKey: 'chat.code.editor.autocompletion' + }, + { + originalKey: 'codeEditor.keymap', + targetKey: 'chat.code.editor.keymap' + }, + { + originalKey: 'codePreview.themeLight', + targetKey: 'chat.code.preview.theme_light' + }, + { + originalKey: 'codePreview.themeDark', + targetKey: 'chat.code.preview.theme_dark' + }, + { + originalKey: 'codeViewer.themeLight', + targetKey: 'chat.code.viewer.theme_light' + }, + { + originalKey: 'codeViewer.themeDark', + targetKey: 'chat.code.viewer.theme_dark' + }, + { + originalKey: 'codeShowLineNumbers', + targetKey: 'chat.code.show_line_numbers' + }, + { + originalKey: 'codeCollapsible', + targetKey: 'chat.code.collapsible' + }, + { + originalKey: 'codeWrappable', + targetKey: 'chat.code.wrappable' + }, + { + originalKey: 'codeImageTools', + targetKey: 'chat.code.image_tools' + }, + { + originalKey: 'mathEngine', + targetKey: 'chat.message.math_engine' + }, + { + originalKey: 'messageStyle', + targetKey: 'chat.message.style' + }, + { + originalKey: 'foldDisplayMode', + targetKey: 'chat.message.multi_model.fold_display_mode' + }, + { + originalKey: 'gridColumns', + targetKey: 'chat.message.multi_model.grid_columns' + }, + { + originalKey: 'gridPopoverTrigger', + targetKey: 'chat.message.multi_model.grid_popover_trigger' + }, + { + originalKey: 'messageNavigation', + targetKey: 'chat.message.navigation_mode' + }, + { + originalKey: 'skipBackupFile', + targetKey: 'data.backup.general.skip_backup_file' + }, + { + originalKey: 'webdavHost', + targetKey: 'data.backup.webdav.host' + }, + { + originalKey: 'webdavUser', + targetKey: 'data.backup.webdav.user' + }, + { + originalKey: 'webdavPass', + targetKey: 'data.backup.webdav.pass' + }, + { + originalKey: 'webdavPath', + targetKey: 'data.backup.webdav.path' + }, + { + originalKey: 'webdavAutoSync', + targetKey: 'data.backup.webdav.auto_sync' + }, + { + originalKey: 'webdavSyncInterval', + targetKey: 'data.backup.webdav.sync_interval' + }, + { + originalKey: 'webdavMaxBackups', + targetKey: 'data.backup.webdav.max_backups' + }, + { + originalKey: 'webdavSkipBackupFile', + targetKey: 'data.backup.webdav.skip_backup_file' + }, + { + originalKey: 'webdavDisableStream', + targetKey: 'data.backup.webdav.disable_stream' + }, + { + originalKey: 'translateModelPrompt', + targetKey: 'feature.translate.model_prompt' + }, + { + originalKey: 'autoTranslateWithSpace', + targetKey: 'chat.input.translate.auto_translate_with_space' + }, + { + originalKey: 'showTranslateConfirm', + targetKey: 'chat.input.translate.show_confirm' + }, + { + originalKey: 'enableTopicNaming', + targetKey: 'topic.naming.enabled' + }, + { + originalKey: 'customCss', + targetKey: 'ui.custom_css' + }, + { + originalKey: 'topicNamingPrompt', + targetKey: 'topic.naming.prompt' + }, + { + originalKey: 'narrowMode', + targetKey: 'chat.narrow_mode' + }, + { + originalKey: 'multiModelMessageStyle', + targetKey: 'chat.message.multi_model.style' + }, + { + originalKey: 'readClipboardAtStartup', + targetKey: 'feature.quick_assistant.read_clipboard_at_startup' + }, + { + originalKey: 'notionDatabaseID', + targetKey: 'data.integration.notion.database_id' + }, + { + originalKey: 'notionApiKey', + targetKey: 'data.integration.notion.api_key' + }, + { + originalKey: 'notionPageNameKey', + targetKey: 'data.integration.notion.page_name_key' + }, + { + originalKey: 'markdownExportPath', + targetKey: 'data.export.markdown.path' + }, + { + originalKey: 'forceDollarMathInMarkdown', + targetKey: 'data.export.markdown.force_dollar_math' + }, + { + originalKey: 'useTopicNamingForMessageTitle', + targetKey: 'data.export.markdown.use_topic_naming_for_message_title' + }, + { + originalKey: 'showModelNameInMarkdown', + targetKey: 'data.export.markdown.show_model_name' + }, + { + originalKey: 'showModelProviderInMarkdown', + targetKey: 'data.export.markdown.show_model_provider' + }, + { + originalKey: 'thoughtAutoCollapse', + targetKey: 'chat.message.thought.auto_collapse' + }, + { + originalKey: 'notionExportReasoning', + targetKey: 'data.integration.notion.export_reasoning' + }, + { + originalKey: 'excludeCitationsInExport', + targetKey: 'data.export.markdown.exclude_citations' + }, + { + originalKey: 'standardizeCitationsInExport', + targetKey: 'data.export.markdown.standardize_citations' + }, + { + originalKey: 'yuqueToken', + targetKey: 'data.integration.yuque.token' + }, + { + originalKey: 'yuqueUrl', + targetKey: 'data.integration.yuque.url' + }, + { + originalKey: 'yuqueRepoId', + targetKey: 'data.integration.yuque.repo_id' + }, + { + originalKey: 'joplinToken', + targetKey: 'data.integration.joplin.token' + }, + { + originalKey: 'joplinUrl', + targetKey: 'data.integration.joplin.url' + }, + { + originalKey: 'joplinExportReasoning', + targetKey: 'data.integration.joplin.export_reasoning' + }, + { + originalKey: 'defaultObsidianVault', + targetKey: 'data.integration.obsidian.default_vault' + }, + { + originalKey: 'siyuanApiUrl', + targetKey: 'data.integration.siyuan.api_url' + }, + { + originalKey: 'siyuanToken', + targetKey: 'data.integration.siyuan.token' + }, + { + originalKey: 'siyuanBoxId', + targetKey: 'data.integration.siyuan.box_id' + }, + { + originalKey: 'siyuanRootPath', + targetKey: 'data.integration.siyuan.root_path' + }, + { + originalKey: 'maxKeepAliveMinapps', + targetKey: 'feature.minapp.max_keep_alive' + }, + { + originalKey: 'showOpenedMinappsInSidebar', + targetKey: 'feature.minapp.show_opened_in_sidebar' + }, + { + originalKey: 'minappsOpenLinkExternal', + targetKey: 'feature.minapp.open_link_external' + }, + { + originalKey: 'enableSpellCheck', + targetKey: 'app.spell_check.enabled' + }, + { + originalKey: 'spellCheckLanguages', + targetKey: 'app.spell_check.languages' + }, + { + originalKey: 'enableQuickPanelTriggers', + targetKey: 'chat.input.quick_panel.triggers_enabled' + }, + { + originalKey: 'exportMenuOptions.image', + targetKey: 'data.export.menus.image' + }, + { + originalKey: 'exportMenuOptions.markdown', + targetKey: 'data.export.menus.markdown' + }, + { + originalKey: 'exportMenuOptions.markdown_reason', + targetKey: 'data.export.menus.markdown_reason' + }, + { + originalKey: 'exportMenuOptions.notion', + targetKey: 'data.export.menus.notion' + }, + { + originalKey: 'exportMenuOptions.yuque', + targetKey: 'data.export.menus.yuque' + }, + { + originalKey: 'exportMenuOptions.joplin', + targetKey: 'data.export.menus.joplin' + }, + { + originalKey: 'exportMenuOptions.obsidian', + targetKey: 'data.export.menus.obsidian' + }, + { + originalKey: 'exportMenuOptions.siyuan', + targetKey: 'data.export.menus.siyuan' + }, + { + originalKey: 'exportMenuOptions.docx', + targetKey: 'data.export.menus.docx' + }, + { + originalKey: 'exportMenuOptions.plain_text', + targetKey: 'data.export.menus.plain_text' + }, + { + originalKey: 'notification.assistant', + targetKey: 'app.notification.assistant.enabled' + }, + { + originalKey: 'notification.backup', + targetKey: 'app.notification.backup.enabled' + }, + { + originalKey: 'notification.knowledge', + targetKey: 'app.notification.knowledge.enabled' + }, + { + originalKey: 'localBackupDir', + targetKey: 'data.backup.local.dir' + }, + { + originalKey: 'localBackupAutoSync', + targetKey: 'data.backup.local.auto_sync' + }, + { + originalKey: 'localBackupSyncInterval', + targetKey: 'data.backup.local.sync_interval' + }, + { + originalKey: 'localBackupMaxBackups', + targetKey: 'data.backup.local.max_backups' + }, + { + originalKey: 'localBackupSkipBackupFile', + targetKey: 'data.backup.local.skip_backup_file' + }, + { + originalKey: 's3.endpoint', + targetKey: 'data.backup.s3.endpoint' + }, + { + originalKey: 's3.region', + targetKey: 'data.backup.s3.region' + }, + { + originalKey: 's3.bucket', + targetKey: 'data.backup.s3.bucket' + }, + { + originalKey: 's3.accessKeyId', + targetKey: 'data.backup.s3.access_key_id' + }, + { + originalKey: 's3.secretAccessKey', + targetKey: 'data.backup.s3.secret_access_key' + }, + { + originalKey: 's3.root', + targetKey: 'data.backup.s3.root' + }, + { + originalKey: 's3.autoSync', + targetKey: 'data.backup.s3.auto_sync' + }, + { + originalKey: 's3.syncInterval', + targetKey: 'data.backup.s3.sync_interval' + }, + { + originalKey: 's3.maxBackups', + targetKey: 'data.backup.s3.max_backups' + }, + { + originalKey: 's3.skipBackupFile', + targetKey: 'data.backup.s3.skip_backup_file' + }, + { + originalKey: 'navbarPosition', + targetKey: 'ui.navbar.position' + }, + { + originalKey: 'apiServer.enabled', + targetKey: 'feature.csaas.enabled' + }, + { + originalKey: 'apiServer.host', + targetKey: 'feature.csaas.host' + }, + { + originalKey: 'apiServer.port', + targetKey: 'feature.csaas.port' + }, + { + originalKey: 'apiServer.apiKey', + targetKey: 'feature.csaas.api_key' + } + ], + selectionStore: [ + { + originalKey: 'selectionEnabled', + targetKey: 'feature.selection.enabled' + }, + { + originalKey: 'filterList', + targetKey: 'feature.selection.filter_list' + }, + { + originalKey: 'filterMode', + targetKey: 'feature.selection.filter_mode' + }, + { + originalKey: 'isFollowToolbar', + targetKey: 'feature.selection.follow_toolbar' + }, + { + originalKey: 'isRemeberWinSize', + targetKey: 'feature.selection.remember_win_size' + }, + { + originalKey: 'triggerMode', + targetKey: 'feature.selection.trigger_mode' + }, + { + originalKey: 'isCompact', + targetKey: 'feature.selection.compact' + }, + { + originalKey: 'isAutoClose', + targetKey: 'feature.selection.auto_close' + }, + { + originalKey: 'isAutoPin', + targetKey: 'feature.selection.auto_pin' + }, + { + originalKey: 'actionWindowOpacity', + targetKey: 'feature.selection.action_window_opacity' + }, + { + originalKey: 'actionItems', + targetKey: 'feature.selection.action_items' + } + ], + nutstore: [ + { + originalKey: 'nutstoreToken', + targetKey: 'data.backup.nutstore.token' + }, + { + originalKey: 'nutstorePath', + targetKey: 'data.backup.nutstore.path' + }, + { + originalKey: 'nutstoreAutoSync', + targetKey: 'data.backup.nutstore.auto_sync' + }, + { + originalKey: 'nutstoreSyncInterval', + targetKey: 'data.backup.nutstore.sync_interval' + }, + { + originalKey: 'nutstoreSyncState', + targetKey: 'data.backup.nutstore.sync_state' + }, + { + originalKey: 'nutstoreSkipBackupFile', + targetKey: 'data.backup.nutstore.skip_backup_file' + } + ], + shortcuts: [ + { + originalKey: 'shortcuts.zoom_in', + targetKey: 'shortcut.app.zoom_in' + }, + { + originalKey: 'shortcuts.zoom_out', + targetKey: 'shortcut.app.zoom_out' + }, + { + originalKey: 'shortcuts.zoom_reset', + targetKey: 'shortcut.app.zoom_reset' + }, + { + originalKey: 'shortcuts.show_settings', + targetKey: 'shortcut.app.show_settings' + }, + { + originalKey: 'shortcuts.show_app', + targetKey: 'shortcut.app.show_main_window' + }, + { + originalKey: 'shortcuts.mini_window', + targetKey: 'shortcut.app.show_mini_window' + }, + { + originalKey: 'shortcuts.selection_assistant_toggle', + targetKey: 'shortcut.selection.toggle_enabled' + }, + { + originalKey: 'shortcuts.selection_assistant_select_text', + targetKey: 'shortcut.selection.get_text' + }, + { + originalKey: 'shortcuts.new_topic', + targetKey: 'shortcut.topic.new' + }, + { + originalKey: 'shortcuts.toggle_show_assistants', + targetKey: 'shortcut.app.toggle_show_assistants' + }, + { + originalKey: 'shortcuts.copy_last_message', + targetKey: 'shortcut.chat.copy_last_message' + }, + { + originalKey: 'shortcuts.search_message_in_chat', + targetKey: 'shortcut.chat.search_message' + }, + { + originalKey: 'shortcuts.search_message', + targetKey: 'shortcut.app.search_message' + }, + { + originalKey: 'shortcuts.clear_topic', + targetKey: 'shortcut.chat.clear' + }, + { + originalKey: 'shortcuts.toggle_new_context', + targetKey: 'shortcut.chat.toggle_new_context' + }, + { + originalKey: 'shortcuts.exit_fullscreen', + targetKey: 'shortcut.app.exit_fullscreen' + } + ] +} as const + +// === AUTO-GENERATED CONTENT END === + +/** + * 映射统计: + * - ElectronStore项: 1 + * - Redux Store项: 175 + * - Redux分类: settings, selectionStore, nutstore, shortcuts + * - 总配置项: 176 + * + * 使用说明: + * 1. ElectronStore读取: configManager.get(mapping.originalKey) + * 2. Redux读取: 需要解析嵌套路径 reduxData[category][originalKey路径] + * 3. 默认值: 从defaultPreferences.default[mapping.targetKey]获取 + */ diff --git a/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts b/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts new file mode 100644 index 0000000000..495570511b --- /dev/null +++ b/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts @@ -0,0 +1,642 @@ +import { dbService } from '@data/db/DbService' +import { preferenceTable } from '@data/db/schemas/preference' +import { loggerService } from '@logger' +import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' +import { and, eq } from 'drizzle-orm' + +import { configManager } from '../../../../services/ConfigManager' +import type { DataRefactorMigrateService } from '../DataRefactorMigrateService' +import { ELECTRON_STORE_MAPPINGS, REDUX_STORE_MAPPINGS } from './PreferencesMappings' + +const logger = loggerService.withContext('PreferencesMigrator') + +export interface MigrationItem { + originalKey: string + targetKey: string + type: string + defaultValue: any + source: 'electronStore' | 'redux' + sourceCategory?: string // Optional for electronStore +} + +export interface MigrationResult { + success: boolean + migratedCount: number + errors: Array<{ + key: string + error: string + }> +} + +export interface PreparedMigrationData { + targetKey: string + value: any + source: 'electronStore' | 'redux' + originalKey: string + sourceCategory?: string +} + +export interface BatchMigrationResult { + newPreferences: PreparedMigrationData[] + updatedPreferences: PreparedMigrationData[] + skippedCount: number + preparationErrors: Array<{ + key: string + error: string + }> +} + +export class PreferencesMigrator { + private db = dbService.getDb() + private migrateService: DataRefactorMigrateService + + constructor(migrateService: DataRefactorMigrateService) { + this.migrateService = migrateService + } + + /** + * Execute preferences migration from all sources using batch operations and transactions + */ + async migrate(onProgress?: (progress: number, message: string) => void): Promise { + logger.info('Starting preferences migration with batch operations') + + const result: MigrationResult = { + success: true, + migratedCount: 0, + errors: [] + } + + try { + // Phase 1: Prepare all migration data in memory (50% of progress) + onProgress?.(10, 'Loading migration items...') + const migrationItems = await this.loadMigrationItems() + logger.info(`Found ${migrationItems.length} items to migrate`) + + onProgress?.(25, 'Preparing migration data...') + const batchResult = await this.prepareMigrationData(migrationItems, (progress) => { + // Map preparation progress to 25-50% of total progress + const totalProgress = 25 + Math.floor(progress * 0.25) + onProgress?.(totalProgress, 'Preparing migration data...') + }) + + // Add preparation errors to result + result.errors.push(...batchResult.preparationErrors) + + if (batchResult.preparationErrors.length > 0) { + logger.warn('Some items failed during preparation', { + errorCount: batchResult.preparationErrors.length + }) + } + + // Phase 2: Execute batch migration in transaction (50% of progress) + onProgress?.(50, 'Executing batch migration...') + + const totalOperations = batchResult.newPreferences.length + batchResult.updatedPreferences.length + if (totalOperations > 0) { + try { + await this.executeBatchMigration(batchResult, (progress) => { + // Map execution progress to 50-90% of total progress + const totalProgress = 50 + Math.floor(progress * 0.4) + onProgress?.(totalProgress, 'Executing batch migration...') + }) + + result.migratedCount = totalOperations + logger.info('Batch migration completed successfully', { + newPreferences: batchResult.newPreferences.length, + updatedPreferences: batchResult.updatedPreferences.length, + skippedCount: batchResult.skippedCount + }) + } catch (batchError) { + logger.error('Batch migration transaction failed - all changes rolled back', batchError as Error) + result.success = false + result.errors.push({ + key: 'batch_migration', + error: `Transaction failed: ${batchError instanceof Error ? batchError.message : String(batchError)}` + }) + // Note: No need to manually rollback - transaction handles this automatically + } + } else { + logger.info('No preferences to migrate') + } + + onProgress?.(100, 'Migration completed') + + // Set success based on whether we had any critical errors + result.success = result.errors.length === 0 + + logger.info('Preferences migration completed', { + migratedCount: result.migratedCount, + errorCount: result.errors.length, + skippedCount: batchResult.skippedCount + }) + } catch (error) { + logger.error('Preferences migration failed', error as Error) + result.success = false + result.errors.push({ + key: 'global', + error: error instanceof Error ? error.message : String(error) + }) + } + + return result + } + + /** + * Load migration items from generated mapping relationships + * This uses the auto-generated PreferencesMappings.ts file + */ + private async loadMigrationItems(): Promise { + logger.info('Loading migration items from generated mappings') + const items: MigrationItem[] = [] + + // Process ElectronStore mappings - no sourceCategory needed + ELECTRON_STORE_MAPPINGS.forEach((mapping) => { + const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null + items.push({ + originalKey: mapping.originalKey, + targetKey: mapping.targetKey, + type: 'unknown', // Type will be inferred from defaultValue during conversion + defaultValue, + source: 'electronStore' + }) + }) + + // Process Redux mappings + Object.entries(REDUX_STORE_MAPPINGS).forEach(([category, mappings]) => { + mappings.forEach((mapping) => { + const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null + items.push({ + originalKey: mapping.originalKey, // May contain nested paths like "codeEditor.enabled" + targetKey: mapping.targetKey, + sourceCategory: category, + type: 'unknown', // Type will be inferred from defaultValue during conversion + defaultValue, + source: 'redux' + }) + }) + }) + + logger.info('Successfully loaded migration items from generated mappings', { + totalItems: items.length, + electronStoreItems: items.filter((i) => i.source === 'electronStore').length, + reduxItems: items.filter((i) => i.source === 'redux').length + }) + + return items + } + + /** + * Prepare all migration data in memory before database operations + * This phase reads all source data and performs conversions/validations + */ + private async prepareMigrationData( + migrationItems: MigrationItem[], + onProgress?: (progress: number) => void + ): Promise { + logger.info('Starting migration data preparation', { itemCount: migrationItems.length }) + + const batchResult: BatchMigrationResult = { + newPreferences: [], + updatedPreferences: [], + skippedCount: 0, + preparationErrors: [] + } + + // Get existing preferences to determine which are new vs updated + const existingPreferences = await this.getExistingPreferences() + const existingKeys = new Set(existingPreferences.map((p) => p.key)) + + // Process each migration item + for (let i = 0; i < migrationItems.length; i++) { + const item = migrationItems[i] + + try { + // Read original value from source + let originalValue: any + if (item.source === 'electronStore') { + originalValue = await this.readFromElectronStore(item.originalKey) + } else if (item.source === 'redux') { + if (!item.sourceCategory) { + throw new Error(`Redux source requires sourceCategory for item: ${item.originalKey}`) + } + originalValue = await this.readFromReduxPersist(item.sourceCategory, item.originalKey) + } else { + throw new Error(`Unknown source: ${item.source}`) + } + + // Determine value to migrate + let valueToMigrate = originalValue + let shouldSkip = false + + if (originalValue === undefined || originalValue === null) { + if (item.defaultValue !== null && item.defaultValue !== undefined) { + valueToMigrate = item.defaultValue + logger.debug('Using default value for preparation', { + targetKey: item.targetKey, + source: item.source, + originalKey: item.originalKey + }) + } else { + shouldSkip = true + batchResult.skippedCount++ + logger.debug('Skipping item - no data and no meaningful default', { + targetKey: item.targetKey, + source: item.source, + originalKey: item.originalKey + }) + } + } + + if (!shouldSkip) { + // Convert value to appropriate type + const convertedValue = this.convertValue(valueToMigrate, item.type) + + // Create prepared migration data + const preparedData: PreparedMigrationData = { + targetKey: item.targetKey, + value: convertedValue, + source: item.source, + originalKey: item.originalKey, + sourceCategory: item.sourceCategory + } + + // Categorize as new or updated + if (existingKeys.has(item.targetKey)) { + batchResult.updatedPreferences.push(preparedData) + } else { + batchResult.newPreferences.push(preparedData) + } + + logger.debug('Prepared migration data', { + targetKey: item.targetKey, + isUpdate: existingKeys.has(item.targetKey), + source: item.source + }) + } + } catch (error) { + logger.error('Failed to prepare migration item', { item, error }) + batchResult.preparationErrors.push({ + key: item.originalKey, + error: error instanceof Error ? error.message : String(error) + }) + } + + // Report progress + const progress = Math.floor(((i + 1) / migrationItems.length) * 100) + onProgress?.(progress) + } + + logger.info('Migration data preparation completed', { + newPreferences: batchResult.newPreferences.length, + updatedPreferences: batchResult.updatedPreferences.length, + skippedCount: batchResult.skippedCount, + errorCount: batchResult.preparationErrors.length + }) + + return batchResult + } + + /** + * Get all existing preferences from database to determine new vs updated items + */ + private async getExistingPreferences(): Promise> { + try { + const preferences = await this.db + .select({ + key: preferenceTable.key, + value: preferenceTable.value + }) + .from(preferenceTable) + .where(eq(preferenceTable.scope, 'default')) + + logger.debug('Loaded existing preferences', { count: preferences.length }) + return preferences + } catch (error) { + logger.error('Failed to load existing preferences', error as Error) + return [] + } + } + + /** + * Execute batch migration using database transaction with bulk operations + */ + private async executeBatchMigration( + batchData: BatchMigrationResult, + onProgress?: (progress: number) => void + ): Promise { + logger.info('Starting batch migration execution', { + newCount: batchData.newPreferences.length, + updateCount: batchData.updatedPreferences.length + }) + + // Validate batch data before starting transaction + this.validateBatchData(batchData) + + await this.db.transaction(async (tx) => { + const scope = 'default' + const timestamp = Date.now() + let completedOperations = 0 + const totalOperations = batchData.newPreferences.length + batchData.updatedPreferences.length + + // Batch insert new preferences + if (batchData.newPreferences.length > 0) { + logger.debug('Executing batch insert for new preferences', { count: batchData.newPreferences.length }) + + const insertValues = batchData.newPreferences.map((item) => ({ + scope, + key: item.targetKey, + value: item.value, + createdAt: timestamp, + updatedAt: timestamp + })) + + await tx.insert(preferenceTable).values(insertValues) + + completedOperations += batchData.newPreferences.length + const progress = Math.floor((completedOperations / totalOperations) * 100) + onProgress?.(progress) + + logger.info('Batch insert completed', { insertedCount: batchData.newPreferences.length }) + } + + // Batch update existing preferences + if (batchData.updatedPreferences.length > 0) { + logger.debug('Executing batch updates for existing preferences', { count: batchData.updatedPreferences.length }) + + // Execute updates in batches to avoid SQL limitations + const BATCH_SIZE = 50 + const updateBatches = this.chunkArray(batchData.updatedPreferences, BATCH_SIZE) + + for (const batch of updateBatches) { + // Use Promise.all to execute updates in parallel within the transaction + await Promise.all( + batch.map((item) => + tx + .update(preferenceTable) + .set({ + value: item.value, + updatedAt: timestamp + }) + .where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, item.targetKey))) + ) + ) + + completedOperations += batch.length + const progress = Math.floor((completedOperations / totalOperations) * 100) + onProgress?.(progress) + } + + logger.info('Batch updates completed', { updatedCount: batchData.updatedPreferences.length }) + } + + logger.info('Transaction completed successfully', { + totalOperations: completedOperations, + newPreferences: batchData.newPreferences.length, + updatedPreferences: batchData.updatedPreferences.length + }) + }) + } + + /** + * Validate batch data before executing migration + */ + private validateBatchData(batchData: BatchMigrationResult): void { + const allData = [...batchData.newPreferences, ...batchData.updatedPreferences] + + // Check for duplicate target keys + const targetKeys = allData.map((item) => item.targetKey) + const duplicateKeys = targetKeys.filter((key, index) => targetKeys.indexOf(key) !== index) + + if (duplicateKeys.length > 0) { + throw new Error(`Duplicate target keys found in migration data: ${duplicateKeys.join(', ')}`) + } + + // Validate each item has required fields + for (const item of allData) { + if (!item.targetKey || item.targetKey.trim() === '') { + throw new Error(`Invalid targetKey found: '${item.targetKey}'`) + } + + if (item.value === undefined) { + throw new Error(`Undefined value for targetKey: '${item.targetKey}'`) + } + } + + logger.debug('Batch data validation passed', { + totalItems: allData.length, + uniqueKeys: targetKeys.length + }) + } + + /** + * Split array into chunks of specified size for batch processing + */ + private chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = [] + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)) + } + return chunks + } + + /** + * Read value from ElectronStore (via ConfigManager) + */ + private async readFromElectronStore(key: string): Promise { + try { + return configManager.get(key) + } catch (error) { + logger.warn('Failed to read from ElectronStore', { key, error }) + return undefined + } + } + + /** + * Read value from Redux persist data with support for nested paths + */ + private async readFromReduxPersist(category: string, key: string): Promise { + try { + // Get cached Redux data from migrate service + const reduxData = this.migrateService?.getReduxData() + + if (!reduxData) { + logger.warn('No Redux persist data available in cache', { category, key }) + return undefined + } + + logger.debug('Reading from cached Redux persist data', { + category, + key, + availableCategories: Object.keys(reduxData), + isNestedKey: key.includes('.') + }) + + // Get the category data from Redux persist cache + const categoryData = reduxData[category] + if (!categoryData) { + logger.debug('Category not found in Redux persist data', { + category, + availableCategories: Object.keys(reduxData) + }) + return undefined + } + + // Redux persist usually stores data as JSON strings + let parsedCategoryData + try { + parsedCategoryData = typeof categoryData === 'string' ? JSON.parse(categoryData) : categoryData + } catch (parseError) { + logger.warn('Failed to parse Redux persist category data', { + category, + categoryData: typeof categoryData, + parseError + }) + return undefined + } + + // Handle nested paths (e.g., "codeEditor.enabled") + let value + if (key.includes('.')) { + // Parse nested path + const keyPath = key.split('.') + let current = parsedCategoryData + + logger.debug('Parsing nested key path', { + category, + key, + keyPath, + rootDataKeys: current ? Object.keys(current) : [] + }) + + for (const pathSegment of keyPath) { + if (current && typeof current === 'object' && !Array.isArray(current)) { + current = current[pathSegment] + logger.debug('Navigated to path segment', { + pathSegment, + foundValue: current !== undefined, + valueType: typeof current + }) + } else { + logger.debug('Failed to navigate nested path - invalid structure', { + pathSegment, + currentType: typeof current, + isArray: Array.isArray(current) + }) + return undefined + } + } + value = current + } else { + // Direct field access (e.g., "theme") + value = parsedCategoryData[key] + } + + if (value !== undefined) { + logger.debug('Successfully read from Redux persist cache', { + category, + key, + value, + valueType: typeof value, + isNested: key.includes('.') + }) + } else { + logger.debug('Key not found in Redux persist data', { + category, + key, + availableKeys: parsedCategoryData ? Object.keys(parsedCategoryData) : [] + }) + } + + return value + } catch (error) { + logger.warn('Failed to read from Redux persist cache', { category, key, error }) + return undefined + } + } + + /** + * Convert value to the specified type + */ + private convertValue(value: any, targetType: string): any { + if (value === null || value === undefined) { + return null + } + + try { + switch (targetType) { + case 'boolean': + return this.toBoolean(value) + case 'string': + return this.toString(value) + case 'number': + return this.toNumber(value) + case 'array': + case 'unknown[]': + return this.toArray(value) + case 'object': + case 'Record': + return this.toObject(value) + default: + return value + } + } catch (error) { + logger.warn('Type conversion failed, using original value', { value, targetType, error }) + return value + } + } + + private toBoolean(value: any): boolean { + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + const lower = value.toLowerCase() + return lower === 'true' || lower === '1' || lower === 'yes' + } + if (typeof value === 'number') return value !== 0 + return Boolean(value) + } + + private toString(value: any): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (typeof value === 'object') return JSON.stringify(value) + return String(value) + } + + private toNumber(value: any): number { + if (typeof value === 'number') return value + if (typeof value === 'string') { + const parsed = parseFloat(value) + return isNaN(parsed) ? 0 : parsed + } + if (typeof value === 'boolean') return value ? 1 : 0 + return 0 + } + + private toArray(value: any): any[] { + if (Array.isArray(value)) return value + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [value] + } catch { + return [value] + } + } + return [value] + } + + private toObject(value: any): Record { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : { value } + } catch { + return { value } + } + } + return { value } + } +} diff --git a/src/main/data/services/TestService.ts b/src/main/data/services/TestService.ts new file mode 100644 index 0000000000..1af016cf44 --- /dev/null +++ b/src/main/data/services/TestService.ts @@ -0,0 +1,446 @@ +import { loggerService } from '@logger' + +const logger = loggerService.withContext('TestService') + +/** + * Test service for API testing scenarios + * Provides mock data and various test cases for comprehensive API testing + */ +export class TestService { + private static instance: TestService + private testItems: any[] = [] + private nextId = 1 + + private constructor() { + this.initializeMockData() + } + + /** + * Get singleton instance + */ + public static getInstance(): TestService { + if (!TestService.instance) { + TestService.instance = new TestService() + } + return TestService.instance + } + + /** + * Initialize mock test data + */ + private initializeMockData() { + // Initialize test items with various types + for (let i = 1; i <= 20; i++) { + this.testItems.push({ + id: `test-item-${i}`, + title: `Test Item ${i}`, + description: `This is test item ${i} for comprehensive API testing`, + type: ['data', 'config', 'user', 'system'][i % 4], + status: ['active', 'inactive', 'pending', 'archived'][i % 4], + priority: ['low', 'medium', 'high'][i % 3], + tags: [`tag${(i % 3) + 1}`, `category${(i % 2) + 1}`], + createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - i * 12 * 60 * 60 * 1000).toISOString(), + metadata: { + version: `1.${i % 10}.0`, + size: Math.floor(Math.random() * 1000) + 100, + author: `TestUser${(i % 5) + 1}` + } + }) + } + + this.nextId = 100 + logger.info('Mock test data initialized', { + itemCount: this.testItems.length, + types: ['data', 'config', 'user', 'system'], + statuses: ['active', 'inactive', 'pending', 'archived'] + }) + } + + /** + * Generate new test ID + */ + private generateId(prefix: string = 'test-item'): string { + return `${prefix}-${this.nextId++}` + } + + /** + * Simulate network delay for realistic testing + */ + private async simulateDelay(min = 100, max = 500): Promise { + const delay = Math.floor(Math.random() * (max - min + 1)) + min + await new Promise((resolve) => setTimeout(resolve, delay)) + } + + /** + * Get paginated list of test items + */ + async getItems( + params: { page?: number; limit?: number; type?: string; status?: string; search?: string } = {} + ): Promise<{ + items: any[] + total: number + page: number + pageCount: number + hasNext: boolean + hasPrev: boolean + }> { + await this.simulateDelay() + + const { page = 1, limit = 20, type, status, search } = params + let filteredItems = [...this.testItems] + + // Apply filters + if (type) { + filteredItems = filteredItems.filter((item) => item.type === type) + } + if (status) { + filteredItems = filteredItems.filter((item) => item.status === status) + } + if (search) { + const searchLower = search.toLowerCase() + filteredItems = filteredItems.filter( + (item) => item.title.toLowerCase().includes(searchLower) || item.description.toLowerCase().includes(searchLower) + ) + } + + // Apply pagination + const startIndex = (page - 1) * limit + const items = filteredItems.slice(startIndex, startIndex + limit) + const total = filteredItems.length + const pageCount = Math.ceil(total / limit) + + logger.debug('Retrieved test items', { + page, + limit, + filters: { type, status, search }, + total, + itemCount: items.length + }) + + return { + items, + total, + page, + pageCount, + hasNext: startIndex + limit < total, + hasPrev: page > 1 + } + } + + /** + * Get single test item by ID + */ + async getItemById(id: string): Promise { + await this.simulateDelay() + + const item = this.testItems.find((item) => item.id === id) + + if (!item) { + logger.warn('Test item not found', { id }) + return null + } + + logger.debug('Retrieved test item by ID', { id, title: item.title }) + return item + } + + /** + * Create new test item + */ + async createItem(data: { + title: string + description?: string + type?: string + status?: string + priority?: string + tags?: string[] + metadata?: Record + }): Promise { + await this.simulateDelay() + + const newItem = { + id: this.generateId(), + title: data.title, + description: data.description || '', + type: data.type || 'data', + status: data.status || 'active', + priority: data.priority || 'medium', + tags: data.tags || [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: { + version: '1.0.0', + size: Math.floor(Math.random() * 1000) + 100, + author: 'TestUser', + ...data.metadata + } + } + + this.testItems.unshift(newItem) + logger.info('Created new test item', { id: newItem.id, title: newItem.title }) + + return newItem + } + + /** + * Update existing test item + */ + async updateItem( + id: string, + data: Partial<{ + title: string + description: string + type: string + status: string + priority: string + tags: string[] + metadata: Record + }> + ): Promise { + await this.simulateDelay() + + const itemIndex = this.testItems.findIndex((item) => item.id === id) + + if (itemIndex === -1) { + logger.warn('Test item not found for update', { id }) + return null + } + + const updatedItem = { + ...this.testItems[itemIndex], + ...data, + updatedAt: new Date().toISOString(), + metadata: { + ...this.testItems[itemIndex].metadata, + ...data.metadata + } + } + + this.testItems[itemIndex] = updatedItem + logger.info('Updated test item', { id, changes: Object.keys(data) }) + + return updatedItem + } + + /** + * Delete test item + */ + async deleteItem(id: string): Promise { + await this.simulateDelay() + + const itemIndex = this.testItems.findIndex((item) => item.id === id) + + if (itemIndex === -1) { + logger.warn('Test item not found for deletion', { id }) + return false + } + + this.testItems.splice(itemIndex, 1) + logger.info('Deleted test item', { id }) + + return true + } + + /** + * Get test statistics + */ + async getStats(): Promise<{ + total: number + byType: Record + byStatus: Record + byPriority: Record + recentActivity: Array<{ + date: string + count: number + }> + }> { + await this.simulateDelay() + + const byType: Record = {} + const byStatus: Record = {} + const byPriority: Record = {} + + this.testItems.forEach((item) => { + byType[item.type] = (byType[item.type] || 0) + 1 + byStatus[item.status] = (byStatus[item.status] || 0) + 1 + byPriority[item.priority] = (byPriority[item.priority] || 0) + 1 + }) + + // Generate recent activity (mock data) + const recentActivity: Array<{ date: string; count: number }> = [] + for (let i = 6; i >= 0; i--) { + const date = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + recentActivity.push({ + date, + count: Math.floor(Math.random() * 10) + 1 + }) + } + + const stats = { + total: this.testItems.length, + byType, + byStatus, + byPriority, + recentActivity + } + + logger.debug('Retrieved test statistics', stats) + return stats + } + + /** + * Bulk operations on test items + */ + async bulkOperation( + operation: 'create' | 'update' | 'delete', + data: any[] + ): Promise<{ + successful: number + failed: number + errors: string[] + }> { + await this.simulateDelay(200, 800) + + let successful = 0 + let failed = 0 + const errors: string[] = [] + + for (const item of data) { + try { + switch (operation) { + case 'create': + await this.createItem(item) + successful++ + break + case 'update': { + const updated = await this.updateItem(item.id, item) + if (updated) successful++ + else { + failed++ + errors.push(`Item not found: ${item.id}`) + } + break + } + case 'delete': { + const deleted = await this.deleteItem(item.id) + if (deleted) successful++ + else { + failed++ + errors.push(`Item not found: ${item.id}`) + } + break + } + } + } catch (error) { + failed++ + errors.push(`Error processing item: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + logger.info('Completed bulk operation', { operation, successful, failed, errorCount: errors.length }) + + return { successful, failed, errors } + } + + /** + * Search test items + */ + async searchItems( + query: string, + options: { + page?: number + limit?: number + filters?: Record + } = {} + ): Promise<{ + items: any[] + total: number + page: number + pageCount: number + hasNext: boolean + hasPrev: boolean + }> { + await this.simulateDelay() + + const { page = 1, limit = 20, filters = {} } = options + const queryLower = query.toLowerCase() + + const results = this.testItems.filter((item) => { + // Text search + const matchesQuery = + item.title.toLowerCase().includes(queryLower) || + item.description.toLowerCase().includes(queryLower) || + item.tags.some((tag: string) => tag.toLowerCase().includes(queryLower)) + + // Apply additional filters + let matchesFilters = true + Object.entries(filters).forEach(([key, value]) => { + if (value && item[key] !== value) { + matchesFilters = false + } + }) + + return matchesQuery && matchesFilters + }) + + // Apply pagination + const startIndex = (page - 1) * limit + const items = results.slice(startIndex, startIndex + limit) + const total = results.length + const pageCount = Math.ceil(total / limit) + + logger.debug('Search completed', { query, total, itemCount: items.length }) + + return { + items, + total, + page, + pageCount, + hasNext: startIndex + limit < total, + hasPrev: page > 1 + } + } + + /** + * Simulate error scenarios for testing + */ + async simulateError(errorType: string): Promise { + await this.simulateDelay() + + logger.warn('Simulating error scenario', { errorType }) + + switch (errorType) { + case 'timeout': + await new Promise((resolve) => setTimeout(resolve, 35000)) + throw new Error('Request timeout') + case 'network': + throw new Error('Network connection failed') + case 'server': + throw new Error('Internal server error (500)') + case 'notfound': + throw new Error('Resource not found (404)') + case 'validation': + throw new Error('Validation failed: Invalid input data') + case 'unauthorized': + throw new Error('Unauthorized access (401)') + case 'ratelimit': + throw new Error('Rate limit exceeded (429)') + default: + throw new Error('Generic test error occurred') + } + } + + /** + * Reset all test data to initial state + */ + async resetData(): Promise { + await this.simulateDelay() + + this.testItems = [] + this.nextId = 1 + this.initializeMockData() + + logger.info('Test data reset to initial state') + } +} diff --git a/src/main/data/services/base/IBaseService.ts b/src/main/data/services/base/IBaseService.ts new file mode 100644 index 0000000000..446de55716 --- /dev/null +++ b/src/main/data/services/base/IBaseService.ts @@ -0,0 +1,108 @@ +import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes' + +/** + * Standard service interface for data operations + * Defines the contract that all services should implement + */ +export interface IBaseService { + /** + * Find entity by ID + */ + findById(id: string, options?: ServiceOptions): Promise + + /** + * Find multiple entities with pagination + */ + findMany( + params: PaginationParams & Record, + options?: ServiceOptions + ): Promise<{ + items: T[] + total: number + hasNext?: boolean + nextCursor?: string + }> + + /** + * Create new entity + */ + create(data: TCreate, options?: ServiceOptions): Promise + + /** + * Update existing entity + */ + update(id: string, data: TUpdate, options?: ServiceOptions): Promise + + /** + * Delete entity (hard or soft delete depending on implementation) + */ + delete(id: string, options?: ServiceOptions): Promise + + /** + * Check if entity exists + */ + exists(id: string, options?: ServiceOptions): Promise +} + +/** + * Extended service interface for soft delete operations + */ +export interface ISoftDeleteService extends IBaseService { + /** + * Archive entity (soft delete) + */ + archive(id: string, options?: ServiceOptions): Promise + + /** + * Restore archived entity + */ + restore(id: string, options?: ServiceOptions): Promise +} + +/** + * Extended service interface for search operations + */ +export interface ISearchableService extends IBaseService { + /** + * Search entities by query + */ + search( + query: string, + params?: PaginationParams, + options?: ServiceOptions + ): Promise<{ + items: T[] + total: number + hasNext?: boolean + nextCursor?: string + }> +} + +/** + * Service interface for hierarchical data (parent-child relationships) + */ +export interface IHierarchicalService extends IBaseService { + /** + * Get child entities for a parent + */ + getChildren( + parentId: string, + params?: PaginationParams, + options?: ServiceOptions + ): Promise<{ + items: TChild[] + total: number + hasNext?: boolean + nextCursor?: string + }> + + /** + * Add child entity to parent + */ + addChild(parentId: string, childData: TChildCreate, options?: ServiceOptions): Promise + + /** + * Remove child entity from parent + */ + removeChild(parentId: string, childId: string, options?: ServiceOptions): Promise +} diff --git a/src/main/index.ts b/src/main/index.ts index d9554e1652..d2ea76bf8a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,8 +7,10 @@ import '@main/config' import { loggerService } from '@logger' import { electronApp, optimizer } from '@electron-toolkit/utils' +import { dbService } from '@data/db/DbService' +import { preferenceService } from '@data/PreferenceService' import { replaceDevtoolsFont } from '@main/utils/windowUtil' -import { app } from 'electron' +import { app, dialog } from 'electron' import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' import { isDev, isLinux, isWin } from './constant' @@ -18,9 +20,9 @@ import { registerIpc } from './ipc' import { agentService } from './services/agents' import { apiServerService } from './services/ApiServerService' import { appMenuService } from './services/AppMenuService' -import { configManager } from './services/ConfigManager' import mcpService from './services/MCPService' import { nodeTraceService } from './services/NodeTraceService' +import powerMonitorService from './services/PowerMonitorService' import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, @@ -30,7 +32,11 @@ import { import selectionService, { initSelectionService } from './services/SelectionService' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' +import { versionService } from './services/VersionService' import { windowService } from './services/WindowService' +import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefactorMigrateService' +import { dataApiService } from '@data/DataApiService' +import { cacheService } from '@data/CacheService' import { initWebviewHotkeys } from './services/WebviewService' const logger = loggerService.withContext('MainEntry') @@ -38,10 +44,12 @@ const logger = loggerService.withContext('MainEntry') /** * Disable hardware acceleration if setting is enabled */ -const disableHardwareAcceleration = configManager.getDisableHardwareAcceleration() -if (disableHardwareAcceleration) { - app.disableHardwareAcceleration() -} +//FIXME should not use preferenceService before initialization +//TODO 我们需要调整配置管理的加载位置,以保证其在 preferenceService 初始化之前被调用 +// const disableHardwareAcceleration = preferenceService.get('app.disable_hardware_acceleration') +// if (disableHardwareAcceleration) { +// app.disableHardwareAcceleration() +// } /** * Disable chromium's window animations @@ -108,25 +116,103 @@ if (!app.requestSingleInstanceLock()) { // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. - app.whenReady().then(async () => { + // First of all, init & migrate the database + await dbService.migrateDb() + await dbService.migrateSeed('preference') + + // Data Refactor Migration + // Check if data migration is needed BEFORE creating any windows + try { + logger.info('Checking if data refactor migration is needed') + const isMigrated = await dataRefactorMigrateService.isMigrated() + logger.info('Migration status check result', { isMigrated }) + + if (!isMigrated) { + logger.info('Data Refactor Migration needed, starting migration process') + + try { + await dataRefactorMigrateService.runMigration() + logger.info('Migration window created successfully') + // Migration service will handle the migration flow, no need to continue startup + return + } catch (migrationError) { + logger.error('Failed to start migration process', migrationError as Error) + + // Migration is required for this version - show error and exit + await dialog.showErrorBox( + 'Migration Required - Application Cannot Start', + `This version of Cherry Studio requires data migration to function properly.\n\nMigration window failed to start: ${(migrationError as Error).message}\n\nThe application will now exit. Please try starting again or contact support if the problem persists.` + ) + + logger.error('Exiting application due to failed migration startup') + app.quit() + return + } + } + } catch (error) { + logger.error('Migration status check failed', error as Error) + + // If we can't check migration status, this could indicate a serious database issue + // Since migration may be required, it's safer to exit and let user investigate + await dialog.showErrorBox( + 'Migration Status Check Failed - Application Cannot Start', + `Could not determine if data migration is completed.\n\nThis may indicate a database connectivity issue: ${(error as Error).message}\n\nThe application will now exit. Please check your installation and try again.` + ) + + logger.error('Exiting application due to migration status check failure') + app.quit() + return + } + + // DATA REFACTOR USE + // TODO: remove when data refactor is stable + //************FOR TESTING ONLY START****************/ + + await preferenceService.initialize() + + // Initialize DataApiService + await dataApiService.initialize() + + // Initialize CacheService + await cacheService.initialize() + + // // Create two test windows for cross-window preference sync testing + // logger.info('Creating test windows for PreferenceService cross-window sync testing') + // const testWindow1 = dataRefactorMigrateService.createTestWindow() + // const testWindow2 = dataRefactorMigrateService.createTestWindow() + + // // Position windows to avoid overlap + // testWindow1.once('ready-to-show', () => { + // const [x, y] = testWindow1.getPosition() + // testWindow2.setPosition(x + 50, y + 50) + // }) + + /************FOR TESTING ONLY END****************/ + + // Record current version for tracking + // A preparation for v2 data refactoring + versionService.recordCurrentVersion() + initWebviewHotkeys() // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') // Mac: Hide dock icon before window creation when launch to tray is set - const isLaunchToTray = configManager.getLaunchToTray() + const isLaunchToTray = preferenceService.get('app.tray.on_launch') if (isLaunchToTray) { app.dock?.hide() } + // Create main window - migration has either completed or was not needed const mainWindow = windowService.createMainWindow() + new TrayService() // Setup macOS application menu appMenuService?.setupApplicationMenu() - nodeTraceService.init() + powerMonitorService.init() app.on('activate', function () { const mainWindow = windowService.getMainWindow() @@ -138,7 +224,6 @@ if (!app.requestSingleInstanceLock()) { }) registerShortcuts(mainWindow) - registerIpc(mainWindow, app) replaceDevtoolsFont(mainWindow) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f1a4de6a59..94c354ce7e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,30 +2,35 @@ import fs from 'node:fs' import { arch } from 'node:os' import path from 'node:path' +import { PreferenceService } from '@data/PreferenceService' +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' -import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' -import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' +import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' +import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' +import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' -import { +import type { AgentPersistedMessage, FileMetadata, Notification, OcrProvider, + PluginError, Provider, Shortcut, - SupportedOcrFile, - ThemeMode + SupportedOcrFile } from '@types' import checkDiskSpace from 'check-disk-space' -import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' +import type { ProxyConfig } from 'electron' +import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron' import fontList from 'font-list' import { agentMessageRepository } from './services/agents/database' +import { PluginService } from './services/agents/plugins/PluginService' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' import AppUpdater from './services/AppUpdater' @@ -46,6 +51,7 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ocrService } from './services/ocr/OcrService' import OvmsManager from './services/OvmsManager' +import powerMonitorService from './services/PowerMonitorService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' import { FileServiceManager } from './services/remotefile/FileServiceManager' @@ -66,8 +72,8 @@ import { tokenUsage } from './services/SpanCacheService' import storeSyncService from './services/StoreSyncService' -import { themeService } from './services/ThemeService' import VertexAIService from './services/VertexAIService' +import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -93,13 +99,34 @@ const vertexAIService = VertexAIService.getInstance() const memoryService = MemoryService.getInstance() const dxtService = new DxtService() const ovmsManager = new OvmsManager() +const pluginService = PluginService.getInstance() + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function extractPluginError(error: unknown): PluginError | null { + if (error && typeof error === 'object' && 'type' in error && typeof (error as { type: unknown }).type === 'string') { + return error as PluginError + } + return null +} export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater() const notificationService = new NotificationService() - // Initialize Python service with main window - pythonService.setMainWindow(mainWindow) + // Register shutdown handlers + powerMonitorService.registerShutdownHandler(() => { + appUpdater.setAutoUpdate(false) + }) + + powerMonitorService.registerShutdownHandler(() => { + const mw = windowService.getMainWindow() + if (mw && !mw.isDestroyed()) { + mw.webContents.send(IpcChannel.App_SaveData) + } + }) const checkMainWindow = () => { if (!mainWindow || mainWindow.isDestroyed()) { @@ -145,9 +172,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall()) // language - ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => { - configManager.setLanguage(language) - }) + // ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => { + // configManager.setLanguage(language) + // }) // spell check ipcMain.handle(IpcChannel.App_SetEnableSpellCheck, (_, isEnable: boolean) => { @@ -167,7 +194,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { windows.forEach((window) => { window.webContents.session.setSpellCheckerLanguages(languages) }) - configManager.set('spellCheckLanguages', languages) + preferenceService.set('app.spell_check.languages', languages) }) // launch on boot @@ -175,40 +202,38 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { appService.setAppLaunchOnBoot(isLaunchOnBoot) }) - // launch to tray - ipcMain.handle(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => { - configManager.setLaunchToTray(isActive) - }) + // // launch to tray + // ipcMain.handle(IpcChannel.App_SetLaunchToTray, (_, isActive: boolean) => { + // configManager.setLaunchToTray(isActive) + // }) - // tray - ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => { - configManager.setTray(isActive) - }) + // // tray + // ipcMain.handle(IpcChannel.App_SetTray, (_, isActive: boolean) => { + // configManager.setTray(isActive) + // }) - // to tray on close - ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => { - configManager.setTrayOnClose(isActive) - }) + // // to tray on close + // ipcMain.handle(IpcChannel.App_SetTrayOnClose, (_, isActive: boolean) => { + // configManager.setTrayOnClose(isActive) + // }) - // auto update - ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => { - appUpdater.setAutoUpdate(isActive) - configManager.setAutoUpdate(isActive) - }) + // // auto update + // ipcMain.handle(IpcChannel.App_SetAutoUpdate, (_, isActive: boolean) => { + // appUpdater.setAutoUpdate(isActive) + // configManager.setAutoUpdate(isActive) + // }) ipcMain.handle(IpcChannel.App_SetTestPlan, async (_, isActive: boolean) => { logger.info(`set test plan: ${isActive}`) - if (isActive !== configManager.getTestPlan()) { + if (isActive !== preferenceService.get('app.dist.test_plan.enabled')) { appUpdater.cancelDownload() - configManager.setTestPlan(isActive) } }) ipcMain.handle(IpcChannel.App_SetTestChannel, async (_, channel: UpgradeChannel) => { logger.info(`set test channel: ${channel}`) - if (channel !== configManager.getTestChannel()) { + if (channel !== preferenceService.get('app.dist.test_plan.channel')) { appUpdater.cancelDownload() - configManager.setTestChannel(channel) } }) @@ -264,23 +289,26 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) - ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { - configManager.set(key, value, isNotify) + ipcMain.handle(IpcChannel.Config_Set, (_, key: string) => { + // Legacy config handler - will be deprecated + logger.warn(`Legacy Config_Set called for key: ${key}`) }) ipcMain.handle(IpcChannel.Config_Get, (_, key: string) => { - return configManager.get(key) + // Legacy config handler - will be deprecated + logger.warn(`Legacy Config_Get called for key: ${key}`) + return undefined }) - // theme - ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => { - themeService.setTheme(theme) - }) + // // theme + // ipcMain.handle(IpcChannel.App_SetTheme, (_, theme: ThemeMode) => { + // themeService.setTheme(theme) + // }) ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => { const windows = BrowserWindow.getAllWindows() handleZoomFactor(windows, delta, reset) - return configManager.getZoomFactor() + return preferenceService.get('app.zoom_factor') }) // clear cache @@ -524,6 +552,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager)) ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager)) ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager)) ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) @@ -800,9 +829,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text)) - ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => { - configManager.setDisableHardwareAcceleration(isDisable) - }) + // ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => { + // configManager.setDisableHardwareAcceleration(isDisable) + // }) ipcMain.handle(IpcChannel.TRACE_SAVE_DATA, (_, topicId: string) => saveSpans(topicId)) ipcMain.handle(IpcChannel.TRACE_GET_DATA, (_, topicId: string, traceId: string, modelName?: string) => getSpans(topicId, traceId, modelName) @@ -890,4 +919,127 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // CherryAI ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params)) + + // Claude Code Plugins + ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListAvailable, async () => { + try { + const data = await pluginService.listAvailable() + return { success: true, data } + } catch (error) { + const pluginError = extractPluginError(error) + if (pluginError) { + logger.error('Failed to list available plugins', pluginError) + return { success: false, error: pluginError } + } + + const err = normalizeError(error) + logger.error('Failed to list available plugins', err) + return { + success: false, + error: { + type: 'TRANSACTION_FAILED', + operation: 'list-available', + reason: err.message + } + } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_Install, async (_, options) => { + try { + const data = await pluginService.install(options) + return { success: true, data } + } catch (error) { + logger.error('Failed to install plugin', { options, error }) + return { success: false, error } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_Uninstall, async (_, options) => { + try { + await pluginService.uninstall(options) + return { success: true, data: undefined } + } catch (error) { + logger.error('Failed to uninstall plugin', { options, error }) + return { success: false, error } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_ListInstalled, async (_, agentId: string) => { + try { + const data = await pluginService.listInstalled(agentId) + return { success: true, data } + } catch (error) { + const pluginError = extractPluginError(error) + if (pluginError) { + logger.error('Failed to list installed plugins', { agentId, error: pluginError }) + return { success: false, error: pluginError } + } + + const err = normalizeError(error) + logger.error('Failed to list installed plugins', { agentId, error: err }) + return { + success: false, + error: { + type: 'TRANSACTION_FAILED', + operation: 'list-installed', + reason: err.message + } + } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_InvalidateCache, async () => { + try { + pluginService.invalidateCache() + return { success: true, data: undefined } + } catch (error) { + const pluginError = extractPluginError(error) + if (pluginError) { + logger.error('Failed to invalidate plugin cache', pluginError) + return { success: false, error: pluginError } + } + + const err = normalizeError(error) + logger.error('Failed to invalidate plugin cache', err) + return { + success: false, + error: { + type: 'TRANSACTION_FAILED', + operation: 'invalidate-cache', + reason: err.message + } + } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_ReadContent, async (_, sourcePath: string) => { + try { + const data = await pluginService.readContent(sourcePath) + return { success: true, data } + } catch (error) { + logger.error('Failed to read plugin content', { sourcePath, error }) + return { success: false, error } + } + }) + + ipcMain.handle(IpcChannel.ClaudeCodePlugin_WriteContent, async (_, options) => { + try { + await pluginService.writeContent(options.agentId, options.filename, options.type, options.content) + return { success: true, data: undefined } + } catch (error) { + logger.error('Failed to write plugin content', { options, error }) + return { success: false, error } + } + }) + + // WebSocket + ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) + ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) + ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) + ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) + ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) + + // Preference handlers + PreferenceService.registerIpcHandler() } diff --git a/src/main/knowledge/embedjs/embeddings/Embeddings.ts b/src/main/knowledge/embedjs/embeddings/Embeddings.ts index 17bb8ff470..3f5b6ced15 100644 --- a/src/main/knowledge/embedjs/embeddings/Embeddings.ts +++ b/src/main/knowledge/embedjs/embeddings/Embeddings.ts @@ -1,6 +1,6 @@ import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import { TraceMethod } from '@mcp-trace/trace-core' -import { ApiClient } from '@types' +import type { ApiClient } from '@types' import EmbeddingsFactory from './EmbeddingsFactory' diff --git a/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts b/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts index 7435ad2bb0..8a780d5618 100644 --- a/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts +++ b/src/main/knowledge/embedjs/embeddings/EmbeddingsFactory.ts @@ -1,15 +1,15 @@ import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama' import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai' -import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings' -import { ApiClient } from '@types' +import type { ApiClient } from '@types' +import { net } from 'electron' import { VoyageEmbeddings } from './VoyageEmbeddings' export default class EmbeddingsFactory { static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings { const batchSize = 10 - const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient + const { model, provider, apiKey, baseURL } = embedApiClient if (provider === 'voyageai') { return new VoyageEmbeddings({ modelName: model, @@ -38,22 +38,13 @@ export default class EmbeddingsFactory { } }) } - if (apiVersion !== undefined) { - return new AzureOpenAiEmbeddings({ - azureOpenAIApiKey: apiKey, - azureOpenAIApiVersion: apiVersion, - azureOpenAIApiDeploymentName: model, - azureOpenAIEndpoint: baseURL, - dimensions, - batchSize - }) - } + // NOTE: Azure OpenAI 也走 OpenAIEmbeddings, baseURL是https://xxxx.openai.azure.com/openai/v1 return new OpenAiEmbeddings({ model, apiKey, dimensions, batchSize, - configuration: { baseURL } + configuration: { baseURL, fetch: net.fetch as typeof fetch } }) } } diff --git a/src/main/knowledge/embedjs/loader/index.ts b/src/main/knowledge/embedjs/loader/index.ts index 4b38418194..9f1efd268c 100644 --- a/src/main/knowledge/embedjs/loader/index.ts +++ b/src/main/knowledge/embedjs/loader/index.ts @@ -1,10 +1,11 @@ -import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs' +import type { RAGApplication } from '@cherrystudio/embedjs' +import { JsonLoader, LocalPathLoader, TextLoader } from '@cherrystudio/embedjs' import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces' import { WebLoader } from '@cherrystudio/embedjs-loader-web' import { loggerService } from '@logger' import { readTextFileWithAutoEncoding } from '@main/utils/file' -import { LoaderReturn } from '@shared/config/types' -import { FileMetadata, KnowledgeBaseParams } from '@types' +import type { LoaderReturn } from '@shared/config/types' +import type { FileMetadata, KnowledgeBaseParams } from '@types' import { DraftsExportLoader } from './draftsExportLoader' import { EpubLoader } from './epubLoader' diff --git a/src/main/knowledge/embedjs/loader/odLoader.ts b/src/main/knowledge/embedjs/loader/odLoader.ts index 03825bf4db..7e325966af 100644 --- a/src/main/knowledge/embedjs/loader/odLoader.ts +++ b/src/main/knowledge/embedjs/loader/odLoader.ts @@ -3,7 +3,8 @@ import { cleanString } from '@cherrystudio/embedjs-utils' import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters' import { loggerService } from '@logger' import md5 from 'md5' -import { OfficeParserConfig, parseOfficeAsync } from 'officeparser' +import type { OfficeParserConfig } from 'officeparser' +import { parseOfficeAsync } from 'officeparser' const logger = loggerService.withContext('OdLoader') diff --git a/src/main/knowledge/preprocess/BasePreprocessProvider.ts b/src/main/knowledge/preprocess/BasePreprocessProvider.ts index daf9901498..ed4a5fb8d4 100644 --- a/src/main/knowledge/preprocess/BasePreprocessProvider.ts +++ b/src/main/knowledge/preprocess/BasePreprocessProvider.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { windowService } from '@main/services/WindowService' import { getFileExt, getTempDir } from '@main/utils/file' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import { PDFDocument } from 'pdf-lib' const logger = loggerService.withContext('BasePreprocessProvider') diff --git a/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts b/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts index 3899a3d25a..64169d935e 100644 --- a/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/DefaultPreprocessProvider.ts @@ -1,4 +1,4 @@ -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import BasePreprocessProvider from './BasePreprocessProvider' diff --git a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts index 6708e8f938..4e2fb09609 100644 --- a/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/Doc2xPreprocessProvider.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' import { net } from 'electron' diff --git a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts index 1976f64c05..146e971713 100644 --- a/src/main/knowledge/preprocess/MineruPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/MineruPreprocessProvider.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileMetadata, PreprocessProvider } from '@types' +import type { FileMetadata, PreprocessProvider } from '@types' import AdmZip from 'adm-zip' import { net } from 'electron' @@ -275,15 +275,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider { try { const fileBuffer = await fs.promises.readFile(filePath) + // https://mineru.net/apiManage/docs const response = await net.fetch(uploadUrl, { method: 'PUT', - body: fileBuffer, - headers: { - 'Content-Type': 'application/pdf' - } - // headers: { - // 'Content-Length': fileBuffer.length.toString() - // } + body: fileBuffer as unknown as BodyInit }) if (!response.ok) { diff --git a/src/main/knowledge/preprocess/MistralPreprocessProvider.ts b/src/main/knowledge/preprocess/MistralPreprocessProvider.ts index d5ad3d4e14..a8f7b350ee 100644 --- a/src/main/knowledge/preprocess/MistralPreprocessProvider.ts +++ b/src/main/knowledge/preprocess/MistralPreprocessProvider.ts @@ -4,11 +4,12 @@ import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' import { MistralClientManager } from '@main/services/MistralClientManager' import { MistralService } from '@main/services/remotefile/MistralService' -import { Mistral } from '@mistralai/mistralai' -import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk' -import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk' -import { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse' -import { FileMetadata, FileTypes, PreprocessProvider, Provider } from '@types' +import type { Mistral } from '@mistralai/mistralai' +import type { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk' +import type { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk' +import type { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse' +import type { FileMetadata, PreprocessProvider, Provider } from '@types' +import { FileTypes } from '@types' import path from 'path' import BasePreprocessProvider from './BasePreprocessProvider' diff --git a/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts b/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts new file mode 100644 index 0000000000..9bfb08920c --- /dev/null +++ b/src/main/knowledge/preprocess/OpenMineruPreprocessProvider.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { loggerService } from '@logger' +import { fileStorage } from '@main/services/FileStorage' +import type { FileMetadata, PreprocessProvider } from '@types' +import AdmZip from 'adm-zip' +import { net } from 'electron' +import FormData from 'form-data' + +import BasePreprocessProvider from './BasePreprocessProvider' + +const logger = loggerService.withContext('MineruPreprocessProvider') + +export default class OpenMineruPreprocessProvider extends BasePreprocessProvider { + constructor(provider: PreprocessProvider, userId?: string) { + super(provider, userId) + } + + public async parseFile( + sourceId: string, + file: FileMetadata + ): Promise<{ processedFile: FileMetadata; quota: number }> { + try { + const filePath = fileStorage.getFilePathById(file) + logger.info(`Open MinerU preprocess processing started: ${filePath}`) + await this.validateFile(filePath) + + // 1. Update progress + await this.sendPreprocessProgress(sourceId, 50) + logger.info(`File ${file.name} is starting processing...`) + + // 2. Upload file and extract + const { path: outputPath } = await this.uploadFileAndExtract(file) + + // 3. Check quota + const quota = await this.checkQuota() + + // 4. Create processed file info + return { + processedFile: this.createProcessedFileInfo(file, outputPath), + quota + } + } catch (error) { + logger.error(`Open MinerU preprocess processing failed for:`, error as Error) + throw error + } + } + + public async checkQuota() { + // self-hosted version always has enough quota + return Infinity + } + + private async validateFile(filePath: string): Promise { + const pdfBuffer = await fs.promises.readFile(filePath) + + const doc = await this.readPdf(pdfBuffer) + + // File page count must be less than 600 pages + if (doc.numPages >= 600) { + throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`) + } + // File size must be less than 200MB + if (pdfBuffer.length >= 200 * 1024 * 1024) { + const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024)) + throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`) + } + } + + private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata { + // Find the main file after extraction + let finalPath = '' + let finalName = file.origin_name.replace('.pdf', '.md') + // Find the corresponding folder by file name + outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`) + try { + const files = fs.readdirSync(outputPath) + + const mdFile = files.find((f) => f.endsWith('.md')) + if (mdFile) { + const originalMdPath = path.join(outputPath, mdFile) + const newMdPath = path.join(outputPath, finalName) + + // Rename file to original file name + try { + fs.renameSync(originalMdPath, newMdPath) + finalPath = newMdPath + logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`) + } catch (renameError) { + logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`) + // If rename fails, use the original file + finalPath = originalMdPath + finalName = mdFile + } + } + } catch (error) { + logger.warn(`Failed to read output directory ${outputPath}:`, error as Error) + finalPath = path.join(outputPath, `${file.id}.md`) + } + + return { + ...file, + name: finalName, + path: finalPath, + ext: '.md', + size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0 + } + } + + private async uploadFileAndExtract( + file: FileMetadata, + maxRetries: number = 5, + intervalMs: number = 5000 + ): Promise<{ path: string }> { + let retries = 0 + + const endpoint = `${this.provider.apiHost}/file_parse` + + // Get file stream + const filePath = fileStorage.getFilePathById(file) + const fileBuffer = await fs.promises.readFile(filePath) + + const formData = new FormData() + formData.append('return_md', 'true') + formData.append('response_format_zip', 'true') + formData.append('files', fileBuffer, { + filename: file.origin_name + }) + + while (retries < maxRetries) { + let zipPath: string | undefined + + try { + const response = await net.fetch(endpoint, { + method: 'POST', + headers: { + token: this.userId ?? '', + ...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}), + ...formData.getHeaders() + }, + body: new Uint8Array(formData.getBuffer()) + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + // Check if response header is application/zip + if (response.headers.get('content-type') !== 'application/zip') { + throw new Error(`Downloaded ZIP file has unexpected content-type: ${response.headers.get('content-type')}`) + } + + const dirPath = this.storageDir + + zipPath = path.join(dirPath, `${file.id}.zip`) + const extractPath = path.join(dirPath, `${file.id}`) + + const arrayBuffer = await response.arrayBuffer() + fs.writeFileSync(zipPath, Buffer.from(arrayBuffer)) + logger.info(`Downloaded ZIP file: ${zipPath}`) + + // Ensure extraction directory exists + if (!fs.existsSync(extractPath)) { + fs.mkdirSync(extractPath, { recursive: true }) + } + + // Extract files + const zip = new AdmZip(zipPath) + zip.extractAllTo(extractPath, true) + logger.info(`Extracted files to: ${extractPath}`) + + return { path: extractPath } + } catch (error) { + logger.warn( + `Failed to upload and extract file: ${(error as Error).message}, retry ${retries + 1}/${maxRetries}` + ) + if (retries === maxRetries - 1) { + throw error + } + } finally { + // Delete temporary ZIP file + if (zipPath && fs.existsSync(zipPath)) { + try { + fs.unlinkSync(zipPath) + logger.info(`Deleted temporary ZIP file: ${zipPath}`) + } catch (deleteError) { + logger.warn(`Failed to delete temporary ZIP file ${zipPath}:`, deleteError as Error) + } + } + } + + retries++ + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + + throw new Error(`Processing timeout for file: ${file.id}`) + } +} diff --git a/src/main/knowledge/preprocess/PreprocessProvider.ts b/src/main/knowledge/preprocess/PreprocessProvider.ts index 44a34f64ae..f0b3d8f12e 100644 --- a/src/main/knowledge/preprocess/PreprocessProvider.ts +++ b/src/main/knowledge/preprocess/PreprocessProvider.ts @@ -1,6 +1,6 @@ -import { FileMetadata, PreprocessProvider as Provider } from '@types' +import type { FileMetadata, PreprocessProvider as Provider } from '@types' -import BasePreprocessProvider from './BasePreprocessProvider' +import type BasePreprocessProvider from './BasePreprocessProvider' import PreprocessProviderFactory from './PreprocessProviderFactory' export default class PreprocessProvider { diff --git a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts index bebecd388f..94d4e70d5a 100644 --- a/src/main/knowledge/preprocess/PreprocessProviderFactory.ts +++ b/src/main/knowledge/preprocess/PreprocessProviderFactory.ts @@ -1,10 +1,11 @@ -import { PreprocessProvider } from '@types' +import type { PreprocessProvider } from '@types' -import BasePreprocessProvider from './BasePreprocessProvider' +import type BasePreprocessProvider from './BasePreprocessProvider' import DefaultPreprocessProvider from './DefaultPreprocessProvider' import Doc2xPreprocessProvider from './Doc2xPreprocessProvider' import MineruPreprocessProvider from './MineruPreprocessProvider' import MistralPreprocessProvider from './MistralPreprocessProvider' +import OpenMineruPreprocessProvider from './OpenMineruPreprocessProvider' export default class PreprocessProviderFactory { static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider { switch (provider.id) { @@ -14,6 +15,8 @@ export default class PreprocessProviderFactory { return new MistralPreprocessProvider(provider) case 'mineru': return new MineruPreprocessProvider(provider, userId) + case 'open-mineru': + return new OpenMineruPreprocessProvider(provider, userId) default: return new DefaultPreprocessProvider(provider) } diff --git a/src/main/knowledge/reranker/BaseReranker.ts b/src/main/knowledge/reranker/BaseReranker.ts index 1e321e2d86..ddf5a089c4 100644 --- a/src/main/knowledge/reranker/BaseReranker.ts +++ b/src/main/knowledge/reranker/BaseReranker.ts @@ -1,7 +1,7 @@ import { DEFAULT_DOCUMENT_COUNT, DEFAULT_RELEVANT_SCORE } from '@main/utils/knowledge' -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import type { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' -import { MultiModalDocument, RerankStrategy } from './strategies/RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './strategies/RerankStrategy' import { StrategyFactory } from './strategies/StrategyFactory' export default abstract class BaseReranker { diff --git a/src/main/knowledge/reranker/GeneralReranker.ts b/src/main/knowledge/reranker/GeneralReranker.ts index e3ac5e8c21..96beffbb4c 100644 --- a/src/main/knowledge/reranker/GeneralReranker.ts +++ b/src/main/knowledge/reranker/GeneralReranker.ts @@ -1,4 +1,4 @@ -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import type { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' import { net } from 'electron' import BaseReranker from './BaseReranker' diff --git a/src/main/knowledge/reranker/Reranker.ts b/src/main/knowledge/reranker/Reranker.ts index 59de4b0470..26cb61f6b9 100644 --- a/src/main/knowledge/reranker/Reranker.ts +++ b/src/main/knowledge/reranker/Reranker.ts @@ -1,4 +1,4 @@ -import { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' +import type { KnowledgeBaseParams, KnowledgeSearchResult } from '@types' import GeneralReranker from './GeneralReranker' diff --git a/src/main/knowledge/reranker/strategies/BailianStrategy.ts b/src/main/knowledge/reranker/strategies/BailianStrategy.ts index e5932b9f40..8953bfe859 100644 --- a/src/main/knowledge/reranker/strategies/BailianStrategy.ts +++ b/src/main/knowledge/reranker/strategies/BailianStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class BailianStrategy implements RerankStrategy { buildUrl(): string { return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' diff --git a/src/main/knowledge/reranker/strategies/DefaultStrategy.ts b/src/main/knowledge/reranker/strategies/DefaultStrategy.ts index 59ee3fb47b..9bd8e69f73 100644 --- a/src/main/knowledge/reranker/strategies/DefaultStrategy.ts +++ b/src/main/knowledge/reranker/strategies/DefaultStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class DefaultStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/knowledge/reranker/strategies/JinaStrategy.ts b/src/main/knowledge/reranker/strategies/JinaStrategy.ts index 200190f544..b348f2f9bd 100644 --- a/src/main/knowledge/reranker/strategies/JinaStrategy.ts +++ b/src/main/knowledge/reranker/strategies/JinaStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class JinaStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/knowledge/reranker/strategies/StrategyFactory.ts b/src/main/knowledge/reranker/strategies/StrategyFactory.ts index 9e04547e31..e0e782f722 100644 --- a/src/main/knowledge/reranker/strategies/StrategyFactory.ts +++ b/src/main/knowledge/reranker/strategies/StrategyFactory.ts @@ -1,7 +1,7 @@ import { BailianStrategy } from './BailianStrategy' import { DefaultStrategy } from './DefaultStrategy' import { JinaStrategy } from './JinaStrategy' -import { RerankStrategy } from './RerankStrategy' +import type { RerankStrategy } from './RerankStrategy' import { TEIStrategy } from './TeiStrategy' import { isTEIProvider, RERANKER_PROVIDERS } from './types' import { VoyageAIStrategy } from './VoyageStrategy' diff --git a/src/main/knowledge/reranker/strategies/TeiStrategy.ts b/src/main/knowledge/reranker/strategies/TeiStrategy.ts index 58f24661ba..df92f1ea5b 100644 --- a/src/main/knowledge/reranker/strategies/TeiStrategy.ts +++ b/src/main/knowledge/reranker/strategies/TeiStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class TEIStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/knowledge/reranker/strategies/VoyageStrategy.ts b/src/main/knowledge/reranker/strategies/VoyageStrategy.ts index e81319f024..2bd13f85e3 100644 --- a/src/main/knowledge/reranker/strategies/VoyageStrategy.ts +++ b/src/main/knowledge/reranker/strategies/VoyageStrategy.ts @@ -1,4 +1,4 @@ -import { MultiModalDocument, RerankStrategy } from './RerankStrategy' +import type { MultiModalDocument, RerankStrategy } from './RerankStrategy' export class VoyageAIStrategy implements RerankStrategy { buildUrl(baseURL?: string): string { if (baseURL && baseURL.endsWith('/')) { diff --git a/src/main/mcpServers/brave-search.ts b/src/main/mcpServers/brave-search.ts index d11a4f2580..7ba6bb65fa 100644 --- a/src/main/mcpServers/brave-search.ts +++ b/src/main/mcpServers/brave-search.ts @@ -2,7 +2,8 @@ // port https://github.com/modelcontextprotocol/servers/blob/main/src/brave-search/index.ts import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { net } from 'electron' const WEB_SEARCH_TOOL: Tool = { diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 74266d7d35..2323701e49 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types' +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import type { BuiltinMCPServerName } from '@types' +import { BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' import DiDiMcpServer from './didi-mcp' diff --git a/src/main/mcpServers/sequentialthinking.ts b/src/main/mcpServers/sequentialthinking.ts index 90c1c329d5..485e0279e7 100644 --- a/src/main/mcpServers/sequentialthinking.ts +++ b/src/main/mcpServers/sequentialthinking.ts @@ -3,7 +3,8 @@ import { loggerService } from '@logger' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' // Fixed chalk import for ESM import chalk from 'chalk' diff --git a/src/main/services/ApiServerService.ts b/src/main/services/ApiServerService.ts index 76a5eca617..40452b6d19 100644 --- a/src/main/services/ApiServerService.ts +++ b/src/main/services/ApiServerService.ts @@ -1,5 +1,5 @@ import { IpcChannel } from '@shared/IpcChannel' -import { +import type { ApiServerConfig, GetApiServerStatusResult, RestartApiServerStatusResult, diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 7492516507..a9b7345c01 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -1,21 +1,39 @@ import { isMac } from '@main/constant' import { windowService } from '@main/services/WindowService' -import { locales } from '@main/utils/locales' +import { getAppLanguage, locales } from '@main/utils/language' import { IpcChannel } from '@shared/IpcChannel' -import { app, Menu, MenuItemConstructorOptions, shell } from 'electron' +import type { MenuItemConstructorOptions } from 'electron' +import { app, Menu, shell } from 'electron' import { configManager } from './ConfigManager' export class AppMenuService { + private languageChangeCallback?: (newLanguage: string) => void + + constructor() { + // Subscribe to language change events + this.languageChangeCallback = () => { + this.setupApplicationMenu() + } + configManager.subscribe('language', this.languageChangeCallback) + } + + public destroy(): void { + // Clean up subscription to prevent memory leaks + if (this.languageChangeCallback) { + configManager.unsubscribe('language', this.languageChangeCallback) + } + } + public setupApplicationMenu(): void { - const locale = locales[configManager.getLanguage()] - const { common } = locale.translation + const locale = locales[getAppLanguage()] + const { appMenu } = locale.translation const template: MenuItemConstructorOptions[] = [ { label: app.name, submenu: [ { - label: common.about + ' ' + app.name, + label: appMenu.about + ' ' + app.name, click: () => { // Emit event to navigate to About page const mainWindow = windowService.getMainWindow() @@ -26,50 +44,78 @@ export class AppMenuService { } }, { type: 'separator' }, - { role: 'services' }, + { role: 'services', label: appMenu.services }, { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, + { role: 'hide', label: `${appMenu.hide} ${app.name}` }, + { role: 'hideOthers', label: appMenu.hideOthers }, + { role: 'unhide', label: appMenu.unhide }, { type: 'separator' }, - { role: 'quit' } + { role: 'quit', label: `${appMenu.quit} ${app.name}` } ] }, { - role: 'fileMenu' + label: appMenu.file, + submenu: [{ role: 'close', label: appMenu.close }] }, { - role: 'editMenu' + label: appMenu.edit, + submenu: [ + { role: 'undo', label: appMenu.undo }, + { role: 'redo', label: appMenu.redo }, + { type: 'separator' }, + { role: 'cut', label: appMenu.cut }, + { role: 'copy', label: appMenu.copy }, + { role: 'paste', label: appMenu.paste }, + { role: 'delete', label: appMenu.delete }, + { role: 'selectAll', label: appMenu.selectAll } + ] }, { - role: 'viewMenu' + label: appMenu.view, + submenu: [ + { role: 'reload', label: appMenu.reload }, + { role: 'forceReload', label: appMenu.forceReload }, + { role: 'toggleDevTools', label: appMenu.toggleDevTools }, + { type: 'separator' }, + { role: 'resetZoom', label: appMenu.resetZoom }, + { role: 'zoomIn', label: appMenu.zoomIn }, + { role: 'zoomOut', label: appMenu.zoomOut }, + { type: 'separator' }, + { role: 'togglefullscreen', label: appMenu.toggleFullscreen } + ] }, { - role: 'windowMenu' + label: appMenu.window, + submenu: [ + { role: 'minimize', label: appMenu.minimize }, + { role: 'zoom', label: appMenu.zoom }, + { type: 'separator' }, + { role: 'front', label: appMenu.front } + ] }, { - role: 'help', + label: appMenu.help, submenu: [ { - label: 'Website', + label: appMenu.website, click: () => { shell.openExternal('https://cherry-ai.com') } }, { - label: 'Documentation', + label: appMenu.documentation, click: () => { shell.openExternal('https://cherry-ai.com/docs') } }, { - label: 'Feedback', + label: appMenu.feedback, click: () => { shell.openExternal('https://github.com/CherryHQ/cherry-studio/issues/new/choose') } }, { - label: 'Releases', + label: appMenu.releases, click: () => { shell.openExternal('https://github.com/CherryHQ/cherry-studio/releases') } diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index ec0f1b97e0..0881bf8504 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,16 +1,19 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isWin } from '@main/constant' import { getIpCountry } from '@main/utils/ipService' -import { generateUserAgent } from '@main/utils/systemInfo' -import { FeedUrl, UpgradeChannel } from '@shared/config/constant' +import { generateUserAgent, getClientId } from '@main/utils/systemInfo' +import { FeedUrl, UpdateConfigUrl, UpdateMirror } from '@shared/config/constant' +import { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' -import { CancellationToken, UpdateInfo } from 'builder-util-runtime' +import type { UpdateInfo } from 'builder-util-runtime' +import { CancellationToken } from 'builder-util-runtime' import { app, net } from 'electron' -import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' +import type { AppUpdater as _AppUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater' +import { autoUpdater } from 'electron-updater' import path from 'path' import semver from 'semver' -import { configManager } from './ConfigManager' import { windowService } from './WindowService' const logger = loggerService.withContext('AppUpdater') @@ -20,7 +23,29 @@ const LANG_MARKERS = { EN_START: '', ZH_CN_START: '', END: '' -} as const +} + +interface UpdateConfig { + lastUpdated: string + versions: { + [versionKey: string]: VersionConfig + } +} + +interface VersionConfig { + minCompatibleVersion: string + description: string + channels: { + latest: ChannelConfig | null + rc: ChannelConfig | null + beta: ChannelConfig | null + } +} + +interface ChannelConfig { + version: string + feedUrls: Record +} export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater @@ -30,12 +55,14 @@ export default class AppUpdater { constructor() { autoUpdater.logger = logger as Logger autoUpdater.forceDevUpdateConfig = !app.isPackaged - autoUpdater.autoDownload = configManager.getAutoUpdate() - autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate() + autoUpdater.autoDownload = preferenceService.get('app.dist.auto_update.enabled') + autoUpdater.autoInstallOnAppQuit = preferenceService.get('app.dist.auto_update.enabled') autoUpdater.requestHeaders = { ...autoUpdater.requestHeaders, 'User-Agent': generateUserAgent(), - 'X-Client-Id': configManager.getClientId() + 'X-Client-Id': getClientId(), + // no-cache + 'Cache-Control': 'no-cache' } autoUpdater.on('error', (error) => { @@ -73,61 +100,6 @@ export default class AppUpdater { this.autoUpdater = autoUpdater } - private async _getReleaseVersionFromGithub(channel: UpgradeChannel) { - const headers = { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'Accept-Language': 'en-US,en;q=0.9' - } - try { - logger.info(`get release version from github: ${channel}`) - const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', { - headers - }) - const data = (await responses.json()) as GithubReleaseInfo[] - let mightHaveLatest = false - const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => { - if (!item.draft && !item.prerelease) { - mightHaveLatest = true - } - - return item.prerelease && item.tag_name.includes(`-${channel}.`) - }) - - if (!release) { - return null - } - - // if the release version is the same as the current version, return null - if (release.tag_name === app.getVersion()) { - return null - } - - if (mightHaveLatest) { - logger.info(`might have latest release, get latest release`) - const latestReleaseResponse = await net.fetch( - 'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest', - { - headers - } - ) - const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo - if (semver.gt(latestRelease.tag_name, release.tag_name)) { - logger.info( - `latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null` - ) - return null - } - } - - logger.info(`release url is ${release.tag_name}, set channel to ${channel}`) - return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}` - } catch (error) { - logger.error('Failed to get latest not draft version from github:', error as Error) - return null - } - } - public setAutoUpdate(isActive: boolean) { autoUpdater.autoDownload = isActive autoUpdater.autoInstallOnAppQuit = isActive @@ -145,7 +117,7 @@ export default class AppUpdater { private _getTestChannel() { const currentChannel = this._getChannelByVersion(app.getVersion()) - const savedChannel = configManager.getTestChannel() + const savedChannel = preferenceService.get('app.dist.test_plan.channel') if (currentChannel === UpgradeChannel.LATEST) { return savedChannel || UpgradeChannel.RC @@ -159,6 +131,88 @@ export default class AppUpdater { return UpgradeChannel.LATEST } + /** + * Fetch update configuration from GitHub or GitCode based on mirror + * @param mirror - Mirror to fetch config from + * @returns UpdateConfig object or null if fetch fails + */ + private async _fetchUpdateConfig(mirror: UpdateMirror): Promise { + const configUrl = mirror === UpdateMirror.GITCODE ? UpdateConfigUrl.GITCODE : UpdateConfigUrl.GITHUB + + try { + logger.info(`Fetching update config from ${configUrl} (mirror: ${mirror})`) + const response = await net.fetch(configUrl, { + headers: { + 'User-Agent': generateUserAgent(), + Accept: 'application/json', + 'X-Client-Id': getClientId(), + // no-cache + 'Cache-Control': 'no-cache' + } + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const config = (await response.json()) as UpdateConfig + logger.info(`Update config fetched successfully, last updated: ${config.lastUpdated}`) + return config + } catch (error) { + logger.error('Failed to fetch update config:', error as Error) + return null + } + } + + /** + * Find compatible channel configuration based on current version + * @param currentVersion - Current app version + * @param requestedChannel - Requested upgrade channel (latest/rc/beta) + * @param config - Update configuration object + * @returns Object containing ChannelConfig and actual channel if found, null otherwise + */ + private _findCompatibleChannel( + currentVersion: string, + requestedChannel: UpgradeChannel, + config: UpdateConfig + ): { config: ChannelConfig; channel: UpgradeChannel } | null { + // Get all version keys and sort descending (newest first) + const versionKeys = Object.keys(config.versions).sort(semver.rcompare) + + logger.info( + `Finding compatible channel for version ${currentVersion}, requested channel: ${requestedChannel}, available versions: ${versionKeys.join(', ')}` + ) + + for (const versionKey of versionKeys) { + const versionConfig = config.versions[versionKey] + const channelConfig = versionConfig.channels[requestedChannel] + const latestChannelConfig = versionConfig.channels[UpgradeChannel.LATEST] + + // Check version compatibility and channel availability + if (semver.gte(currentVersion, versionConfig.minCompatibleVersion) && channelConfig !== null) { + logger.info( + `Found compatible version: ${versionKey} (minCompatibleVersion: ${versionConfig.minCompatibleVersion}), version: ${channelConfig.version}` + ) + + if ( + requestedChannel !== UpgradeChannel.LATEST && + latestChannelConfig && + semver.gte(latestChannelConfig.version, channelConfig.version) + ) { + logger.info( + `latest channel version is greater than the requested channel version: ${latestChannelConfig.version} > ${channelConfig.version}, using latest instead` + ) + return { config: latestChannelConfig, channel: UpgradeChannel.LATEST } + } + + return { config: channelConfig, channel: requestedChannel } + } + } + + logger.warn(`No compatible channel found for version ${currentVersion} and channel ${requestedChannel}`) + return null + } + private _setChannel(channel: UpgradeChannel, feedUrl: string) { this.autoUpdater.channel = channel this.autoUpdater.setFeedURL(feedUrl) @@ -170,33 +224,42 @@ export default class AppUpdater { } private async _setFeedUrl() { - const testPlan = configManager.getTestPlan() - if (testPlan) { - const channel = this._getTestChannel() + const currentVersion = app.getVersion() + const testPlan = preferenceService.get('app.dist.test_plan.enabled') + const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST - if (channel === UpgradeChannel.LATEST) { - this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST) - return - } - - const releaseUrl = await this._getReleaseVersionFromGithub(channel) - if (releaseUrl) { - logger.info(`release url is ${releaseUrl}, set channel to ${channel}`) - this._setChannel(channel, releaseUrl) - return - } - - // if no prerelease url, use github latest to get release - this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST) - return - } - - this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION) + // Determine mirror based on IP country const ipCountry = await getIpCountry() - logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`) - if (ipCountry.toLowerCase() !== 'cn') { - this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST) + const mirror = ipCountry.toLowerCase() === 'cn' ? UpdateMirror.GITCODE : UpdateMirror.GITHUB + + logger.info( + `Setting feed URL for version ${currentVersion}, testPlan: ${testPlan}, requested channel: ${requestedChannel}, mirror: ${mirror} (IP country: ${ipCountry})` + ) + + // Try to fetch update config from remote + const config = await this._fetchUpdateConfig(mirror) + + if (config) { + // Use new config-based system + const result = this._findCompatibleChannel(currentVersion, requestedChannel, config) + + if (result) { + const { config: channelConfig, channel: actualChannel } = result + const feedUrl = channelConfig.feedUrls[mirror] + logger.info( + `Using config-based feed URL: ${feedUrl} for channel ${actualChannel} (requested: ${requestedChannel}, mirror: ${mirror})` + ) + this._setChannel(actualChannel, feedUrl) + return + } } + + logger.info('Failed to fetch update config, falling back to default feed URL') + // Fallback: use default feed URL based on mirror + const defaultFeedUrl = mirror === UpdateMirror.GITCODE ? FeedUrl.PRODUCTION : FeedUrl.GITHUB_LATEST + + logger.info(`Using fallback feed URL: ${defaultFeedUrl}`) + this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl) } public cancelDownload() { @@ -265,7 +328,7 @@ export default class AppUpdater { */ private parseMultiLangReleaseNotes(releaseNotes: string): string { try { - const language = configManager.getLanguage() + const language = preferenceService.get('app.language') const isChineseUser = language === 'zh-CN' || language === 'zh-TW' // Create regex patterns using constants @@ -318,8 +381,3 @@ export default class AppUpdater { return processedInfo } } -interface GithubReleaseInfo { - draft: boolean - prerelease: boolean - tag_name: string -} diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index c6d3ee1841..f331254fdf 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,14 +1,14 @@ import { loggerService } from '@logger' import { IpcChannel } from '@shared/IpcChannel' -import { WebDavConfig } from '@types' -import { S3Config } from '@types' +import type { WebDavConfig } from '@types' +import type { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' import * as fs from 'fs-extra' import StreamZip from 'node-stream-zip' import * as path from 'path' -import { CreateDirectoryOptions, FileStat } from 'webdav' +import type { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' import S3Storage from './S3Storage' diff --git a/src/main/services/CacheService.ts b/src/main/services/CacheService.ts deleted file mode 100644 index d2984a9984..0000000000 --- a/src/main/services/CacheService.ts +++ /dev/null @@ -1,74 +0,0 @@ -interface CacheItem { - data: T - timestamp: number - duration: number -} - -export class CacheService { - private static cache: Map> = new Map() - - /** - * Set cache - * @param key Cache key - * @param data Cache data - * @param duration Cache duration (in milliseconds) - */ - static set(key: string, data: T, duration: number): void { - this.cache.set(key, { - data, - timestamp: Date.now(), - duration - }) - } - - /** - * Get cache - * @param key Cache key - * @returns Returns data if cache exists and not expired, otherwise returns null - */ - static get(key: string): T | null { - const item = this.cache.get(key) - if (!item) return null - - const now = Date.now() - if (now - item.timestamp > item.duration) { - this.remove(key) - return null - } - - return item.data - } - - /** - * Remove specific cache - * @param key Cache key - */ - static remove(key: string): void { - this.cache.delete(key) - } - - /** - * Clear all cache - */ - static clear(): void { - this.cache.clear() - } - - /** - * Check if cache exists and is valid - * @param key Cache key - * @returns boolean - */ - static has(key: string): boolean { - const item = this.cache.get(key) - if (!item) return false - - const now = Date.now() - if (now - item.timestamp > item.duration) { - this.remove(key) - return false - } - - return true - } -} diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index d6eea8a9e6..82c9c64f87 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -7,13 +7,13 @@ import { isMac, isWin } from '@main/constant' import { removeEnvProxy } from '@main/utils' import { isUserInChina } from '@main/utils/ipService' import { getBinaryName } from '@main/utils/process' +import type { TerminalConfig, TerminalConfigWithCommand } from '@shared/config/constant' import { codeTools, + HOME_CHERRY_DIR, MACOS_TERMINALS, MACOS_TERMINALS_WITH_COMMANDS, terminalApps, - TerminalConfig, - TerminalConfigWithCommand, WINDOWS_TERMINALS, WINDOWS_TERMINALS_WITH_COMMANDS } from '@shared/config/constant' @@ -67,7 +67,7 @@ class CodeToolsService { } public async getBunPath() { - const dir = path.join(os.homedir(), '.cherrystudio', 'bin') + const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const bunName = await getBinaryName('bun') const bunPath = path.join(dir, bunName) return bunPath @@ -363,7 +363,7 @@ class CodeToolsService { private async isPackageInstalled(cliTool: string): Promise { const executableName = await this.getCliExecutableName(cliTool) - const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) // Ensure bin directory exists @@ -390,7 +390,7 @@ class CodeToolsService { logger.info(`${cliTool} is installed, getting current version`) try { const executableName = await this.getCliExecutableName(cliTool) - const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) const { stdout } = await execAsync(`"${executablePath}" --version`, { @@ -501,7 +501,7 @@ class CodeToolsService { try { const packageName = await this.getPackageName(cliTool) const bunPath = await this.getBunPath() - const bunInstallPath = path.join(os.homedir(), '.cherrystudio') + const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR) const registryUrl = await this.getNpmRegistryUrl() const installEnvPrefix = isWin @@ -551,7 +551,7 @@ class CodeToolsService { const packageName = await this.getPackageName(cliTool) const bunPath = await this.getBunPath() const executableName = await this.getCliExecutableName(cliTool) - const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) logger.debug(`Package name: ${packageName}`) @@ -653,7 +653,7 @@ class CodeToolsService { baseCommand = `${baseCommand} ${configParams}` } - const bunInstallPath = path.join(os.homedir(), '.cherrystudio') + const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR) if (isInstalled) { // If already installed, run executable directly (with optional update message) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 3cab0bf91d..b6f4e877e4 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -1,11 +1,8 @@ -import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/constant' -import { LanguageVarious, Shortcut, ThemeMode } from '@types' -import { app } from 'electron' +import { ZOOM_SHORTCUTS } from '@shared/config/constant' +import type { Shortcut } from '@types' import Store from 'electron-store' import { v4 as uuidv4 } from 'uuid' -import { locales } from '../utils/locales' - export enum ConfigKeys { Language = 'language', Theme = 'theme', @@ -40,46 +37,46 @@ export class ConfigManager { this.store = new Store() } - getLanguage(): LanguageVarious { - const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : defaultLanguage - return this.get(ConfigKeys.Language, locale) as LanguageVarious - } + // getLanguage(): LanguageVarious { + // const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : defaultLanguage + // return this.get(ConfigKeys.Language, locale) as LanguageVarious + // } - setLanguage(lang: LanguageVarious) { - this.setAndNotify(ConfigKeys.Language, lang) - } + // setLanguage(lang: LanguageVarious) { + // this.setAndNotify(ConfigKeys.Language, lang) + // } - getTheme(): ThemeMode { - return this.get(ConfigKeys.Theme, ThemeMode.system) - } + // getTheme(): ThemeMode { + // return this.get(ConfigKeys.Theme, ThemeMode.system) + // } - setTheme(theme: ThemeMode) { - this.set(ConfigKeys.Theme, theme) - } + // setTheme(theme: ThemeMode) { + // this.set(ConfigKeys.Theme, theme) + // } - getLaunchToTray(): boolean { - return !!this.get(ConfigKeys.LaunchToTray, false) - } + // getLaunchToTray(): boolean { + // return !!this.get(ConfigKeys.LaunchToTray, false) + // } - setLaunchToTray(value: boolean) { - this.set(ConfigKeys.LaunchToTray, value) - } + // setLaunchToTray(value: boolean) { + // this.set(ConfigKeys.LaunchToTray, value) + // } - getTray(): boolean { - return !!this.get(ConfigKeys.Tray, true) - } + // getTray(): boolean { + // return !!this.get(ConfigKeys.Tray, true) + // } - setTray(value: boolean) { - this.setAndNotify(ConfigKeys.Tray, value) - } + // setTray(value: boolean) { + // this.setAndNotify(ConfigKeys.Tray, value) + // } - getTrayOnClose(): boolean { - return !!this.get(ConfigKeys.TrayOnClose, true) - } + // getTrayOnClose(): boolean { + // return !!this.get(ConfigKeys.TrayOnClose, true) + // } - setTrayOnClose(value: boolean) { - this.set(ConfigKeys.TrayOnClose, value) - } + // setTrayOnClose(value: boolean) { + // this.set(ConfigKeys.TrayOnClose, value) + // } getZoomFactor(): number { return this.get(ConfigKeys.ZoomFactor, 1) @@ -124,124 +121,124 @@ export class ConfigManager { ) } - getClickTrayToShowQuickAssistant(): boolean { - return this.get(ConfigKeys.ClickTrayToShowQuickAssistant, false) - } + // getClickTrayToShowQuickAssistant(): boolean { + // return this.get(ConfigKeys.ClickTrayToShowQuickAssistant, false) + // } - setClickTrayToShowQuickAssistant(value: boolean) { - this.set(ConfigKeys.ClickTrayToShowQuickAssistant, value) - } + // setClickTrayToShowQuickAssistant(value: boolean) { + // this.set(ConfigKeys.ClickTrayToShowQuickAssistant, value) + // } - getEnableQuickAssistant(): boolean { - return this.get(ConfigKeys.EnableQuickAssistant, false) - } + // getEnableQuickAssistant(): boolean { + // return this.get(ConfigKeys.EnableQuickAssistant, false) + // } - setEnableQuickAssistant(value: boolean) { - this.setAndNotify(ConfigKeys.EnableQuickAssistant, value) - } + // setEnableQuickAssistant(value: boolean) { + // this.setAndNotify(ConfigKeys.EnableQuickAssistant, value) + // } - getAutoUpdate(): boolean { - return this.get(ConfigKeys.AutoUpdate, true) - } + // getAutoUpdate(): boolean { + // return this.get(ConfigKeys.AutoUpdate, true) + // } - setAutoUpdate(value: boolean) { - this.set(ConfigKeys.AutoUpdate, value) - } + // setAutoUpdate(value: boolean) { + // this.set(ConfigKeys.AutoUpdate, value) + // } - getTestPlan(): boolean { - return this.get(ConfigKeys.TestPlan, false) - } + // getTestPlan(): boolean { + // return this.get(ConfigKeys.TestPlan, false) + // } - setTestPlan(value: boolean) { - this.set(ConfigKeys.TestPlan, value) - } + // setTestPlan(value: boolean) { + // this.set(ConfigKeys.TestPlan, value) + // } - getTestChannel(): UpgradeChannel { - return this.get(ConfigKeys.TestChannel) - } + // getTestChannel(): UpgradeChannel { + // return this.get(ConfigKeys.TestChannel) + // } - setTestChannel(value: UpgradeChannel) { - this.set(ConfigKeys.TestChannel, value) - } + // setTestChannel(value: UpgradeChannel) { + // this.set(ConfigKeys.TestChannel, value) + // } - getEnableDataCollection(): boolean { - return this.get(ConfigKeys.EnableDataCollection, true) - } + // getEnableDataCollection(): boolean { + // return this.get(ConfigKeys.EnableDataCollection, true) + // } - setEnableDataCollection(value: boolean) { - this.set(ConfigKeys.EnableDataCollection, value) - } + // setEnableDataCollection(value: boolean) { + // this.set(ConfigKeys.EnableDataCollection, value) + // } - // Selection Assistant: is enabled the selection assistant - getSelectionAssistantEnabled(): boolean { - return this.get(ConfigKeys.SelectionAssistantEnabled, false) - } + // // Selection Assistant: is enabled the selection assistant + // getSelectionAssistantEnabled(): boolean { + // return this.get(ConfigKeys.SelectionAssistantEnabled, false) + // } - setSelectionAssistantEnabled(value: boolean) { - this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value) - } + // setSelectionAssistantEnabled(value: boolean) { + // this.setAndNotify(ConfigKeys.SelectionAssistantEnabled, value) + // } - // Selection Assistant: trigger mode (selected, ctrlkey) - getSelectionAssistantTriggerMode(): string { - return this.get(ConfigKeys.SelectionAssistantTriggerMode, 'selected') - } + // // Selection Assistant: trigger mode (selected, ctrlkey) + // getSelectionAssistantTriggerMode(): string { + // return this.get(ConfigKeys.SelectionAssistantTriggerMode, 'selected') + // } - setSelectionAssistantTriggerMode(value: string) { - this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value) - } + // setSelectionAssistantTriggerMode(value: string) { + // this.setAndNotify(ConfigKeys.SelectionAssistantTriggerMode, value) + // } - // Selection Assistant: if action window position follow toolbar - getSelectionAssistantFollowToolbar(): boolean { - return this.get(ConfigKeys.SelectionAssistantFollowToolbar, true) - } + // // Selection Assistant: if action window position follow toolbar + // getSelectionAssistantFollowToolbar(): boolean { + // return this.get(ConfigKeys.SelectionAssistantFollowToolbar, true) + // } - setSelectionAssistantFollowToolbar(value: boolean) { - this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value) - } + // setSelectionAssistantFollowToolbar(value: boolean) { + // this.setAndNotify(ConfigKeys.SelectionAssistantFollowToolbar, value) + // } - getSelectionAssistantRemeberWinSize(): boolean { - return this.get(ConfigKeys.SelectionAssistantRemeberWinSize, false) - } + // getSelectionAssistantRemeberWinSize(): boolean { + // return this.get(ConfigKeys.SelectionAssistantRemeberWinSize, false) + // } - setSelectionAssistantRemeberWinSize(value: boolean) { - this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value) - } + // setSelectionAssistantRemeberWinSize(value: boolean) { + // this.setAndNotify(ConfigKeys.SelectionAssistantRemeberWinSize, value) + // } - getSelectionAssistantFilterMode(): string { - return this.get(ConfigKeys.SelectionAssistantFilterMode, 'default') - } + // getSelectionAssistantFilterMode(): string { + // return this.get(ConfigKeys.SelectionAssistantFilterMode, 'default') + // } - setSelectionAssistantFilterMode(value: string) { - this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value) - } + // setSelectionAssistantFilterMode(value: string) { + // this.setAndNotify(ConfigKeys.SelectionAssistantFilterMode, value) + // } - getSelectionAssistantFilterList(): string[] { - return this.get(ConfigKeys.SelectionAssistantFilterList, []) - } + // getSelectionAssistantFilterList(): string[] { + // return this.get(ConfigKeys.SelectionAssistantFilterList, []) + // } - setSelectionAssistantFilterList(value: string[]) { - this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value) - } + // setSelectionAssistantFilterList(value: string[]) { + // this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value) + // } getDisableHardwareAcceleration(): boolean { return this.get(ConfigKeys.DisableHardwareAcceleration, false) } - setDisableHardwareAcceleration(value: boolean) { - this.set(ConfigKeys.DisableHardwareAcceleration, value) - } + // setDisableHardwareAcceleration(value: boolean) { + // this.set(ConfigKeys.DisableHardwareAcceleration, value) + // } setAndNotify(key: string, value: unknown) { this.set(key, value, true) } - getEnableDeveloperMode(): boolean { - return this.get(ConfigKeys.EnableDeveloperMode, false) - } + // getEnableDeveloperMode(): boolean { + // return this.get(ConfigKeys.EnableDeveloperMode, false) + // } - setEnableDeveloperMode(value: boolean) { - this.set(ConfigKeys.EnableDeveloperMode, value) - } + // setEnableDeveloperMode(value: boolean) { + // this.set(ConfigKeys.EnableDeveloperMode, value) + // } getClientId(): string { let clientId = this.get(ConfigKeys.ClientId) diff --git a/src/main/services/ContextMenu.ts b/src/main/services/ContextMenu.ts index 411d6e075d..ab81ff8eb0 100644 --- a/src/main/services/ContextMenu.ts +++ b/src/main/services/ContextMenu.ts @@ -1,7 +1,6 @@ -import { Menu, MenuItemConstructorOptions } from 'electron' - -import { locales } from '../utils/locales' -import { configManager } from './ConfigManager' +import { getI18n } from '@main/utils/language' +import type { MenuItemConstructorOptions } from 'electron' +import { Menu } from 'electron' class ContextMenu { public contextMenu(w: Electron.WebContents) { @@ -27,8 +26,8 @@ class ContextMenu { } private createInspectMenuItems(w: Electron.WebContents): MenuItemConstructorOptions[] { - const locale = locales[configManager.getLanguage()] - const { common } = locale.translation + const i18n = getI18n() + const { common } = i18n.translation const template: MenuItemConstructorOptions[] = [ { id: 'inspect', @@ -44,8 +43,8 @@ class ContextMenu { } private createEditMenuItems(properties: Electron.ContextMenuParams): MenuItemConstructorOptions[] { - const locale = locales[configManager.getLanguage()] - const { common } = locale.translation + const i18n = getI18n() + const { common } = i18n.translation const hasText = properties.selectionText.trim().length > 0 const can = (type: string) => properties.editFlags[`can${type}`] && hasText diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 115acc402c..9590906e85 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -10,19 +10,14 @@ import { scanDir } from '@main/utils/file' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' -import { FileMetadata, NotesTreeNode } from '@types' +import type { FileMetadata, NotesTreeNode } from '@types' import chardet from 'chardet' -import chokidar, { FSWatcher } from 'chokidar' +import type { FSWatcher } from 'chokidar' +import chokidar from 'chokidar' import * as crypto from 'crypto' -import { - dialog, - net, - OpenDialogOptions, - OpenDialogReturnValue, - SaveDialogOptions, - SaveDialogReturnValue, - shell -} from 'electron' +import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' +import { app } from 'electron' +import { dialog, net, shell } from 'electron' import * as fs from 'fs' import { writeFileSync } from 'fs' import { readFile } from 'fs/promises' @@ -36,6 +31,73 @@ import WordExtractor from 'word-extractor' const logger = loggerService.withContext('FileStorage') +// Get ripgrep binary path +const getRipgrepBinaryPath = (): string | null => { + try { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux' + let ripgrepBinaryPath = path.join( + __dirname, + '../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', + `${arch}-${platform}`, + process.platform === 'win32' ? 'rg.exe' : 'rg' + ) + + if (app.isPackaged) { + ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1') + } + + if (fs.existsSync(ripgrepBinaryPath)) { + return ripgrepBinaryPath + } + return null + } catch (error) { + logger.error('Failed to locate ripgrep binary:', error as Error) + return null + } +} + +/** + * Execute ripgrep with captured output + */ +function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve, reject) => { + const ripgrepBinaryPath = getRipgrepBinaryPath() + + if (!ripgrepBinaryPath) { + reject(new Error('Ripgrep binary not available')) + return + } + + const { spawn } = require('child_process') + const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], { + stdio: ['pipe', 'pipe', 'pipe'] + }) + + let output = '' + let errorOutput = '' + + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + }) + + child.stderr.on('data', (data: Buffer) => { + errorOutput += data.toString() + }) + + child.on('close', (code: number) => { + resolve({ + exitCode: code || 0, + output: output || errorOutput + }) + }) + + child.on('error', (error: Error) => { + reject(error) + }) + }) +} + interface FileWatcherConfig { watchExtensions?: string[] ignoredPatterns?: (string | RegExp)[] @@ -60,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required = { eventChannel: 'file-change' } +interface DirectoryListOptions { + recursive?: boolean + maxDepth?: number + includeHidden?: boolean + includeFiles?: boolean + includeDirectories?: boolean + maxEntries?: number + searchPattern?: string +} + +const DEFAULT_DIRECTORY_LIST_OPTIONS: Required = { + recursive: true, + maxDepth: 3, + includeHidden: false, + includeFiles: true, + includeDirectories: true, + maxEntries: 10, + searchPattern: '.' +} + class FileStorage { private storageDir = getFilesDir() private notesDir = getNotesDir() @@ -752,6 +834,284 @@ class FileStorage { } } + public listDirectory = async ( + _: Electron.IpcMainInvokeEvent, + dirPath: string, + options?: DirectoryListOptions + ): Promise => { + const mergedOptions: Required = { + ...DEFAULT_DIRECTORY_LIST_OPTIONS, + ...options + } + + const resolvedPath = path.resolve(dirPath) + + const stat = await fs.promises.stat(resolvedPath).catch((error) => { + logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error) + throw error + }) + + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${resolvedPath}`) + } + + // Use ripgrep for file listing with relevance-based sorting + if (!getRipgrepBinaryPath()) { + throw new Error('Ripgrep binary not available') + } + + return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions) + } + + /** + * Search directories by name pattern + */ + private async searchDirectories( + resolvedPath: string, + options: Required, + currentDepth: number = 0 + ): Promise { + if (!options.includeDirectories) return [] + if (!options.recursive && currentDepth > 0) return [] + if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return [] + + const directories: string[] = [] + const excludedDirs = new Set([ + 'node_modules', + '.git', + '.idea', + '.vscode', + 'dist', + 'build', + '.next', + '.nuxt', + 'coverage', + '.cache' + ]) + + try { + const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true }) + const searchPatternLower = options.searchPattern.toLowerCase() + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + // Skip hidden directories unless explicitly included + if (!options.includeHidden && entry.name.startsWith('.')) continue + + // Skip excluded directories + if (excludedDirs.has(entry.name)) continue + + const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/') + + // Check if directory name matches search pattern + if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) { + directories.push(fullPath) + } + + // Recursively search subdirectories + if (options.recursive && currentDepth < options.maxDepth) { + const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1) + directories.push(...subDirs) + } + } + } catch (error) { + logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error) + } + + return directories + } + + /** + * Search files by filename pattern + */ + private async searchByFilename(resolvedPath: string, options: Required): Promise { + const files: string[] = [] + const directories: string[] = [] + + // Search for files using ripgrep + if (options.includeFiles) { + const args: string[] = ['--files'] + + // Handle hidden files + if (!options.includeHidden) { + args.push('--glob', '!.*') + } + + // Use --iglob to let ripgrep filter filenames (case-insensitive) + if (options.searchPattern && options.searchPattern !== '.') { + args.push('--iglob', `*${options.searchPattern}*`) + } + + // Exclude common hidden directories and large directories + args.push('-g', '!**/node_modules/**') + args.push('-g', '!**/.git/**') + args.push('-g', '!**/.idea/**') + args.push('-g', '!**/.vscode/**') + args.push('-g', '!**/.DS_Store') + args.push('-g', '!**/dist/**') + args.push('-g', '!**/build/**') + args.push('-g', '!**/.next/**') + args.push('-g', '!**/.nuxt/**') + args.push('-g', '!**/coverage/**') + args.push('-g', '!**/.cache/**') + + // Handle max depth + if (!options.recursive) { + args.push('--max-depth', '1') + } else if (options.maxDepth > 0) { + args.push('--max-depth', options.maxDepth.toString()) + } + + // Add the directory path + args.push(resolvedPath) + + const { exitCode, output } = await executeRipgrep(args) + + // Exit code 0 means files found, 1 means no files found (still success), 2+ means error + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + // Parse ripgrep output (no need to filter by filename - ripgrep already did it) + files.push( + ...output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + ) + } + + // Search for directories + if (options.includeDirectories) { + directories.push(...(await this.searchDirectories(resolvedPath, options))) + } + + // Combine and sort: directories first (alphabetically), then files (alphabetically) + const sortedDirectories = directories.sort((a, b) => { + const aName = path.basename(a) + const bName = path.basename(b) + return aName.localeCompare(bName) + }) + + const sortedFiles = files.sort((a, b) => { + const aName = path.basename(a) + const bName = path.basename(b) + return aName.localeCompare(bName) + }) + + return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries) + } + + /** + * Search files by content pattern + */ + private async searchByContent(resolvedPath: string, options: Required): Promise { + const args: string[] = ['-l'] + + // Handle hidden files + if (!options.includeHidden) { + args.push('--glob', '!.*') + } + + // Exclude common hidden directories and large directories + args.push('-g', '!**/node_modules/**') + args.push('-g', '!**/.git/**') + args.push('-g', '!**/.idea/**') + args.push('-g', '!**/.vscode/**') + args.push('-g', '!**/.DS_Store') + args.push('-g', '!**/dist/**') + args.push('-g', '!**/build/**') + args.push('-g', '!**/.next/**') + args.push('-g', '!**/.nuxt/**') + args.push('-g', '!**/coverage/**') + args.push('-g', '!**/.cache/**') + + // Handle max depth + if (!options.recursive) { + args.push('--max-depth', '1') + } else if (options.maxDepth > 0) { + args.push('--max-depth', options.maxDepth.toString()) + } + + // Handle max count + if (options.maxEntries > 0) { + args.push('--max-count', options.maxEntries.toString()) + } + + // Add search pattern (search in content) + args.push(options.searchPattern) + + // Add the directory path + args.push(resolvedPath) + + const { exitCode, output } = await executeRipgrep(args) + + // Exit code 0 means files found, 1 means no files found (still success), 2+ means error + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + // Parse ripgrep output (already sorted by relevance) + const results = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + .slice(0, options.maxEntries) + + return results + } + + private async listDirectoryWithRipgrep( + resolvedPath: string, + options: Required + ): Promise { + const maxEntries = options.maxEntries + + // Step 1: Search by filename first + logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath }) + const filenameResults = await this.searchByFilename(resolvedPath, options) + + logger.debug('Found matches by filename', { count: filenameResults.length }) + + // If we have enough filename matches, return them + if (filenameResults.length >= maxEntries) { + return filenameResults.slice(0, maxEntries) + } + + // Step 2: If filename matches are less than maxEntries, search by content to fill up + logger.debug('Filename matches insufficient, searching by content to fill up', { + filenameCount: filenameResults.length, + needed: maxEntries - filenameResults.length + }) + + // Adjust maxEntries for content search to get enough results + const contentOptions = { + ...options, + maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates + } + + const contentResults = await this.searchByContent(resolvedPath, contentOptions) + + logger.debug('Found matches by content', { count: contentResults.length }) + + // Combine results: filename matches first, then content matches (deduplicated) + const combined = [...filenameResults] + const filenameSet = new Set(filenameResults) + + for (const filePath of contentResults) { + if (!filenameSet.has(filePath)) { + combined.push(filePath) + if (combined.length >= maxEntries) { + break + } + } + } + + logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length }) + return combined.slice(0, maxEntries) + } + public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { try { if (!dirPath || typeof dirPath !== 'string') { diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index 4e8707b2b7..f139b3e2c1 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -16,7 +16,8 @@ import * as fs from 'node:fs' import path from 'node:path' -import { RAGApplication, RAGApplicationBuilder } from '@cherrystudio/embedjs' +import type { RAGApplication } from '@cherrystudio/embedjs' +import { RAGApplicationBuilder } from '@cherrystudio/embedjs' import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { WebLoader } from '@cherrystudio/embedjs-loader-web' @@ -34,7 +35,7 @@ import { TraceMethod } from '@mcp-trace/trace-core' import { MB } from '@shared/config/constant' import type { LoaderReturn } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' -import { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types' +import type { FileMetadata, KnowledgeBaseParams, KnowledgeItem, KnowledgeSearchResult } from '@types' import { v4 as uuidv4 } from 'uuid' const logger = loggerService.withContext('MainKnowledgeService') diff --git a/src/main/services/LoggerService.ts b/src/main/services/LoggerService.ts index b48c601cd5..aa78405705 100644 --- a/src/main/services/LoggerService.ts +++ b/src/main/services/LoggerService.ts @@ -48,7 +48,7 @@ const DEFAULT_LEVEL = isDev ? LEVEL.SILLY : LEVEL.INFO * English: `docs/technical/how-to-use-logger-en.md` * Chinese: `docs/technical/how-to-use-logger-zh.md` */ -class LoggerService { +export class LoggerService { private static instance: LoggerService private logger: winston.Logger diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index f1bfbaa841..c403552fd2 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto' import os from 'node:os' import path from 'node:path' +import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import { createInMemoryMCPServer } from '@main/mcpServers/factory' import { makeSureDirExists, removeEnvProxy } from '@main/utils' @@ -10,7 +11,8 @@ import { getBinaryName, getBinaryPath } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core' import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' +import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport, @@ -29,7 +31,8 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' import { nanoid } from '@reduxjs/toolkit' -import { MCPProgressEvent } from '@shared/config/types' +import { HOME_CHERRY_DIR } from '@shared/config/constant' +import type { MCPProgressEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { defaultAppHeaders } from '@shared/utils' import { @@ -46,7 +49,6 @@ import { app, net } from 'electron' import { EventEmitter } from 'events' import { v4 as uuidv4 } from 'uuid' -import { CacheService } from './CacheService' import DxtService from './DxtService' import { CallBackServer } from './mcp/oauth/callback' import { McpOAuthClientProvider } from './mcp/oauth/provider' @@ -115,9 +117,9 @@ function withCache( return async (...args: T): Promise => { const cacheKey = getCacheKey(...args) - if (CacheService.has(cacheKey)) { + if (cacheService.has(cacheKey)) { logger.debug(`${logPrefix} loaded from cache`, { cacheKey }) - const cachedData = CacheService.get(cacheKey) + const cachedData = cacheService.get(cacheKey) if (cachedData) { return cachedData } @@ -125,7 +127,7 @@ function withCache( const start = Date.now() const result = await fn(...args) - CacheService.set(cacheKey, result, ttl) + cacheService.set(cacheKey, result, ttl) logger.debug(`${logPrefix} cached`, { cacheKey, ttlMs: ttl, durationMs: Date.now() - start }) return result } @@ -468,21 +470,21 @@ class McpService { client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { logger.debug(`Tools list changed for server: ${server.name}`) // Clear tools cache - CacheService.remove(`mcp:list_tool:${serverKey}`) + cacheService.delete(`mcp:list_tool:${serverKey}`) }) // Set up resources list changed notification handler client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => { logger.debug(`Resources list changed for server: ${server.name}`) // Clear resources cache - CacheService.remove(`mcp:list_resources:${serverKey}`) + cacheService.delete(`mcp:list_resources:${serverKey}`) }) // Set up prompts list changed notification handler client.setNotificationHandler(PromptListChangedNotificationSchema, async () => { logger.debug(`Prompts list changed for server: ${server.name}`) // Clear prompts cache - CacheService.remove(`mcp:list_prompts:${serverKey}`) + cacheService.delete(`mcp:list_prompts:${serverKey}`) }) // Set up resource updated notification handler @@ -512,16 +514,16 @@ class McpService { * Clear resource-specific caches for a server */ private clearResourceCaches(serverKey: string) { - CacheService.remove(`mcp:list_resources:${serverKey}`) + cacheService.delete(`mcp:list_resources:${serverKey}`) } /** * Clear all caches for a specific server */ private clearServerCache(serverKey: string) { - CacheService.remove(`mcp:list_tool:${serverKey}`) - CacheService.remove(`mcp:list_prompts:${serverKey}`) - CacheService.remove(`mcp:list_resources:${serverKey}`) + cacheService.delete(`mcp:list_tool:${serverKey}`) + cacheService.delete(`mcp:list_prompts:${serverKey}`) + cacheService.delete(`mcp:list_resources:${serverKey}`) logger.debug(`Cleared all caches for server`, { serverKey }) } @@ -714,7 +716,7 @@ class McpService { } public async getInstallInfo() { - const dir = path.join(os.homedir(), '.cherrystudio', 'bin') + const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const uvName = await getBinaryName('uv') const bunName = await getBinaryName('bun') const uvPath = path.join(dir, uvName) diff --git a/src/main/services/MistralClientManager.ts b/src/main/services/MistralClientManager.ts index c2efe35d1d..8f5a90f76e 100644 --- a/src/main/services/MistralClientManager.ts +++ b/src/main/services/MistralClientManager.ts @@ -1,5 +1,5 @@ import { Mistral } from '@mistralai/mistralai' -import { Provider } from '@types' +import type { Provider } from '@types' export class MistralClientManager { private static instance: MistralClientManager diff --git a/src/main/services/NodeTraceService.ts b/src/main/services/NodeTraceService.ts index d2e4db20c9..ec74baee90 100644 --- a/src/main/services/NodeTraceService.ts +++ b/src/main/services/NodeTraceService.ts @@ -1,12 +1,13 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isDev } from '@main/constant' import { CacheBatchSpanProcessor, FunctionSpanExporter } from '@mcp-trace/trace-core' import { NodeTracer as MCPNodeTracer } from '@mcp-trace/trace-node/nodeTracer' -import { context, SpanContext, trace } from '@opentelemetry/api' +import type { SpanContext } from '@opentelemetry/api' +import { context, trace } from '@opentelemetry/api' import { BrowserWindow, ipcMain } from 'electron' import * as path from 'path' -import { ConfigKeys, configManager } from './ConfigManager' import { spanCacheService } from './SpanCacheService' export const TRACER_NAME = 'CherryStudio' @@ -90,8 +91,13 @@ export function openTraceWindow(topicId: string, traceId: string, autoOpen = tru } else { traceWin.loadFile(path.join(__dirname, '../renderer/traceWindow.html')) } + let unsubscribeLanguage: (() => void) | null = null + traceWin.on('closed', () => { - configManager.unsubscribe(ConfigKeys.Language, setLanguageCallback) + if (unsubscribeLanguage) { + unsubscribeLanguage() + unsubscribeLanguage = null + } try { traceWin?.destroy() } finally { @@ -105,13 +111,15 @@ export function openTraceWindow(topicId: string, traceId: string, autoOpen = tru topicId, modelName }) - traceWin!.webContents.send('set-language', { lang: configManager.get(ConfigKeys.Language) }) - configManager.subscribe(ConfigKeys.Language, setLanguageCallback) + traceWin!.webContents.send('set-language', { lang: preferenceService.get('app.language') }) + unsubscribeLanguage = preferenceService.subscribeChange('app.language', setLanguageCallback) }) } -const setLanguageCallback = (lang: string) => { - traceWin!.webContents.send('set-language', { lang }) +const setLanguageCallback = (lang: string | null) => { + if (lang) { + traceWin?.webContents.send('set-language', { lang }) + } } export const setTraceWindowTitle = (title: string) => { diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts index fa9261aa7e..5a599b409d 100644 --- a/src/main/services/NotificationService.ts +++ b/src/main/services/NotificationService.ts @@ -1,4 +1,4 @@ -import { Notification } from '@types' +import type { Notification } from '@types' import { Notification as ElectronNotification } from 'electron' import { windowService } from './WindowService' diff --git a/src/main/services/OvmsManager.ts b/src/main/services/OvmsManager.ts index f319200ac3..3a32d74ecf 100644 --- a/src/main/services/OvmsManager.ts +++ b/src/main/services/OvmsManager.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { promisify } from 'node:util' import { loggerService } from '@logger' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import * as fs from 'fs-extra' import * as path from 'path' @@ -145,7 +146,7 @@ class OvmsManager { */ public async runOvms(): Promise<{ success: boolean; message?: string }> { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') const runBatPath = path.join(ovmsDir, 'run.bat') @@ -195,7 +196,7 @@ class OvmsManager { */ public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> { const homeDir = homedir() - const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe') + const ovmsPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'ovms.exe') try { // Check if OVMS executable exists @@ -273,7 +274,7 @@ class OvmsManager { } const homeDir = homedir() - const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json') + const configPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'models', 'config.json') try { if (!(await fs.pathExists(configPath))) { logger.warn(`Config file does not exist: ${configPath}`) @@ -304,7 +305,7 @@ class OvmsManager { private async applyModelPath(modelDirPath: string): Promise { const homeDir = homedir() - const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch') + const patchDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'patch') if (!(await fs.pathExists(patchDir))) { return true } @@ -355,7 +356,7 @@ class OvmsManager { logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`) const homeDir = homedir() - const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovdndDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const pathModel = path.join(ovdndDir, 'models', modelId) try { @@ -468,7 +469,7 @@ class OvmsManager { */ public async checkModelExists(modelId: string): Promise { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') try { @@ -495,7 +496,7 @@ class OvmsManager { */ public async updateModelConfig(modelName: string, modelId: string): Promise { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') try { @@ -548,7 +549,7 @@ class OvmsManager { */ public async getModels(): Promise { const homeDir = homedir() - const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms') + const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms') const configPath = path.join(ovmsDir, 'models', 'config.json') try { diff --git a/src/main/services/PowerMonitorService.ts b/src/main/services/PowerMonitorService.ts new file mode 100644 index 0000000000..aab3906c9e --- /dev/null +++ b/src/main/services/PowerMonitorService.ts @@ -0,0 +1,112 @@ +import { loggerService } from '@logger' +import { isLinux, isMac, isWin } from '@main/constant' +import ElectronShutdownHandler from '@paymoapp/electron-shutdown-handler' +import { BrowserWindow } from 'electron' +import { powerMonitor } from 'electron' + +const logger = loggerService.withContext('PowerMonitorService') + +type ShutdownHandler = () => void | Promise + +export class PowerMonitorService { + private static instance: PowerMonitorService + private initialized = false + private shutdownHandlers: ShutdownHandler[] = [] + + private constructor() { + // Private constructor to prevent direct instantiation + } + + public static getInstance(): PowerMonitorService { + if (!PowerMonitorService.instance) { + PowerMonitorService.instance = new PowerMonitorService() + } + return PowerMonitorService.instance + } + + /** + * Register a shutdown handler to be called when system shutdown is detected + * @param handler - The handler function to be called on shutdown + */ + public registerShutdownHandler(handler: ShutdownHandler): void { + this.shutdownHandlers.push(handler) + logger.info('Shutdown handler registered', { totalHandlers: this.shutdownHandlers.length }) + } + + /** + * Initialize power monitor to listen for shutdown events + */ + public init(): void { + if (this.initialized) { + logger.warn('PowerMonitorService already initialized') + return + } + + if (isWin) { + this.initWindowsShutdownHandler() + } else if (isMac || isLinux) { + this.initElectronPowerMonitor() + } + + this.initialized = true + logger.info('PowerMonitorService initialized', { platform: process.platform }) + } + + /** + * Execute all registered shutdown handlers + */ + private async executeShutdownHandlers(): Promise { + logger.info('Executing shutdown handlers', { count: this.shutdownHandlers.length }) + for (const handler of this.shutdownHandlers) { + try { + await handler() + } catch (error) { + logger.error('Error executing shutdown handler', error as Error) + } + } + } + + /** + * Initialize shutdown handler for Windows using @paymoapp/electron-shutdown-handler + */ + private initWindowsShutdownHandler(): void { + try { + const zeroMemoryWindow = new BrowserWindow({ show: false }) + // Set the window handle for the shutdown handler + ElectronShutdownHandler.setWindowHandle(zeroMemoryWindow.getNativeWindowHandle()) + + // Listen for shutdown event + ElectronShutdownHandler.on('shutdown', async () => { + logger.info('System shutdown event detected (Windows)') + // Execute all registered shutdown handlers + await this.executeShutdownHandlers() + // Release the shutdown block to allow the system to shut down + ElectronShutdownHandler.releaseShutdown() + }) + + logger.info('Windows shutdown handler registered') + } catch (error) { + logger.error('Failed to initialize Windows shutdown handler', error as Error) + } + } + + /** + * Initialize power monitor for macOS and Linux using Electron's powerMonitor + */ + private initElectronPowerMonitor(): void { + try { + powerMonitor.on('shutdown', async () => { + logger.info('System shutdown event detected', { platform: process.platform }) + // Execute all registered shutdown handlers + await this.executeShutdownHandlers() + }) + + logger.info('Electron powerMonitor shutdown listener registered') + } catch (error) { + logger.error('Failed to initialize Electron powerMonitor', error as Error) + } + } +} + +// Default export as singleton instance +export default PowerMonitorService.getInstance() diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 6fcf20da1c..a4a2211a20 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import axios from 'axios' -import { app, ProxyConfig, session } from 'electron' +import type { ProxyConfig } from 'electron' +import { app, session } from 'electron' import { socksDispatcher } from 'fetch-socks' import http from 'http' import https from 'https' diff --git a/src/main/services/PythonService.ts b/src/main/services/PythonService.ts index 13b4546e56..e9f59fa3be 100644 --- a/src/main/services/PythonService.ts +++ b/src/main/services/PythonService.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto' -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' + +import { windowService } from './WindowService' interface PythonExecutionRequest { id: string @@ -20,7 +22,6 @@ interface PythonExecutionResponse { */ export class PythonService { private static instance: PythonService | null = null - private mainWindow: BrowserWindow | null = null private pendingRequests = new Map void; reject: (error: Error) => void }>() private constructor() { @@ -50,10 +51,6 @@ export class PythonService { }) } - public setMainWindow(mainWindow: BrowserWindow) { - this.mainWindow = mainWindow - } - /** * Execute Python code by sending request to renderer PyodideService */ @@ -62,8 +59,8 @@ export class PythonService { context: Record = {}, timeout: number = 60000 ): Promise { - if (!this.mainWindow) { - throw new Error('Main window not set in PythonService') + if (!windowService.getMainWindow()) { + throw new Error('Main window not found') } return new Promise((resolve, reject) => { @@ -94,7 +91,7 @@ export class PythonService { // Send request to renderer const request: PythonExecutionRequest = { id: requestId, script, context, timeout } - this.mainWindow?.webContents.send('python-execution-request', request) + windowService.getMainWindow()?.webContents.send('python-execution-request', request) }) } } diff --git a/src/main/services/ReduxService.ts b/src/main/services/ReduxService.ts index cdbaff42bf..2df15bf3eb 100644 --- a/src/main/services/ReduxService.ts +++ b/src/main/services/ReduxService.ts @@ -1,3 +1,7 @@ +/** + * @deprecated this file will be removed after v2 refactor + */ + import { loggerService } from '@logger' import { IpcChannel } from '@shared/IpcChannel' import { ipcMain } from 'electron' diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index a096dfcfd7..96dda36eb6 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1,6 +1,9 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig' import { isDev, isMac, isWin } from '@main/constant' +import type { SelectionActionItem } from '@shared/data/preference/preferenceTypes' +import { SelectionTriggerMode } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron' import { join } from 'path' @@ -12,10 +15,6 @@ import type { TextSelectionData } from 'selection-hook' -import type { ActionItem } from '../../renderer/src/types/selectionTypes' -import { ConfigKeys, configManager } from './ConfigManager' -import storeSyncService from './StoreSyncService' - const logger = loggerService.withContext('SelectionService') const isSupportedOS = isWin || isMac @@ -43,12 +42,6 @@ type RelativeOrientation = | 'middleRight' | 'center' -enum TriggerMode { - Selected = 'selected', - Ctrlkey = 'ctrlkey', - Shortcut = 'shortcut' -} - /** SelectionService is a singleton class that manages the selection hook and the toolbar window * * Features: @@ -71,12 +64,14 @@ export class SelectionService { private initStatus: boolean = false private started: boolean = false - private triggerMode = TriggerMode.Selected + private triggerMode = SelectionTriggerMode.Selected private isFollowToolbar = true private isRemeberWinSize = false private filterMode = 'default' private filterList: string[] = [] + private unsubscriberForChangeListeners: (() => void)[] = [] + private toolbarWindow: BrowserWindow | null = null private actionWindows = new Set() private preloadedActionWindows: BrowserWindow[] = [] @@ -144,12 +139,15 @@ export class SelectionService { * Ensures UI elements scale properly with system DPI settings */ private initZoomFactor(): void { - const zoomFactor = configManager.getZoomFactor() + const zoomFactor = preferenceService.get('app.zoom_factor') + if (zoomFactor) { this.setZoomFactor(zoomFactor) } - configManager.subscribe('ZoomFactor', this.setZoomFactor) + preferenceService.subscribeChange('app.zoom_factor', (zoomFactor: number) => { + this.setZoomFactor(zoomFactor) + }) } public setZoomFactor = (zoomFactor: number) => { @@ -157,51 +155,57 @@ export class SelectionService { } private initConfig(): void { - this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode - this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() - this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize() - this.filterMode = configManager.getSelectionAssistantFilterMode() - this.filterList = configManager.getSelectionAssistantFilterList() + this.triggerMode = preferenceService.get('feature.selection.trigger_mode') + this.isFollowToolbar = preferenceService.get('feature.selection.follow_toolbar') + this.isRemeberWinSize = preferenceService.get('feature.selection.remember_win_size') + this.filterMode = preferenceService.get('feature.selection.filter_mode') + this.filterList = preferenceService.get('feature.selection.filter_list') this.setHookGlobalFilterMode(this.filterMode, this.filterList) this.setHookFineTunedList() - configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: TriggerMode) => { - const oldTriggerMode = this.triggerMode + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.trigger_mode', (triggerMode: SelectionTriggerMode) => { + const oldTriggerMode = this.triggerMode - this.triggerMode = triggerMode - this.processTriggerMode() + this.triggerMode = triggerMode + this.processTriggerMode() - //trigger mode changed, need to update the filter list - if (oldTriggerMode !== triggerMode) { - this.setHookGlobalFilterMode(this.filterMode, this.filterList) - } - }) - - configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => { - this.isFollowToolbar = isFollowToolbar - }) - - configManager.subscribe(ConfigKeys.SelectionAssistantRemeberWinSize, (isRemeberWinSize: boolean) => { - this.isRemeberWinSize = isRemeberWinSize - //when off, reset the last action window size to default - if (!this.isRemeberWinSize) { - this.lastActionWindowSize = { - width: this.ACTION_WINDOW_WIDTH, - height: this.ACTION_WINDOW_HEIGHT + //trigger mode changed, need to update the filter list + if (oldTriggerMode !== triggerMode) { + this.setHookGlobalFilterMode(this.filterMode, this.filterList) } - } - }) - - configManager.subscribe(ConfigKeys.SelectionAssistantFilterMode, (filterMode: string) => { - this.filterMode = filterMode - this.setHookGlobalFilterMode(this.filterMode, this.filterList) - }) - - configManager.subscribe(ConfigKeys.SelectionAssistantFilterList, (filterList: string[]) => { - this.filterList = filterList - this.setHookGlobalFilterMode(this.filterMode, this.filterList) - }) + }) + ) + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.follow_toolbar', (followToolbar: boolean) => { + this.isFollowToolbar = followToolbar + }) + ) + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.remember_win_size', (rememberWinSize: boolean) => { + this.isRemeberWinSize = rememberWinSize + //when off, reset the last action window size to default + if (!this.isRemeberWinSize) { + this.lastActionWindowSize = { + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT + } + } + }) + ) + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.filter_mode', (filterMode: string) => { + this.filterMode = filterMode + this.setHookGlobalFilterMode(this.filterMode, this.filterList) + }) + ) + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.filter_list', (filterList: string[]) => { + this.filterList = filterList + this.setHookGlobalFilterMode(this.filterMode, this.filterList) + }) + ) } /** @@ -224,7 +228,7 @@ export class SelectionService { let combinedMode = mode //only the selected mode need to combine the predefined blacklist with the user-defined blacklist - if (this.triggerMode === TriggerMode.Selected) { + if (this.triggerMode === SelectionTriggerMode.Selected) { switch (mode) { case 'blacklist': //combine the predefined blacklist with the user-defined blacklist @@ -340,9 +344,13 @@ export class SelectionService { if (!this.selectionHook) return false this.selectionHook.stop() - this.selectionHook.cleanup() //already remove all listeners + for (const unsubscriber of this.unsubscriberForChangeListeners) { + unsubscriber() + } + this.unsubscriberForChangeListeners = [] + //reset the listener states this.isCtrlkeyListenerActive = false this.isHideByMouseKeyListenerActive = false @@ -381,12 +389,9 @@ export class SelectionService { public toggleEnabled(enabled: boolean | undefined = undefined): void { if (!this.selectionHook) return - const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled + const newEnabled = enabled === undefined ? !preferenceService.get('feature.selection.enabled') : enabled - configManager.setSelectionAssistantEnabled(newEnabled) - - //sync the new enabled state to all renderer windows - storeSyncService.syncToRenderer('selectionStore/setSelectionEnabled', newEnabled) + preferenceService.set('feature.selection.enabled', newEnabled) } /** @@ -743,7 +748,7 @@ export class SelectionService { * it's a public method used by shortcut service */ public processSelectTextByShortcut(): void { - if (!this.selectionHook || !this.started || this.triggerMode !== TriggerMode.Shortcut) return + if (!this.selectionHook || !this.started || this.triggerMode !== SelectionTriggerMode.Shortcut) return const selectionData = this.selectionHook.getCurrentSelection() @@ -1002,7 +1007,7 @@ export class SelectionService { */ private handleKeyDownHide = (data: KeyboardEventData) => { //dont hide toolbar when ctrlkey is pressed - if (this.triggerMode === TriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) { + if (this.triggerMode === SelectionTriggerMode.Ctrlkey && this.isCtrlkey(data.vkCode)) { return } //dont hide toolbar when shiftkey or altkey is pressed, because it's used for selection @@ -1121,7 +1126,7 @@ export class SelectionService { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, - sandbox: true, + sandbox: false, devTools: true } }) @@ -1236,7 +1241,7 @@ export class SelectionService { * @param actionItem Action item to process * @param isFullScreen [macOS] only macOS has the available isFullscreen mode */ - public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void { + public processAction(actionItem: SelectionActionItem, isFullScreen: boolean = false): void { const actionWindow = this.popActionWindow() actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) @@ -1402,7 +1407,7 @@ export class SelectionService { if (!this.selectionHook) return switch (this.triggerMode) { - case TriggerMode.Selected: + case SelectionTriggerMode.Selected: if (this.isCtrlkeyListenerActive) { this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode) this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode) @@ -1412,7 +1417,7 @@ export class SelectionService { this.selectionHook.setSelectionPassiveMode(false) break - case TriggerMode.Ctrlkey: + case SelectionTriggerMode.Ctrlkey: if (!this.isCtrlkeyListenerActive) { this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode) this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode) @@ -1422,7 +1427,7 @@ export class SelectionService { this.selectionHook.setSelectionPassiveMode(true) break - case TriggerMode.Shortcut: + case SelectionTriggerMode.Shortcut: //remove the ctrlkey listener, don't need any key listener for shortcut mode if (this.isCtrlkeyListenerActive) { this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode) @@ -1460,34 +1465,13 @@ export class SelectionService { selectionService?.determineToolbarSize(width, height) }) - ipcMain.handle(IpcChannel.Selection_SetEnabled, (_, enabled: boolean) => { - configManager.setSelectionAssistantEnabled(enabled) - }) - - ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: string) => { - configManager.setSelectionAssistantTriggerMode(triggerMode) - }) - - ipcMain.handle(IpcChannel.Selection_SetFollowToolbar, (_, isFollowToolbar: boolean) => { - configManager.setSelectionAssistantFollowToolbar(isFollowToolbar) - }) - - ipcMain.handle(IpcChannel.Selection_SetRemeberWinSize, (_, isRemeberWinSize: boolean) => { - configManager.setSelectionAssistantRemeberWinSize(isRemeberWinSize) - }) - - ipcMain.handle(IpcChannel.Selection_SetFilterMode, (_, filterMode: string) => { - configManager.setSelectionAssistantFilterMode(filterMode) - }) - - ipcMain.handle(IpcChannel.Selection_SetFilterList, (_, filterList: string[]) => { - configManager.setSelectionAssistantFilterList(filterList) - }) - // [macOS] only macOS has the available isFullscreen mode - ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => { - selectionService?.processAction(actionItem, isFullScreen) - }) + ipcMain.handle( + IpcChannel.Selection_ProcessAction, + (_, actionItem: SelectionActionItem, isFullScreen: boolean = false) => { + selectionService?.processAction(actionItem, isFullScreen) + } + ) ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { const actionWindow = BrowserWindow.fromWebContents(event.sender) @@ -1532,7 +1516,9 @@ export class SelectionService { export function initSelectionService(): boolean { if (!isSupportedOS) return false - configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => { + const enabled = preferenceService.get('feature.selection.enabled') + + preferenceService.subscribeChange('feature.selection.enabled', (enabled: boolean): void => { //avoid closure const ss = SelectionService.getInstance() if (!ss) { @@ -1547,7 +1533,7 @@ export function initSelectionService(): boolean { } }) - if (!configManager.getSelectionAssistantEnabled()) return false + if (!enabled) return false const ss = SelectionService.getInstance() if (!ss) { diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 97216e6a65..4f4e93256b 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,12 +1,13 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { handleZoomFactor } from '@main/utils/zoom' -import { Shortcut } from '@types' -import { BrowserWindow, globalShortcut } from 'electron' +import type { Shortcut } from '@types' +import type { BrowserWindow } from 'electron' +import { globalShortcut } from 'electron' import { configManager } from './ConfigManager' import selectionService from './SelectionService' import { windowService } from './WindowService' - const logger = loggerService.withContext('ShortcutService') let showAppAccelerator: string | null = null @@ -137,7 +138,7 @@ const convertShortcutFormat = (shortcut: string | string[]): string => { export function registerShortcuts(window: BrowserWindow) { if (isRegisterOnBoot) { window.once('ready-to-show', () => { - if (configManager.getLaunchToTray()) { + if (preferenceService.get('app.tray.on_launch')) { registerOnlyUniversalShortcuts() } }) @@ -190,7 +191,7 @@ export function registerShortcuts(window: BrowserWindow) { case 'mini_window': //available only when QuickAssistant enabled - if (!configManager.getEnableQuickAssistant()) { + if (!preferenceService.get('feature.quick_assistant.enabled')) { return } showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut) diff --git a/src/main/services/SpanCacheService.ts b/src/main/services/SpanCacheService.ts index 98ff36d298..9fe9c7d7f1 100644 --- a/src/main/services/SpanCacheService.ts +++ b/src/main/services/SpanCacheService.ts @@ -1,13 +1,14 @@ +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' -import { Attributes, convertSpanToSpanEntity, SpanEntity, TokenUsage, TraceCache } from '@mcp-trace/trace-core' +import type { Attributes, SpanEntity, TokenUsage, TraceCache } from '@mcp-trace/trace-core' +import { convertSpanToSpanEntity } from '@mcp-trace/trace-core' import { SpanStatusCode } from '@opentelemetry/api' -import { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import fs from 'fs/promises' import * as os from 'os' import * as path from 'path' -import { configManager } from './ConfigManager' - const logger = loggerService.withContext('SpanCacheService') class SpanCacheService implements TraceCache { @@ -17,11 +18,11 @@ class SpanCacheService implements TraceCache { pri constructor() { - this.fileDir = path.join(os.homedir(), '.cherrystudio', 'trace') + this.fileDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'trace') } createSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => { - if (!configManager.getEnableDeveloperMode()) { + if (!preferenceService.get('app.developer_mode.enabled')) { return } const spanEntity = convertSpanToSpanEntity(span) @@ -31,7 +32,7 @@ class SpanCacheService implements TraceCache { } endSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => { - if (!configManager.getEnableDeveloperMode()) { + if (!preferenceService.get('app.developer_mode.enabled')) { return } const spanId = span.spanContext().spanId @@ -87,7 +88,7 @@ class SpanCacheService implements TraceCache { } async saveSpans(topicId: string) { - if (!configManager.getEnableDeveloperMode()) { + if (!preferenceService.get('app.developer_mode.enabled')) { return } let traceId: string | undefined @@ -138,7 +139,7 @@ class SpanCacheService implements TraceCache { } saveEntity(entity: SpanEntity) { - if (!configManager.getEnableDeveloperMode()) { + if (!preferenceService.get('app.developer_mode.enabled')) { return } if (this.cache.has(entity.id)) { diff --git a/src/main/services/ThemeService.ts b/src/main/services/ThemeService.ts index a56b559357..9213192dea 100644 --- a/src/main/services/ThemeService.ts +++ b/src/main/services/ThemeService.ts @@ -1,23 +1,28 @@ +import { preferenceService } from '@data/PreferenceService' +import { ThemeMode } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' -import { ThemeMode } from '@types' import { BrowserWindow, nativeTheme } from 'electron' import { titleBarOverlayDark, titleBarOverlayLight } from '../config' -import { configManager } from './ConfigManager' class ThemeService { private theme: ThemeMode = ThemeMode.system constructor() { - this.theme = configManager.getTheme() + this.theme = preferenceService.get('ui.theme_mode') if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) { nativeTheme.themeSource = this.theme } else { // 兼容旧版本 - configManager.setTheme(ThemeMode.system) + preferenceService.set('ui.theme_mode', ThemeMode.system) nativeTheme.themeSource = ThemeMode.system } nativeTheme.on('updated', this.themeUpdatadHandler.bind(this)) + + preferenceService.subscribeChange('ui.theme_mode', (newTheme) => { + this.theme = newTheme + nativeTheme.themeSource = newTheme + }) } themeUpdatadHandler() { @@ -30,19 +35,12 @@ class ThemeService { // Because it may be called with some windows have some title bar } } - win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light) + win.webContents.send( + IpcChannel.NativeThemeUpdated, + nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light + ) }) } - - setTheme(theme: ThemeMode) { - if (theme === this.theme) { - return - } - - this.theme = theme - nativeTheme.themeSource = theme - configManager.setTheme(theme) - } } export const themeService = new ThemeService() diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index 205d7fdee9..ccabfe2ca0 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -1,14 +1,14 @@ +import { preferenceService } from '@data/PreferenceService' import { isLinux, isMac, isWin } from '@main/constant' -import { locales } from '@main/utils/locales' -import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron' +import { getI18n } from '@main/utils/language' +import type { MenuItemConstructorOptions } from 'electron' +import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron' import icon from '../../../build/tray_icon.png?asset' import iconDark from '../../../build/tray_icon_dark.png?asset' import iconLight from '../../../build/tray_icon_light.png?asset' -import { ConfigKeys, configManager } from './ConfigManager' import selectionService from './SelectionService' import { windowService } from './WindowService' - export class TrayService { private static instance: TrayService private tray: Tray | null = null @@ -60,7 +60,10 @@ export class TrayService { }) this.tray.on('click', () => { - if (configManager.getEnableQuickAssistant() && configManager.getClickTrayToShowQuickAssistant()) { + const quickAssistantEnabled = preferenceService.get('feature.quick_assistant.enabled') + const clickTrayToShowQuickAssistant = preferenceService.get('feature.quick_assistant.click_tray_to_show') + + if (quickAssistantEnabled && clickTrayToShowQuickAssistant) { windowService.showMiniWindow() } else { windowService.showMainWindow() @@ -69,11 +72,11 @@ export class TrayService { } private updateContextMenu() { - const locale = locales[configManager.getLanguage()] - const { tray: trayLocale, selection: selectionLocale } = locale.translation + const i18n = getI18n() + const { tray: trayLocale, selection: selectionLocale } = i18n.translation - const quickAssistantEnabled = configManager.getEnableQuickAssistant() - const selectionAssistantEnabled = configManager.getSelectionAssistantEnabled() + const quickAssistantEnabled = preferenceService.get('feature.quick_assistant.enabled') + const selectionAssistantEnabled = preferenceService.get('feature.selection.enabled') const template = [ { @@ -104,7 +107,7 @@ export class TrayService { } private updateTray() { - const showTray = configManager.getTray() + const showTray = preferenceService.get('app.tray.enabled') if (showTray) { this.createTray() } else { @@ -120,19 +123,10 @@ export class TrayService { } private watchConfigChanges() { - configManager.subscribe(ConfigKeys.Tray, () => this.updateTray()) - - configManager.subscribe(ConfigKeys.Language, () => { - this.updateContextMenu() - }) - - configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => { - this.updateContextMenu() - }) - - configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, () => { - this.updateContextMenu() - }) + preferenceService.subscribeChange('app.tray.enabled', () => this.updateTray()) + preferenceService.subscribeChange('app.language', () => this.updateContextMenu()) + preferenceService.subscribeChange('feature.quick_assistant.enabled', () => this.updateContextMenu()) + preferenceService.subscribeChange('feature.selection.enabled', () => this.updateContextMenu()) } private quit() { diff --git a/src/main/services/VersionService.ts b/src/main/services/VersionService.ts new file mode 100644 index 0000000000..a853b99074 --- /dev/null +++ b/src/main/services/VersionService.ts @@ -0,0 +1,285 @@ +import { loggerService } from '@logger' +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +const logger = loggerService.withContext('VersionService') + +type OS = 'win' | 'mac' | 'linux' | 'unknown' +type Environment = 'prod' | 'dev' +type Packaged = 'packaged' | 'unpackaged' +type Mode = 'install' | 'portable' + +/** + * Version record stored in version.log + */ +interface VersionRecord { + version: string + os: OS + environment: Environment + packaged: Packaged + mode: Mode + timestamp: string +} + +/** + * Service for tracking application version history + * Stores version information in userData/version.log for data migration and diagnostics + */ +class VersionService { + private readonly VERSION_LOG_FILE = 'version.log' + private versionLogPath: string | null = null + + constructor() { + // Lazy initialization of path since app.getPath may not be available during construction + } + + /** + * Gets the full path to version.log file + * @returns {string} Full path to version log file + */ + private getVersionLogPath(): string { + if (!this.versionLogPath) { + this.versionLogPath = path.join(app.getPath('userData'), this.VERSION_LOG_FILE) + } + return this.versionLogPath + } + + /** + * Gets current operating system identifier + * @returns {OS} OS identifier + */ + private getCurrentOS(): OS { + switch (process.platform) { + case 'win32': + return 'win' + case 'darwin': + return 'mac' + case 'linux': + return 'linux' + default: + return 'unknown' + } + } + + /** + * Gets current environment (production or development) + * @returns {Environment} Environment identifier + */ + private getCurrentEnvironment(): Environment { + return import.meta.env.MODE === 'production' ? 'prod' : 'dev' + } + + /** + * Gets packaging status + * @returns {Packaged} Packaging status + */ + private getPackagedStatus(): Packaged { + return app.isPackaged ? 'packaged' : 'unpackaged' + } + + /** + * Gets installation mode (install or portable) + * @returns {Mode} Installation mode + */ + private getInstallMode(): Mode { + return process.env.PORTABLE_EXECUTABLE_DIR !== undefined ? 'portable' : 'install' + } + + /** + * Generates version log line for current application state + * @returns {string} Pipe-separated version record line + */ + private generateCurrentVersionLine(): string { + const version = app.getVersion() + const os = this.getCurrentOS() + const environment = this.getCurrentEnvironment() + const packaged = this.getPackagedStatus() + const mode = this.getInstallMode() + const timestamp = new Date().toISOString() + + return `${version}|${os}|${environment}|${packaged}|${mode}|${timestamp}` + } + + /** + * Parses a version log line into a VersionRecord object + * @param {string} line - Pipe-separated version record line + * @returns {VersionRecord | null} Parsed version record or null if invalid + */ + private parseVersionLine(line: string): VersionRecord | null { + try { + const parts = line.trim().split('|') + if (parts.length !== 6) { + return null + } + + const [version, os, environment, packaged, mode, timestamp] = parts + + // Validate data + if ( + !version || + !['win', 'mac', 'linux', 'unknown'].includes(os) || + !['prod', 'dev'].includes(environment) || + !['packaged', 'unpackaged'].includes(packaged) || + !['install', 'portable'].includes(mode) || + !timestamp + ) { + return null + } + + return { + version, + os: os as OS, + environment: environment as Environment, + packaged: packaged as Packaged, + mode: mode as Mode, + timestamp + } + } catch (error) { + logger.warn(`Failed to parse version line: ${line}`, error as Error) + return null + } + } + + /** + * Reads the last 1KB from version.log and returns all lines + * Uses reverse reading from file end to avoid reading the entire file + * @returns {string[]} Array of version lines from the last 1KB + */ + private readLastVersionLines(): string[] { + const logPath = this.getVersionLogPath() + + try { + if (!fs.existsSync(logPath)) { + return [] + } + + const stats = fs.statSync(logPath) + const fileSize = stats.size + + if (fileSize === 0) { + return [] + } + + // Read from the end of the file, 1KB is enough to find previous version + // Typical line: "1.7.0-beta.3|win|prod|packaged|install|2025-01-15T08:30:00.000Z\n" (~70 bytes) + // 1KB can store ~14 lines, which is more than enough + const bufferSize = Math.min(1024, fileSize) + const buffer = Buffer.alloc(bufferSize) + + const fd = fs.openSync(logPath, 'r') + try { + const startPosition = Math.max(0, fileSize - bufferSize) + fs.readSync(fd, buffer, 0, bufferSize, startPosition) + + const content = buffer.toString('utf-8') + const lines = content + .trim() + .split('\n') + .filter((line) => line.trim()) + + return lines + } finally { + fs.closeSync(fd) + } + } catch (error) { + logger.error('Failed to read version log:', error as Error) + return [] + } + } + + /** + * Appends a version record line to version.log + * @param {string} line - Version record line to append + */ + private appendVersionLine(line: string): void { + const logPath = this.getVersionLogPath() + + try { + fs.appendFileSync(logPath, line + '\n', 'utf-8') + logger.debug(`Version recorded: ${line}`) + } catch (error) { + logger.error('Failed to append version log:', error as Error) + } + } + + /** + * Records the current version on application startup + * Only adds a new record if the version has changed since the last run + */ + recordCurrentVersion(): void { + try { + const currentLine = this.generateCurrentVersionLine() + const lines = this.readLastVersionLines() + + // Add new record if this is the first run or version has changed + if (lines.length === 0) { + logger.info('First run detected, creating version log') + this.appendVersionLine(currentLine) + return + } + + const lastLine = lines[lines.length - 1] + const lastRecord = this.parseVersionLine(lastLine) + const currentVersion = app.getVersion() + + // Check if any meaningful field has changed (version, os, environment, packaged, mode) + const currentOS = this.getCurrentOS() + const currentEnvironment = this.getCurrentEnvironment() + const currentPackaged = this.getPackagedStatus() + const currentMode = this.getInstallMode() + + const hasMeaningfulChange = + !lastRecord || + lastRecord.version !== currentVersion || + lastRecord.os !== currentOS || + lastRecord.environment !== currentEnvironment || + lastRecord.packaged !== currentPackaged || + lastRecord.mode !== currentMode + + if (hasMeaningfulChange) { + logger.info(`Version information changed, recording new entry`) + this.appendVersionLine(currentLine) + } else { + logger.debug(`Version information not changed, skip recording`) + } + } catch (error) { + logger.error('Failed to record current version:', error as Error) + } + } + + /** + * Gets the previous version record (last record with different version than current) + * Reads from the last 1KB of version.log to find the most recent different version + * Useful for detecting version upgrades and running migrations + * @returns {VersionRecord | null} Previous version record or null if not available + */ + getPreviousVersion(): VersionRecord | null { + try { + const lines = this.readLastVersionLines() + if (lines.length === 0) { + return null + } + + const currentVersion = app.getVersion() + + // Read from the end backwards to find the first different version + for (let i = lines.length - 1; i >= 0; i--) { + const record = this.parseVersionLine(lines[i]) + if (record && record.version !== currentVersion) { + return record + } + } + + return null + } catch (error) { + logger.error('Failed to get previous version:', error as Error) + return null + } + } +} + +/** + * Singleton instance of VersionService + */ +export const versionService = new VersionService() diff --git a/src/main/services/WebDav.ts b/src/main/services/WebDav.ts index 11a2d7ebfb..150227f98c 100644 --- a/src/main/services/WebDav.ts +++ b/src/main/services/WebDav.ts @@ -1,16 +1,16 @@ import { loggerService } from '@logger' -import { WebDavConfig } from '@types' +import type { WebDavConfig } from '@types' import https from 'https' import path from 'path' -import Stream from 'stream' -import { +import type Stream from 'stream' +import type { BufferLike, - createClient, CreateDirectoryOptions, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav' +import { createClient } from 'webdav' const logger = loggerService.withContext('WebDav') diff --git a/src/main/services/WebSocketService.ts b/src/main/services/WebSocketService.ts new file mode 100644 index 0000000000..e52919e96a --- /dev/null +++ b/src/main/services/WebSocketService.ts @@ -0,0 +1,359 @@ +import { loggerService } from '@logger' +import type { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types' +import * as fs from 'fs' +import { networkInterfaces } from 'os' +import * as path from 'path' +import type { Socket } from 'socket.io' +import { Server } from 'socket.io' + +import { windowService } from './WindowService' + +const logger = loggerService.withContext('WebSocketService') + +class WebSocketService { + private io: Server | null = null + private isStarted = false + private port = 7017 + private connectedClients = new Set() + + private getLocalIpAddress(): string | undefined { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ interface: string; address: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + interface: name, + address: iface.address, + priority + }) + } + } + } + + if (candidates.length === 0) { + logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1') + return '127.0.0.1' + } + + // 按优先级排序,选择优先级最高的 + candidates.sort((a, b) => a.priority - b.priority) + const best = candidates[0] + + logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`) + return best.address + } + + public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => { + if (this.isStarted && this.io) { + return { success: true, port: this.port } + } + + try { + this.io = new Server(this.port, { + cors: { + origin: '*', + methods: ['GET', 'POST'] + }, + transports: ['websocket', 'polling'], + allowEIO3: true, + pingTimeout: 60000, + pingInterval: 25000 + }) + + this.io.on('connection', (socket: Socket) => { + this.connectedClients.add(socket.id) + + const mainWindow = windowService.getMainWindow() + if (!mainWindow) { + logger.error('Main window is null, cannot send connection event') + } else { + mainWindow.webContents.send('websocket-client-connected', { + connected: true, + clientId: socket.id + }) + logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`) + } + + socket.on('message', (data) => { + logger.info('Received message from mobile:', data) + mainWindow?.webContents.send('websocket-message-received', data) + socket.emit('message_received', { success: true }) + }) + + socket.on('disconnect', () => { + logger.info(`Client disconnected: ${socket.id}`) + this.connectedClients.delete(socket.id) + + if (this.connectedClients.size === 0) { + mainWindow?.webContents.send('websocket-client-connected', { + connected: false, + clientId: socket.id + }) + } + }) + }) + + // Engine 层面的事件监听 + this.io.engine.on('connection_error', (err) => { + logger.error('Engine connection error:', err) + }) + + this.io.engine.on('connection', (rawSocket) => { + const remoteAddr = rawSocket.request.connection.remoteAddress + logger.info(`[Engine] Raw connection from: ${remoteAddr}`) + logger.info(`[Engine] Transport: ${rawSocket.transport.name}`) + + rawSocket.on('packet', (packet: { type: string; data?: any }) => { + logger.info( + `[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`, + packet.data ? { data: packet.data } : {} + ) + }) + + rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => { + logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`) + }) + + rawSocket.on('close', (reason: string) => { + logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`) + }) + + rawSocket.on('error', (error: Error) => { + logger.error(`[Engine] Connection error from ${remoteAddr}:`, error) + }) + }) + + // Socket.IO 握手失败监听 + this.io.on('connection_error', (err) => { + logger.error('[Socket.IO] Connection error during handshake:', err) + }) + + this.isStarted = true + logger.info(`WebSocket server started on port ${this.port}`) + + return { success: true, port: this.port } + } catch (error) { + logger.error('Failed to start WebSocket server:', error as Error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + public stop = async (): Promise<{ success: boolean }> => { + if (!this.isStarted || !this.io) { + return { success: true } + } + + try { + await new Promise((resolve) => { + this.io!.close(() => { + resolve() + }) + }) + + this.io = null + this.isStarted = false + this.connectedClients.clear() + logger.info('WebSocket server stopped') + + return { success: true } + } catch (error) { + logger.error('Failed to stop WebSocket server:', error as Error) + return { success: false } + } + } + + public getStatus = async (): Promise => { + return { + isRunning: this.isStarted, + port: this.isStarted ? this.port : undefined, + ip: this.isStarted ? this.getLocalIpAddress() : undefined, + clientConnected: this.connectedClients.size > 0 + } + } + + public getAllCandidates = async (): Promise => { + const interfaces = networkInterfaces() + + // 按优先级排序的网络接口名称模式 + const interfacePriority = [ + // macOS: 以太网/Wi-Fi 优先 + /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) + /^(en|eth)[0-9]+$/, // 以太网接口 + /^wlan[0-9]+$/, // 无线接口 + // Windows: 以太网/Wi-Fi 优先 + /^(Ethernet|Wi-Fi|Local Area Connection)/, + /^(Wi-Fi|无线网络连接)/, + // Linux: 以太网/Wi-Fi 优先 + /^(eth|enp|wlp|wlan)[0-9]+/, + // 虚拟化接口(低优先级) + /^bridge[0-9]+$/, // Docker bridge + /^veth[0-9]+$/, // Docker veth + /^docker[0-9]+/, // Docker interfaces + /^br-[0-9a-f]+/, // Docker bridge + /^vmnet[0-9]+$/, // VMware + /^vboxnet[0-9]+$/, // VirtualBox + // VPN 隧道接口(低优先级) + /^utun[0-9]+$/, // macOS VPN + /^tun[0-9]+$/, // Linux/Unix VPN + /^tap[0-9]+$/, // TAP interfaces + /^tailscale[0-9]*$/, // Tailscale VPN + /^wg[0-9]+$/ // WireGuard VPN + ] + + const candidates: Array<{ host: string; interface: string; priority: number }> = [] + + for (const [name, ifaces] of Object.entries(interfaces)) { + for (const iface of ifaces || []) { + if (iface.family === 'IPv4' && !iface.internal) { + // 计算接口优先级 + let priority = 999 // 默认最低优先级 + for (let i = 0; i < interfacePriority.length; i++) { + if (interfacePriority[i].test(name)) { + priority = i + break + } + } + + candidates.push({ + host: iface.address, + interface: name, + priority + }) + + logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`) + } + } + } + + // 按优先级排序返回 + candidates.sort((a, b) => a.priority - b.priority) + logger.info( + `Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}` + ) + return candidates + } + + public sendFile = async ( + _: Electron.IpcMainInvokeEvent, + filePath: string + ): Promise<{ success: boolean; error?: string }> => { + if (!this.isStarted || !this.io) { + const errorMsg = 'WebSocket server is not running.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + if (this.connectedClients.size === 0) { + const errorMsg = 'No client connected.' + logger.error(errorMsg) + return { success: false, error: errorMsg } + } + + const mainWindow = windowService.getMainWindow() + + return new Promise((resolve, reject) => { + const stats = fs.statSync(filePath) + const totalSize = stats.size + const filename = path.basename(filePath) + const stream = fs.createReadStream(filePath) + let bytesSent = 0 + const startTime = Date.now() + + logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`) + + // 向客户端发送文件开始的信号,包含文件名和总大小 + this.io!.emit('zip-file-start', { filename, totalSize }) + + stream.on('data', (chunk) => { + bytesSent += chunk.length + const progress = (bytesSent / totalSize) * 100 + + // 向客户端发送文件块 + this.io!.emit('zip-file-chunk', chunk) + + // 向渲染进程发送进度更新 + mainWindow?.webContents.send('file-send-progress', { progress }) + + // 每10%记录一次进度 + if (Math.floor(progress) % 10 === 0) { + const elapsed = (Date.now() - startTime) / 1000 + const speed = elapsed > 0 ? bytesSent / elapsed : 0 + logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`) + } + }) + + stream.on('end', () => { + const totalTime = (Date.now() - startTime) / 1000 + const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0 + logger.info( + `File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)` + ) + + // 确保发送100%的进度 + mainWindow?.webContents.send('file-send-progress', { progress: 100 }) + // 向客户端发送文件结束的信号 + this.io!.emit('zip-file-end') + resolve({ success: true }) + }) + + stream.on('error', (error) => { + logger.error(`File transfer failed: ${filename}`, error) + reject({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }) + }) + }) + } + + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } +} + +export default new WebSocketService() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 66aed098e7..c21d336d5c 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -1,6 +1,7 @@ // just import the themeService to ensure the theme is initialized import './ThemeService' +import { preferenceService } from '@data/PreferenceService' import { is } from '@electron-toolkit/utils' import { loggerService } from '@logger' import { isDev, isLinux, isMac, isWin } from '@main/constant' @@ -13,7 +14,6 @@ import { join } from 'path' import icon from '../../../build/icon.png?asset' import { titleBarOverlayDark, titleBarOverlayLight } from '../config' -import { configManager } from './ConfigManager' import { contextMenu } from './ContextMenu' import { initSessionUserAgent } from './WebviewService' @@ -86,7 +86,7 @@ export class WindowService { webSecurity: false, webviewTag: true, allowRunningInsecureContent: true, - zoomFactor: configManager.getZoomFactor(), + zoomFactor: preferenceService.get('app.zoom_factor'), backgroundThrottling: false } }) @@ -94,7 +94,7 @@ export class WindowService { this.setupMainWindow(this.mainWindow, mainWindowState) //preload miniWindow to resolve series of issues about miniWindow in Mac - const enableQuickAssistant = configManager.getEnableQuickAssistant() + const enableQuickAssistant = preferenceService.get('feature.quick_assistant.enabled') if (enableQuickAssistant && !this.miniWindow) { this.miniWindow = this.createMiniWindow(true) } @@ -119,10 +119,10 @@ export class WindowService { } private setupSpellCheck(mainWindow: BrowserWindow) { - const enableSpellCheck = configManager.get('enableSpellCheck', false) + const enableSpellCheck = preferenceService.get('app.spell_check.enabled') if (enableSpellCheck) { try { - const spellCheckLanguages = configManager.get('spellCheckLanguages', []) as string[] + const spellCheckLanguages = preferenceService.get('app.spell_check.languages') spellCheckLanguages.length > 0 && mainWindow.webContents.session.setSpellCheckerLanguages(spellCheckLanguages) } catch (error) { logger.error('Failed to set spell check languages:', error as Error) @@ -149,7 +149,7 @@ export class WindowService { private setupMaximize(mainWindow: BrowserWindow, isMaximized: boolean) { if (isMaximized) { // 如果是从托盘启动,则需要延迟最大化,否则显示的就不是重启前的最大化窗口了 - configManager.getLaunchToTray() + preferenceService.get('app.tray.on_launch') ? mainWindow.once('show', () => { mainWindow.maximize() }) @@ -174,10 +174,10 @@ export class WindowService { private setupWindowEvents(mainWindow: BrowserWindow) { mainWindow.once('ready-to-show', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) // show window only when laucn to tray not set - const isLaunchToTray = configManager.getLaunchToTray() + const isLaunchToTray = preferenceService.get('app.tray.on_launch') if (!isLaunchToTray) { //[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared app.dock?.show() @@ -203,14 +203,14 @@ export class WindowService { // and resize ipc // mainWindow.on('will-resize', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize()) }) // set the zoom factor again when the window is going to restore // minimize and restore will cause zoom reset mainWindow.on('restore', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) }) // ARCH: as `will-resize` is only for Win & Mac, @@ -218,7 +218,7 @@ export class WindowService { // but `resize` will fliker the ui if (isLinux) { mainWindow.on('resize', () => { - mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + mainWindow.webContents.setZoomFactor(preferenceService.get('app.zoom_factor')) mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize()) }) } @@ -351,8 +351,8 @@ export class WindowService { } // 托盘及关闭行为设置 - const isShowTray = configManager.getTray() - const isTrayOnClose = configManager.getTrayOnClose() + const isShowTray = preferenceService.get('app.tray.enabled') + const isTrayOnClose = preferenceService.get('app.tray.on_close') // 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出 if (!isShowTray || (isShowTray && !isTrayOnClose)) { @@ -375,13 +375,16 @@ export class WindowService { mainWindow.hide() - // TODO: don't hide dock icon when close to tray - // will cause the cmd+h behavior not working - // after the electron fix the bug, we can restore this code - // //for mac users, should hide dock icon if close to tray - // if (isMac && isTrayOnClose) { - // app.dock?.hide() - // } + //for mac users, should hide dock icon if close to tray + if (isMac && isTrayOnClose) { + app.dock?.hide() + + mainWindow.once('show', () => { + //restore the window can hide by cmd+h when the window is shown again + // https://github.com/electron/electron/pull/47970 + app.dock?.show() + }) + } }) mainWindow.on('closed', () => { @@ -453,7 +456,7 @@ export class WindowService { if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) { if (this.mainWindow.isFocused()) { // if tray is enabled, hide the main window, else do nothing - if (configManager.getTray()) { + if (preferenceService.get('app.tray.on_close')) { this.mainWindow.hide() app.dock?.hide() } @@ -556,7 +559,7 @@ export class WindowService { } public showMiniWindow() { - const enableQuickAssistant = configManager.getEnableQuickAssistant() + const enableQuickAssistant = preferenceService.get('feature.quick_assistant.enabled') if (!enableQuickAssistant) { return diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index 4b3ac70d45..babc76ca81 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -1,4 +1,4 @@ -import { UpdateInfo } from 'builder-util-runtime' +import type { UpdateInfo } from 'builder-util-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' // Mock dependencies @@ -12,15 +12,11 @@ vi.mock('@logger', () => ({ } })) -vi.mock('../ConfigManager', () => ({ - configManager: { - getLanguage: vi.fn(), - getAutoUpdate: vi.fn(() => false), - getTestPlan: vi.fn(() => false), - getTestChannel: vi.fn(), - getClientId: vi.fn(() => 'test-client-id') - } -})) +// Mock PreferenceService using the existing mock +vi.mock('@data/PreferenceService', async () => { + const { MockMainPreferenceServiceExport } = await import('../../../../tests/__mocks__/main/PreferenceService') + return MockMainPreferenceServiceExport +}) vi.mock('../WindowService', () => ({ windowService: { @@ -44,7 +40,8 @@ vi.mock('@main/utils/locales', () => ({ })) vi.mock('@main/utils/systemInfo', () => ({ - generateUserAgent: vi.fn(() => 'test-user-agent') + generateUserAgent: vi.fn(() => 'test-user-agent'), + getClientId: vi.fn(() => 'test-client-id') })) vi.mock('electron', () => ({ @@ -85,14 +82,26 @@ vi.mock('electron-updater', () => ({ })) // Import after mocks +import { preferenceService } from '@data/PreferenceService' +import { UpdateMirror } from '@shared/config/constant' +import { app, net } from 'electron' + +import { MockMainPreferenceServiceUtils } from '../../../../tests/__mocks__/main/PreferenceService' import AppUpdater from '../AppUpdater' -import { configManager } from '../ConfigManager' + +// Mock clientId for ConfigManager since it's not migrated yet +vi.mock('../ConfigManager', () => ({ + configManager: { + getClientId: vi.fn(() => 'test-client-id') + } +})) describe('AppUpdater', () => { let appUpdater: AppUpdater beforeEach(() => { vi.clearAllMocks() + MockMainPreferenceServiceUtils.resetMocks() appUpdater = new AppUpdater() }) @@ -114,7 +123,7 @@ describe('AppUpdater', () => { ` it('should return Chinese notes for zh-CN users', () => { - vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN') + MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'zh-CN') const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) @@ -124,7 +133,7 @@ describe('AppUpdater', () => { }) it('should return Chinese notes for zh-TW users', () => { - vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW') + MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'zh-TW') const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) @@ -134,7 +143,7 @@ describe('AppUpdater', () => { }) it('should return English notes for non-Chinese users', () => { - vi.mocked(configManager.getLanguage).mockReturnValue('en-US') + MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'en-US') const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) @@ -144,7 +153,7 @@ describe('AppUpdater', () => { }) it('should return English notes for other language users', () => { - vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU') + MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'ru-RU') const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) @@ -162,7 +171,7 @@ describe('AppUpdater', () => { it('should handle malformed markers', () => { const malformedNotes = `English only` - vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN') + MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'zh-CN') const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes) @@ -178,12 +187,15 @@ describe('AppUpdater', () => { }) it('should handle errors gracefully', () => { - // Force an error by mocking configManager to throw - vi.mocked(configManager.getLanguage).mockImplementation(() => { + // Create a fresh instance for this test to avoid issues with constructor mocking + const testAppUpdater = new AppUpdater() + + // Force an error by mocking preferenceService to throw + vi.mocked(preferenceService.get).mockImplementationOnce(() => { throw new Error('Test error') }) - const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) + const result = (testAppUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes) // Should return original notes as fallback expect(result).toBe(sampleReleaseNotes) @@ -210,7 +222,7 @@ describe('AppUpdater', () => { describe('processReleaseInfo', () => { it('should process multi-language release notes in string format', () => { - vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN') + MockMainPreferenceServiceUtils.setPreferenceValue('app.language', 'zh-CN') const releaseInfo = { version: '1.0.0', @@ -274,4 +286,711 @@ describe('AppUpdater', () => { expect(result.releaseNotes).toBeNull() }) }) + + describe('_fetchUpdateConfig', () => { + const mockConfig = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.6.7': { + minCompatibleVersion: '1.0.0', + description: 'Test version', + channels: { + latest: { + version: '1.6.7', + feedUrls: { + github: 'https://github.com/test/v1.6.7', + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }, + rc: null, + beta: null + } + } + } + } + + it('should fetch config from GitHub mirror', async () => { + vi.mocked(net.fetch).mockResolvedValue({ + ok: true, + json: async () => mockConfig + } as any) + + const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB) + + expect(result).toEqual(mockConfig) + expect(net.fetch).toHaveBeenCalledWith(expect.stringContaining('github'), expect.any(Object)) + }) + + it('should fetch config from GitCode mirror', async () => { + vi.mocked(net.fetch).mockResolvedValue({ + ok: true, + json: async () => mockConfig + } as any) + + const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITCODE) + + expect(result).toEqual(mockConfig) + // GitCode URL may vary, just check that fetch was called + expect(net.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object)) + }) + + it('should return null on HTTP error', async () => { + vi.mocked(net.fetch).mockResolvedValue({ + ok: false, + status: 404 + } as any) + + const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB) + + expect(result).toBeNull() + }) + + it('should return null on network error', async () => { + vi.mocked(net.fetch).mockRejectedValue(new Error('Network error')) + + const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB) + + expect(result).toBeNull() + }) + }) + + describe('_findCompatibleChannel', () => { + const mockConfig = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.6.7': { + minCompatibleVersion: '1.0.0', + description: 'v1.6.7', + channels: { + latest: { + version: '1.6.7', + feedUrls: { + github: 'https://github.com/test/v1.6.7', + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }, + rc: { + version: '1.7.0-rc.1', + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }, + beta: { + version: '1.7.0-beta.3', + feedUrls: { + github: 'https://github.com/test/v1.7.0-beta.3', + gitcode: 'https://gitcode.com/test/v1.7.0-beta.3' + } + } + } + }, + '2.0.0': { + minCompatibleVersion: '1.7.0', + description: 'v2.0.0', + channels: { + latest: null, + rc: null, + beta: null + } + } + } + } + + it('should find compatible latest channel', () => { + vi.mocked(app.getVersion).mockReturnValue('1.5.0') + + const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'latest', mockConfig) + + expect(result?.config).toEqual({ + version: '1.6.7', + feedUrls: { + github: 'https://github.com/test/v1.6.7', + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }) + expect(result?.channel).toBe('latest') + }) + + it('should find compatible rc channel', () => { + vi.mocked(app.getVersion).mockReturnValue('1.5.0') + + const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', mockConfig) + + expect(result?.config).toEqual({ + version: '1.7.0-rc.1', + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }) + expect(result?.channel).toBe('rc') + }) + + it('should find compatible beta channel', () => { + vi.mocked(app.getVersion).mockReturnValue('1.5.0') + + const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'beta', mockConfig) + + expect(result?.config).toEqual({ + version: '1.7.0-beta.3', + feedUrls: { + github: 'https://github.com/test/v1.7.0-beta.3', + gitcode: 'https://gitcode.com/test/v1.7.0-beta.3' + } + }) + expect(result?.channel).toBe('beta') + }) + + it('should return latest when latest version >= rc version', () => { + const configWithNewerLatest = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.7.0': { + minCompatibleVersion: '1.0.0', + description: 'v1.7.0', + channels: { + latest: { + version: '1.7.0', + feedUrls: { + github: 'https://github.com/test/v1.7.0', + gitcode: 'https://gitcode.com/test/v1.7.0' + } + }, + rc: { + version: '1.7.0-rc.1', + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }, + beta: null + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerLatest) + + // Should return latest instead of rc because 1.7.0 >= 1.7.0-rc.1 + expect(result?.config).toEqual({ + version: '1.7.0', + feedUrls: { + github: 'https://github.com/test/v1.7.0', + gitcode: 'https://gitcode.com/test/v1.7.0' + } + }) + expect(result?.channel).toBe('latest') // ✅ 返回 latest 频道 + }) + + it('should return latest when latest version >= beta version', () => { + const configWithNewerLatest = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.7.0': { + minCompatibleVersion: '1.0.0', + description: 'v1.7.0', + channels: { + latest: { + version: '1.7.0', + + feedUrls: { + github: 'https://github.com/test/v1.7.0', + + gitcode: 'https://gitcode.com/test/v1.7.0' + } + }, + rc: null, + beta: { + version: '1.6.8-beta.1', + + feedUrls: { + github: 'https://github.com/test/v1.6.8-beta.1', + + gitcode: 'https://gitcode.com/test/v1.6.8-beta.1' + } + } + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerLatest) + + // Should return latest instead of beta because 1.7.0 >= 1.6.8-beta.1 + expect(result?.config).toEqual({ + version: '1.7.0', + + feedUrls: { + github: 'https://github.com/test/v1.7.0', + + gitcode: 'https://gitcode.com/test/v1.7.0' + } + }) + }) + + it('should not compare latest with itself when requesting latest channel', () => { + const config = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.7.0': { + minCompatibleVersion: '1.0.0', + description: 'v1.7.0', + channels: { + latest: { + version: '1.7.0', + + feedUrls: { + github: 'https://github.com/test/v1.7.0', + + gitcode: 'https://gitcode.com/test/v1.7.0' + } + }, + rc: { + version: '1.7.0-rc.1', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }, + beta: null + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'latest', config) + + // Should return latest directly without comparing with itself + expect(result?.config).toEqual({ + version: '1.7.0', + + feedUrls: { + github: 'https://github.com/test/v1.7.0', + + gitcode: 'https://gitcode.com/test/v1.7.0' + } + }) + }) + + it('should return rc when rc version > latest version', () => { + const configWithNewerRc = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.7.0': { + minCompatibleVersion: '1.0.0', + description: 'v1.7.0', + channels: { + latest: { + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }, + rc: { + version: '1.7.0-rc.1', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }, + beta: null + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerRc) + + // Should return rc because 1.7.0-rc.1 > 1.6.7 + expect(result?.config).toEqual({ + version: '1.7.0-rc.1', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }) + }) + + it('should return beta when beta version > latest version', () => { + const configWithNewerBeta = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.7.0': { + minCompatibleVersion: '1.0.0', + description: 'v1.7.0', + channels: { + latest: { + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }, + rc: null, + beta: { + version: '1.7.0-beta.5', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-beta.5', + + gitcode: 'https://gitcode.com/test/v1.7.0-beta.5' + } + } + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerBeta) + + // Should return beta because 1.7.0-beta.5 > 1.6.7 + expect(result?.config).toEqual({ + version: '1.7.0-beta.5', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-beta.5', + + gitcode: 'https://gitcode.com/test/v1.7.0-beta.5' + } + }) + }) + + it('should return lower version when higher version has no compatible channel', () => { + vi.mocked(app.getVersion).mockReturnValue('1.8.0') + + const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'latest', mockConfig) + + // 1.8.0 >= 1.7.0 but 2.0.0 has no latest channel, so return 1.6.7 + expect(result?.config).toEqual({ + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }) + }) + + it('should return null when current version does not meet minCompatibleVersion', () => { + vi.mocked(app.getVersion).mockReturnValue('0.9.0') + + const result = (appUpdater as any)._findCompatibleChannel('0.9.0', 'latest', mockConfig) + + // 0.9.0 < 1.0.0 (minCompatibleVersion) + expect(result).toBeNull() + }) + + it('should return lower version rc when higher version has no rc channel', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'rc', mockConfig) + + // 1.8.0 >= 1.7.0 but 2.0.0 has no rc channel, so return 1.6.7 rc + expect(result?.config).toEqual({ + version: '1.7.0-rc.1', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }) + }) + + it('should return null when no version has the requested channel', () => { + const configWithoutRc = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.6.7': { + minCompatibleVersion: '1.0.0', + description: 'v1.6.7', + channels: { + latest: { + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }, + rc: null, + beta: null + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', configWithoutRc) + + expect(result).toBeNull() + }) + }) + + describe('Upgrade Path', () => { + const fullConfig = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.6.7': { + minCompatibleVersion: '1.0.0', + description: 'Last v1.x', + channels: { + latest: { + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }, + rc: { + version: '1.7.0-rc.1', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-rc.1', + + gitcode: 'https://gitcode.com/test/v1.7.0-rc.1' + } + }, + beta: { + version: '1.7.0-beta.3', + + feedUrls: { + github: 'https://github.com/test/v1.7.0-beta.3', + + gitcode: 'https://gitcode.com/test/v1.7.0-beta.3' + } + } + } + }, + '2.0.0': { + minCompatibleVersion: '1.7.0', + description: 'First v2.x', + channels: { + latest: null, + rc: null, + beta: null + } + } + } + } + + it('should upgrade from 1.6.3 to 1.6.7', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullConfig) + + expect(result?.config).toEqual({ + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }) + }) + + it('should block upgrade from 1.6.7 to 2.0.0 (minCompatibleVersion not met)', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.6.7', 'latest', fullConfig) + + // Should return 1.6.7, not 2.0.0, because 1.6.7 < 1.7.0 (minCompatibleVersion of 2.0.0) + expect(result?.config).toEqual({ + version: '1.6.7', + + feedUrls: { + github: 'https://github.com/test/v1.6.7', + + gitcode: 'https://gitcode.com/test/v1.6.7' + } + }) + }) + + it('should allow upgrade from 1.7.0 to 2.0.0', () => { + const configWith2x = { + ...fullConfig, + versions: { + ...fullConfig.versions, + '2.0.0': { + minCompatibleVersion: '1.7.0', + description: 'First v2.x', + channels: { + latest: { + version: '2.0.0', + + feedUrls: { + github: 'https://github.com/test/v2.0.0', + + gitcode: 'https://gitcode.com/test/v2.0.0' + } + }, + rc: null, + beta: null + } + } + } + } + + const result = (appUpdater as any)._findCompatibleChannel('1.7.0', 'latest', configWith2x) + + expect(result?.config).toEqual({ + version: '2.0.0', + + feedUrls: { + github: 'https://github.com/test/v2.0.0', + + gitcode: 'https://gitcode.com/test/v2.0.0' + } + }) + }) + }) + + describe('Complete Multi-Step Upgrade Path', () => { + const fullUpgradeConfig = { + lastUpdated: '2025-01-05T00:00:00Z', + versions: { + '1.7.5': { + minCompatibleVersion: '1.0.0', + description: 'Last v1.x stable', + channels: { + latest: { + version: '1.7.5', + + feedUrls: { + github: 'https://github.com/test/v1.7.5', + + gitcode: 'https://gitcode.com/test/v1.7.5' + } + }, + rc: null, + beta: null + } + }, + '2.0.0': { + minCompatibleVersion: '1.7.0', + description: 'First v2.x - intermediate version', + channels: { + latest: { + version: '2.0.0', + + feedUrls: { + github: 'https://github.com/test/v2.0.0', + + gitcode: 'https://gitcode.com/test/v2.0.0' + } + }, + rc: null, + beta: null + } + }, + '2.1.6': { + minCompatibleVersion: '2.0.0', + description: 'Current v2.x stable', + channels: { + latest: { + version: '2.1.6', + + feedUrls: { + github: 'https://github.com/test/latest', + + gitcode: 'https://gitcode.com/test/latest' + } + }, + rc: null, + beta: null + } + } + } + } + + it('should upgrade from 1.6.3 to 1.7.5 (step 1)', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig) + + expect(result?.config).toEqual({ + version: '1.7.5', + + feedUrls: { + github: 'https://github.com/test/v1.7.5', + + gitcode: 'https://gitcode.com/test/v1.7.5' + } + }) + }) + + it('should upgrade from 1.7.5 to 2.0.0 (step 2)', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig) + + expect(result?.config).toEqual({ + version: '2.0.0', + + feedUrls: { + github: 'https://github.com/test/v2.0.0', + + gitcode: 'https://gitcode.com/test/v2.0.0' + } + }) + }) + + it('should upgrade from 2.0.0 to 2.1.6 (step 3)', () => { + const result = (appUpdater as any)._findCompatibleChannel('2.0.0', 'latest', fullUpgradeConfig) + + expect(result?.config).toEqual({ + version: '2.1.6', + + feedUrls: { + github: 'https://github.com/test/latest', + + gitcode: 'https://gitcode.com/test/latest' + } + }) + }) + + it('should complete full upgrade path: 1.6.3 -> 1.7.5 -> 2.0.0 -> 2.1.6', () => { + // Step 1: 1.6.3 -> 1.7.5 + let currentVersion = '1.6.3' + let result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig) + expect(result?.config.version).toBe('1.7.5') + + // Step 2: 1.7.5 -> 2.0.0 + currentVersion = result?.config.version! + result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig) + expect(result?.config.version).toBe('2.0.0') + + // Step 3: 2.0.0 -> 2.1.6 + currentVersion = result?.config.version! + result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig) + expect(result?.config.version).toBe('2.1.6') + + // Final: 2.1.6 is the latest, no more upgrades + currentVersion = result?.config.version! + result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig) + expect(result?.config.version).toBe('2.1.6') + }) + + it('should block direct upgrade from 1.6.3 to 2.0.0 (skip intermediate)', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig) + + // Should return 1.7.5, not 2.0.0, because 1.6.3 < 1.7.0 (minCompatibleVersion of 2.0.0) + expect(result?.config.version).toBe('1.7.5') + expect(result?.config.version).not.toBe('2.0.0') + }) + + it('should block direct upgrade from 1.7.5 to 2.1.6 (skip intermediate)', () => { + const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig) + + // Should return 2.0.0, not 2.1.6, because 1.7.5 < 2.0.0 (minCompatibleVersion of 2.1.6) + expect(result?.config.version).toBe('2.0.0') + expect(result?.config.version).not.toBe('2.1.6') + }) + }) }) diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 86d4aef52c..c85aaf6c9e 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -1,8 +1,9 @@ import { type Client, createClient } from '@libsql/client' import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' -import { ModelValidationError, validateModelId } from '@main/apiServer/utils' -import { AgentType, MCPTool, objectKeys, SlashCommand, Tool } from '@types' +import { type ModelValidationError, validateModelId } from '@main/apiServer/utils' +import type { AgentType, MCPTool, SlashCommand, Tool } from '@types' +import { objectKeys } from '@types' import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' @@ -10,7 +11,7 @@ import path from 'path' import { MigrationService } from './database/MigrationService' import * as schema from './database/schema' import { dbPath } from './drizzle.config' -import { AgentModelField, AgentModelValidationError } from './errors' +import { type AgentModelField, AgentModelValidationError } from './errors' import { builtinSlashCommands } from './services/claudecode/commands' import { builtinTools } from './services/claudecode/tools' @@ -33,7 +34,14 @@ export abstract class BaseService { protected static db: LibSQLDatabase | null = null protected static isInitialized = false protected static initializationPromise: Promise | null = null - protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools'] + protected jsonFields: string[] = [ + 'tools', + 'mcps', + 'configuration', + 'accessible_paths', + 'allowed_tools', + 'slash_commands' + ] /** * Initialize database with retry logic and proper error handling diff --git a/src/main/services/agents/database/MigrationService.ts b/src/main/services/agents/database/MigrationService.ts index fce09bc68b..834e39dd80 100644 --- a/src/main/services/agents/database/MigrationService.ts +++ b/src/main/services/agents/database/MigrationService.ts @@ -5,7 +5,7 @@ import { type LibSQLDatabase } from 'drizzle-orm/libsql' import fs from 'fs' import path from 'path' -import * as schema from './schema' +import type * as schema from './schema' import { migrations, type NewMigration } from './schema/migrations.schema' const logger = loggerService.withContext('MigrationService') diff --git a/src/main/services/agents/database/schema/sessions.schema.ts b/src/main/services/agents/database/schema/sessions.schema.ts index 21ac2fe2c6..4b16a9ec41 100644 --- a/src/main/services/agents/database/schema/sessions.schema.ts +++ b/src/main/services/agents/database/schema/sessions.schema.ts @@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', { mcps: text('mcps'), // JSON array of MCP tool IDs allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist) + slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init configuration: text('configuration'), // JSON, extensible settings diff --git a/src/main/services/agents/errors.ts b/src/main/services/agents/errors.ts index b0df2341d7..95f1202e86 100644 --- a/src/main/services/agents/errors.ts +++ b/src/main/services/agents/errors.ts @@ -1,5 +1,5 @@ -import { ModelValidationError } from '@main/apiServer/utils' -import { AgentType } from '@types' +import type { ModelValidationError } from '@main/apiServer/utils' +import type { AgentType } from '@types' export type AgentModelField = 'model' | 'plan_model' | 'small_model' diff --git a/src/main/services/agents/interfaces/AgentStreamInterface.ts b/src/main/services/agents/interfaces/AgentStreamInterface.ts index 1b9c6f136d..7b52515256 100644 --- a/src/main/services/agents/interfaces/AgentStreamInterface.ts +++ b/src/main/services/agents/interfaces/AgentStreamInterface.ts @@ -1,9 +1,9 @@ // Agent-agnostic streaming interface // This interface should be implemented by all agent services -import { EventEmitter } from 'node:events' +import type { EventEmitter } from 'node:events' -import { GetAgentSessionResponse } from '@types' +import type { GetAgentSessionResponse } from '@types' import type { TextStreamPart } from 'ai' // Generic agent stream event that works with any agent type diff --git a/src/main/services/agents/plugins/PluginCacheStore.ts b/src/main/services/agents/plugins/PluginCacheStore.ts new file mode 100644 index 0000000000..77b427f4e8 --- /dev/null +++ b/src/main/services/agents/plugins/PluginCacheStore.ts @@ -0,0 +1,426 @@ +import { loggerService } from '@logger' +import { findAllSkillDirectories, parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { CachedPluginsData, InstalledPlugin, PluginError, PluginMetadata, PluginType } from '@types' +import { CachedPluginsDataSchema } from '@types' +import * as fs from 'fs' +import * as path from 'path' + +const logger = loggerService.withContext('PluginCacheStore') + +interface PluginCacheStoreDeps { + allowedExtensions: string[] + getPluginDirectoryName: (type: PluginType) => 'agents' | 'commands' | 'skills' + getClaudeBasePath: (workdir: string) => string + getClaudePluginDirectory: (workdir: string, type: PluginType) => string + getPluginsBasePath: () => string +} + +export class PluginCacheStore { + constructor(private readonly deps: PluginCacheStoreDeps) {} + + async listAvailableFilePlugins(type: 'agent' | 'command'): Promise { + const basePath = this.deps.getPluginsBasePath() + const directory = path.join(basePath, this.deps.getPluginDirectoryName(type)) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch (error) { + logger.warn(`Plugin directory not accessible: ${directory}`, { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + + const plugins: PluginMetadata[] = [] + const categories = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const categoryEntry of categories) { + if (!categoryEntry.isDirectory()) { + continue + } + + const category = categoryEntry.name + const categoryPath = path.join(directory, category) + const files = await fs.promises.readdir(categoryPath, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(categoryPath, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), category, file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, category, type) + plugins.push(metadata) + } catch (error) { + logger.warn(`Failed to parse plugin: ${file.name}`, { + category, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + return plugins + } + + async listAvailableSkills(): Promise { + const basePath = this.deps.getPluginsBasePath() + const skillsPath = path.join(basePath, this.deps.getPluginDirectoryName('skill')) + const skills: PluginMetadata[] = [] + + try { + await fs.promises.access(skillsPath) + } catch { + logger.warn('Skills directory not found', { skillsPath }) + return [] + } + + try { + const skillDirectories = await findAllSkillDirectories(skillsPath, basePath) + logger.info(`Found ${skillDirectories.length} skill directories`, { skillsPath }) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + skills.push(metadata) + } catch (error) { + logger.warn(`Failed to parse skill folder: ${sourcePath}`, { + folderPath, + error: error instanceof Error ? error.message : String(error) + }) + } + } + } catch (error) { + logger.error('Failed to scan skill directory', { + skillsPath, + error: error instanceof Error ? error.message : String(error) + }) + } + + return skills + } + + async readSourceContent(sourcePath: string): Promise { + const absolutePath = this.resolveSourcePath(sourcePath) + + try { + await fs.promises.access(absolutePath, fs.constants.R_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourcePath + } as PluginError + } + + try { + return await fs.promises.readFile(absolutePath, 'utf-8') + } catch (error) { + throw { + type: 'READ_FAILED', + path: sourcePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + } + + resolveSourcePath(sourcePath: string): string { + const normalized = path.normalize(sourcePath) + + if (normalized.includes('..')) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path traversal detected', + path: sourcePath + } as PluginError + } + + const basePath = this.deps.getPluginsBasePath() + const absolutePath = path.join(basePath, normalized) + const resolvedPath = path.resolve(absolutePath) + + if (!resolvedPath.startsWith(path.resolve(basePath))) { + throw { + type: 'PATH_TRAVERSAL', + message: 'Path outside plugins directory', + path: sourcePath + } as PluginError + } + + return resolvedPath + } + + async ensureSkillSourceDirectory(sourceAbsolutePath: string, sourcePath: string): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(sourceAbsolutePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: sourceAbsolutePath + } as PluginError + } + + if (!stats.isDirectory()) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill source is not a directory', + path: sourcePath + } as PluginError + } + } + + async validatePluginFile(filePath: string, maxFileSize: number): Promise { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + if (stats.size > maxFileSize) { + throw { + type: 'FILE_TOO_LARGE', + size: stats.size, + max: maxFileSize + } as PluginError + } + + const ext = path.extname(filePath).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + throw { + type: 'INVALID_FILE_TYPE', + extension: ext + } as PluginError + } + + try { + const basePath = this.deps.getPluginsBasePath() + const relativeSourcePath = path.relative(basePath, filePath) + const segments = relativeSourcePath.split(path.sep) + const rootDir = segments[0] + const agentDir = this.deps.getPluginDirectoryName('agent') + const type: 'agent' | 'command' = rootDir === agentDir ? 'agent' : 'command' + const category = path.basename(path.dirname(filePath)) + + await parsePluginMetadata(filePath, relativeSourcePath, category, type) + } catch (error) { + throw { + type: 'INVALID_METADATA', + reason: 'Failed to parse frontmatter', + path: filePath + } as PluginError + } + } + + async listInstalled(workdir: string): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + const cacheData = await this.readCacheFile(claudePath) + + if (cacheData) { + logger.debug(`Loaded ${cacheData.plugins.length} plugins from cache`, { workdir }) + return cacheData.plugins + } + + logger.info('Cache read failed, rebuilding from filesystem', { workdir }) + return await this.rebuild(workdir) + } + + async upsert(workdir: string, plugin: InstalledPlugin): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const updatedPlugin: InstalledPlugin = { + ...plugin, + metadata: { + ...plugin.metadata, + installedAt: plugin.metadata.installedAt ?? Date.now() + } + } + + const index = plugins.findIndex((p) => p.filename === updatedPlugin.filename && p.type === updatedPlugin.type) + if (index >= 0) { + plugins[index] = updatedPlugin + } else { + plugins.push(updatedPlugin) + } + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async remove(workdir: string, filename: string, type: PluginType): Promise { + const claudePath = this.deps.getClaudeBasePath(workdir) + let cacheData = await this.readCacheFile(claudePath) + let plugins = cacheData?.plugins + + if (!plugins) { + plugins = await this.rebuild(workdir) + cacheData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + } + + const filtered = plugins.filter((p) => !(p.filename === filename && p.type === type)) + + const data: CachedPluginsData = { + version: cacheData?.version ?? 1, + lastUpdated: Date.now(), + plugins: filtered + } + + await fs.promises.mkdir(claudePath, { recursive: true }) + await this.writeCacheFile(claudePath, data) + } + + async rebuild(workdir: string): Promise { + logger.info('Rebuilding plugin cache from filesystem', { workdir }) + + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(claudePath, fs.constants.R_OK) + } catch { + logger.warn('.claude directory not found, returning empty plugin list', { claudePath }) + return [] + } + + const plugins: InstalledPlugin[] = [] + + await Promise.all([ + this.collectFilePlugins(workdir, 'agent', plugins), + this.collectFilePlugins(workdir, 'command', plugins), + this.collectSkillPlugins(workdir, plugins) + ]) + + try { + const cacheData: CachedPluginsData = { + version: 1, + lastUpdated: Date.now(), + plugins + } + await this.writeCacheFile(claudePath, cacheData) + logger.info(`Rebuilt cache with ${plugins.length} plugins`, { workdir }) + } catch (error) { + logger.error('Failed to write cache file after rebuild', { + error: error instanceof Error ? error.message : String(error) + }) + } + + return plugins + } + + private async collectFilePlugins( + workdir: string, + type: Exclude, + plugins: InstalledPlugin[] + ): Promise { + const directory = this.deps.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.access(directory, fs.constants.R_OK) + } catch { + logger.debug(`${type} directory not found or not accessible`, { directory }) + return + } + + const files = await fs.promises.readdir(directory, { withFileTypes: true }) + + for (const file of files) { + if (!file.isFile()) { + continue + } + + const ext = path.extname(file.name).toLowerCase() + if (!this.deps.allowedExtensions.includes(ext)) { + continue + } + + try { + const filePath = path.join(directory, file.name) + const sourcePath = path.join(this.deps.getPluginDirectoryName(type), file.name) + const metadata = await parsePluginMetadata(filePath, sourcePath, this.deps.getPluginDirectoryName(type), type) + plugins.push({ filename: file.name, type, metadata }) + } catch (error) { + logger.warn(`Failed to parse ${type} plugin: ${file.name}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async collectSkillPlugins(workdir: string, plugins: InstalledPlugin[]): Promise { + const skillsPath = this.deps.getClaudePluginDirectory(workdir, 'skill') + const claudePath = this.deps.getClaudeBasePath(workdir) + + try { + await fs.promises.access(skillsPath, fs.constants.R_OK) + } catch { + logger.debug('Skills directory not found or not accessible', { skillsPath }) + return + } + + const skillDirectories = await findAllSkillDirectories(skillsPath, claudePath) + + for (const { folderPath, sourcePath } of skillDirectories) { + try { + const metadata = await parseSkillMetadata(folderPath, sourcePath, 'skills') + plugins.push({ filename: metadata.filename, type: 'skill', metadata }) + } catch (error) { + logger.warn(`Failed to parse skill plugin: ${sourcePath}`, { + error: error instanceof Error ? error.message : String(error) + }) + } + } + } + + private async readCacheFile(claudePath: string): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + try { + const content = await fs.promises.readFile(cachePath, 'utf-8') + const data = JSON.parse(content) + return CachedPluginsDataSchema.parse(data) + } catch (err) { + logger.warn(`Failed to read cache file at ${cachePath}`, { + error: err instanceof Error ? err.message : String(err) + }) + return null + } + } + + private async writeCacheFile(claudePath: string, data: CachedPluginsData): Promise { + const cachePath = path.join(claudePath, 'plugins.json') + const tempPath = `${cachePath}.tmp` + + const content = JSON.stringify(data, null, 2) + await fs.promises.writeFile(tempPath, content, 'utf-8') + await fs.promises.rename(tempPath, cachePath) + } +} diff --git a/src/main/services/agents/plugins/PluginInstaller.ts b/src/main/services/agents/plugins/PluginInstaller.ts new file mode 100644 index 0000000000..75acfc211f --- /dev/null +++ b/src/main/services/agents/plugins/PluginInstaller.ts @@ -0,0 +1,149 @@ +import { loggerService } from '@logger' +import { copyDirectoryRecursive, deleteDirectoryRecursive } from '@main/utils/fileOperations' +import type { PluginError } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' + +const logger = loggerService.withContext('PluginInstaller') + +export class PluginInstaller { + async installFilePlugin(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const tempPath = `${destPath}.tmp` + let fileCopied = false + + try { + await fs.promises.copyFile(sourceAbsolutePath, tempPath) + fileCopied = true + logger.debug('File copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logger.debug('File moved to final location', { agentId, destPath }) + } catch (error) { + if (fileCopied) { + await this.safeUnlink(tempPath, 'temp file') + } + throw this.toPluginError('install', error) + } + } + + async uninstallFilePlugin( + agentId: string, + filename: string, + type: 'agent' | 'command', + filePath: string + ): Promise { + try { + await fs.promises.unlink(filePath) + logger.debug('Plugin file deleted', { agentId, filename, type, filePath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall', error) + } + logger.warn('Plugin file already deleted', { agentId, filename, type, filePath }) + } + } + + async updateFilePluginContent(agentId: string, filePath: string, content: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.W_OK) + } catch { + throw { + type: 'FILE_NOT_FOUND', + path: filePath + } as PluginError + } + + try { + await fs.promises.writeFile(filePath, content, 'utf8') + logger.debug('Plugin content written successfully', { + agentId, + filePath, + size: Buffer.byteLength(content, 'utf8') + }) + } catch (error) { + throw { + type: 'WRITE_FAILED', + path: filePath, + reason: error instanceof Error ? error.message : String(error) + } as PluginError + } + + return crypto.createHash('sha256').update(content).digest('hex') + } + + async installSkill(agentId: string, sourceAbsolutePath: string, destPath: string): Promise { + const logContext = logger.withContext('installSkill') + let folderCopied = false + const tempPath = `${destPath}.tmp` + + try { + try { + await fs.promises.access(destPath) + await deleteDirectoryRecursive(destPath) + logContext.info('Removed existing skill folder', { agentId, destPath }) + } catch { + // No existing folder + } + + await copyDirectoryRecursive(sourceAbsolutePath, tempPath) + folderCopied = true + logContext.info('Skill folder copied to temp location', { agentId, tempPath }) + + await fs.promises.rename(tempPath, destPath) + logContext.info('Skill folder moved to final location', { agentId, destPath }) + } catch (error) { + if (folderCopied) { + await this.safeRemoveDirectory(tempPath, 'temp folder') + } + throw this.toPluginError('install-skill', error) + } + } + + async uninstallSkill(agentId: string, folderName: string, skillPath: string): Promise { + const logContext = logger.withContext('uninstallSkill') + + try { + await deleteDirectoryRecursive(skillPath) + logContext.info('Skill folder deleted', { agentId, folderName, skillPath }) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code !== 'ENOENT') { + throw this.toPluginError('uninstall-skill', error) + } + logContext.warn('Skill folder already deleted', { agentId, folderName, skillPath }) + } + } + + private toPluginError(operation: string, error: unknown): PluginError { + return { + type: 'TRANSACTION_FAILED', + operation, + reason: error instanceof Error ? error.message : String(error) + } + } + + private async safeUnlink(targetPath: string, label: string): Promise { + try { + await fs.promises.unlink(targetPath) + logger.debug(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } + + private async safeRemoveDirectory(targetPath: string, label: string): Promise { + try { + await deleteDirectoryRecursive(targetPath) + logger.info(`Rolled back ${label}`, { targetPath }) + } catch (unlinkError) { + logger.error(`Failed to rollback ${label}`, { + targetPath, + error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError) + }) + } + } +} diff --git a/src/main/services/agents/plugins/PluginService.ts b/src/main/services/agents/plugins/PluginService.ts new file mode 100644 index 0000000000..3076522a26 --- /dev/null +++ b/src/main/services/agents/plugins/PluginService.ts @@ -0,0 +1,614 @@ +import { loggerService } from '@logger' +import { parsePluginMetadata, parseSkillMetadata } from '@main/utils/markdownParser' +import type { + GetAgentResponse, + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginError, + PluginMetadata, + PluginType, + UninstallPluginOptions +} from '@types' +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { AgentService } from '../services/AgentService' +import { PluginCacheStore } from './PluginCacheStore' +import { PluginInstaller } from './PluginInstaller' + +const logger = loggerService.withContext('PluginService') + +interface PluginServiceConfig { + maxFileSize: number // bytes + cacheTimeout: number // milliseconds +} + +/** + * PluginService manages agent and command plugins from resources directory. + * + * Features: + * - Singleton pattern for consistent state management + * - Caching of available plugins for performance + * - Security validation (path traversal, file size, extensions) + * - Transactional install/uninstall operations + * - Integration with AgentService for metadata persistence + */ +export class PluginService { + private static instance: PluginService | null = null + + private availablePluginsCache: ListAvailablePluginsResult | null = null + private cacheTimestamp = 0 + private config: PluginServiceConfig + private readonly cacheStore: PluginCacheStore + private readonly installer: PluginInstaller + private readonly agentService: AgentService + + private readonly ALLOWED_EXTENSIONS = ['.md', '.markdown'] + + private constructor(config?: Partial) { + this.config = { + maxFileSize: config?.maxFileSize ?? 1024 * 1024, // 1MB default + cacheTimeout: config?.cacheTimeout ?? 5 * 60 * 1000 // 5 minutes default + } + this.agentService = AgentService.getInstance() + this.cacheStore = new PluginCacheStore({ + allowedExtensions: this.ALLOWED_EXTENSIONS, + getPluginDirectoryName: this.getPluginDirectoryName.bind(this), + getClaudeBasePath: this.getClaudeBasePath.bind(this), + getClaudePluginDirectory: this.getClaudePluginDirectory.bind(this), + getPluginsBasePath: this.getPluginsBasePath.bind(this) + }) + this.installer = new PluginInstaller() + + logger.info('PluginService initialized', { + maxFileSize: this.config.maxFileSize, + cacheTimeout: this.config.cacheTimeout + }) + } + + /** + * Get singleton instance + */ + static getInstance(config?: Partial): PluginService { + if (!PluginService.instance) { + PluginService.instance = new PluginService(config) + } + return PluginService.instance + } + + /** + * List all available plugins from resources directory (with caching) + */ + async listAvailable(): Promise { + const now = Date.now() + + // Return cached data if still valid + if (this.availablePluginsCache && now - this.cacheTimestamp < this.config.cacheTimeout) { + logger.debug('Returning cached plugin list', { + cacheAge: now - this.cacheTimestamp + }) + return this.availablePluginsCache + } + + logger.info('Scanning available plugins') + + // Scan all plugin types + const [agents, commands, skills] = await Promise.all([ + this.cacheStore.listAvailableFilePlugins('agent'), + this.cacheStore.listAvailableFilePlugins('command'), + this.cacheStore.listAvailableSkills() + ]) + + const result: ListAvailablePluginsResult = { + agents, + commands, + skills, // NEW: include skills + total: agents.length + commands.length + skills.length + } + + // Update cache + this.availablePluginsCache = result + this.cacheTimestamp = now + + logger.info('Available plugins scanned', { + agentsCount: agents.length, + commandsCount: commands.length, + skillsCount: skills.length, + total: result.total + }) + + return result + } + + /** + * Install plugin with validation and transactional safety + */ + async install(options: InstallPluginOptions): Promise { + logger.info('Installing plugin', options) + + const context = await this.prepareInstallContext(options) + + if (options.type === 'skill') { + return await this.installSkillPlugin(options, context) + } + + return await this.installFilePlugin(options, context) + } + + private async prepareInstallContext(options: InstallPluginOptions): Promise<{ + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + }> { + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + const sourceAbsolutePath = this.cacheStore.resolveSourcePath(options.sourcePath) + + return { agent, workdir, sourceAbsolutePath } + } + + private async installSkillPlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + await this.cacheStore.ensureSkillSourceDirectory(sourceAbsolutePath, options.sourcePath) + + const metadata = await parseSkillMetadata(sourceAbsolutePath, options.sourcePath, 'skills') + const sanitizedFolderName = this.sanitizeFolderName(metadata.filename) + + await this.ensureClaudeDirectory(workdir, 'skill') + const destPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + metadata.filename = sanitizedFolderName + + await this.installer.installSkill(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFolderName, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: 'skill' + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFolderName, + type: 'skill', + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Skill installed successfully', { + agentId: options.agentId, + sourcePath: options.sourcePath, + folderName: sanitizedFolderName + }) + + return metadataWithInstall + } + + private async installFilePlugin( + options: InstallPluginOptions, + context: { + agent: GetAgentResponse + workdir: string + sourceAbsolutePath: string + } + ): Promise { + const { agent, workdir, sourceAbsolutePath } = context + + if (options.type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: options.type + } as PluginError + } + + const filePluginType: 'agent' | 'command' = options.type + + await this.cacheStore.validatePluginFile(sourceAbsolutePath, this.config.maxFileSize) + + const category = path.basename(path.dirname(options.sourcePath)) + const metadata = await parsePluginMetadata(sourceAbsolutePath, options.sourcePath, category, filePluginType) + + const sanitizedFilename = this.sanitizeFilename(metadata.filename) + metadata.filename = sanitizedFilename + + await this.ensureClaudeDirectory(workdir, filePluginType) + const destPath = this.getClaudePluginPath(workdir, filePluginType, sanitizedFilename) + + await this.installer.installFilePlugin(agent.id, sourceAbsolutePath, destPath) + + const installedAt = Date.now() + const metadataWithInstall: PluginMetadata = { + ...metadata, + filename: sanitizedFilename, + installedAt, + updatedAt: metadata.updatedAt ?? installedAt, + type: filePluginType + } + const installedPlugin: InstalledPlugin = { + filename: sanitizedFilename, + type: filePluginType, + metadata: metadataWithInstall + } + + await this.cacheStore.upsert(workdir, installedPlugin) + this.upsertAgentPlugin(agent, installedPlugin) + + logger.info('Plugin installed successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: filePluginType + }) + + return metadataWithInstall + } + + /** + * Uninstall plugin with cleanup + */ + async uninstall(options: UninstallPluginOptions): Promise { + logger.info('Uninstalling plugin', options) + + const agent = await this.getAgentOrThrow(options.agentId) + const workdir = this.getWorkdirOrThrow(agent, options.agentId) + + await this.validateWorkdir(agent, workdir) + + if (options.type === 'skill') { + const sanitizedFolderName = this.sanitizeFolderName(options.filename) + const skillPath = this.getClaudePluginPath(workdir, 'skill', sanitizedFolderName) + + await this.installer.uninstallSkill(agent.id, sanitizedFolderName, skillPath) + await this.cacheStore.remove(workdir, sanitizedFolderName, 'skill') + this.removeAgentPlugin(agent, sanitizedFolderName, 'skill') + + logger.info('Skill uninstalled successfully', { + agentId: options.agentId, + folderName: sanitizedFolderName + }) + + return + } + + const sanitizedFilename = this.sanitizeFilename(options.filename) + const filePath = this.getClaudePluginPath(workdir, options.type, sanitizedFilename) + + await this.installer.uninstallFilePlugin(agent.id, sanitizedFilename, options.type, filePath) + await this.cacheStore.remove(workdir, sanitizedFilename, options.type) + this.removeAgentPlugin(agent, sanitizedFilename, options.type) + + logger.info('Plugin uninstalled successfully', { + agentId: options.agentId, + filename: sanitizedFilename, + type: options.type + }) + } + + /** + * List installed plugins for an agent (from database + filesystem validation) + */ + async listInstalled(agentId: string): Promise { + logger.debug('Listing installed plugins', { agentId }) + + const agent = await this.getAgentOrThrow(agentId) + + const workdir = agent.accessible_paths?.[0] + + if (!workdir) { + logger.warn('Agent has no accessible paths', { agentId }) + return [] + } + + const plugins = await this.listInstalledFromCache(workdir) + + logger.debug('Listed installed plugins from cache', { + agentId, + count: plugins.length + }) + + return plugins + } + + /** + * Invalidate plugin cache (for development/testing) + */ + invalidateCache(): void { + this.availablePluginsCache = null + this.cacheTimestamp = 0 + logger.info('Plugin cache invalidated') + } + + // ============================================================================ + // Cache File Management (for installed plugins) + // ============================================================================ + + /** + * Read cache file from .claude/plugins.json + * Returns null if cache doesn't exist or is invalid + */ + + /** + * List installed plugins from cache file + * Falls back to filesystem scan if cache is missing or corrupt + */ + async listInstalledFromCache(workdir: string): Promise { + logger.debug('Listing installed plugins from cache', { workdir }) + return await this.cacheStore.listInstalled(workdir) + } + + /** + * Read plugin content from source (resources directory) + */ + async readContent(sourcePath: string): Promise { + logger.info('Reading plugin content', { sourcePath }) + const content = await this.cacheStore.readSourceContent(sourcePath) + logger.debug('Plugin content read successfully', { + sourcePath, + size: content.length + }) + return content + } + + /** + * Write plugin content to installed plugin (in agent's .claude directory) + * Note: Only works for file-based plugins (agents/commands), not skills + */ + async writeContent(agentId: string, filename: string, type: PluginType, content: string): Promise { + logger.info('Writing plugin content', { agentId, filename, type }) + + const agent = await this.getAgentOrThrow(agentId) + const workdir = this.getWorkdirOrThrow(agent, agentId) + + await this.validateWorkdir(agent, workdir) + + // Check if plugin is installed + let installedPlugins = agent.installed_plugins ?? [] + if (installedPlugins.length === 0) { + installedPlugins = await this.cacheStore.listInstalled(workdir) + agent.installed_plugins = installedPlugins + } + const installedPlugin = installedPlugins.find((p) => p.filename === filename && p.type === type) + + if (!installedPlugin) { + throw { + type: 'PLUGIN_NOT_INSTALLED', + filename, + agentId + } as PluginError + } + + if (type === 'skill') { + throw { + type: 'INVALID_FILE_TYPE', + extension: type + } as PluginError + } + + const filePluginType = type as 'agent' | 'command' + const filePath = this.getClaudePluginPath(workdir, filePluginType, filename) + const newContentHash = await this.installer.updateFilePluginContent(agent.id, filePath, content) + + const updatedMetadata: PluginMetadata = { + ...installedPlugin.metadata, + contentHash: newContentHash, + size: Buffer.byteLength(content, 'utf8'), + updatedAt: Date.now(), + filename, + type: filePluginType + } + const updatedPlugin: InstalledPlugin = { + filename, + type: filePluginType, + metadata: updatedMetadata + } + + await this.cacheStore.upsert(workdir, updatedPlugin) + this.upsertAgentPlugin(agent, updatedPlugin) + + logger.info('Plugin content updated successfully', { + agentId, + filename, + type: filePluginType, + newContentHash + }) + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Resolve plugin type to directory name under .claude + */ + private getPluginDirectoryName(type: PluginType): 'agents' | 'commands' | 'skills' { + if (type === 'agent') { + return 'agents' + } + if (type === 'command') { + return 'commands' + } + return 'skills' + } + + /** + * Get the base .claude directory for a workdir + */ + private getClaudeBasePath(workdir: string): string { + return path.join(workdir, '.claude') + } + + /** + * Get the directory for a specific plugin type inside .claude + */ + private getClaudePluginDirectory(workdir: string, type: PluginType): string { + return path.join(this.getClaudeBasePath(workdir), this.getPluginDirectoryName(type)) + } + + /** + * Get the absolute path for a plugin file/folder inside .claude + */ + private getClaudePluginPath(workdir: string, type: PluginType, filename: string): string { + return path.join(this.getClaudePluginDirectory(workdir, type), filename) + } + + /** + * Get absolute path to plugins directory (handles packaged vs dev) + */ + private getPluginsBasePath(): string { + // Use the utility function which handles both dev and production correctly + if (app.isPackaged) { + return path.join(process.resourcesPath, 'claude-code-plugins') + } + return path.join(__dirname, '../../node_modules/claude-code-plugins/plugins') + } + + /** + * Validate source path to prevent path traversal attacks + */ + private async getAgentOrThrow(agentId: string): Promise { + const agent = await this.agentService.getAgent(agentId) + if (!agent) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent not found' + } as PluginError + } + return agent + } + + private getWorkdirOrThrow(agent: GetAgentResponse, agentId: string): string { + const workdir = agent.accessible_paths?.[0] + if (!workdir) { + throw { + type: 'INVALID_WORKDIR', + agentId, + workdir: '', + message: 'Agent has no accessible paths' + } as PluginError + } + return workdir + } + + /** + * Validate workdir against agent's accessible paths + */ + private async validateWorkdir(agent: GetAgentResponse, workdir: string): Promise { + // Verify workdir is in agent's accessible_paths + if (!agent.accessible_paths?.includes(workdir)) { + throw { + type: 'INVALID_WORKDIR', + workdir, + agentId: agent.id, + message: 'Workdir not in agent accessible paths' + } as PluginError + } + + // Verify workdir exists and is accessible + try { + await fs.promises.access(workdir, fs.constants.R_OK | fs.constants.W_OK) + } catch (error) { + throw { + type: 'WORKDIR_NOT_FOUND', + workdir, + message: 'Workdir does not exist or is not accessible' + } as PluginError + } + } + + private upsertAgentPlugin(agent: GetAgentResponse, plugin: InstalledPlugin): void { + const existing = agent.installed_plugins ?? [] + const filtered = existing.filter((p) => !(p.filename === plugin.filename && p.type === plugin.type)) + agent.installed_plugins = [...filtered, plugin] + } + + private removeAgentPlugin(agent: GetAgentResponse, filename: string, type: PluginType): void { + if (!agent.installed_plugins) { + agent.installed_plugins = [] + return + } + agent.installed_plugins = agent.installed_plugins.filter((p) => !(p.filename === filename && p.type === type)) + } + + /** + * Sanitize filename to remove unsafe characters (for agents/commands) + */ + private sanitizeFilename(filename: string): string { + // Remove path separators + let sanitized = filename.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore, dot) + sanitized = sanitized.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Ensure .md extension + if (!sanitized.endsWith('.md') && !sanitized.endsWith('.markdown')) { + sanitized += '.md' + } + + return sanitized + } + + /** + * Sanitize folder name for skills (different rules than file names) + * NO dots allowed to avoid confusion with file extensions + */ + private sanitizeFolderName(folderName: string): string { + // Remove path separators + let sanitized = folderName.replace(/[/\\]/g, '_') + // Remove null bytes using String method to avoid control-regex lint error + sanitized = sanitized.replace(new RegExp(String.fromCharCode(0), 'g'), '') + // Limit to safe characters (alphanumeric, dash, underscore) + // NOTE: No dots allowed to avoid confusion with file extensions + sanitized = sanitized.replace(/[^a-zA-Z0-9_-]/g, '_') + + // Validate no extension was provided + if (folderName.includes('.')) { + logger.warn('Skill folder name contained dots, sanitized', { + original: folderName, + sanitized + }) + } + + return sanitized + } + + /** + * Ensure .claude subdirectory exists for the given plugin type + */ + private async ensureClaudeDirectory(workdir: string, type: PluginType): Promise { + const typeDir = this.getClaudePluginDirectory(workdir, type) + + try { + await fs.promises.mkdir(typeDir, { recursive: true }) + logger.debug('Ensured directory exists', { typeDir }) + } catch (error) { + logger.error('Failed to create directory', { + typeDir, + error: error instanceof Error ? error.message : String(error) + }) + throw { + type: 'PERMISSION_DENIED', + path: typeDir + } as PluginError + } + } +} + +export const pluginService = PluginService.getInstance() diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index 53af37f670..07ed89a0f3 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -1,8 +1,9 @@ import path from 'node:path' +import { loggerService } from '@logger' +import { pluginService } from '@main/services/agents/plugins/PluginService' import { getDataPath } from '@main/utils' -import { - AgentBaseSchema, +import type { AgentEntity, CreateAgentRequest, CreateAgentResponse, @@ -11,11 +12,14 @@ import { UpdateAgentRequest, UpdateAgentResponse } from '@types' +import { AgentBaseSchema } from '@types' import { asc, count, desc, eq } from 'drizzle-orm' import { BaseService } from '../BaseService' import { type AgentRow, agentsTable, type InsertAgentRow } from '../database/schema' -import { AgentModelField } from '../errors' +import type { AgentModelField } from '../errors' + +const logger = loggerService.withContext('AgentService') export class AgentService extends BaseService { private static instance: AgentService | null = null @@ -92,6 +96,24 @@ export class AgentService extends BaseService { const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse agent.tools = await this.listMcpTools(agent.type, agent.mcps) + + // Load installed_plugins from cache file instead of database + const workdir = agent.accessible_paths?.[0] + if (workdir) { + try { + agent.installed_plugins = await pluginService.listInstalledFromCache(workdir) + } catch (error) { + // Log error but don't fail the request + logger.warn(`Failed to load installed plugins for agent ${id}`, { + workdir, + error: error instanceof Error ? error.message : String(error) + }) + agent.installed_plugins = [] + } + } else { + agent.installed_plugins = [] + } + return agent } diff --git a/src/main/services/agents/services/SessionMessageService.ts b/src/main/services/agents/services/SessionMessageService.ts index f7d44e1612..46435fa371 100644 --- a/src/main/services/agents/services/SessionMessageService.ts +++ b/src/main/services/agents/services/SessionMessageService.ts @@ -5,12 +5,12 @@ import type { GetAgentSessionResponse, ListOptions } from '@types' -import { TextStreamPart } from 'ai' +import type { TextStreamPart } from 'ai' import { and, desc, eq, not } from 'drizzle-orm' import { BaseService } from '../BaseService' import { sessionMessagesTable } from '../database/schema' -import { AgentStreamEvent } from '../interfaces/AgentStreamInterface' +import type { AgentStreamEvent } from '../interfaces/AgentStreamInterface' import ClaudeCodeService from './claudecode' const logger = loggerService.withContext('SessionMessageService') diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index 5fcb60600d..275306ec20 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -1,18 +1,24 @@ -import { - AgentBaseSchema, - type AgentEntity, - type AgentSessionEntity, - type CreateSessionRequest, - type GetAgentSessionResponse, - type ListOptions, - type UpdateSessionRequest, +import { loggerService } from '@logger' +import type { + AgentEntity, + AgentSessionEntity, + CreateSessionRequest, + GetAgentSessionResponse, + ListOptions, + SlashCommand, + UpdateSessionRequest, UpdateSessionResponse } from '@types' +import { AgentBaseSchema } from '@types' import { and, count, desc, eq, type SQL } from 'drizzle-orm' import { BaseService } from '../BaseService' import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema' -import { AgentModelField } from '../errors' +import type { AgentModelField } from '../errors' +import { pluginService } from '../plugins/PluginService' +import { builtinSlashCommands } from './claudecode/commands' + +const logger = loggerService.withContext('SessionService') export class SessionService extends BaseService { private static instance: SessionService | null = null @@ -29,6 +35,52 @@ export class SessionService extends BaseService { await BaseService.initialize() } + /** + * Override BaseService.listSlashCommands to merge builtin and plugin commands + */ + async listSlashCommands(agentType: string, agentId?: string): Promise { + const commands: SlashCommand[] = [] + + // Add builtin slash commands + if (agentType === 'claude-code') { + commands.push(...builtinSlashCommands) + } + + // Add local command plugins from .claude/commands/ + if (agentId) { + try { + const installedPlugins = await pluginService.listInstalled(agentId) + + // Filter for command type plugins + const commandPlugins = installedPlugins.filter((p) => p.type === 'command') + + // Convert plugin metadata to SlashCommand format + for (const plugin of commandPlugins) { + const commandName = plugin.metadata.filename.replace(/\.md$/i, '') + commands.push({ + command: `/${commandName}`, + description: plugin.metadata.description + }) + } + + logger.info('Listed slash commands', { + agentType, + agentId, + builtinCount: builtinSlashCommands.length, + localCount: commandPlugins.length, + totalCount: commands.length + }) + } catch (error) { + logger.warn('Failed to list local command plugins', { + agentId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + return commands + } + async createSession( agentId: string, req: Partial = {} @@ -78,6 +130,7 @@ export class SessionService extends BaseService { plan_model: serializedData.plan_model || null, small_model: serializedData.small_model || null, mcps: serializedData.mcps || null, + allowed_tools: serializedData.allowed_tools || null, configuration: serializedData.configuration || null, created_at: now, updated_at: now @@ -110,7 +163,13 @@ export class SessionService extends BaseService { const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse session.tools = await this.listMcpTools(session.agent_type, session.mcps) - session.slash_commands = await this.listSlashCommands(session.agent_type) + + // If slash_commands is not in database yet (e.g., first invoke before init message), + // fall back to builtin + local commands. Otherwise, use the merged commands from database. + if (!session.slash_commands || session.slash_commands.length === 0) { + session.slash_commands = await this.listSlashCommands(session.agent_type, agentId) + } + return session } diff --git a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts index 1c5c2ade6b..8f8c1df038 100644 --- a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts +++ b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts @@ -1,7 +1,7 @@ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' import { describe, expect, it } from 'vitest' -import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform' +import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform' const baseStreamMetadata = { parent_tool_use_id: null, @@ -10,6 +10,19 @@ const baseStreamMetadata = { const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}` +describe('stripLocalCommandTags', () => { + it('removes stdout wrapper while preserving inner text', () => { + const input = 'before echo "hi" after' + expect(stripLocalCommandTags(input)).toBe('before echo "hi" after') + }) + + it('strips multiple stdout/stderr blocks and leaves other content intact', () => { + const input = + 'line1\nkeep\nError' + expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError') + }) +}) + describe('Claude → AiSDK transform', () => { it('handles tool call streaming lifecycle', () => { const state = new ClaudeStreamState() diff --git a/src/main/services/agents/services/claudecode/commands.ts b/src/main/services/agents/services/claudecode/commands.ts index ce90e0978a..0ce4f4ccef 100644 --- a/src/main/services/agents/services/claudecode/commands.ts +++ b/src/main/services/agents/services/claudecode/commands.ts @@ -1,25 +1,12 @@ -import { SlashCommand } from '@types' +import type { SlashCommand } from '@types' export const builtinSlashCommands: SlashCommand[] = [ - { command: '/add-dir', description: 'Add additional working directories' }, - { command: '/agents', description: 'Manage custom AI subagents for specialized tasks' }, - { command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' }, { command: '/clear', description: 'Clear conversation history' }, { command: '/compact', description: 'Compact conversation with optional focus instructions' }, - { command: '/config', description: 'View/modify configuration' }, - { command: '/cost', description: 'Show token usage statistics' }, - { command: '/doctor', description: 'Checks the health of your Claude Code installation' }, - { command: '/help', description: 'Get usage help' }, - { command: '/init', description: 'Initialize project with CLAUDE.md guide' }, - { command: '/login', description: 'Switch Anthropic accounts' }, - { command: '/logout', description: 'Sign out from your Anthropic account' }, - { command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' }, - { command: '/memory', description: 'Edit CLAUDE.md memory files' }, - { command: '/model', description: 'Select or change the AI model' }, - { command: '/permissions', description: 'View or update permissions' }, - { command: '/pr_comments', description: 'View pull request comments' }, - { command: '/review', description: 'Request code review' }, - { command: '/status', description: 'View account and system statuses' }, - { command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' }, - { command: '/vim', description: 'Enter vim mode for alternating insert and command modes' } + { command: '/context', description: 'Visualize current context usage as a colored grid' }, + { + command: '/cost', + description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)' + }, + { command: '/todos', description: 'List current todo items' } ] diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 7b2f119afb..a8f3f54fa8 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -2,19 +2,35 @@ import { EventEmitter } from 'node:events' import { createRequire } from 'node:module' -import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' -import { GetAgentSessionResponse } from '../..' -import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' +import type { GetAgentSessionResponse } from '../..' +import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' +import { sessionService } from '../SessionService' +import { promptForToolApproval } from './tool-permissions' import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform' const require_ = createRequire(import.meta.url) const logger = loggerService.withContext('ClaudeCodeService') +const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep']) +const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' +const NO_RESUME_COMMANDS = ['/clear'] + +type UserInputMessage = { + type: 'user' + parent_tool_use_id: string | null + session_id: string + message: { + role: 'user' + content: string + } +} class ClaudeCodeStream extends EventEmitter implements AgentStream { declare emit: (event: 'data', data: AgentStreamEvent) => boolean @@ -92,13 +108,51 @@ class ClaudeCodeService implements AgentServiceInterface { ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey, ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost, ANTHROPIC_MODEL: modelInfo.modelId, - ANTHROPIC_SMALL_FAST_MODEL: modelInfo.modelId, + ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId, + ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId, + // TODO: support set small model in UI + ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId, ELECTRON_RUN_AS_NODE: '1', ELECTRON_NO_ATTACH_CONSOLE: '1' } const errorChunks: string[] = [] + const sessionAllowedTools = new Set(session.allowed_tools ?? []) + const autoAllowTools = new Set([...DEFAULT_AUTO_ALLOW_TOOLS, ...sessionAllowedTools]) + const normalizeToolName = (name: string) => (name.startsWith('builtin_') ? name.slice('builtin_'.length) : name) + + const canUseTool: CanUseTool = async (toolName, input, options) => { + logger.info('Handling tool permission check', { + toolName, + suggestionCount: options.suggestions?.length ?? 0 + }) + + if (shouldAutoApproveTools) { + logger.debug('Auto-approving tool due to CHERRY_AUTO_ALLOW_TOOLS flag', { toolName }) + return { behavior: 'allow', updatedInput: input } + } + + if (options.signal.aborted) { + logger.debug('Permission request signal already aborted; denying tool', { toolName }) + return { + behavior: 'deny', + message: 'Tool request was cancelled before prompting the user' + } + } + + const normalizedToolName = normalizeToolName(toolName) + if (autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)) { + logger.debug('Auto-allowing tool from allowed list', { + toolName, + normalizedToolName + }) + return { behavior: 'allow', updatedInput: input } + } + + return promptForToolApproval(toolName, input, options) + } + // Build SDK options from parameters const options: Options = { abortController, @@ -121,7 +175,8 @@ class ClaudeCodeService implements AgentServiceInterface { includePartialMessages: true, permissionMode: session.configuration?.permission_mode, maxTurns: session.configuration?.max_turns, - allowedTools: session.allowed_tools + allowedTools: session.allowed_tools, + canUseTool } if (session.accessible_paths.length > 1) { @@ -144,7 +199,7 @@ class ClaudeCodeService implements AgentServiceInterface { options.strictMcpConfig = true } - if (lastAgentSessionId) { + if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) { options.resume = lastAgentSessionId // TODO: use fork session when we support branching sessions // options.forkSession = true @@ -160,9 +215,22 @@ class ClaudeCodeService implements AgentServiceInterface { resume: options.resume }) + const { stream: userInputStream, close: closeUserStream } = this.createUserMessageStream( + prompt, + abortController.signal + ) + // Start async processing on the next tick so listeners can subscribe first setImmediate(() => { - this.processSDKQuery(prompt, options, aiStream, errorChunks).catch((error) => { + this.processSDKQuery( + userInputStream, + closeUserStream, + options, + aiStream, + errorChunks, + session.agent_id, + session.id + ).catch((error) => { logger.error('Unhandled Claude Code stream error', { error: error instanceof Error ? { name: error.name, message: error.message } : String(error) }) @@ -176,17 +244,90 @@ class ClaudeCodeService implements AgentServiceInterface { return aiStream } - private async *userMessages(prompt: string) { - { - yield { - type: 'user' as const, - parent_tool_use_id: null, - session_id: '', - message: { - role: 'user' as const, - content: prompt + private createUserMessageStream(initialPrompt: string, abortSignal: AbortSignal) { + const queue: Array = [] + const waiters: Array<(value: UserInputMessage | null) => void> = [] + let closed = false + + const flushWaiters = (value: UserInputMessage | null) => { + const resolve = waiters.shift() + if (resolve) { + resolve(value) + return true + } + return false + } + + const enqueue = (value: UserInputMessage | null) => { + if (closed) return + if (value === null) { + closed = true + } + if (!flushWaiters(value)) { + queue.push(value) + } + } + + const close = () => { + if (closed) return + enqueue(null) + } + + const onAbort = () => { + close() + } + + if (abortSignal.aborted) { + close() + } else { + abortSignal.addEventListener('abort', onAbort, { once: true }) + } + + const iterator = (async function* () { + try { + while (true) { + let value: UserInputMessage | null + if (queue.length > 0) { + value = queue.shift() ?? null + } else if (closed) { + break + } else { + // Wait for next message or close signal + value = await new Promise((resolve) => { + waiters.push(resolve) + }) + } + + if (value === null) { + break + } + + yield value + } + } finally { + closed = true + abortSignal.removeEventListener('abort', onAbort) + while (waiters.length > 0) { + const resolve = waiters.shift() + resolve?.(null) } } + })() + + enqueue({ + type: 'user', + parent_tool_use_id: null, + session_id: '', + message: { + role: 'user', + content: initialPrompt + } + }) + + return { + stream: iterator, + enqueue, + close } } @@ -194,36 +335,91 @@ class ClaudeCodeService implements AgentServiceInterface { * Process SDK query and emit stream events */ private async processSDKQuery( - prompt: string, + promptStream: AsyncIterable, + closePromptStream: () => void, options: Options, stream: ClaudeCodeStream, - errorChunks: string[] + errorChunks: string[], + agentId: string, + sessionId: string ): Promise { const jsonOutput: SDKMessage[] = [] let hasCompleted = false const startTime = Date.now() - const streamState = new ClaudeStreamState() + try { - // Process streaming responses using SDK query - for await (const message of query({ - prompt: this.userMessages(prompt), - options - })) { + for await (const message of query({ prompt: promptStream, options })) { if (hasCompleted) break jsonOutput.push(message) + // Handle init message - merge builtin and SDK slash_commands + if (message.type === 'system' && message.subtype === 'init') { + const sdkSlashCommands = message.slash_commands || [] + logger.info('Received init message with slash commands', { + sessionId, + commands: sdkSlashCommands + }) + + try { + // Get builtin + local slash commands from BaseService + const existingCommands = await sessionService.listSlashCommands('claude-code', agentId) + + // Convert SDK slash_commands (string[]) to SlashCommand[] format + // Ensure all commands start with '/' + const sdkCommands = sdkSlashCommands.map((cmd) => { + const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}` + return { + command: normalizedCmd, + description: undefined + } + }) + + // Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name + const commandMap = new Map() + + for (const cmd of existingCommands) { + commandMap.set(cmd.command, cmd) + } + + for (const cmd of sdkCommands) { + if (!commandMap.has(cmd.command)) { + commandMap.set(cmd.command, cmd) + } + } + + const mergedCommands = Array.from(commandMap.values()) + + // Update session in database + await sessionService.updateSession(agentId, sessionId, { + slash_commands: mergedCommands + }) + + logger.info('Updated session with merged slash commands', { + sessionId, + existingCount: existingCommands.length, + sdkCount: sdkCommands.length, + totalCount: mergedCommands.length + }) + } catch (error) { + logger.error('Failed to update session slash_commands', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + if (message.type === 'assistant' || message.type === 'user') { logger.silly('claude response', { message, content: JSON.stringify(message.message.content) }) } else if (message.type === 'stream_event') { - logger.silly('Claude stream event', { - message, - event: JSON.stringify(message.event) - }) + // logger.silly('Claude stream event', { + // message, + // event: JSON.stringify(message.event) + // }) } else { logger.silly('Claude response', { message, @@ -231,18 +427,25 @@ class ClaudeCodeService implements AgentServiceInterface { }) } - // Transform SDKMessage to UIMessageChunks const chunks = transformSDKMessageToStreamParts(message, streamState) for (const chunk of chunks) { stream.emit('data', { type: 'chunk', chunk }) + + // Close prompt stream when SDK signals completion or error + if (chunk.type === 'finish' || chunk.type === 'error') { + logger.info('Closing prompt stream as SDK signaled completion', { + chunkType: chunk.type, + reason: chunk.type === 'finish' ? 'finished' : 'error_occurred' + }) + closePromptStream() + logger.info('Prompt stream closed successfully') + } } } - // Successfully completed - hasCompleted = true const duration = Date.now() - startTime logger.debug('SDK query completed successfully', { @@ -250,7 +453,6 @@ class ClaudeCodeService implements AgentServiceInterface { messageCount: jsonOutput.length }) - // Emit completion event stream.emit('data', { type: 'complete' }) @@ -259,8 +461,6 @@ class ClaudeCodeService implements AgentServiceInterface { hasCompleted = true const duration = Date.now() - startTime - - // Check if this is an abort error const errorObj = error as any const isAborted = errorObj?.name === 'AbortError' || @@ -269,7 +469,6 @@ class ClaudeCodeService implements AgentServiceInterface { if (isAborted) { logger.info('SDK query aborted by client disconnect', { duration }) - // Simply cleanup and return - don't emit error events stream.emit('data', { type: 'cancelled', error: new Error('Request aborted by client') @@ -284,11 +483,13 @@ class ClaudeCodeService implements AgentServiceInterface { error: errorObj instanceof Error ? { name: errorObj.name, message: errorObj.message } : String(errorObj), stderr: errorChunks }) - // Emit error event + stream.emit('data', { type: 'error', error: new Error(errorMessage) }) + } finally { + closePromptStream() } } } diff --git a/src/main/services/agents/services/claudecode/tool-permissions.ts b/src/main/services/agents/services/claudecode/tool-permissions.ts new file mode 100644 index 0000000000..c95f4c679e --- /dev/null +++ b/src/main/services/agents/services/claudecode/tool-permissions.ts @@ -0,0 +1,323 @@ +import { randomUUID } from 'node:crypto' + +import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' +import { loggerService } from '@logger' +import { IpcChannel } from '@shared/IpcChannel' +import { ipcMain } from 'electron' + +import { windowService } from '../../../WindowService' +import { builtinTools } from './tools' + +const logger = loggerService.withContext('ClaudeCodeService') + +const TOOL_APPROVAL_TIMEOUT_MS = 30_000 +const MAX_PREVIEW_LENGTH = 2_000 +const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' + +type ToolPermissionBehavior = 'allow' | 'deny' + +type ToolPermissionResponsePayload = { + requestId: string + behavior: ToolPermissionBehavior + updatedInput?: unknown + message?: string + updatedPermissions?: PermissionUpdate[] +} + +type PendingPermissionRequest = { + fulfill: (update: PermissionResult) => void + timeout: NodeJS.Timeout + signal?: AbortSignal + abortListener?: () => void + originalInput: Record + toolName: string +} + +type RendererPermissionRequestPayload = { + requestId: string + toolName: string + toolId: string + description?: string + requiresPermissions: boolean + input: Record + inputPreview: string + createdAt: number + expiresAt: number + suggestions: PermissionUpdate[] +} + +type RendererPermissionResultPayload = { + requestId: string + behavior: ToolPermissionBehavior + message?: string + reason: 'response' | 'timeout' | 'aborted' | 'no-window' +} + +const pendingRequests = new Map() +let ipcHandlersInitialized = false + +const jsonReplacer = (_key: string, value: unknown) => { + if (typeof value === 'bigint') return value.toString() + if (value instanceof Map) return Object.fromEntries(value.entries()) + if (value instanceof Set) return Array.from(value.values()) + if (value instanceof Date) return value.toISOString() + if (typeof value === 'function') return undefined + if (value === undefined) return undefined + return value +} + +const sanitizeStructuredData = (value: T): T => { + try { + return JSON.parse(JSON.stringify(value, jsonReplacer)) as T + } catch (error) { + logger.warn('Failed to sanitize structured data for tool permission payload', { + error: error instanceof Error ? { name: error.name, message: error.message } : String(error) + }) + return value + } +} + +const buildInputPreview = (value: unknown): string => { + let preview: string + + try { + preview = JSON.stringify(value, null, 2) + } catch (error) { + preview = typeof value === 'string' ? value : String(value) + } + + if (preview.length > MAX_PREVIEW_LENGTH) { + preview = `${preview.slice(0, MAX_PREVIEW_LENGTH)}...` + } + + return preview +} + +const broadcastToRenderer = ( + channel: IpcChannel, + payload: RendererPermissionRequestPayload | RendererPermissionResultPayload +): boolean => { + const mainWindow = windowService.getMainWindow() + + if (!mainWindow) { + logger.warn('Unable to send agent tool permission payload – main window unavailable', { + channel, + requestId: 'requestId' in payload ? payload.requestId : undefined + }) + return false + } + + mainWindow.webContents.send(channel, payload) + + return true +} + +const finalizeRequest = ( + requestId: string, + update: PermissionResult, + reason: RendererPermissionResultPayload['reason'] +) => { + const pending = pendingRequests.get(requestId) + + if (!pending) { + logger.debug('Attempted to finalize unknown tool permission request', { requestId, reason }) + return false + } + + logger.debug('Finalizing tool permission request', { + requestId, + toolName: pending.toolName, + behavior: update.behavior, + reason + }) + + pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + + if (pending.signal && pending.abortListener) { + pending.signal.removeEventListener('abort', pending.abortListener) + } + + pending.fulfill(update) + + const resultPayload: RendererPermissionResultPayload = { + requestId, + behavior: update.behavior, + message: update.behavior === 'deny' ? update.message : undefined, + reason + } + + const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload) + + logger.debug('Sent tool permission result to renderer', { + requestId, + dispatched + }) + + return true +} + +const ensureIpcHandlersRegistered = () => { + if (ipcHandlersInitialized) return + + ipcHandlersInitialized = true + + ipcMain.handle(IpcChannel.AgentToolPermission_Response, async (_event, payload: ToolPermissionResponsePayload) => { + logger.debug('main received AgentToolPermission_Response', payload) + const { requestId, behavior, updatedInput, message } = payload + const pending = pendingRequests.get(requestId) + + if (!pending) { + logger.warn('Received renderer tool permission response for unknown request', { requestId }) + return { success: false, error: 'unknown-request' } + } + + logger.debug('Received renderer response for tool permission', { + requestId, + toolName: pending.toolName, + behavior, + hasUpdatedPermissions: Array.isArray(payload.updatedPermissions) && payload.updatedPermissions.length > 0 + }) + + const maybeUpdatedInput = + updatedInput && typeof updatedInput === 'object' && !Array.isArray(updatedInput) + ? (updatedInput as Record) + : pending.originalInput + + const sanitizedUpdatedPermissions = Array.isArray(payload.updatedPermissions) + ? payload.updatedPermissions.map((perm) => sanitizeStructuredData(perm)) + : undefined + + const finalUpdate: PermissionResult = + behavior === 'allow' + ? { + behavior: 'allow', + updatedInput: sanitizeStructuredData(maybeUpdatedInput), + updatedPermissions: sanitizedUpdatedPermissions + } + : { + behavior: 'deny', + message: message ?? 'User denied permission for this tool' + } + + finalizeRequest(requestId, finalUpdate, 'response') + + return { success: true } + }) +} + +export async function promptForToolApproval( + toolName: string, + input: Record, + options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] } +): Promise { + if (shouldAutoApproveTools) { + logger.debug('promptForToolApproval auto-approving tool for test', { + toolName + }) + + return { behavior: 'allow', updatedInput: input } + } + + ensureIpcHandlersRegistered() + + if (options?.signal?.aborted) { + logger.info('Skipping tool approval prompt because request signal is already aborted', { toolName }) + return { behavior: 'deny', message: 'Tool request was cancelled before prompting the user' } + } + + const mainWindow = windowService.getMainWindow() + + if (!mainWindow) { + logger.warn('Denying tool usage because no renderer window is available to obtain approval', { toolName }) + return { behavior: 'deny', message: 'Unable to request approval – renderer not ready' } + } + + const toolMetadata = builtinTools.find((tool) => tool.name === toolName || tool.id === toolName) + const sanitizedInput = sanitizeStructuredData(input) + const inputPreview = buildInputPreview(sanitizedInput) + const sanitizedSuggestions = (options?.suggestions ?? []).map((suggestion) => sanitizeStructuredData(suggestion)) + + const requestId = randomUUID() + const createdAt = Date.now() + const expiresAt = createdAt + TOOL_APPROVAL_TIMEOUT_MS + + logger.info('Requesting user approval for tool usage', { + requestId, + toolName, + description: toolMetadata?.description + }) + + const requestPayload: RendererPermissionRequestPayload = { + requestId, + toolName, + toolId: toolMetadata?.id ?? toolName, + description: toolMetadata?.description, + requiresPermissions: toolMetadata?.requirePermissions ?? false, + input: sanitizedInput, + inputPreview, + createdAt, + expiresAt, + suggestions: sanitizedSuggestions + } + + const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' } + + logger.debug('Registering tool permission request', { + requestId, + toolName, + requiresPermissions: requestPayload.requiresPermissions, + timeoutMs: TOOL_APPROVAL_TIMEOUT_MS, + suggestionCount: sanitizedSuggestions.length + }) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + logger.info('User tool permission request timed out', { requestId, toolName }) + finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout') + }, TOOL_APPROVAL_TIMEOUT_MS) + + const pending: PendingPermissionRequest = { + fulfill: resolve, + timeout, + originalInput: sanitizedInput, + toolName, + signal: options?.signal + } + + if (options?.signal) { + const abortListener = () => { + logger.info('Tool permission request aborted before user responded', { requestId, toolName }) + finalizeRequest(requestId, defaultDenyUpdate, 'aborted') + } + + pending.abortListener = abortListener + options.signal.addEventListener('abort', abortListener, { once: true }) + } + + pendingRequests.set(requestId, pending) + + logger.debug('Pending tool permission request count', { + count: pendingRequests.size + }) + + const sent = broadcastToRenderer(IpcChannel.AgentToolPermission_Request, requestPayload) + + logger.debug('Broadcasted tool permission request to renderer', { + requestId, + toolName, + sent + }) + + if (!sent) { + finalizeRequest( + requestId, + { + behavior: 'deny', + message: 'Unable to request approval because the renderer window is unavailable' + }, + 'no-window' + ) + } + }) +} diff --git a/src/main/services/agents/services/claudecode/tools.ts b/src/main/services/agents/services/claudecode/tools.ts index 0785827cd5..483caded84 100644 --- a/src/main/services/agents/services/claudecode/tools.ts +++ b/src/main/services/agents/services/claudecode/tools.ts @@ -1,4 +1,4 @@ -import { Tool } from '@types' +import type { Tool } from '@types' // https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude export const builtinTools: Tool[] = [ diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index 4af3716c1d..41285175b4 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -20,7 +20,7 @@ * emitting `text-*` parts and a synthetic `finish-step`. */ -import { SDKMessage } from '@anthropic-ai/claude-agent-sdk' +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' import type { BetaStopReason } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { loggerService } from '@logger' import type { FinishReason, LanguageModelUsage, ProviderMetadata, TextStreamPart } from 'ai' @@ -73,6 +73,23 @@ const emptyUsage: LanguageModelUsage = { */ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}` +/** + * Removes any local command stdout/stderr XML wrappers that should never surface to the UI. + */ +export const stripLocalCommandTags = (text: string): string => { + return text.replace(/(.*?)<\/local-command-\1>/gs, '$2') +} + +/** + * Filters out command-* tags from text content to prevent internal command + * messages from appearing in the user-facing UI. + * Removes tags like ... and ... + */ +const filterCommandTags = (text: string): string => { + const withoutLocalCommandTags = stripLocalCommandTags(text) + return withoutLocalCommandTags.replace(/]+>.*?<\/command-[^>]+>/gs, '').trim() +} + /** * Extracts provider metadata from the raw Claude message so we can surface it * on every emitted stream part for observability and debugging purposes. @@ -93,6 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => * blocks across calls so that incremental deltas can be correlated correctly. */ export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] { + logger.silly('Transforming SDKMessage', { message: sdkMessage }) switch (sdkMessage.type) { case 'assistant': return handleAssistantMessage(sdkMessage, state) @@ -126,7 +144,8 @@ function handleAssistantMessage( const isStreamingActive = state.hasActiveStep() if (typeof content === 'string') { - if (!content) { + const sanitizedContent = stripLocalCommandTags(content) + if (!sanitizedContent) { return chunks } @@ -148,7 +167,7 @@ function handleAssistantMessage( chunks.push({ type: 'text-delta', id: textId, - text: content, + text: sanitizedContent, providerMetadata }) chunks.push({ @@ -169,7 +188,10 @@ function handleAssistantMessage( switch (block.type) { case 'text': if (!isStreamingActive) { - textBlocks.push(block.text) + const sanitizedText = stripLocalCommandTags(block.text) + if (sanitizedText) { + textBlocks.push(sanitizedText) + } } break case 'tool_use': @@ -270,12 +292,17 @@ function handleUserMessage( const chunks: AgentStreamPart[] = [] const providerMetadata = sdkMessageToProviderMetadata(message) const content = message.message.content - + const isSynthetic = message.isSynthetic ?? false if (typeof content === 'string') { if (!content) { return chunks } + const filteredContent = filterCommandTags(content) + if (!filteredContent) { + return chunks + } + const id = message.uuid?.toString() || generateMessageId() chunks.push({ type: 'text-start', @@ -285,7 +312,7 @@ function handleUserMessage( chunks.push({ type: 'text-delta', id, - text: content, + text: filteredContent, providerMetadata }) chunks.push({ @@ -323,24 +350,30 @@ function handleUserMessage( providerExecuted: true }) } - } else if (block.type === 'text') { - const id = message.uuid?.toString() || generateMessageId() - chunks.push({ - type: 'text-start', - id, - providerMetadata - }) - chunks.push({ - type: 'text-delta', - id, - text: (block as { text: string }).text, - providerMetadata - }) - chunks.push({ - type: 'text-end', - id, - providerMetadata - }) + } else if (block.type === 'text' && !isSynthetic) { + const rawText = (block as { text: string }).text + const filteredText = filterCommandTags(rawText) + + // Only push text chunks if there's content after filtering + if (filteredText) { + const id = message.uuid?.toString() || generateMessageId() + chunks.push({ + type: 'text-start', + id, + providerMetadata + }) + chunks.push({ + type: 'text-delta', + id, + text: filteredText, + providerMetadata + }) + chunks.push({ + type: 'text-end', + id, + providerMetadata + }) + } } else { logger.warn('Unhandled user content block', { type: (block as any).type }) } @@ -517,6 +550,10 @@ function handleContentBlockDelta( logger.warn('Received text_delta for unknown block', { index }) return } + block.text = stripLocalCommandTags(block.text) + if (!block.text) { + break + } chunks.push({ type: 'text-delta', id: block.id, diff --git a/src/main/services/mcp/oauth/callback.ts b/src/main/services/mcp/oauth/callback.ts index 22d5b4c6bd..0cf63853c4 100644 --- a/src/main/services/mcp/oauth/callback.ts +++ b/src/main/services/mcp/oauth/callback.ts @@ -1,12 +1,43 @@ import { loggerService } from '@logger' -import EventEmitter from 'events' +import { getAppLanguage, locales } from '@main/utils/language' +import type EventEmitter from 'events' import http from 'http' import { URL } from 'url' -import { OAuthCallbackServerOptions } from './types' +import type { OAuthCallbackServerOptions } from './types' const logger = loggerService.withContext('MCP:OAuthCallbackServer') +function getTranslation(key: string): string { + const language = getAppLanguage() + const localeData = locales[language] + + if (!localeData) { + logger.warn(`No locale data found for language: ${language}`) + return key + } + + const translations = localeData.translation as any + if (!translations) { + logger.warn(`No translations found for language: ${language}`) + return key + } + + const keys = key.split('.') + let value = translations + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k] + } else { + logger.warn(`Translation key not found: ${key} (failed at: ${k})`) + return key // fallback to key if translation not found + } + } + + return typeof value === 'string' ? value : key +} + export class CallBackServer { private server: Promise private events: EventEmitter @@ -28,6 +59,55 @@ export class CallBackServer { if (code) { // Emit the code event this.events.emit('auth-code-received', code) + // Send success response to browser + const title = getTranslation('settings.mcp.oauth.callback.title') + const message = getTranslation('settings.mcp.oauth.callback.message') + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(` + + + + + ${title} + + + +
+

${title}

+

${message}

+
+ + + `) + } else { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing authorization code') } } catch (error) { logger.error('Error processing OAuth callback:', error as Error) diff --git a/src/main/services/mcp/oauth/provider.ts b/src/main/services/mcp/oauth/provider.ts index 811ce8a275..29fdfc0c50 100644 --- a/src/main/services/mcp/oauth/provider.ts +++ b/src/main/services/mcp/oauth/provider.ts @@ -2,13 +2,17 @@ import path from 'node:path' import { loggerService } from '@logger' import { getConfigDir } from '@main/utils/file' -import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth' -import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth' +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth' +import type { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth' import open from 'open' import { sanitizeUrl } from 'strict-url-sanitise' import { JsonFileStorage } from './storage' -import { OAuthProviderOptions } from './types' +import type { OAuthProviderOptions } from './types' const logger = loggerService.withContext('MCP:OAuthClientProvider') diff --git a/src/main/services/mcp/oauth/storage.ts b/src/main/services/mcp/oauth/storage.ts index d2dbb589cc..1a872d1e1c 100644 --- a/src/main/services/mcp/oauth/storage.ts +++ b/src/main/services/mcp/oauth/storage.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens @@ -7,7 +7,8 @@ import { import fs from 'fs/promises' import path from 'path' -import { IOAuthStorage, OAuthStorageData, OAuthStorageSchema } from './types' +import type { IOAuthStorage, OAuthStorageData } from './types' +import { OAuthStorageSchema } from './types' const logger = loggerService.withContext('MCP:OAuthStorage') diff --git a/src/main/services/mcp/oauth/types.ts b/src/main/services/mcp/oauth/types.ts index f081fbe70c..1eecf08d4f 100644 --- a/src/main/services/mcp/oauth/types.ts +++ b/src/main/services/mcp/oauth/types.ts @@ -1,9 +1,9 @@ -import { +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' -import EventEmitter from 'events' +import type EventEmitter from 'events' import * as z from 'zod' export interface OAuthStorageData { diff --git a/src/main/services/memory/MemoryService.ts b/src/main/services/memory/MemoryService.ts index 85f182b686..3466e2c3c6 100644 --- a/src/main/services/memory/MemoryService.ts +++ b/src/main/services/memory/MemoryService.ts @@ -1,4 +1,5 @@ -import { Client, createClient } from '@libsql/client' +import type { Client } from '@libsql/client' +import { createClient } from '@libsql/client' import { loggerService } from '@logger' import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings' import type { diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index b2943e30ec..80cd547671 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import { isLinux } from '@main/constant' -import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' +import type { OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' +import { BuiltinOcrProviderIds } from '@types' import { ovOcrService } from './builtin/OvOcrService' import { ppocrService } from './builtin/PpocrService' diff --git a/src/main/services/ocr/builtin/OcrBaseService.ts b/src/main/services/ocr/builtin/OcrBaseService.ts index 9c36e79c3a..dabe2e50b8 100644 --- a/src/main/services/ocr/builtin/OcrBaseService.ts +++ b/src/main/services/ocr/builtin/OcrBaseService.ts @@ -1,4 +1,4 @@ -import { OcrHandler } from '@types' +import type { OcrHandler } from '@types' export abstract class OcrBaseService { abstract ocr: OcrHandler diff --git a/src/main/services/ocr/builtin/OvOcrService.ts b/src/main/services/ocr/builtin/OvOcrService.ts index 1650ca8832..052682be64 100644 --- a/src/main/services/ocr/builtin/OvOcrService.ts +++ b/src/main/services/ocr/builtin/OvOcrService.ts @@ -1,6 +1,8 @@ import { loggerService } from '@logger' import { isWin } from '@main/constant' -import { isImageFileMetadata, OcrOvConfig, OcrResult, SupportedOcrFile } from '@types' +import { HOME_CHERRY_DIR } from '@shared/config/constant' +import type { OcrOvConfig, OcrResult, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { exec } from 'child_process' import * as fs from 'fs' import * as os from 'os' @@ -12,7 +14,7 @@ import { OcrBaseService } from './OcrBaseService' const logger = loggerService.withContext('OvOcrService') const execAsync = promisify(exec) -const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat') +const PATH_BAT_FILE = path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr', 'run.npu.bat') export class OvOcrService extends OcrBaseService { constructor() { @@ -29,7 +31,7 @@ export class OvOcrService extends OcrBaseService { } private getOvOcrPath(): string { - return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr') } private getImgDir(): string { diff --git a/src/main/services/ocr/builtin/PpocrService.ts b/src/main/services/ocr/builtin/PpocrService.ts index 4b00be4bed..61c923c542 100644 --- a/src/main/services/ocr/builtin/PpocrService.ts +++ b/src/main/services/ocr/builtin/PpocrService.ts @@ -1,5 +1,6 @@ import { loadOcrImage } from '@main/utils/ocr' -import { ImageFileMetadata, isImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types' +import type { ImageFileMetadata, OcrPpocrConfig, OcrResult, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { net } from 'electron' import * as z from 'zod' diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts index b496df398e..f166718e4a 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,7 +1,8 @@ import { isLinux, isWin } from '@main/constant' import { loadOcrImage } from '@main/utils/ocr' import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' -import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types' +import type { ImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { OcrBaseService } from './OcrBaseService' diff --git a/src/main/services/ocr/builtin/TesseractService.ts b/src/main/services/ocr/builtin/TesseractService.ts index 9fd7bbcf01..8d41ce085d 100644 --- a/src/main/services/ocr/builtin/TesseractService.ts +++ b/src/main/services/ocr/builtin/TesseractService.ts @@ -2,12 +2,15 @@ import { loggerService } from '@logger' import { getIpCountry } from '@main/utils/ipService' import { loadOcrImage } from '@main/utils/ocr' import { MB } from '@shared/config/constant' -import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types' +import type { ImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types' +import { isImageFileMetadata } from '@types' import { app } from 'electron' import fs from 'fs' import { isEqual } from 'lodash' import path from 'path' -import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' +import type { LanguageCode } from 'tesseract.js' +import type Tesseract from 'tesseract.js' +import { createWorker } from 'tesseract.js' import { OcrBaseService } from './OcrBaseService' diff --git a/src/main/services/remotefile/BaseFileService.ts b/src/main/services/remotefile/BaseFileService.ts index ff06eb0b44..49067b424d 100644 --- a/src/main/services/remotefile/BaseFileService.ts +++ b/src/main/services/remotefile/BaseFileService.ts @@ -1,4 +1,4 @@ -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' export abstract class BaseFileService { protected readonly provider: Provider diff --git a/src/main/services/remotefile/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts index 14f622757a..962214af51 100644 --- a/src/main/services/remotefile/FileServiceManager.ts +++ b/src/main/services/remotefile/FileServiceManager.ts @@ -1,6 +1,6 @@ -import { Provider } from '@types' +import type { Provider } from '@types' -import { BaseFileService } from './BaseFileService' +import type { BaseFileService } from './BaseFileService' import { GeminiService } from './GeminiService' import { MistralService } from './MistralService' import { OpenaiService } from './OpenAIService' diff --git a/src/main/services/remotefile/GeminiService.ts b/src/main/services/remotefile/GeminiService.ts index ba5a8fae80..8b2000327b 100644 --- a/src/main/services/remotefile/GeminiService.ts +++ b/src/main/services/remotefile/GeminiService.ts @@ -1,10 +1,11 @@ -import { File, Files, FileState, GoogleGenAI } from '@google/genai' +import { cacheService } from '@data/CacheService' +import type { File, Files } from '@google/genai' +import { FileState, GoogleGenAI } from '@google/genai' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' import { v4 as uuidv4 } from 'uuid' -import { CacheService } from '../CacheService' import { BaseFileService } from './BaseFileService' const logger = loggerService.withContext('GeminiService') @@ -67,7 +68,7 @@ export class GeminiService extends BaseFileService { // 只缓存成功的文件 if (status === 'success') { const cacheKey = `${GeminiService.FILE_LIST_CACHE_KEY}_${response.fileId}` - CacheService.set(cacheKey, response, GeminiService.FILE_CACHE_DURATION) + cacheService.set(cacheKey, response, GeminiService.FILE_CACHE_DURATION) } return response @@ -84,7 +85,7 @@ export class GeminiService extends BaseFileService { async retrieveFile(fileId: string): Promise { try { - const cachedResponse = CacheService.get(`${GeminiService.FILE_LIST_CACHE_KEY}_${fileId}`) + const cachedResponse = cacheService.get(`${GeminiService.FILE_LIST_CACHE_KEY}_${fileId}`) logger.debug('[GeminiService] cachedResponse', cachedResponse) if (cachedResponse) { return cachedResponse @@ -130,7 +131,7 @@ export class GeminiService extends BaseFileService { async listFiles(): Promise { try { - const cachedList = CacheService.get(GeminiService.FILE_LIST_CACHE_KEY) + const cachedList = cacheService.get(GeminiService.FILE_LIST_CACHE_KEY) if (cachedList) { return cachedList } @@ -153,7 +154,7 @@ export class GeminiService extends BaseFileService { file } } - CacheService.set( + cacheService.set( `${GeminiService.FILE_LIST_CACHE_KEY}_${file.name}`, fileResponse, GeminiService.FILE_CACHE_DURATION @@ -173,7 +174,7 @@ export class GeminiService extends BaseFileService { } // 更新文件列表缓存 - CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, fileList, GeminiService.LIST_CACHE_DURATION) + cacheService.set(GeminiService.FILE_LIST_CACHE_KEY, fileList, GeminiService.LIST_CACHE_DURATION) return fileList } catch (error) { logger.error('Error listing files from Gemini:', error as Error) diff --git a/src/main/services/remotefile/MistralService.ts b/src/main/services/remotefile/MistralService.ts index d3867a619d..6045e25972 100644 --- a/src/main/services/remotefile/MistralService.ts +++ b/src/main/services/remotefile/MistralService.ts @@ -2,8 +2,8 @@ import fs from 'node:fs/promises' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { Mistral } from '@mistralai/mistralai' -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { Mistral } from '@mistralai/mistralai' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' import { MistralClientManager } from '../MistralClientManager' import { BaseFileService } from './BaseFileService' diff --git a/src/main/services/remotefile/OpenAIService.ts b/src/main/services/remotefile/OpenAIService.ts index 734bf6f26a..f92d6abebf 100644 --- a/src/main/services/remotefile/OpenAIService.ts +++ b/src/main/services/remotefile/OpenAIService.ts @@ -1,10 +1,10 @@ import OpenAI from '@cherrystudio/openai' +import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import { fileStorage } from '@main/services/FileStorage' -import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' +import type { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types' import * as fs from 'fs' -import { CacheService } from '../CacheService' import { BaseFileService } from './BaseFileService' const logger = loggerService.withContext('OpenAIService') @@ -38,7 +38,7 @@ export class OpenaiService extends BaseFileService { throw new Error('File id not found in response') } // 映射RemoteFileId到UIFileId上 - CacheService.set( + cacheService.set( OpenaiService.generateUIFileIdCacheKey(file.id), response.id, OpenaiService.FILE_CACHE_DURATION @@ -88,7 +88,7 @@ export class OpenaiService extends BaseFileService { async deleteFile(fileId: string): Promise { try { - const cachedRemoteFileId = CacheService.get(OpenaiService.generateUIFileIdCacheKey(fileId)) + const cachedRemoteFileId = cacheService.get(OpenaiService.generateUIFileIdCacheKey(fileId)) await this.client.files.delete(cachedRemoteFileId || fileId) logger.debug(`File ${fileId} deleted`) } catch (error) { @@ -100,7 +100,7 @@ export class OpenaiService extends BaseFileService { async retrieveFile(fileId: string): Promise { try { // 尝试反映射RemoteFileId - const cachedRemoteFileId = CacheService.get(OpenaiService.generateUIFileIdCacheKey(fileId)) + const cachedRemoteFileId = cacheService.get(OpenaiService.generateUIFileIdCacheKey(fileId)) const response = await this.client.files.retrieve(cachedRemoteFileId || fileId) return { diff --git a/src/main/services/urlschema/mcp-install.ts b/src/main/services/urlschema/mcp-install.ts index ceb2e41ece..2d783255e4 100644 --- a/src/main/services/urlschema/mcp-install.ts +++ b/src/main/services/urlschema/mcp-install.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import { IpcChannel } from '@shared/IpcChannel' -import { MCPServer } from '@types' +import type { MCPServer } from '@types' import { windowService } from '../WindowService' @@ -9,13 +9,20 @@ const logger = loggerService.withContext('URLSchema:handleMcpProtocolUrl') function installMCPServer(server: MCPServer) { const mainWindow = windowService.getMainWindow() + const now = Date.now() - if (!server.id) { - server.id = nanoid() + const payload: MCPServer = { + ...server, + id: server.id ?? nanoid(), + installSource: 'protocol', + isTrusted: false, + isActive: false, + trustedAt: undefined, + installedAt: server.installedAt ?? now } if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IpcChannel.Mcp_AddServer, server) + mainWindow.webContents.send(IpcChannel.Mcp_AddServer, payload) } } diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts index f6f6d2c40e..74a4f63850 100644 --- a/src/main/utils/__tests__/file.test.ts +++ b/src/main/utils/__tests__/file.test.ts @@ -264,7 +264,7 @@ describe('file', () => { const buffer = iconv.encode(content, 'GB18030') // 模拟文件读取和编码检测 - vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer) + vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer as unknown as string) vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030') const result = await readTextFileWithAutoEncoding(mockFilePath) @@ -276,7 +276,7 @@ describe('file', () => { const buffer = iconv.encode(content, 'UTF-8') // 模拟文件读取 - vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer) + vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer as unknown as string) vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030') const result = await readTextFileWithAutoEncoding(mockFilePath) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 20305d1c9e..1432dccc8a 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -5,8 +5,9 @@ import os from 'node:os' import path from 'node:path' import { loggerService } from '@logger' -import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant' -import { FileMetadata, FileTypes, NotesTreeNode } from '@types' +import { audioExts, documentExts, HOME_CHERRY_DIR, imageExts, MB, textExts, videoExts } from '@shared/config/constant' +import type { FileMetadata, NotesTreeNode } from '@types' +import { FileTypes } from '@types' import chardet from 'chardet' import { app } from 'electron' import iconv from 'iconv-lite' @@ -159,7 +160,7 @@ export function getNotesDir() { } export function getConfigDir() { - return path.join(os.homedir(), '.cherrystudio', 'config') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'config') } export function getCacheDir() { @@ -171,7 +172,7 @@ export function getAppConfigDir(name: string) { } export function getMcpDir() { - return path.join(os.homedir(), '.cherrystudio', 'mcp') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'mcp') } /** diff --git a/src/main/utils/fileOperations.ts b/src/main/utils/fileOperations.ts new file mode 100644 index 0000000000..6352126e2a --- /dev/null +++ b/src/main/utils/fileOperations.ts @@ -0,0 +1,223 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { loggerService } from '@logger' + +import { isPathInside } from './file' + +const logger = loggerService.withContext('Utils:FileOperations') + +const MAX_RECURSION_DEPTH = 1000 + +/** + * Recursively copy a directory and all its contents + * @param source - Source directory path (must be absolute) + * @param destination - Destination directory path (must be absolute) + * @param options - Copy options + * @param depth - Current recursion depth (internal use) + * @throws If copy operation fails or paths are invalid + */ +export async function copyDirectoryRecursive( + source: string, + destination: string, + options?: { allowedBasePath?: string }, + depth = 0 +): Promise { + // Input validation + if (!source || !destination) { + throw new TypeError('Source and destination paths are required') + } + + if (!path.isAbsolute(source) || !path.isAbsolute(destination)) { + throw new Error('Source and destination paths must be absolute') + } + + // Depth limit to prevent stack overflow + if (depth > MAX_RECURSION_DEPTH) { + throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`) + } + + // Path validation - ensure operations stay within allowed boundaries + if (options?.allowedBasePath) { + if (!isPathInside(source, options.allowedBasePath)) { + throw new Error(`Source path is outside allowed directory: ${source}`) + } + if (!isPathInside(destination, options.allowedBasePath)) { + throw new Error(`Destination path is outside allowed directory: ${destination}`) + } + } + + try { + // Verify source exists and is a directory + const sourceStats = await fs.promises.lstat(source) + if (!sourceStats.isDirectory()) { + throw new Error(`Source is not a directory: ${source}`) + } + + // Create destination directory + await fs.promises.mkdir(destination, { recursive: true }) + logger.debug('Created destination directory', { destination }) + + // Read source directory + const entries = await fs.promises.readdir(source, { withFileTypes: true }) + + // Copy each entry + for (const entry of entries) { + const sourcePath = path.join(source, entry.name) + const destPath = path.join(destination, entry.name) + + // Use lstat to detect symlinks and prevent following them + const entryStats = await fs.promises.lstat(sourcePath) + + if (entryStats.isSymbolicLink()) { + logger.warn('Skipping symlink for security', { path: sourcePath }) + continue + } + + if (entryStats.isDirectory()) { + // Recursively copy subdirectory + await copyDirectoryRecursive(sourcePath, destPath, options, depth + 1) + } else if (entryStats.isFile()) { + // Copy file with error handling for race conditions + try { + await fs.promises.copyFile(sourcePath, destPath) + // Preserve file permissions + await fs.promises.chmod(destPath, entryStats.mode) + logger.debug('Copied file', { from: sourcePath, to: destPath }) + } catch (error) { + // Handle race condition where file was deleted during copy + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.warn('File disappeared during copy', { sourcePath }) + continue + } + throw error + } + } else { + // Skip special files (pipes, sockets, devices, etc.) + logger.debug('Skipping special file', { path: sourcePath }) + } + } + + logger.info('Directory copied successfully', { from: source, to: destination, depth }) + } catch (error) { + logger.error('Failed to copy directory', { source, destination, depth, error }) + throw error + } +} + +/** + * Recursively delete a directory and all its contents + * @param dirPath - Directory path to delete (must be absolute) + * @param options - Delete options + * @throws If deletion fails or path is invalid + */ +export async function deleteDirectoryRecursive(dirPath: string, options?: { allowedBasePath?: string }): Promise { + // Input validation + if (!dirPath) { + throw new TypeError('Directory path is required') + } + + if (!path.isAbsolute(dirPath)) { + throw new Error('Directory path must be absolute') + } + + // Path validation - ensure operations stay within allowed boundaries + if (options?.allowedBasePath) { + if (!isPathInside(dirPath, options.allowedBasePath)) { + throw new Error(`Path is outside allowed directory: ${dirPath}`) + } + } + + try { + // Verify path exists before attempting deletion + try { + const stats = await fs.promises.lstat(dirPath) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${dirPath}`) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.warn('Directory already deleted', { dirPath }) + return + } + throw error + } + + // Node.js 14.14+ has fs.rm with recursive option + await fs.promises.rm(dirPath, { recursive: true, force: true }) + logger.info('Directory deleted successfully', { dirPath }) + } catch (error) { + logger.error('Failed to delete directory', { dirPath, error }) + throw error + } +} + +/** + * Get total size of a directory (in bytes) + * @param dirPath - Directory path (must be absolute) + * @param options - Size calculation options + * @param depth - Current recursion depth (internal use) + * @returns Total size in bytes + * @throws If size calculation fails or path is invalid + */ +export async function getDirectorySize( + dirPath: string, + options?: { allowedBasePath?: string }, + depth = 0 +): Promise { + // Input validation + if (!dirPath) { + throw new TypeError('Directory path is required') + } + + if (!path.isAbsolute(dirPath)) { + throw new Error('Directory path must be absolute') + } + + // Depth limit to prevent stack overflow + if (depth > MAX_RECURSION_DEPTH) { + throw new Error(`Maximum recursion depth exceeded: ${MAX_RECURSION_DEPTH}`) + } + + // Path validation - ensure operations stay within allowed boundaries + if (options?.allowedBasePath) { + if (!isPathInside(dirPath, options.allowedBasePath)) { + throw new Error(`Path is outside allowed directory: ${dirPath}`) + } + } + + let totalSize = 0 + + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name) + + // Use lstat to detect symlinks and prevent following them + const entryStats = await fs.promises.lstat(entryPath) + + if (entryStats.isSymbolicLink()) { + logger.debug('Skipping symlink in size calculation', { path: entryPath }) + continue + } + + if (entryStats.isDirectory()) { + // Recursively get size of subdirectory + totalSize += await getDirectorySize(entryPath, options, depth + 1) + } else if (entryStats.isFile()) { + // Get file size from lstat (already have it) + totalSize += entryStats.size + } else { + // Skip special files + logger.debug('Skipping special file in size calculation', { path: entryPath }) + } + } + + logger.debug('Calculated directory size', { dirPath, size: totalSize, depth }) + return totalSize + } catch (error) { + logger.error('Failed to calculate directory size', { dirPath, depth, error }) + throw error + } +} diff --git a/src/main/utils/init.ts b/src/main/utils/init.ts index 63cf69e89b..20884b1eeb 100644 --- a/src/main/utils/init.ts +++ b/src/main/utils/init.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' import { isLinux, isPortable, isWin } from '@main/constant' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import { app } from 'electron' // Please don't import any other modules which is not node/electron built-in modules @@ -17,7 +18,7 @@ function hasWritePermission(path: string) { } function getConfigDir() { - return path.join(os.homedir(), '.cherrystudio', 'config') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'config') } export function initAppDataDir() { diff --git a/src/main/utils/locales.ts b/src/main/utils/language.ts similarity index 57% rename from src/main/utils/locales.ts rename to src/main/utils/language.ts index b41cba7c75..a0f2c8dc9b 100644 --- a/src/main/utils/locales.ts +++ b/src/main/utils/language.ts @@ -1,3 +1,8 @@ +import { preferenceService } from '@data/PreferenceService' +import { defaultLanguage } from '@shared/config/constant' +import type { LanguageVarious } from '@shared/data/preference/preferenceTypes' +import { app } from 'electron' + import EnUs from '../../renderer/src/i18n/locales/en-us.json' import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json' import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json' @@ -10,7 +15,7 @@ import JaJP from '../../renderer/src/i18n/translate/ja-jp.json' import ptPT from '../../renderer/src/i18n/translate/pt-pt.json' import RuRu from '../../renderer/src/i18n/translate/ru-ru.json' -const locales = Object.fromEntries( +export const locales = Object.fromEntries( [ ['en-US', EnUs], ['zh-CN', ZhCn], @@ -25,4 +30,18 @@ const locales = Object.fromEntries( ].map(([locale, translation]) => [locale, { translation }]) ) -export { locales } +export const getAppLanguage = (): LanguageVarious => { + const language = preferenceService.get('app.language') + const appLocale = app.getLocale() + + if (language) { + return language + } + + return (Object.keys(locales).includes(appLocale) ? appLocale : defaultLanguage) as LanguageVarious +} + +export const getI18n = (): Record => { + const language = getAppLanguage() + return locales[language] +} diff --git a/src/main/utils/markdownParser.ts b/src/main/utils/markdownParser.ts new file mode 100644 index 0000000000..9c3d7c9540 --- /dev/null +++ b/src/main/utils/markdownParser.ts @@ -0,0 +1,309 @@ +import { loggerService } from '@logger' +import type { PluginError, PluginMetadata } from '@types' +import * as crypto from 'crypto' +import * as fs from 'fs' +import matter from 'gray-matter' +import * as yaml from 'js-yaml' +import * as path from 'path' + +import { getDirectorySize } from './fileOperations' + +const logger = loggerService.withContext('Utils:MarkdownParser') + +/** + * Parse plugin metadata from a markdown file with frontmatter + * @param filePath Absolute path to the markdown file + * @param sourcePath Relative source path from plugins directory + * @param category Category name derived from parent folder + * @param type Plugin type (agent or command) + * @returns PluginMetadata object with parsed frontmatter and file info + */ +export async function parsePluginMetadata( + filePath: string, + sourcePath: string, + category: string, + type: 'agent' | 'command' +): Promise { + const content = await fs.promises.readFile(filePath, 'utf8') + const stats = await fs.promises.stat(filePath) + + // Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks + const { data } = matter(content, { + engines: { + yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object + } + }) + + // Calculate content hash for integrity checking + const contentHash = crypto.createHash('sha256').update(content).digest('hex') + + // Extract filename + const filename = path.basename(filePath) + + // Parse allowed_tools - handle both array and comma-separated string + let allowedTools: string[] | undefined + if (data['allowed-tools'] || data.allowed_tools) { + const toolsData = data['allowed-tools'] || data.allowed_tools + if (Array.isArray(toolsData)) { + allowedTools = toolsData + } else if (typeof toolsData === 'string') { + allowedTools = toolsData + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Parse tools - similar handling + let tools: string[] | undefined + if (data.tools) { + if (Array.isArray(data.tools)) { + tools = data.tools + } else if (typeof data.tools === 'string') { + tools = data.tools + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Parse tags + let tags: string[] | undefined + if (data.tags) { + if (Array.isArray(data.tags)) { + tags = data.tags + } else if (typeof data.tags === 'string') { + tags = data.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + return { + sourcePath, + filename, + name: data.name || filename.replace(/\.md$/, ''), + description: data.description, + allowed_tools: allowedTools, + tools, + category, + type, + tags, + version: data.version, + author: data.author, + size: stats.size, + contentHash + } +} + +/** + * Recursively find all directories containing SKILL.md + * + * @param dirPath - Directory to search in + * @param basePath - Base path for calculating relative source paths + * @param maxDepth - Maximum depth to search (default: 10 to prevent infinite loops) + * @param currentDepth - Current search depth (used internally) + * @returns Array of objects with absolute folder path and relative source path + */ +export async function findAllSkillDirectories( + dirPath: string, + basePath: string, + maxDepth = 10, + currentDepth = 0 +): Promise> { + const results: Array<{ folderPath: string; sourcePath: string }> = [] + + // Prevent excessive recursion + if (currentDepth > maxDepth) { + return results + } + + // Check if current directory contains SKILL.md + const skillMdPath = path.join(dirPath, 'SKILL.md') + + try { + await fs.promises.stat(skillMdPath) + // Found SKILL.md in this directory + const relativePath = path.relative(basePath, dirPath) + results.push({ + folderPath: dirPath, + sourcePath: relativePath + }) + return results + } catch { + // SKILL.md not in current directory + } + + // Only search subdirectories if current directory doesn't have SKILL.md + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isDirectory()) { + const subDirPath = path.join(dirPath, entry.name) + const subResults = await findAllSkillDirectories(subDirPath, basePath, maxDepth, currentDepth + 1) + results.push(...subResults) + } + } + } catch (error: any) { + // Ignore errors when reading subdirectories (e.g., permission denied) + logger.debug('Failed to read subdirectory during skill search', { + dirPath, + error: error.message + }) + } + + return results +} + +/** + * Parse metadata from SKILL.md within a skill folder + * + * @param skillFolderPath - Absolute path to skill folder (must be absolute and contain SKILL.md) + * @param sourcePath - Relative path from plugins base (e.g., "skills/my-skill") + * @param category - Category name (typically "skills" for flat structure) + * @returns PluginMetadata with folder name as filename (no extension) + * @throws PluginError if SKILL.md not found or parsing fails + */ +export async function parseSkillMetadata( + skillFolderPath: string, + sourcePath: string, + category: string +): Promise { + // Input validation + if (!skillFolderPath || !path.isAbsolute(skillFolderPath)) { + throw { + type: 'INVALID_METADATA', + reason: 'Skill folder path must be absolute', + path: skillFolderPath + } as PluginError + } + + // Look for SKILL.md directly in this folder (no recursion) + const skillMdPath = path.join(skillFolderPath, 'SKILL.md') + + // Check if SKILL.md exists + try { + await fs.promises.stat(skillMdPath) + } catch (error: any) { + if (error.code === 'ENOENT') { + logger.error('SKILL.md not found in skill folder', { skillMdPath }) + throw { + type: 'FILE_NOT_FOUND', + path: skillMdPath, + message: 'SKILL.md not found in skill folder' + } as PluginError + } + throw error + } + + // Read SKILL.md content + let content: string + try { + content = await fs.promises.readFile(skillMdPath, 'utf8') + } catch (error: any) { + logger.error('Failed to read SKILL.md', { skillMdPath, error }) + throw { + type: 'READ_FAILED', + path: skillMdPath, + reason: error.message || 'Unknown error' + } as PluginError + } + + // Parse frontmatter safely with FAILSAFE_SCHEMA to prevent deserialization attacks + let data: any + try { + const parsed = matter(content, { + engines: { + yaml: (s) => yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA }) as object + } + }) + data = parsed.data + } catch (error: any) { + logger.error('Failed to parse SKILL.md frontmatter', { skillMdPath, error }) + throw { + type: 'INVALID_METADATA', + reason: `Failed to parse frontmatter: ${error.message}`, + path: skillMdPath + } as PluginError + } + + // Calculate hash of SKILL.md only (not entire folder) + // Note: This means changes to other files in the skill won't trigger cache invalidation + // This is intentional - only SKILL.md metadata changes should trigger updates + const contentHash = crypto.createHash('sha256').update(content).digest('hex') + + // Get folder name as identifier (NO EXTENSION) + const folderName = path.basename(skillFolderPath) + + // Get total folder size + let folderSize: number + try { + folderSize = await getDirectorySize(skillFolderPath) + } catch (error: any) { + logger.error('Failed to calculate skill folder size', { skillFolderPath, error }) + // Use 0 as fallback instead of failing completely + folderSize = 0 + } + + // Parse tools (skills use 'tools', not 'allowed_tools') + let tools: string[] | undefined + if (data.tools) { + if (Array.isArray(data.tools)) { + // Validate all elements are strings + tools = data.tools.filter((t) => typeof t === 'string') + } else if (typeof data.tools === 'string') { + tools = data.tools + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Parse tags + let tags: string[] | undefined + if (data.tags) { + if (Array.isArray(data.tags)) { + // Validate all elements are strings + tags = data.tags.filter((t) => typeof t === 'string') + } else if (typeof data.tags === 'string') { + tags = data.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + } + + // Validate and sanitize name + const name = typeof data.name === 'string' && data.name.trim() ? data.name.trim() : folderName + + // Validate and sanitize description + const description = + typeof data.description === 'string' && data.description.trim() ? data.description.trim() : undefined + + // Validate version and author + const version = typeof data.version === 'string' ? data.version : undefined + const author = typeof data.author === 'string' ? data.author : undefined + + logger.debug('Successfully parsed skill metadata', { + skillFolderPath, + folderName, + size: folderSize + }) + + return { + sourcePath, // e.g., "skills/my-skill" + filename: folderName, // e.g., "my-skill" (folder name, NO .md extension) + name, + description, + tools, + category, // "skills" for flat structure + type: 'skill', + tags, + version, + author, + size: folderSize, + contentHash // Hash of SKILL.md content only + } +} diff --git a/src/main/utils/ocr.ts b/src/main/utils/ocr.ts index 493b837dd9..fdb5ff1743 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -1,4 +1,4 @@ -import { ImageFileMetadata } from '@types' +import type { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' const preprocessImage = async (buffer: Buffer): Promise => { diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index f028f2d3c7..f36e86861d 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { HOME_CHERRY_DIR } from '@shared/config/constant' import { spawn } from 'child_process' import fs from 'fs' import os from 'os' @@ -46,11 +47,11 @@ export async function getBinaryName(name: string): Promise { export async function getBinaryPath(name?: string): Promise { if (!name) { - return path.join(os.homedir(), '.cherrystudio', 'bin') + return path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') } const binaryName = await getBinaryName(name) - const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin') + const binariesDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin') const binariesDirExists = fs.existsSync(binariesDir) return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName } diff --git a/src/main/utils/systemInfo.ts b/src/main/utils/systemInfo.ts index 84db4efed7..427501726a 100644 --- a/src/main/utils/systemInfo.ts +++ b/src/main/utils/systemInfo.ts @@ -1,6 +1,8 @@ +import { preferenceService } from '@data/PreferenceService' import { app } from 'electron' import macosRelease from 'macos-release' import os from 'os' +import { v4 as uuidv4 } from 'uuid' /** * System information interface @@ -90,3 +92,19 @@ export function generateUserAgent(): string { return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36` } + +/** + * Get or generate a unique client ID + * @returns {string} Client ID + */ +export function getClientId(): string { + let clientId = preferenceService.get('app.user.id') + + // If it's the placeholder value, generate a new UUID + if (!clientId || clientId.length === 0) { + clientId = uuidv4() + preferenceService.set('app.user.id', clientId) + } + + return clientId +} diff --git a/src/main/utils/windowUtil.ts b/src/main/utils/windowUtil.ts index 4000156fff..454ce917f5 100644 --- a/src/main/utils/windowUtil.ts +++ b/src/main/utils/windowUtil.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron' +import type { BrowserWindow } from 'electron' import { isDev, isWin } from '../constant' diff --git a/src/main/utils/zoom.ts b/src/main/utils/zoom.ts index d91d411591..969859a7f9 100644 --- a/src/main/utils/zoom.ts +++ b/src/main/utils/zoom.ts @@ -1,13 +1,12 @@ -import { BrowserWindow } from 'electron' - -import { configManager } from '../services/ConfigManager' +import { preferenceService } from '@data/PreferenceService' +import type { BrowserWindow } from 'electron' export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) { if (reset) { wins.forEach((win) => { win.webContents.setZoomFactor(1) }) - configManager.setZoomFactor(1) + preferenceService.set('app.zoom_factor', 1) return } @@ -15,12 +14,12 @@ export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: bo return } - const currentZoom = configManager.getZoomFactor() + const currentZoom = preferenceService.get('app.zoom_factor') const newZoom = Number((currentZoom + delta).toFixed(1)) if (newZoom >= 0.5 && newZoom <= 2.0) { wins.forEach((win) => { win.webContents.setZoomFactor(newZoom) }) - configManager.setZoomFactor(newZoom) + preferenceService.set('app.zoom_factor', newZoom) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 9004560045..767728a615 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,12 +1,20 @@ +import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import { electronAPI } from '@electron-toolkit/preload' -import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' -import { SpanContext } from '@opentelemetry/api' -import { TerminalConfig, UpgradeChannel } from '@shared/config/constant' +import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' +import type { SpanContext } from '@opentelemetry/api' +import type { TerminalConfig } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' +import type { CacheSyncMessage } from '@shared/data/cache/cacheTypes' +import type { + PreferenceDefaultScopeType, + PreferenceKeyType, + SelectionActionItem +} from '@shared/data/preference/preferenceTypes' +import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' import type { Notification } from '@types' -import { +import type { AddMemoryOptions, AssistantMessage, FileListResponse, @@ -29,13 +37,31 @@ import { StartApiServerStatusResult, StopApiServerStatusResult, SupportedOcrFile, - ThemeMode, WebDavConfig } from '@types' -import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' -import { CreateDirectoryOptions } from 'webdav' +import type { OpenDialogOptions } from 'electron' +import { contextBridge, ipcRenderer, shell, webUtils } from 'electron' +import type { CreateDirectoryOptions } from 'webdav' -import type { ActionItem } from '../renderer/src/types/selectionTypes' +import type { + InstalledPlugin, + InstallPluginOptions, + ListAvailablePluginsResult, + PluginMetadata, + PluginResult, + UninstallPluginOptions, + WritePluginContentOptions +} from '../renderer/src/types/plugin' + +type DirectoryListOptions = { + recursive?: boolean + maxDepth?: number + includeHidden?: boolean + includeFiles?: boolean + includeDirectories?: boolean + maxEntries?: number + searchPattern?: string +} export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) { if (spanContext) { @@ -56,7 +82,7 @@ const api = { ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules), checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate), quitAndInstall: () => ipcRenderer.invoke(IpcChannel.App_QuitAndInstall), - setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), + // setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang), setEnableSpellCheck: (isEnable: boolean) => ipcRenderer.invoke(IpcChannel.App_SetEnableSpellCheck, isEnable), setSpellCheckLanguages: (languages: string[]) => ipcRenderer.invoke(IpcChannel.App_SetSpellCheckLanguages, languages), setLaunchOnBoot: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchOnBoot, isActive), @@ -65,7 +91,7 @@ const api = { setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive), setTestPlan: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTestPlan, isActive), setTestChannel: (channel: UpgradeChannel) => ipcRenderer.invoke(IpcChannel.App_SetTestChannel, channel), - setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), + // setTheme: (theme: ThemeMode) => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), handleZoomFactor: (delta: number, reset: boolean = false) => ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive), @@ -190,6 +216,8 @@ const api = { openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath), getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath), + listDirectory: (dirPath: string, options?: DirectoryListOptions) => + ipcRenderer.invoke(IpcChannel.File_ListDirectory, dirPath, options), checkFileName: (dirPath: string, fileName: string, isFile: boolean) => ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile), validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath), @@ -415,23 +443,24 @@ const api = { writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text), determineToolbarSize: (width: number, height: number) => ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height), - setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled), - setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode), - setFollowToolbar: (isFollowToolbar: boolean) => - ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar), - setRemeberWinSize: (isRemeberWinSize: boolean) => - ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize), - setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode), - setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList), - processAction: (actionItem: ActionItem, isFullScreen: boolean = false) => + processAction: (actionItem: SelectionActionItem, isFullScreen: boolean = false) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen), closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) }, + agentTools: { + respondToPermission: (payload: { + requestId: string + behavior: 'allow' | 'deny' + updatedInput?: Record + message?: string + updatedPermissions?: PermissionUpdate[] + }) => ipcRenderer.invoke(IpcChannel.AgentToolPermission_Response, payload) + }, quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text), - setDisableHardwareAcceleration: (isDisable: boolean) => - ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable), + // setDisableHardwareAcceleration: (isDisable: boolean) => + // ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable), trace: { saveData: (topicId: string) => ipcRenderer.invoke(IpcChannel.TRACE_SAVE_DATA, topicId), getData: (topicId: string, traceId: string, modelName?: string) => @@ -502,11 +531,86 @@ const api = { } } }, + // CacheService related APIs + cache: { + // Broadcast sync message to other windows + broadcastSync: (message: CacheSyncMessage): void => ipcRenderer.send(IpcChannel.Cache_Sync, message), + + // Listen for sync messages from other windows + onSync: (callback: (message: CacheSyncMessage) => void) => { + const listener = (_: any, message: CacheSyncMessage) => callback(message) + ipcRenderer.on(IpcChannel.Cache_Sync, listener) + return () => ipcRenderer.off(IpcChannel.Cache_Sync, listener) + } + }, + + // PreferenceService related APIs + // DO NOT MODIFY THIS SECTION + preference: { + get: (key: K): Promise => + ipcRenderer.invoke(IpcChannel.Preference_Get, key), + set: (key: K, value: PreferenceDefaultScopeType[K]): Promise => + ipcRenderer.invoke(IpcChannel.Preference_Set, key, value), + getMultiple: (keys: PreferenceKeyType[]): Promise> => + ipcRenderer.invoke(IpcChannel.Preference_GetMultiple, keys), + setMultiple: (updates: Partial) => + ipcRenderer.invoke(IpcChannel.Preference_SetMultiple, updates), + getAll: (): Promise => ipcRenderer.invoke(IpcChannel.Preference_GetAll), + subscribe: (keys: PreferenceKeyType[]) => ipcRenderer.invoke(IpcChannel.Preference_Subscribe, keys), + onChanged: (callback: (key: PreferenceKeyType, value: any) => void) => { + const listener = (_: any, key: PreferenceKeyType, value: any) => callback(key, value) + ipcRenderer.on(IpcChannel.Preference_Changed, listener) + return () => ipcRenderer.off(IpcChannel.Preference_Changed, listener) + } + }, + // Data API related APIs + dataApi: { + request: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Request, req), + batch: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Batch, req), + transaction: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Transaction, req), + subscribe: (path: string, callback: (data: any, event: string) => void) => { + const channel = `${IpcChannel.DataApi_Stream}:${path}` + const listener = (_: any, data: any, event: string) => callback(data, event) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.off(channel, listener) + } + }, apiServer: { getStatus: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_GetStatus), start: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Start), restart: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Restart), - stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop) + stop: (): Promise => ipcRenderer.invoke(IpcChannel.ApiServer_Stop), + onReady: (callback: () => void): (() => void) => { + const listener = () => { + callback() + } + ipcRenderer.on(IpcChannel.ApiServer_Ready, listener) + return () => { + ipcRenderer.removeListener(IpcChannel.ApiServer_Ready, listener) + } + } + }, + claudeCodePlugin: { + listAvailable: (): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListAvailable), + install: (options: InstallPluginOptions): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Install, options), + uninstall: (options: UninstallPluginOptions): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_Uninstall, options), + listInstalled: (agentId: string): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ListInstalled, agentId), + invalidateCache: (): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_InvalidateCache), + readContent: (sourcePath: string): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_ReadContent, sourcePath), + writeContent: (options: WritePluginContentOptions): Promise> => + ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) + }, + webSocket: { + start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), + stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), + status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), + sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), + getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) } } diff --git a/src/preload/preload.d.ts b/src/preload/preload.d.ts index 7e46ae82a8..a7c633130a 100644 --- a/src/preload/preload.d.ts +++ b/src/preload/preload.d.ts @@ -1,4 +1,4 @@ -import { ElectronAPI } from '@electron-toolkit/preload' +import type { ElectronAPI } from '@electron-toolkit/preload' import type { WindowApiType } from './index' diff --git a/src/preload/simplest.ts b/src/preload/simplest.ts new file mode 100644 index 0000000000..43043aab85 --- /dev/null +++ b/src/preload/simplest.ts @@ -0,0 +1,16 @@ +import { electronAPI } from '@electron-toolkit/preload' +import { contextBridge } from 'electron' + +// Use `contextBridge` APIs to expose Electron APIs to +// renderer only if context isolation is enabled, otherwise +// just add to the DOM global. +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', electronAPI) + } catch (error) { + console.error('[Preload]Failed to expose APIs:', error as Error) + } +} else { + // @ts-ignore (define in dts) + window.electron = electronAPI +} diff --git a/src/renderer/dataRefactorMigrate.html b/src/renderer/dataRefactorMigrate.html new file mode 100644 index 0000000000..6b0287db2c --- /dev/null +++ b/src/renderer/dataRefactorMigrate.html @@ -0,0 +1,61 @@ + + + + + Cherry Studio - Data Refactor Migration + + + + + + + + + diff --git a/src/renderer/dataRefactorTest.html b/src/renderer/dataRefactorTest.html new file mode 100644 index 0000000000..9a8c31a041 --- /dev/null +++ b/src/renderer/dataRefactorTest.html @@ -0,0 +1,62 @@ + + + + + Data Refactor Test Window - PreferenceService Testing + + + + + + + + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 78396c49e7..fb34a13a26 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,16 +1,15 @@ import '@renderer/databases' +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import store, { persistor } from '@renderer/store' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' -import { ToastPortal } from './components/ToastPortal' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' import { CodeStyleProvider } from './context/CodeStyleProvider' -import { HeroUIProvider } from './context/HeroUIProvider' import { NotificationProvider } from './context/NotificationProvider' import StyleSheetManager from './context/StyleSheetManager' import { ThemeProvider } from './context/ThemeProvider' @@ -18,6 +17,8 @@ import Router from './Router' const logger = loggerService.withContext('App.tsx') +preferenceService.preloadAll() + // 创建 React Query 客户端 const queryClient = new QueryClient({ defaultOptions: { @@ -34,24 +35,21 @@ function App(): React.ReactElement { return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ) diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index edaebfa144..fb555d8bc3 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -1,13 +1,14 @@ import '@renderer/databases' -import { FC, useMemo } from 'react' +import type { FC } from 'react' +import { useMemo } from 'react' import { HashRouter, Route, Routes } from 'react-router-dom' import Sidebar from './components/app/Sidebar' import { ErrorBoundary } from './components/ErrorBoundary' import TabsContainer from './components/Tab/TabContainer' import NavigationHandler from './handler/NavigationHandler' -import { useNavbarPosition } from './hooks/useSettings' +import { useNavbarPosition } from './hooks/useNavbar' import CodeToolsPage from './pages/code/CodeToolsPage' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 3f27f9440c..acf2ce69d2 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -4,12 +4,13 @@ */ import { loggerService } from '@logger' -import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' -import { Chunk, ChunkType } from '@renderer/types/chunk' +import { type Chunk, ChunkType } from '@renderer/types/chunk' import { ProviderSpecificError } from '@renderer/types/provider-specific-error' import { formatErrorMessage } from '@renderer/utils/error' import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types' +import type { AISDKWebSearchResult, MCPTool, WebSearchResults } from '@types' +import { WebSearchSource } from '@types' import { AISDKError, type TextStreamPart, type ToolSet } from 'ai' import { ToolCallChunkHandler } from './handleToolCallChunk' @@ -28,18 +29,22 @@ export class AiSdkToChunkAdapter { private onSessionUpdate?: (sessionId: string) => void private responseStartTimestamp: number | null = null private firstTokenTimestamp: number | null = null + private hasTextContent = false + private getSessionWasCleared?: () => boolean constructor( private onChunk: (chunk: Chunk) => void, mcpTools: MCPTool[] = [], accumulate?: boolean, enableWebSearch?: boolean, - onSessionUpdate?: (sessionId: string) => void + onSessionUpdate?: (sessionId: string) => void, + getSessionWasCleared?: () => boolean ) { this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools) this.accumulate = accumulate this.enableWebSearch = enableWebSearch || false this.onSessionUpdate = onSessionUpdate + this.getSessionWasCleared = getSessionWasCleared } private markFirstTokenIfNeeded() { @@ -82,8 +87,9 @@ export class AiSdkToChunkAdapter { } this.resetTimingState() this.responseStartTimestamp = Date.now() - // Reset link converter state at the start of stream + // Reset state at the start of stream this.isFirstChunk = true + this.hasTextContent = false try { while (true) { @@ -127,6 +133,8 @@ export class AiSdkToChunkAdapter { const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue if (agentRawMessage.type === 'init' && agentRawMessage.session_id) { this.onSessionUpdate?.(agentRawMessage.session_id) + } else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) { + this.onSessionUpdate?.(agentRawMessage.session_id) } this.onChunk({ type: ChunkType.RAW, @@ -141,6 +149,7 @@ export class AiSdkToChunkAdapter { }) break case 'text-delta': { + this.hasTextContent = true const processedText = chunk.text || '' let finalText: string @@ -299,6 +308,25 @@ export class AiSdkToChunkAdapter { } case 'finish': { + // Check if session was cleared (e.g., /clear command) and no text was output + const sessionCleared = this.getSessionWasCleared?.() ?? false + if (sessionCleared && !this.hasTextContent) { + // Inject a "context cleared" message for the user + const clearMessage = '✨ Context cleared. Starting fresh conversation.' + this.onChunk({ + type: ChunkType.TEXT_START + }) + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: clearMessage + }) + this.onChunk({ + type: ChunkType.TEXT_COMPLETE, + text: clearMessage + }) + final.text = clearMessage + } + const usage = { completion_tokens: chunk.totalUsage?.outputTokens || 0, prompt_tokens: chunk.totalUsage?.inputTokens || 0, diff --git a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts index 39aefc6a5f..32c7e534e3 100644 --- a/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts +++ b/src/renderer/src/aiCore/chunk/handleToolCallChunk.ts @@ -6,8 +6,16 @@ import { loggerService } from '@logger' import { processKnowledgeReferences } from '@renderer/services/KnowledgeService' -import { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types' -import { Chunk, ChunkType } from '@renderer/types/chunk' +import type { + BaseTool, + MCPCallToolResponse, + MCPTool, + MCPToolResponse, + MCPToolResultContent, + NormalToolResponse +} from '@renderer/types' +import type { Chunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai' const logger = loggerService.withContext('ToolCallChunkHandler') @@ -254,6 +262,7 @@ export class ToolCallChunkHandler { type: 'tool-result' } & TypedToolResult ): void { + // TODO: 基于AI SDK为供应商内置工具做更好的展示和类型安全处理 const { toolCallId, output, input } = chunk if (!toolCallId) { @@ -299,12 +308,7 @@ export class ToolCallChunkHandler { responses: [toolResponse] }) - const images: string[] = [] - for (const content of toolResponse.response?.content || []) { - if (content.type === 'image' && content.data) { - images.push(`data:${content.mimeType};base64,${content.data}`) - } - } + const images = extractImagesFromToolOutput(toolResponse.response) if (images.length) { this.onChunk({ @@ -351,3 +355,41 @@ export class ToolCallChunkHandler { } export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler) + +function extractImagesFromToolOutput(output: unknown): string[] { + if (!output) { + return [] + } + + const contents: unknown[] = [] + + if (isMcpCallToolResponse(output)) { + contents.push(...output.content) + } else if (Array.isArray(output)) { + contents.push(...output) + } else if (hasContentArray(output)) { + contents.push(...output.content) + } + + return contents + .filter(isMcpImageContent) + .map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`) +} + +function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse { + return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content) +} + +function hasContentArray(value: unknown): value is { content: unknown[] } { + return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content) +} + +function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } { + if (typeof content !== 'object' || content === null) { + return false + } + + const resultContent = content as MCPToolResultContent + + return resultContent.type === 'image' && typeof resultContent.data === 'string' +} diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index b748e3c832..b4fa11529f 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -7,20 +7,23 @@ * 2. 暂时保持接口兼容性 */ +import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway' import { createExecutor } from '@cherrystudio/ai-core' +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' -import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' import { addSpan, endSpan } from '@renderer/services/SpanManagerService' -import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' -import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' +import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' +import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } from '@renderer/types' import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes' +import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils' import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic' -import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai' +import { gateway, type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai' import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter' import LegacyAiProvider from './legacy/index' -import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas' -import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder' +import type { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas' +import type { AiSdkMiddlewareConfig } from './middleware/AiSdkMiddlewareBuilder' +import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder' import { buildPlugins } from './plugins/PluginBuilder' import { createAiSdkProvider } from './provider/factory' import { @@ -77,7 +80,7 @@ export default class ModernAiProvider { return this.actualProvider } - public async completions(modelId: string, params: StreamTextParams, config: ModernAiProviderConfig) { + public async completions(modelId: string, params: StreamTextParams, providerConfig: ModernAiProviderConfig) { // 检查model是否存在 if (!this.model) { throw new Error('Model is required for completions. Please use constructor with model parameter.') @@ -85,7 +88,10 @@ export default class ModernAiProvider { // 每次请求时重新生成配置以确保API key轮换生效 this.config = providerToAiSdkConfig(this.actualProvider, this.model) - + logger.debug('Generated provider config for completions', this.config) + if (SUPPORTED_IMAGE_ENDPOINT_LIST.includes(this.config.options.endpoint)) { + providerConfig.isImageGenerationEndpoint = true + } // 准备特殊配置 await prepareSpecialProviderConfig(this.actualProvider, this.config) @@ -96,12 +102,13 @@ export default class ModernAiProvider { // 提前构建中间件 const middlewares = buildAiSdkMiddlewares({ - ...config, - provider: this.actualProvider + ...providerConfig, + provider: this.actualProvider, + assistant: providerConfig.assistant }) logger.debug('Built middlewares in completions', { middlewareCount: middlewares.length, - isImageGeneration: config.isImageGenerationEndpoint + isImageGeneration: providerConfig.isImageGenerationEndpoint }) if (!this.localProvider) { throw new Error('Local provider not created') @@ -109,7 +116,7 @@ export default class ModernAiProvider { // 根据endpoint类型创建对应的模型 let model: AiSdkModel | undefined - if (config.isImageGenerationEndpoint) { + if (providerConfig.isImageGenerationEndpoint) { model = this.localProvider.imageModel(modelId) } else { model = this.localProvider.languageModel(modelId) @@ -125,15 +132,15 @@ export default class ModernAiProvider { params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])] } - if (config.topicId && getEnableDeveloperMode()) { + if (providerConfig.topicId && (await preferenceService.get('app.developer_mode.enabled'))) { // TypeScript类型窄化:确保topicId是string类型 const traceConfig = { - ...config, - topicId: config.topicId + ...providerConfig, + topicId: providerConfig.topicId } return await this._completionsForTrace(model, params, traceConfig) } else { - return await this._completionsOrImageGeneration(model, params, config) + return await this._completionsOrImageGeneration(model, params, providerConfig) } } @@ -194,7 +201,7 @@ export default class ModernAiProvider { isImageGeneration: config.isImageGenerationEndpoint }) - const span = addSpan(traceParams) + const span = await addSpan(traceParams) if (!span) { logger.warn('Failed to create span, falling back to regular completions', { topicId: config.topicId, @@ -270,7 +277,7 @@ export default class ModernAiProvider { // }) // 根据条件构建插件数组 - const plugins = buildPlugins(config) + const plugins = await buildPlugins(config) // 用构建好的插件数组创建executor const executor = createExecutor(this.config!.providerId, this.config!.options, plugins) @@ -433,6 +440,18 @@ export default class ModernAiProvider { // 代理其他方法到原有实现 public async models() { + if (this.actualProvider.id === SystemProviderIds['ai-gateway']) { + const formatModel = function (models: GatewayLanguageModelEntry[]): Model[] { + return models.map((m) => ({ + id: m.id, + name: m.name, + provider: 'gateway', + group: m.id.split('/')[0], + description: m.description ?? undefined + })) + } + return formatModel((await gateway.getAvailableModels()).models) + } return this.legacyProvider.models() } diff --git a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts index e7194c240b..bc416161c4 100644 --- a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts @@ -1,11 +1,11 @@ import { loggerService } from '@logger' import { isNewApiProvider } from '@renderer/config/providers' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient' -import { BaseApiClient } from './BaseApiClient' +import type { BaseApiClient } from './BaseApiClient' import { CherryAiAPIClient } from './cherryai/CherryAiAPIClient' import { GeminiAPIClient } from './gemini/GeminiAPIClient' import { VertexAPIClient } from './gemini/VertexAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 4a41db7c97..0707e9bd40 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -1,3 +1,4 @@ +import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import { isFunctionCallingModel, @@ -5,33 +6,34 @@ import { isOpenAIModel, isSupportFlexServiceTierModel } from '@renderer/config/models' -import { REFERENCE_PROMPT } from '@renderer/config/prompts' import { isSupportServiceTierProvider } from '@renderer/config/providers' import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio' import { getAssistantSettings } from '@renderer/services/AssistantService' -import { +import type { Assistant, - FileTypes, GenerateImageParams, - GroqServiceTiers, - isGroqServiceTier, - isOpenAIServiceTier, KnowledgeReference, MCPCallToolResponse, MCPTool, MCPToolResponse, MemoryItem, Model, - OpenAIServiceTiers, OpenAIVerbosity, Provider, - SystemProviderIds, ToolCallResponse, WebSearchProviderResponse, WebSearchResponse } from '@renderer/types' -import { Message } from '@renderer/types/newMessage' import { + FileTypes, + GroqServiceTiers, + isGroqServiceTier, + isOpenAIServiceTier, + OpenAIServiceTiers, + SystemProviderIds +} from '@renderer/types' +import type { Message } from '@renderer/types/newMessage' +import type { RequestOptions, SdkInstance, SdkMessageParam, @@ -46,12 +48,12 @@ import { isJSON, parseJSON } from '@renderer/utils' import { addAbortController, removeAbortController } from '@renderer/utils/abortController' import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { defaultTimeout } from '@shared/config/constant' +import { REFERENCE_PROMPT } from '@shared/config/prompts' import { defaultAppHeaders } from '@shared/utils' import { isEmpty } from 'lodash' -import { CompletionsContext } from '../middleware/types' -import { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types' - +import type { CompletionsContext } from '../middleware/types' +import type { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types' const logger = loggerService.withContext('BaseApiClient') /** @@ -170,16 +172,16 @@ export abstract class BaseApiClient< return keys[0] } - const lastUsedKey = window.keyv.get(keyName) - if (!lastUsedKey) { - window.keyv.set(keyName, keys[0]) + const lastUsedKey = cacheService.getShared(keyName) as string | undefined + if (lastUsedKey === undefined) { + cacheService.setShared(keyName, keys[0]) return keys[0] } const currentIndex = keys.indexOf(lastUsedKey) const nextIndex = (currentIndex + 1) % keys.length const nextKey = keys[nextIndex] - window.keyv.set(keyName, nextKey) + cacheService.setShared(keyName, nextKey) return nextKey } @@ -335,7 +337,7 @@ export abstract class BaseApiClient< } private getMemoryReferencesFromCache(message: Message) { - const memories = window.keyv.get(`memory-search-${message.id}`) as MemoryItem[] | undefined + const memories = cacheService.get(`memory-search-${message.id}`) as MemoryItem[] | undefined if (memories) { const memoryReferences: KnowledgeReference[] = memories.map((mem, index) => ({ id: index + 1, @@ -353,10 +355,10 @@ export abstract class BaseApiClient< if (isEmpty(content)) { return [] } - const webSearch: WebSearchResponse = window.keyv.get(`web-search-${message.id}`) + const webSearch: WebSearchResponse | undefined = cacheService.get(`web-search-${message.id}`) if (webSearch) { - window.keyv.remove(`web-search-${message.id}`) + cacheService.delete(`web-search-${message.id}`) return (webSearch.results as WebSearchProviderResponse).results.map( (result, index) => ({ @@ -379,10 +381,10 @@ export abstract class BaseApiClient< if (isEmpty(content)) { return [] } - const knowledgeReferences: KnowledgeReference[] = window.keyv.get(`knowledge-search-${message.id}`) + const knowledgeReferences: KnowledgeReference[] | undefined = cacheService.get(`knowledge-search-${message.id}`) - if (!isEmpty(knowledgeReferences)) { - window.keyv.remove(`knowledge-search-${message.id}`) + if (knowledgeReferences && !isEmpty(knowledgeReferences)) { + cacheService.delete(`knowledge-search-${message.id}`) logger.debug(`Found ${knowledgeReferences.length} knowledge base references in cache for ID: ${message.id}`) return knowledgeReferences } diff --git a/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts index 36a207ecb3..fb5568a6e8 100644 --- a/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/MixedBaseApiClient.ts @@ -1,4 +1,4 @@ -import { +import type { GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -7,7 +7,7 @@ import { Provider, ToolCallResponse } from '@renderer/types' -import { +import type { RequestOptions, SdkInstance, SdkMessageParam, @@ -19,13 +19,13 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { CompletionsContext } from '../middleware/types' -import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' +import type { CompletionsContext } from '../middleware/types' +import type { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' import { BaseApiClient } from './BaseApiClient' -import { GeminiAPIClient } from './gemini/GeminiAPIClient' -import { OpenAIAPIClient } from './openai/OpenAIApiClient' -import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' -import { RequestTransformer, ResponseChunkTransformer } from './types' +import type { GeminiAPIClient } from './gemini/GeminiAPIClient' +import type { OpenAIAPIClient } from './openai/OpenAIApiClient' +import type { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' +import type { RequestTransformer, ResponseChunkTransformer } from './types' /** * MixedAPIClient - 适用于可能含有多种接口类型的Provider diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts index 550486afb2..03ec1e1ea2 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts @@ -1,5 +1,4 @@ -import { Provider } from '@renderer/types' -import { isOpenAIProvider } from '@renderer/utils' +import type { Provider } from '@renderer/types' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient' @@ -202,36 +201,4 @@ describe('ApiClientFactory', () => { expect(client).toBeDefined() }) }) - - describe('isOpenAIProvider', () => { - it('should return true for openai type', () => { - const provider = createTestProvider('openai', 'openai') - expect(isOpenAIProvider(provider)).toBe(true) - }) - - it('should return true for azure-openai type', () => { - const provider = createTestProvider('azure-openai', 'azure-openai') - expect(isOpenAIProvider(provider)).toBe(true) - }) - - it('should return true for unknown type (fallback to OpenAI)', () => { - const provider = createTestProvider('unknown', 'unknown') - expect(isOpenAIProvider(provider)).toBe(true) - }) - - it('should return false for vertexai type', () => { - const provider = createTestProvider('vertex', 'vertexai') - expect(isOpenAIProvider(provider)).toBe(false) - }) - - it('should return false for anthropic type', () => { - const provider = createTestProvider('anthropic', 'anthropic') - expect(isOpenAIProvider(provider)).toBe(false) - }) - - it('should return false for gemini type', () => { - const provider = createTestProvider('gemini', 'gemini') - expect(isOpenAIProvider(provider)).toBe(false) - }) - }) }) diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts index dd85730c36..3157e4b63a 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts @@ -6,7 +6,7 @@ import { VertexAPIClient } from '@renderer/aiCore/legacy/clients/gemini/VertexAP import { NewAPIClient } from '@renderer/aiCore/legacy/clients/newapi/NewAPIClient' import { OpenAIAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIResponseAPIClient' -import { EndpointType, Model, Provider } from '@renderer/types' +import type { EndpointType, Model, Provider } from '@renderer/types' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@renderer/config/models', () => ({ @@ -72,7 +72,8 @@ vi.mock('@logger', () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), - silly: vi.fn() + silly: vi.fn(), + verbose: vi.fn() }) } })) diff --git a/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts index 1149c04b35..a8a0ca5ac6 100644 --- a/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts @@ -1,8 +1,8 @@ import { isOpenAILLMModel } from '@renderer/config/models' -import { Model, Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' -import { BaseApiClient } from '../BaseApiClient' +import type { BaseApiClient } from '../BaseApiClient' import { GeminiAPIClient } from '../gemini/GeminiAPIClient' import { MixedBaseAPIClient } from '../MixedBaseApiClient' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts index 4f9bb28e41..15f3cf1007 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts @@ -1,5 +1,5 @@ -import Anthropic from '@anthropic-ai/sdk' -import { +import type Anthropic from '@anthropic-ai/sdk' +import type { Base64ImageSource, ImageBlockParam, MessageParam, @@ -8,7 +8,7 @@ import { ToolUseBlock, WebSearchTool20250305 } from '@anthropic-ai/sdk/resources' -import { +import type { ContentBlock, ContentBlockParam, MessageCreateParamsBase, @@ -23,27 +23,24 @@ import { WebSearchToolResultError } from '@anthropic-ai/sdk/resources/messages' import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages' -import AnthropicVertex from '@anthropic-ai/vertex-sdk' +import type AnthropicVertex from '@anthropic-ai/vertex-sdk' import { loggerService } from '@logger' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, isClaudeReasoningModel, isReasoningModel, isWebSearchModel } from '@renderer/config/models' import { getAssistantSettings } from '@renderer/services/AssistantService' import FileManager from '@renderer/services/FileManager' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { Assistant, - EFFORT_RATIO, - FileTypes, MCPCallToolResponse, MCPTool, MCPToolResponse, Model, Provider, - ToolCallResponse, - WebSearchSource + ToolCallResponse } from '@renderer/types' -import { - ChunkType, +import { EFFORT_RATIO, FileTypes, WebSearchSource } from '@renderer/types' +import type { ErrorChunk, LLMWebSearchCompleteChunk, LLMWebSearchInProgressChunk, @@ -53,8 +50,9 @@ import { ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { type Message } from '@renderer/types/newMessage' -import { +import type { AnthropicSdkMessageParam, AnthropicSdkParams, AnthropicSdkRawChunk, @@ -71,9 +69,9 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic' import { t } from 'i18next' -import { GenericChunk } from '../../middleware/schemas' +import type { GenericChunk } from '../../middleware/schemas' import { BaseApiClient } from '../BaseApiClient' -import { AnthropicStreamListener, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from '../types' +import type { AnthropicStreamListener, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from '../types' const logger = loggerService.withContext('AnthropicAPIClient') diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts index bb96ac90ae..2fe16e8875 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicVertexClient.ts @@ -1,8 +1,8 @@ -import Anthropic from '@anthropic-ai/sdk' +import type Anthropic from '@anthropic-ai/sdk' import AnthropicVertex from '@anthropic-ai/vertex-sdk' import { loggerService } from '@logger' import { getVertexAILocation, getVertexAIProjectId, getVertexAIServiceAccount } from '@renderer/hooks/useVertexAI' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { isEmpty } from 'lodash' import { AnthropicAPIClient } from './AnthropicAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts index 1de8a724c4..c4b0140579 100644 --- a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts @@ -1,25 +1,26 @@ import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from '@aws-sdk/client-bedrock' import { BedrockRuntimeClient, + type BedrockRuntimeClientConfig, ConverseCommand, InvokeModelCommand, InvokeModelWithResponseStreamCommand } from '@aws-sdk/client-bedrock-runtime' import { loggerService } from '@logger' -import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' +import type { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, isReasoningModel } from '@renderer/config/models' import { getAwsBedrockAccessKeyId, + getAwsBedrockApiKey, + getAwsBedrockAuthType, getAwsBedrockRegion, getAwsBedrockSecretAccessKey } from '@renderer/hooks/useAwsBedrock' import { getAssistantSettings } from '@renderer/services/AssistantService' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { Assistant, - EFFORT_RATIO, - FileTypes, GenerateImageParams, MCPCallToolResponse, MCPTool, @@ -28,15 +29,11 @@ import { Provider, ToolCallResponse } from '@renderer/types' -import { - ChunkType, - MCPToolCreatedChunk, - TextDeltaChunk, - ThinkingDeltaChunk, - ThinkingStartChunk -} from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import { EFFORT_RATIO, FileTypes } from '@renderer/types' +import type { MCPToolCreatedChunk, TextDeltaChunk, ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { AwsBedrockSdkInstance, AwsBedrockSdkMessageParam, AwsBedrockSdkParams, @@ -58,7 +55,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi import { t } from 'i18next' import { BaseApiClient } from '../BaseApiClient' -import { RequestTransformer, ResponseChunkTransformer } from '../types' +import type { RequestTransformer, ResponseChunkTransformer } from '../types' const logger = loggerService.withContext('AwsBedrockAPIClient') @@ -81,32 +78,48 @@ export class AwsBedrockAPIClient extends BaseApiClient< } const region = getAwsBedrockRegion() - const accessKeyId = getAwsBedrockAccessKeyId() - const secretAccessKey = getAwsBedrockSecretAccessKey() + const authType = getAwsBedrockAuthType() if (!region) { - throw new Error('AWS region is required. Please configure AWS-Region in extra headers.') + throw new Error('AWS region is required. Please configure AWS region in settings.') } - if (!accessKeyId || !secretAccessKey) { - throw new Error('AWS credentials are required. Please configure AWS-Access-Key-ID and AWS-Secret-Access-Key.') + // Build client configuration based on auth type + let clientConfig: BedrockRuntimeClientConfig + + if (authType === 'iam') { + // IAM credentials authentication + const accessKeyId = getAwsBedrockAccessKeyId() + const secretAccessKey = getAwsBedrockSecretAccessKey() + + if (!accessKeyId || !secretAccessKey) { + throw new Error('AWS credentials are required. Please configure Access Key ID and Secret Access Key.') + } + + clientConfig = { + region, + credentials: { + accessKeyId, + secretAccessKey + } + } + } else { + // API Key authentication + const awsBedrockApiKey = getAwsBedrockApiKey() + + if (!awsBedrockApiKey) { + throw new Error('AWS Bedrock API Key is required. Please configure API Key in settings.') + } + + clientConfig = { + region, + token: { token: awsBedrockApiKey }, + authSchemePreference: ['httpBearerAuth'] + } } - const client = new BedrockRuntimeClient({ - region, - credentials: { - accessKeyId, - secretAccessKey - } - }) - - const bedrockClient = new BedrockClient({ - region, - credentials: { - accessKeyId, - secretAccessKey - } - }) + const client = new BedrockRuntimeClient(clientConfig) + const bedrockClient = new BedrockClient(clientConfig) this.sdkInstance = { client, bedrockClient, region } return this.sdkInstance diff --git a/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts index 08e4d9df34..b72e0a8829 100644 --- a/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/cherryai/CherryAiAPIClient.ts @@ -1,6 +1,6 @@ -import OpenAI from '@cherrystudio/openai' -import { Provider } from '@renderer/types' -import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk' +import type OpenAI from '@cherrystudio/openai' +import type { Provider } from '@renderer/types' +import type { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts index 33d34c7961..27e659c1af 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts @@ -1,14 +1,9 @@ -import { +import type { Content, - createPartFromUri, File, FunctionCall, GenerateContentConfig, GenerateImagesConfig, - GoogleGenAI, - HarmBlockThreshold, - HarmCategory, - Modality, Model as GeminiModel, Part, SafetySetting, @@ -16,6 +11,7 @@ import { ThinkingConfig, Tool } from '@google/genai' +import { createPartFromUri, GoogleGenAI, HarmBlockThreshold, HarmCategory, Modality } from '@google/genai' import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import { @@ -26,11 +22,9 @@ import { isVisionModel } from '@renderer/config/models' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { Assistant, - EFFORT_RATIO, FileMetadata, - FileTypes, FileUploadResponse, GenerateImageParams, MCPCallToolResponse, @@ -38,12 +32,13 @@ import { MCPToolResponse, Model, Provider, - ToolCallResponse, - WebSearchSource + ToolCallResponse } from '@renderer/types' -import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import { EFFORT_RATIO, FileTypes, WebSearchSource } from '@renderer/types' +import type { LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { GeminiOptions, GeminiSdkMessageParam, GeminiSdkParams, @@ -62,9 +57,9 @@ import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/u import { defaultTimeout, MB } from '@shared/config/constant' import { t } from 'i18next' -import { GenericChunk } from '../../middleware/schemas' +import type { GenericChunk } from '../../middleware/schemas' import { BaseApiClient } from '../BaseApiClient' -import { RequestTransformer, ResponseChunkTransformer } from '../types' +import type { RequestTransformer, ResponseChunkTransformer } from '../types' const logger = loggerService.withContext('GeminiAPIClient') diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts index 37e6677367..49a96a8f19 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts @@ -1,7 +1,7 @@ import { GoogleGenAI } from '@google/genai' import { loggerService } from '@logger' import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI' -import { Model, Provider, VertexProvider } from '@renderer/types' +import type { Model, Provider, VertexProvider } from '@renderer/types' import { isEmpty } from 'lodash' import { AnthropicVertexClient } from '../anthropic/AnthropicVertexClient' diff --git a/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts index 58b349a2be..f3e04e0d55 100644 --- a/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' -import { Model, Provider } from '@renderer/types' -import { NewApiModel } from '@renderer/types/sdk' +import type { Model, Provider } from '@renderer/types' +import type { NewApiModel } from '@renderer/types/sdk' import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' -import { BaseApiClient } from '../BaseApiClient' +import type { BaseApiClient } from '../BaseApiClient' import { GeminiAPIClient } from '../gemini/GeminiAPIClient' import { MixedBaseAPIClient } from '../MixedBaseApiClient' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index 618d9b461b..8ff25e356d 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -1,5 +1,6 @@ -import OpenAI, { AzureOpenAI } from '@cherrystudio/openai' -import { +import type { AzureOpenAI } from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' +import type { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool @@ -48,25 +49,28 @@ import { mapLanguageToQwenMTModel } from '@renderer/config/translate' import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' import { estimateTextTokens } from '@renderer/services/TokenService' // For Copilot token -import { +import type { Assistant, - EFFORT_RATIO, - FileTypes, - isSystemProvider, - isTranslateAssistant, MCPCallToolResponse, MCPTool, MCPToolResponse, Model, OpenAIServiceTier, Provider, + ToolCallResponse +} from '@renderer/types' +import { + EFFORT_RATIO, + FileTypes, + isSystemProvider, + isTranslateAssistant, SystemProviderIds, - ToolCallResponse, WebSearchSource } from '@renderer/types' -import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import type { TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { OpenAIExtraBody, OpenAIModality, OpenAISdkMessageParam, @@ -86,8 +90,8 @@ import { import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' import { t } from 'i18next' -import { GenericChunk } from '../../middleware/schemas' -import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' +import type { GenericChunk } from '../../middleware/schemas' +import type { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' import { OpenAIBaseClient } from './OpenAIBaseClient' const logger = loggerService.withContext('OpenAIApiClient') @@ -188,7 +192,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< extra_body: { google: { thinking_config: { - thinkingBudget: 0 + thinking_budget: 0 } } } @@ -323,8 +327,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient< extra_body: { google: { thinking_config: { - thinkingBudget: -1, - includeThoughts: true + thinking_budget: -1, + include_thoughts: true } } } @@ -334,8 +338,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient< extra_body: { google: { thinking_config: { - thinkingBudget: budgetTokens, - includeThoughts: true + thinking_budget: budgetTokens, + include_thoughts: true } } } @@ -666,7 +670,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) { suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}` } else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) { - suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinkingBudget}` + suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}` } // FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题 // 临时解决方案是强制poe用string content,但是其实poe部分支持array diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 8a0a3fe0f4..abd1793618 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -10,9 +10,9 @@ import { import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings } from '@renderer/services/AssistantService' import store from '@renderer/store' -import { SettingsState } from '@renderer/store/settings' -import { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' -import { +import type { SettingsState } from '@renderer/store/settings' +import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' +import type { OpenAIResponseSdkMessageParam, OpenAIResponseSdkParams, OpenAIResponseSdkRawChunk, diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts index 5d13d6ff70..40cace50ea 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts @@ -1,8 +1,8 @@ import OpenAI, { AzureOpenAI } from '@cherrystudio/openai' -import { ResponseInput } from '@cherrystudio/openai/resources/responses/responses' +import type { ResponseInput } from '@cherrystudio/openai/resources/responses/responses' import { loggerService } from '@logger' -import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' -import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types' +import type { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas' +import type { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types' import { isGPT5SeriesModel, isOpenAIChatCompletionOnlyModel, @@ -14,21 +14,20 @@ import { } from '@renderer/config/models' import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { estimateTextTokens } from '@renderer/services/TokenService' -import { +import type { FileMetadata, - FileTypes, MCPCallToolResponse, MCPTool, MCPToolResponse, Model, OpenAIServiceTier, Provider, - ToolCallResponse, - WebSearchSource + ToolCallResponse } from '@renderer/types' +import { FileTypes, WebSearchSource } from '@renderer/types' import { ChunkType } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { +import type { Message } from '@renderer/types/newMessage' +import type { OpenAIResponseSdkMessageParam, OpenAIResponseSdkParams, OpenAIResponseSdkRawChunk, @@ -48,7 +47,7 @@ import { MB } from '@shared/config/constant' import { t } from 'i18next' import { isEmpty } from 'lodash' -import { RequestTransformer, ResponseChunkTransformer } from '../types' +import type { RequestTransformer, ResponseChunkTransformer } from '../types' import { OpenAIAPIClient } from './OpenAIApiClient' import { OpenAIBaseClient } from './OpenAIBaseClient' @@ -91,7 +90,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) { if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') { this.provider = { ...this.provider, apiHost: this.formatApiHost() } - if (this.provider.apiVersion === 'preview') { + if (this.provider.apiVersion === 'preview' || this.provider.apiVersion === 'v1') { return this } else { return this.client diff --git a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts index 5dc91550a0..1a93baa2de 100644 --- a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts @@ -1,7 +1,7 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' -import { objectKeys, Provider } from '@renderer/types' +import { objectKeys, type Provider } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts index 57b54b9618..345496e156 100644 --- a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts @@ -1,7 +1,7 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' -import { Model, Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/clients/types.ts b/src/renderer/src/aiCore/legacy/clients/types.ts index 6d10f4285c..2964ede526 100644 --- a/src/renderer/src/aiCore/legacy/clients/types.ts +++ b/src/renderer/src/aiCore/legacy/clients/types.ts @@ -1,8 +1,7 @@ -import Anthropic from '@anthropic-ai/sdk' -import OpenAI from '@cherrystudio/openai' -import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types' -import { Provider } from '@renderer/types' -import { +import type Anthropic from '@anthropic-ai/sdk' +import type OpenAI from '@cherrystudio/openai' +import type { Assistant, MCPTool, MCPToolResponse, Model, Provider, ToolCallResponse } from '@renderer/types' +import type { AnthropicSdkRawChunk, OpenAIResponseSdkRawChunk, OpenAIResponseSdkRawOutput, @@ -15,8 +14,8 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { CompletionsParams, GenericChunk } from '../middleware/schemas' -import { CompletionsContext } from '../middleware/types' +import type { CompletionsParams, GenericChunk } from '../middleware/schemas' +import type { CompletionsContext } from '../middleware/types' /** * 原始流监听器接口 diff --git a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts index c04e08fb7c..2c77649634 100644 --- a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts @@ -1,7 +1,6 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' -import { Provider } from '@renderer/types' -import { GenerateImageParams } from '@renderer/types' +import type { GenerateImageParams, Provider } from '@renderer/types' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/aiCore/legacy/index.ts b/src/renderer/src/aiCore/legacy/index.ts index adc81f03ad..da6cdb6726 100644 --- a/src/renderer/src/aiCore/legacy/index.ts +++ b/src/renderer/src/aiCore/legacy/index.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' import { ApiClientFactory } from '@renderer/aiCore/legacy/clients/ApiClientFactory' -import { BaseApiClient } from '@renderer/aiCore/legacy/clients/BaseApiClient' +import type { BaseApiClient } from '@renderer/aiCore/legacy/clients/BaseApiClient' import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@renderer/config/models' import { getProviderByModel } from '@renderer/services/AssistantService' import { withSpanResult } from '@renderer/services/SpanManagerService' -import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' +import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' import type { GenerateImageParams, Model, Provider } from '@renderer/types' import type { RequestOptions, SdkModel } from '@renderer/types/sdk' import { isSupportedToolUse } from '@renderer/utils/mcp-tools' diff --git a/src/renderer/src/aiCore/legacy/middleware/builder.ts b/src/renderer/src/aiCore/legacy/middleware/builder.ts index 2ea20d4937..1d0b9d136d 100644 --- a/src/renderer/src/aiCore/legacy/middleware/builder.ts +++ b/src/renderer/src/aiCore/legacy/middleware/builder.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { DefaultCompletionsNamedMiddlewares } from './register' -import { BaseContext, CompletionsMiddleware, MethodMiddleware } from './types' +import type { BaseContext, CompletionsMiddleware, MethodMiddleware } from './types' const logger = loggerService.withContext('aiCore:MiddlewareBuilder') diff --git a/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts index a733e45d70..5f24797813 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/AbortHandlerMiddleware.ts @@ -1,8 +1,9 @@ import { loggerService } from '@logger' -import { Chunk, ChunkType, ErrorChunk } from '@renderer/types/chunk' +import type { Chunk, ErrorChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { addAbortController, removeAbortController } from '@renderer/utils/abortController' -import { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsParams, CompletionsResult } from '../schemas' import type { CompletionsContext, CompletionsMiddleware } from '../types' const logger = loggerService.withContext('aiCore:AbortHandlerMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts index dde98cbd1e..7d6a7f631a 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' import { isZhipuModel } from '@renderer/config/models' import { getStoreProviders } from '@renderer/hooks/useStore' -import { Chunk } from '@renderer/types/chunk' +import type { Chunk } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext } from '../types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext } from '../types' import { createErrorChunk } from '../utils' const logger = loggerService.withContext('ErrorHandlerMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts index 57498b97fb..0325e4e21a 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/FinalChunkConsumerMiddleware.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' -import { Usage } from '@renderer/types' +import type { Usage } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'FinalChunkConsumerAndNotifierMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts index acd371d777..480cbbc39f 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' -import { BaseContext, MethodMiddleware, MiddlewareAPI } from '../types' +import type { BaseContext, MethodMiddleware, MiddlewareAPI } from '../types' const logger = loggerService.withContext('LoggingMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/composer.ts b/src/renderer/src/aiCore/legacy/middleware/composer.ts index 82b9fd1704..97bbf0a38d 100644 --- a/src/renderer/src/aiCore/legacy/middleware/composer.ts +++ b/src/renderer/src/aiCore/legacy/middleware/composer.ts @@ -1,5 +1,5 @@ import { withSpanResult } from '@renderer/services/SpanManagerService' -import { +import type { RequestOptions, SdkInstance, SdkMessageParam, @@ -10,16 +10,10 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { BaseApiClient } from '../clients' -import { CompletionsParams, CompletionsResult } from './schemas' -import { - BaseContext, - CompletionsContext, - CompletionsMiddleware, - MethodMiddleware, - MIDDLEWARE_CONTEXT_SYMBOL, - MiddlewareAPI -} from './types' +import type { BaseApiClient } from '../clients' +import type { CompletionsParams, CompletionsResult } from './schemas' +import type { BaseContext, CompletionsContext, CompletionsMiddleware, MethodMiddleware, MiddlewareAPI } from './types' +import { MIDDLEWARE_CONTEXT_SYMBOL } from './types' /** * Creates the initial context for a method call, populating method-specific fields. / diff --git a/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts index fc0327925e..6affa5a565 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/McpToolChunkMiddleware.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' -import { MCPCallToolResponse, MCPTool, MCPToolResponse, Model } from '@renderer/types' -import { ChunkType, MCPToolCreatedChunk } from '@renderer/types/chunk' -import { SdkMessageParam, SdkRawOutput, SdkToolCall } from '@renderer/types/sdk' +import type { MCPCallToolResponse, MCPTool, MCPToolResponse, Model } from '@renderer/types' +import type { MCPToolCreatedChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' +import type { SdkMessageParam, SdkRawOutput, SdkToolCall } from '@renderer/types/sdk' import { callBuiltInTool, callMCPTool, @@ -12,8 +13,8 @@ import { } from '@renderer/utils/mcp-tools' import { confirmSameNameTools, requestToolConfirmation, setToolIdToNameMapping } from '@renderer/utils/userConfirmation' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'McpToolChunkMiddleware' const MAX_TOOL_RECURSION_DEPTH = 20 // 防止无限递归 diff --git a/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts index 0d59ad9de6..04bfd751e2 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/RawStreamListenerMiddleware.ts @@ -1,9 +1,9 @@ import { AnthropicAPIClient } from '@renderer/aiCore/legacy/clients/anthropic/AnthropicAPIClient' -import { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' +import type { AnthropicSdkRawChunk, AnthropicSdkRawOutput } from '@renderer/types/sdk' -import { AnthropicStreamListener } from '../../clients/types' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { AnthropicStreamListener } from '../../clients/types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'RawStreamListenerMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts index 850da8306d..bdab7b8783 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/ResponseTransformMiddleware.ts @@ -1,9 +1,9 @@ import { loggerService } from '@logger' -import { SdkRawChunk } from '@renderer/types/sdk' +import type { SdkRawChunk } from '@renderer/types/sdk' -import { ResponseChunkTransformerContext } from '../../clients/types' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { ResponseChunkTransformerContext } from '../../clients/types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ResponseTransformMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts index 8bb5266319..b6dc13e602 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/StreamAdapterMiddleware.ts @@ -1,8 +1,8 @@ -import { SdkRawChunk } from '@renderer/types/sdk' +import type { SdkRawChunk } from '@renderer/types/sdk' import { asyncGeneratorToReadableStream, createSingleChunkReadableStream } from '@renderer/utils/stream' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' import { isAsyncIterable } from '../utils' export const MIDDLEWARE_NAME = 'StreamAdapterMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts index 41157bf504..837244981a 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/TextChunkMiddleware.ts @@ -1,8 +1,8 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'TextChunkMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts index 2149d8fe79..5920cdc0ea 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/ThinkChunkMiddleware.ts @@ -1,8 +1,9 @@ import { loggerService } from '@logger' -import { ChunkType, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk' +import type { ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ThinkChunkMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts index 71831a3ef6..ebc86f5a5e 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/TransformCoreToSdkParamsMiddleware.ts @@ -1,8 +1,8 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' -import { CompletionsParams, CompletionsResult } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'TransformCoreToSdkParamsMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts index ae346af836..3365b163b6 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts @@ -2,8 +2,8 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' const logger = loggerService.withContext('WebSearchMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts index 40ab43c561..0876903426 100644 --- a/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/feat/ImageGenerationMiddleware.ts @@ -1,4 +1,4 @@ -import OpenAI from '@cherrystudio/openai' +import type OpenAI from '@cherrystudio/openai' import { toFile } from '@cherrystudio/openai/uploads' import { isDedicatedImageGenerationModel } from '@renderer/config/models' import FileManager from '@renderer/services/FileManager' @@ -6,9 +6,9 @@ import { ChunkType } from '@renderer/types/chunk' import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { defaultTimeout } from '@shared/config/constant' -import { BaseApiClient } from '../../clients/BaseApiClient' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { BaseApiClient } from '../../clients/BaseApiClient' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ImageGenerationMiddleware' @@ -50,7 +50,11 @@ export const ImageGenerationMiddleware: CompletionsMiddleware = if (!block.file) return null const binaryData: Uint8Array = await FileManager.readBinaryImage(block.file) const mimeType = `${block.file.type}/${block.file.ext.slice(1)}` - return await toFile(new Blob([binaryData]), block.file.origin_name || 'image.png', { type: mimeType }) + return await toFile( + new Blob([binaryData as unknown as BlobPart]), + block.file.origin_name || 'image.png', + { type: mimeType } + ) }) ) imageFiles = imageFiles.concat(userImages.filter(Boolean) as Blob[]) diff --git a/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts index 447b9d2f23..dea679eaac 100644 --- a/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/feat/ThinkingTagExtractionMiddleware.ts @@ -1,17 +1,18 @@ import { loggerService } from '@logger' -import { Model } from '@renderer/types' -import { - ChunkType, +import type { Model } from '@renderer/types' +import type { TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk, ThinkingStartChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { getLowerBaseModelName } from '@renderer/utils' -import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction' +import type { TagConfig } from '@renderer/utils/tagExtraction' +import { TagExtractor } from '@renderer/utils/tagExtraction' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' const logger = loggerService.withContext('ThinkingTagExtractionMiddleware') diff --git a/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts index 1f559bf5ad..38d842e08d 100644 --- a/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/feat/ToolUseExtractionMiddleware.ts @@ -1,11 +1,13 @@ import { loggerService } from '@logger' -import { MCPTool } from '@renderer/types' -import { ChunkType, MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk' +import type { MCPTool } from '@renderer/types' +import type { MCPToolCreatedChunk, TextDeltaChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' import { parseToolUse } from '@renderer/utils/mcp-tools' -import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction' +import type { TagConfig } from '@renderer/utils/tagExtraction' +import { TagExtractor } from '@renderer/utils/tagExtraction' -import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' -import { CompletionsContext, CompletionsMiddleware } from '../types' +import type { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' +import type { CompletionsContext, CompletionsMiddleware } from '../types' export const MIDDLEWARE_NAME = 'ToolUseExtractionMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/index.ts b/src/renderer/src/aiCore/legacy/middleware/index.ts index 64be4edd44..66213c33b8 100644 --- a/src/renderer/src/aiCore/legacy/middleware/index.ts +++ b/src/renderer/src/aiCore/legacy/middleware/index.ts @@ -1,4 +1,4 @@ -import { CompletionsMiddleware, MethodMiddleware } from './types' +import type { CompletionsMiddleware, MethodMiddleware } from './types' // /** // * Wraps a provider instance with middlewares. diff --git a/src/renderer/src/aiCore/legacy/middleware/schemas.ts b/src/renderer/src/aiCore/legacy/middleware/schemas.ts index ce89934f02..9119d818db 100644 --- a/src/renderer/src/aiCore/legacy/middleware/schemas.ts +++ b/src/renderer/src/aiCore/legacy/middleware/schemas.ts @@ -1,9 +1,9 @@ -import { Assistant, MCPTool } from '@renderer/types' -import { Chunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { SdkRawChunk, SdkRawOutput } from '@renderer/types/sdk' +import type { Assistant, MCPTool } from '@renderer/types' +import type { Chunk } from '@renderer/types/chunk' +import type { Message } from '@renderer/types/newMessage' +import type { SdkRawChunk, SdkRawOutput } from '@renderer/types/sdk' -import { ProcessingState } from './types' +import type { ProcessingState } from './types' // ============================================================================ // Core Request Types - 核心请求结构 diff --git a/src/renderer/src/aiCore/legacy/middleware/types.ts b/src/renderer/src/aiCore/legacy/middleware/types.ts index 0a7dbe390b..3762035107 100644 --- a/src/renderer/src/aiCore/legacy/middleware/types.ts +++ b/src/renderer/src/aiCore/legacy/middleware/types.ts @@ -1,6 +1,6 @@ -import { MCPToolResponse, Metrics, Usage, WebSearchResponse } from '@renderer/types' -import { Chunk, ErrorChunk } from '@renderer/types/chunk' -import { +import type { MCPToolResponse, Metrics, Usage, WebSearchResponse } from '@renderer/types' +import type { Chunk, ErrorChunk } from '@renderer/types/chunk' +import type { SdkInstance, SdkMessageParam, SdkParams, @@ -10,8 +10,8 @@ import { SdkToolCall } from '@renderer/types/sdk' -import { BaseApiClient } from '../clients' -import { CompletionsParams, CompletionsResult } from './schemas' +import type { BaseApiClient } from '../clients' +import type { CompletionsParams, CompletionsResult } from './schemas' /** * Symbol to uniquely identify middleware context objects. diff --git a/src/renderer/src/aiCore/legacy/middleware/utils.ts b/src/renderer/src/aiCore/legacy/middleware/utils.ts index 12a2fe651d..32e94e16b6 100644 --- a/src/renderer/src/aiCore/legacy/middleware/utils.ts +++ b/src/renderer/src/aiCore/legacy/middleware/utils.ts @@ -1,4 +1,5 @@ -import { ChunkType, ErrorChunk } from '@renderer/types/chunk' +import type { ErrorChunk } from '@renderer/types/chunk' +import { ChunkType } from '@renderer/types/chunk' /** * Creates an ErrorChunk object with a standardized structure. diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 924cc5f47e..3f14917cdd 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,12 +1,18 @@ -import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' +import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' -import { type MCPTool, type Message, type Model, type Provider } from '@renderer/types' +import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models' +import { isSupportEnableThinkingProvider } from '@renderer/config/providers' +import type { MCPTool } from '@renderer/types' +import { type Assistant, type Message, type Model, type Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' -import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' +import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' +import { isEmpty } from 'lodash' import { isOpenRouterGeminiGenerateImageModel } from '../utils/image' import { noThinkMiddleware } from './noThinkMiddleware' import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMiddleware' +import { qwenThinkingMiddleware } from './qwenThinkingMiddleware' import { toolChoiceMiddleware } from './toolChoiceMiddleware' const logger = loggerService.withContext('AiSdkMiddlewareBuilder') @@ -19,6 +25,7 @@ export interface AiSdkMiddlewareConfig { onChunk?: (chunk: Chunk) => void model?: Model provider?: Provider + assistant?: Assistant enableReasoning: boolean // 是否开启提示词工具调用 isPromptToolUse: boolean @@ -127,7 +134,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo const builder = new AiSdkMiddlewareBuilder() // 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库) - if (config.knowledgeRecognition === 'off') { + if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') { builder.add({ name: 'force-knowledge-first', middleware: toolChoiceMiddleware('builtin_knowledge_search') @@ -218,6 +225,21 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: function addModelSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: AiSdkMiddlewareConfig): void { if (!config.model || !config.provider) return + // Qwen models on providers that don't support enable_thinking parameter (like Ollama, LM Studio, NVIDIA) + // Use /think or /no_think suffix to control thinking mode + if ( + config.provider && + isSupportedThinkingTokenQwenModel(config.model) && + !isSupportEnableThinkingProvider(config.provider) + ) { + const enableThinking = config.assistant?.settings?.reasoning_effort !== undefined + builder.add({ + name: 'qwen-thinking-control', + middleware: qwenThinkingMiddleware(enableThinking) + }) + logger.debug(`Added Qwen thinking middleware with thinking ${enableThinking ? 'enabled' : 'disabled'}`) + } + // 可以根据模型ID或特性添加特定中间件 // 例如:图像生成模型、多模态模型等 if (isOpenRouterGeminiGenerateImageModel(config.model, config.provider)) { diff --git a/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts b/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts index 9d7d933bc1..3e5624983c 100644 --- a/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/noThinkMiddleware.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' const logger = loggerService.withContext('noThinkMiddleware') diff --git a/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts b/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts index 0110d9a4f0..792192b931 100644 --- a/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/openrouterGenerateImageMiddleware.ts @@ -1,4 +1,4 @@ -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' /** * Returns a LanguageModelMiddleware that ensures the OpenRouter provider is configured to support both diff --git a/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts b/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts new file mode 100644 index 0000000000..931831a1c6 --- /dev/null +++ b/src/renderer/src/aiCore/middleware/qwenThinkingMiddleware.ts @@ -0,0 +1,39 @@ +import type { LanguageModelMiddleware } from 'ai' + +/** + * Qwen Thinking Middleware + * Controls thinking mode for Qwen models on providers that don't support enable_thinking parameter (like Ollama) + * Appends '/think' or '/no_think' suffix to user messages based on reasoning_effort setting + * @param enableThinking - Whether thinking mode is enabled (based on reasoning_effort !== undefined) + * @returns LanguageModelMiddleware + */ +export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMiddleware { + const suffix = enableThinking ? ' /think' : ' /no_think' + + return { + middlewareVersion: 'v2', + + transformParams: async ({ params }) => { + const transformedParams = { ...params } + // Process messages in prompt + if (transformedParams.prompt && Array.isArray(transformedParams.prompt)) { + transformedParams.prompt = transformedParams.prompt.map((message) => { + // Only process user messages + if (message.role === 'user') { + // Process content array + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) { + part.text += suffix + } + } + } + } + return message + }) + } + + return transformedParams + } + } +} diff --git a/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts b/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts index 6d3ba37d1d..7bb00aff55 100644 --- a/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/toolChoiceMiddleware.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import { LanguageModelMiddleware } from 'ai' +import type { LanguageModelMiddleware } from 'ai' const logger = loggerService.withContext('toolChoiceMiddleware') diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index 7767564bd9..efe870fc0f 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -1,10 +1,10 @@ -import { AiPlugin } from '@cherrystudio/ai-core' -import { createPromptToolUsePlugin, googleToolsPlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins' +import type { AiPlugin } from '@cherrystudio/ai-core' +import { createPromptToolUsePlugin, webSearchPlugin } from '@cherrystudio/ai-core/built-in/plugins' +import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' -import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' -import { Assistant } from '@renderer/types' +import type { Assistant } from '@renderer/types' -import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder' +import type { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder' import { searchOrchestrationPlugin } from './searchOrchestrationPlugin' import { createTelemetryPlugin } from './telemetryPlugin' @@ -12,12 +12,12 @@ const logger = loggerService.withContext('PluginBuilder') /** * 根据条件构建插件数组 */ -export function buildPlugins( +export async function buildPlugins( middlewareConfig: AiSdkMiddlewareConfig & { assistant: Assistant; topicId?: string } -): AiPlugin[] { +): Promise { const plugins: AiPlugin[] = [] - if (middlewareConfig.topicId && getEnableDeveloperMode()) { + if (middlewareConfig.topicId && (await preferenceService.get('app.developer_mode.enabled'))) { // 0. 添加 telemetry 插件 plugins.push( createTelemetryPlugin({ @@ -68,9 +68,9 @@ export function buildPlugins( ) } - if (middlewareConfig.enableUrlContext) { - plugins.push(googleToolsPlugin({ urlContext: true })) - } + // if (middlewareConfig.enableUrlContext && middlewareConfig.) { + // plugins.push(googleToolsPlugin({ urlContext: true })) + // } logger.debug( 'Final plugin list:', diff --git a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts index 7e662ddee6..6e3b4ba968 100644 --- a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts +++ b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts @@ -8,17 +8,18 @@ */ import { type AiRequestContext, definePlugin } from '@cherrystudio/ai-core' import { loggerService } from '@logger' +import { getDefaultModel, getProviderByModel } from '@renderer/services/AssistantService' +import store from '@renderer/store' +import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory' +import type { Assistant } from '@renderer/types' +import type { ExtractResults } from '@renderer/utils/extract' +import { extractInfoFromXML } from '@renderer/utils/extract' // import { generateObject } from '@cherrystudio/ai-core' import { SEARCH_SUMMARY_PROMPT, SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY, SEARCH_SUMMARY_PROMPT_WEB_ONLY -} from '@renderer/config/prompts' -import { getDefaultModel, getProviderByModel } from '@renderer/services/AssistantService' -import store from '@renderer/store' -import { selectCurrentUserId, selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory' -import type { Assistant } from '@renderer/types' -import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract' +} from '@shared/config/prompts' import type { LanguageModel, ModelMessage } from 'ai' import { generateText } from 'ai' import { isEmpty } from 'lodash' diff --git a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts index 0f06091c5a..485d339d25 100644 --- a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts +++ b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts @@ -8,10 +8,11 @@ import { definePlugin } from '@cherrystudio/ai-core' import { loggerService } from '@logger' -import { Context, context as otelContext, Span, SpanContext, trace, Tracer } from '@opentelemetry/api' +import type { Context, Span, SpanContext, Tracer } from '@opentelemetry/api' +import { context as otelContext, trace } from '@opentelemetry/api' import { currentSpan } from '@renderer/services/SpanManagerService' import { webTraceService } from '@renderer/services/WebTraceService' -import { Assistant } from '@renderer/types' +import type { Assistant } from '@renderer/types' import { AiSdkSpanAdapter } from '../trace/AiSdkSpanAdapter' diff --git a/src/renderer/src/aiCore/prepareParams/fileProcessor.ts b/src/renderer/src/aiCore/prepareParams/fileProcessor.ts index 9e46f0c627..5ee8812238 100644 --- a/src/renderer/src/aiCore/prepareParams/fileProcessor.ts +++ b/src/renderer/src/aiCore/prepareParams/fileProcessor.ts @@ -8,7 +8,7 @@ import { loggerService } from '@logger' import { getProviderByModel } from '@renderer/services/AssistantService' import type { FileMetadata, Message, Model } from '@renderer/types' import { FileTypes } from '@renderer/types' -import { FileMessageBlock } from '@renderer/types/newMessage' +import type { FileMessageBlock } from '@renderer/types/newMessage' import { findFileBlocks } from '@renderer/utils/messageUtils/find' import type { FilePart, TextPart } from 'ai' @@ -114,7 +114,7 @@ export async function handleGeminiFileUpload(file: FileMetadata, model: Model): } /** - * 处理OpenAI大文件上传 + * 处理OpenAI兼容大文件上传 */ export async function handleOpenAILargeFileUpload( file: FileMetadata, diff --git a/src/renderer/src/aiCore/prepareParams/messageConverter.ts b/src/renderer/src/aiCore/prepareParams/messageConverter.ts index 46cacb5b74..72f387d9a4 100644 --- a/src/renderer/src/aiCore/prepareParams/messageConverter.ts +++ b/src/renderer/src/aiCore/prepareParams/messageConverter.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models' import type { Message, Model } from '@renderer/types' -import { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage' +import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage' import { findFileBlocks, findImageBlocks, diff --git a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts index 4a3c3f4bbf..b6e4b25843 100644 --- a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts +++ b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts @@ -85,6 +85,19 @@ export function supportsLargeFileUpload(model: Model): boolean { }) } +/** + * 检查模型是否支持TopP + */ +export function supportsTopP(model: Model): boolean { + const provider = getProviderByModel(model) + + if (provider?.type === 'anthropic' || model?.endpoint_type === 'anthropic') { + return false + } + + return true +} + /** * 获取提供商特定的文件大小限制 */ diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index b53293ea88..397c481cf3 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -3,9 +3,11 @@ * 构建AI SDK的流式和非流式参数 */ +import { anthropic } from '@ai-sdk/anthropic' +import { google } from '@ai-sdk/google' import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge' import { vertex } from '@ai-sdk/google-vertex/edge' -import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' +import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas' import { loggerService } from '@logger' import { @@ -19,7 +21,7 @@ import { } from '@renderer/config/models' import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' import store from '@renderer/store' -import { CherryWebSearchConfig } from '@renderer/store/websearch' +import type { CherryWebSearchConfig } from '@renderer/store/websearch' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' @@ -32,6 +34,7 @@ import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' import { getAnthropicThinkingBudget } from '../utils/reasoning' import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' +import { supportsTopP } from './modelCapabilities' import { getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') @@ -97,10 +100,6 @@ export async function buildStreamTextParams( let tools = setupToolsConfig(mcpTools) - // if (webSearchProviderId) { - // tools['builtin_web_search'] = webSearchTool(webSearchProviderId) - // } - // 构建真正的 providerOptions const webSearchConfig: CherryWebSearchConfig = { maxResults: store.getState().websearch.maxResults, @@ -143,12 +142,34 @@ export async function buildStreamTextParams( } } - // google-vertex - if (enableUrlContext && aiSdkProviderId === 'google-vertex') { + if (enableUrlContext) { if (!tools) { tools = {} } - tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool + const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains) + + switch (aiSdkProviderId) { + case 'google-vertex': + tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool + break + case 'google': + tools.url_context = google.tools.urlContext({}) as ProviderDefinedTool + break + case 'anthropic': + case 'google-vertex-anthropic': + tools.web_fetch = ( + aiSdkProviderId === 'anthropic' + ? anthropic.tools.webFetch_20250910({ + maxUses: webSearchConfig.maxResults, + blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined + }) + : vertexAnthropic.tools.webFetch_20250910({ + maxUses: webSearchConfig.maxResults, + blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined + }) + ) as ProviderDefinedTool + break + } } // 构建基础参数 @@ -156,20 +177,27 @@ export async function buildStreamTextParams( messages: sdkMessages, maxOutputTokens: maxTokens, temperature: getTemperature(assistant, model), - topP: getTopP(assistant, model), abortSignal: options.requestOptions?.signal, headers: options.requestOptions?.headers, providerOptions, stopWhen: stepCountIs(20), maxRetries: 0 } + + if (supportsTopP(model)) { + params.topP = getTopP(assistant, model) + } + if (tools) { params.tools = tools } + if (assistant.prompt) { params.system = await replacePromptVariables(assistant.prompt, model.name) } + logger.debug('params', params) + return { params, modelId: model.id, diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index eb6e73c8ae..39786231e6 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -21,10 +21,45 @@ vi.mock('@renderer/store', () => ({ } })) +vi.mock('@renderer/utils/api', () => ({ + formatApiHost: vi.fn((host, isSupportedAPIVersion = true) => { + if (isSupportedAPIVersion === false) { + return host // Return host as-is when isSupportedAPIVersion is false + } + return `${host}/v1` // Default behavior when isSupportedAPIVersion is true + }), + routeToEndpoint: vi.fn((host) => ({ + baseURL: host, + endpoint: '/chat/completions' + })) +})) + +vi.mock('@renderer/config/providers', async (importOriginal) => { + const actual = (await importOriginal()) as any + return { + ...actual, + isCherryAIProvider: vi.fn(), + isPerplexityProvider: vi.fn(), + isAnthropicProvider: vi.fn(() => false), + isAzureOpenAIProvider: vi.fn(() => false), + isGeminiProvider: vi.fn(() => false), + isNewApiProvider: vi.fn(() => false) + } +}) + +vi.mock('@renderer/hooks/useVertexAI', () => ({ + isVertexProvider: vi.fn(() => false), + isVertexAIConfigured: vi.fn(() => false), + createVertexProvider: vi.fn() +})) + +import { isCherryAIProvider, isPerplexityProvider } from '@renderer/config/providers' +import { getProviderByModel } from '@renderer/services/AssistantService' import type { Model, Provider } from '@renderer/types' +import { formatApiHost } from '@renderer/utils/api' import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' -import { providerToAiSdkConfig } from '../providerConfig' +import { getActualProvider, providerToAiSdkConfig } from '../providerConfig' const createWindowKeyv = () => { const store = new Map() @@ -46,11 +81,31 @@ const createCopilotProvider = (): Provider => ({ isSystem: true }) -const createModel = (id: string, name = id): Model => ({ +const createModel = (id: string, name = id, provider = 'copilot'): Model => ({ id, name, - provider: 'copilot', - group: 'copilot' + provider, + group: provider +}) + +const createCherryAIProvider = (): Provider => ({ + id: 'cherryai', + type: 'openai', + name: 'CherryAI', + apiKey: 'test-key', + apiHost: 'https://api.cherryai.com', + models: [], + isSystem: false +}) + +const createPerplexityProvider = (): Provider => ({ + id: 'perplexity', + type: 'openai', + name: 'Perplexity', + apiKey: 'test-key', + apiHost: 'https://api.perplexity.ai', + models: [], + isSystem: false }) describe('Copilot responses routing', () => { @@ -87,3 +142,134 @@ describe('Copilot responses routing', () => { expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id']) }) }) + +describe('CherryAI provider configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + it('formats CherryAI provider apiHost with false parameter', () => { + const provider = createCherryAIProvider() + const model = createModel('gpt-4', 'GPT-4', 'cherryai') + + // Mock the functions to simulate CherryAI provider detection + vi.mocked(isCherryAIProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider which should trigger formatProviderApiHost + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with false as the second parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.cherryai.com', false) + expect(actualProvider.apiHost).toBe('https://api.cherryai.com') + }) + + it('does not format non-CherryAI provider with false parameter', () => { + const provider = { + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: false + } as Provider + const model = createModel('gpt-4', 'GPT-4', 'openai') + + // Mock the functions to simulate non-CherryAI provider + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with default parameters (true) + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') + }) + + it('handles CherryAI provider with empty apiHost', () => { + const provider = createCherryAIProvider() + provider.apiHost = '' + const model = createModel('gpt-4', 'GPT-4', 'cherryai') + + vi.mocked(isCherryAIProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + const actualProvider = getActualProvider(model) + + expect(formatApiHost).toHaveBeenCalledWith('', false) + expect(actualProvider.apiHost).toBe('') + }) +}) + +describe('Perplexity provider configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + it('formats Perplexity provider apiHost with false parameter', () => { + const provider = createPerplexityProvider() + const model = createModel('sonar', 'Sonar', 'perplexity') + + // Mock the functions to simulate Perplexity provider detection + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider which should trigger formatProviderApiHost + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with false as the second parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.perplexity.ai', false) + expect(actualProvider.apiHost).toBe('https://api.perplexity.ai') + }) + + it('does not format non-Perplexity provider with false parameter', () => { + const provider = { + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: false + } as Provider + const model = createModel('gpt-4', 'GPT-4', 'openai') + + // Mock the functions to simulate non-Perplexity provider + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(false) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with default parameters (true) + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') + }) + + it('handles Perplexity provider with empty apiHost', () => { + const provider = createPerplexityProvider() + provider.apiHost = '' + const model = createModel('sonar', 'Sonar', 'perplexity') + + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + const actualProvider = getActualProvider(model) + + expect(formatApiHost).toHaveBeenCalledWith('', false) + expect(actualProvider.apiHost).toBe('') + }) +}) diff --git a/src/renderer/src/aiCore/provider/config/aihubmix.ts b/src/renderer/src/aiCore/provider/config/aihubmix.ts index d0ac83dc66..8feed89909 100644 --- a/src/renderer/src/aiCore/provider/config/aihubmix.ts +++ b/src/renderer/src/aiCore/provider/config/aihubmix.ts @@ -2,7 +2,7 @@ * AiHubMix规则集 */ import { isOpenAILLMModel } from '@renderer/config/models' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { provider2Provider, startsWith } from './helper' import type { RuleSet } from './types' @@ -32,7 +32,8 @@ const AIHUBMIX_RULES: RuleSet = { match: (model) => (startsWith('gemini')(model) || startsWith('imagen')(model)) && !model.id.endsWith('-nothink') && - !model.id.endsWith('-search'), + !model.id.endsWith('-search') && + !model.id.includes('embedding'), provider: (provider: Provider) => { return extraProviderConfig({ ...provider, @@ -51,7 +52,7 @@ const AIHUBMIX_RULES: RuleSet = { } } ], - fallbackRule: (provider: Provider) => provider + fallbackRule: (provider: Provider) => extraProviderConfig(provider) } export const aihubmixProviderCreator = provider2Provider.bind(null, AIHUBMIX_RULES) diff --git a/src/renderer/src/aiCore/provider/config/newApi.ts b/src/renderer/src/aiCore/provider/config/newApi.ts index 5277495cdb..97de62597d 100644 --- a/src/renderer/src/aiCore/provider/config/newApi.ts +++ b/src/renderer/src/aiCore/provider/config/newApi.ts @@ -1,7 +1,7 @@ /** * NewAPI规则集 */ -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { endpointIs, provider2Provider } from './helper' import type { RuleSet } from './types' diff --git a/src/renderer/src/aiCore/provider/factory.ts b/src/renderer/src/aiCore/provider/factory.ts index 62211100fe..569b5628cd 100644 --- a/src/renderer/src/aiCore/provider/factory.ts +++ b/src/renderer/src/aiCore/provider/factory.ts @@ -1,7 +1,7 @@ import { hasProviderConfigByAlias, type ProviderId, resolveProviderConfigId } from '@cherrystudio/ai-core/provider' import { createProvider as createProviderCore } from '@cherrystudio/ai-core/provider' import { loggerService } from '@logger' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import type { Provider as AiSdkProvider } from 'ai' import { initializeNewProviders } from './providerInitialization' @@ -84,6 +84,8 @@ export async function createAiSdkProvider(config) { config.providerId = `${config.providerId}-chat` } else if (config.providerId === 'azure' && config.options?.mode === 'responses') { config.providerId = `${config.providerId}-responses` + } else if (config.providerId === 'cherryin' && config.options?.mode === 'chat') { + config.providerId = 'cherryin-chat' } localProvider = await createProviderCore(config.providerId, config.options) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 0a3bbc7b58..094ab3de1e 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -5,27 +5,34 @@ import { type ProviderId, type ProviderSettingsMap } from '@cherrystudio/ai-core/provider' +import { cacheService } from '@data/CacheService' import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' -import { isNewApiProvider } from '@renderer/config/providers' +import { + isAnthropicProvider, + isAzureOpenAIProvider, + isCherryAIProvider, + isGeminiProvider, + isNewApiProvider, + isPerplexityProvider +} from '@renderer/config/providers' import { getAwsBedrockAccessKeyId, + getAwsBedrockApiKey, + getAwsBedrockAuthType, getAwsBedrockRegion, getAwsBedrockSecretAccessKey } from '@renderer/hooks/useAwsBedrock' -import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI' +import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI' import { getProviderByModel } from '@renderer/services/AssistantService' -import { loggerService } from '@renderer/services/LoggerService' import store from '@renderer/store' -import { isSystemProvider, type Model, type Provider } from '@renderer/types' -import { formatApiHost } from '@renderer/utils/api' -import { cloneDeep, trim } from 'lodash' +import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types' +import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api' +import { cloneDeep } from 'lodash' import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config' import { COPILOT_DEFAULT_HEADERS } from './constants' import { getAiSdkProviderId } from './factory' -const logger = loggerService.withContext('ProviderConfigProcessor') - /** * 获取轮询的API key * 复用legacy架构的多key轮询逻辑 @@ -38,16 +45,16 @@ function getRotatedApiKey(provider: Provider): string { return keys[0] } - const lastUsedKey = window.keyv.get(keyName) - if (!lastUsedKey) { - window.keyv.set(keyName, keys[0]) + const lastUsedKey = cacheService.getShared(keyName) as string | undefined + if (lastUsedKey === undefined) { + cacheService.setShared(keyName, keys[0]) return keys[0] } const currentIndex = keys.indexOf(lastUsedKey) const nextIndex = (currentIndex + 1) % keys.length const nextKey = keys[nextIndex] - window.keyv.set(keyName, nextKey) + cacheService.setShared(keyName, nextKey) return nextKey } @@ -56,13 +63,6 @@ function getRotatedApiKey(provider: Provider): string { * 处理特殊provider的转换逻辑 */ function handleSpecialProviders(model: Model, provider: Provider): Provider { - // if (provider.type === 'vertexai' && !isVertexProvider(provider)) { - // if (!isVertexAIConfigured()) { - // throw new Error('VertexAI is not configured. Please configure project, location and service account credentials.') - // } - // return createVertexProvider(provider) - // } - if (isNewApiProvider(provider)) { return newApiResolverCreator(model, provider) } @@ -79,43 +79,34 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { } /** - * 格式化provider的API Host + * 主要用来对齐AISdk的BaseURL格式 + * @param provider + * @returns */ -function formatAnthropicApiHost(host: string): string { - const trimmedHost = host?.trim() - - if (!trimmedHost) { - return '' - } - - if (trimmedHost.endsWith('/')) { - return trimmedHost - } - - if (trimmedHost.endsWith('/v1')) { - return `${trimmedHost}/` - } - - return formatApiHost(trimmedHost) -} - function formatProviderApiHost(provider: Provider): Provider { const formatted = { ...provider } if (formatted.anthropicApiHost) { - formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost) + formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost) } - if (formatted.type === 'anthropic') { + if (isAnthropicProvider(provider)) { const baseHost = formatted.anthropicApiHost || formatted.apiHost - formatted.apiHost = formatAnthropicApiHost(baseHost) + formatted.apiHost = formatApiHost(baseHost) if (!formatted.anthropicApiHost) { formatted.anthropicApiHost = formatted.apiHost } - } else if (formatted.id === 'copilot') { - const trimmed = trim(formatted.apiHost) - formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed - } else if (formatted.type === 'gemini') { - formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta') + } else if (formatted.id === SystemProviderIds.copilot || formatted.id === SystemProviderIds.github) { + formatted.apiHost = formatApiHost(formatted.apiHost, false) + } else if (isGeminiProvider(formatted)) { + formatted.apiHost = formatApiHost(formatted.apiHost, true, 'v1beta') + } else if (isAzureOpenAIProvider(formatted)) { + formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost) + } else if (isVertexProvider(formatted)) { + formatted.apiHost = formatVertexApiHost(formatted) + } else if (isCherryAIProvider(formatted)) { + formatted.apiHost = formatApiHost(formatted.apiHost, false) + } else if (isPerplexityProvider(formatted)) { + formatted.apiHost = formatApiHost(formatted.apiHost, false) } else { formatted.apiHost = formatApiHost(formatted.apiHost) } @@ -149,15 +140,15 @@ export function providerToAiSdkConfig( options: ProviderSettingsMap[keyof ProviderSettingsMap] } { const aiSdkProviderId = getAiSdkProviderId(actualProvider) - logger.debug('providerToAiSdkConfig', { aiSdkProviderId }) // 构建基础配置 + const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost) const baseConfig = { - baseURL: trim(actualProvider.apiHost), + baseURL: baseURL, apiKey: getRotatedApiKey(actualProvider) } - const isCopilotProvider = actualProvider.id === 'copilot' + const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot if (isCopilotProvider) { const storedHeaders = store.getState().copilot.defaultHeaders ?? {} const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, { @@ -178,9 +169,10 @@ export function providerToAiSdkConfig( // 处理OpenAI模式 const extraOptions: any = {} + extraOptions.endpoint = endpoint if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) { extraOptions.mode = 'responses' - } else if (aiSdkProviderId === 'openai') { + } else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) { extraOptions.mode = 'chat' } @@ -198,22 +190,28 @@ export function providerToAiSdkConfig( } } // azure + // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest + // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') { - extraOptions.apiVersion = actualProvider.apiVersion - baseConfig.baseURL += '/openai' - if (actualProvider.apiVersion === 'preview') { + // extraOptions.apiVersion = actualProvider.apiVersion === 'preview' ? 'v1' : actualProvider.apiVersion 默认使用v1,不使用azure endpoint + if (actualProvider.apiVersion === 'preview' || actualProvider.apiVersion === 'v1') { extraOptions.mode = 'responses' } else { extraOptions.mode = 'chat' - extraOptions.useDeploymentBasedUrls = true } } // bedrock if (aiSdkProviderId === 'bedrock') { + const authType = getAwsBedrockAuthType() extraOptions.region = getAwsBedrockRegion() - extraOptions.accessKeyId = getAwsBedrockAccessKeyId() - extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey() + + if (authType === 'apiKey') { + extraOptions.apiKey = getAwsBedrockApiKey() + } else { + extraOptions.accessKeyId = getAwsBedrockAccessKeyId() + extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey() + } } // google-vertex if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') { @@ -227,22 +225,7 @@ export function providerToAiSdkConfig( ...googleCredentials, privateKey: formatPrivateKey(googleCredentials.privateKey) } - // extraOptions.headers = window.api.vertexAI.getAuthHeaders({ - // projectId: project, - // serviceAccount: { - // privateKey: googleCredentials.privateKey, - // clientEmail: googleCredentials.clientEmail - // } - // }) - if (baseConfig.baseURL.endsWith('/v1/')) { - baseConfig.baseURL = baseConfig.baseURL.slice(0, -4) - } else if (baseConfig.baseURL.endsWith('/v1')) { - baseConfig.baseURL = baseConfig.baseURL.slice(0, -3) - } - - if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) { - baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google` - } + baseConfig.baseURL += aiSdkProviderId === 'google-vertex' ? '/publishers/google' : '/publishers/anthropic/models' } if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') { diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index 665f2bd05c..baf400508a 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -71,6 +71,21 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [ creatorFunctionName: 'createHuggingFace', supportsImageGeneration: true, aliases: ['hf', 'hugging-face'] + }, + { + id: 'ai-gateway', + name: 'AI Gateway', + import: () => import('@ai-sdk/gateway'), + creatorFunctionName: 'createGateway', + supportsImageGeneration: true, + aliases: ['gateway'] + }, + { + id: 'cerebras', + name: 'Cerebras', + import: () => import('@ai-sdk/cerebras'), + creatorFunctionName: 'createCerebras', + supportsImageGeneration: false } ] as const diff --git a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts index f3a4761894..dcd2ef6095 100644 --- a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts +++ b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts @@ -1,7 +1,7 @@ -import { REFERENCE_PROMPT } from '@renderer/config/prompts' import { processKnowledgeSearch } from '@renderer/services/KnowledgeService' import type { Assistant, KnowledgeReference } from '@renderer/types' -import { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract' +import type { ExtractResults, KnowledgeExtractResults } from '@renderer/utils/extract' +import { REFERENCE_PROMPT } from '@shared/config/prompts' import { type InferToolInput, type InferToolOutput, tool } from 'ai' import { isEmpty } from 'lodash' import * as z from 'zod' diff --git a/src/renderer/src/aiCore/tools/WebSearchTool.ts b/src/renderer/src/aiCore/tools/WebSearchTool.ts index 61d5d3b2c1..19b4595223 100644 --- a/src/renderer/src/aiCore/tools/WebSearchTool.ts +++ b/src/renderer/src/aiCore/tools/WebSearchTool.ts @@ -1,7 +1,7 @@ -import { REFERENCE_PROMPT } from '@renderer/config/prompts' import WebSearchService from '@renderer/services/WebSearchService' -import { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' -import { ExtractResults } from '@renderer/utils/extract' +import type { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' +import type { ExtractResults } from '@renderer/utils/extract' +import { REFERENCE_PROMPT } from '@shared/config/prompts' import { type InferToolInput, type InferToolOutput, tool } from 'ai' import * as z from 'zod' diff --git a/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts b/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts index fc844b5fe5..732397de40 100644 --- a/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts +++ b/src/renderer/src/aiCore/trace/AiSdkSpanAdapter.ts @@ -6,8 +6,9 @@ */ import { loggerService } from '@logger' -import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' -import { Span, SpanKind, SpanStatusCode } from '@opentelemetry/api' +import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' +import type { Span } from '@opentelemetry/api' +import { SpanKind, SpanStatusCode } from '@opentelemetry/api' const logger = loggerService.withContext('AiSdkSpanAdapter') diff --git a/src/renderer/src/aiCore/utils/image.ts b/src/renderer/src/aiCore/utils/image.ts index 43d916640a..37dbe76a2c 100644 --- a/src/renderer/src/aiCore/utils/image.ts +++ b/src/renderer/src/aiCore/utils/image.ts @@ -1,4 +1,5 @@ -import { isSystemProvider, Model, Provider, SystemProviderIds } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' +import { isSystemProvider, SystemProviderIds } from '@renderer/types' export function buildGeminiGenerateImageParams(): Record { return { diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts index 9606d9ea6e..84bc661aa0 100644 --- a/src/renderer/src/aiCore/utils/mcp.ts +++ b/src/renderer/src/aiCore/utils/mcp.ts @@ -1,10 +1,10 @@ import { loggerService } from '@logger' -import { MCPTool, MCPToolResponse } from '@renderer/types' +import type { MCPTool, MCPToolResponse } from '@renderer/types' import { callMCPTool, getMcpServerByTool, isToolAutoApproved } from '@renderer/utils/mcp-tools' import { requestToolConfirmation } from '@renderer/utils/userConfirmation' import { type Tool, type ToolSet } from 'ai' import { jsonSchema, tool } from 'ai' -import { JSONSchema7 } from 'json-schema' +import type { JSONSchema7 } from 'json-schema' const logger = loggerService.withContext('MCP-utils') diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 087a9ef157..88f556438b 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -2,15 +2,13 @@ import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-c import { isOpenAIModel, isQwenMTModel, isSupportFlexServiceTierModel } from '@renderer/config/models' import { isSupportServiceTierProvider } from '@renderer/config/providers' import { mapLanguageToQwenMTModel } from '@renderer/config/translate' +import type { Assistant, Model, Provider } from '@renderer/types' import { - Assistant, GroqServiceTiers, isGroqServiceTier, isOpenAIServiceTier, isTranslateAssistant, - Model, OpenAIServiceTiers, - Provider, SystemProviderIds } from '@renderer/types' import { t } from 'i18next' @@ -19,6 +17,7 @@ import { getAiSdkProviderId } from '../provider/factory' import { buildGeminiGenerateImageParams } from './image' import { getAnthropicReasoningParams, + getBedrockReasoningParams, getCustomParameters, getGeminiReasoningParams, getOpenAIReasoningParams, @@ -114,6 +113,9 @@ export function buildProviderOptions( } break } + case 'cherryin': + providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider) + break default: throw new Error(`Unsupported base provider ${baseProviderId}`) } @@ -129,6 +131,9 @@ export function buildProviderOptions( case 'google-vertex-anthropic': providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities) break + case 'bedrock': + providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities) + break default: // 对于其他 provider,使用通用的构建逻辑 providerSpecificOptions = { @@ -146,11 +151,12 @@ export function buildProviderOptions( ...providerSpecificOptions, ...getCustomParameters(assistant) } - // vertex需要映射到google或anthropic + const rawProviderKey = { 'google-vertex': 'google', - 'google-vertex-anthropic': 'anthropic' + 'google-vertex-anthropic': 'anthropic', + 'ai-gateway': 'gateway' }[rawProviderId] || rawProviderId // 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions } @@ -268,6 +274,60 @@ function buildXAIProviderOptions( return providerOptions } +function buildCherryInProviderOptions( + assistant: Assistant, + model: Model, + capabilities: { + enableReasoning: boolean + enableWebSearch: boolean + enableGenerateImage: boolean + }, + actualProvider: Provider +): Record { + const serviceTierSetting = getServiceTier(model, actualProvider) + + switch (actualProvider.type) { + case 'openai': + return { + ...buildOpenAIProviderOptions(assistant, model, capabilities), + serviceTier: serviceTierSetting + } + + case 'anthropic': + return buildAnthropicProviderOptions(assistant, model, capabilities) + + case 'gemini': + return buildGeminiProviderOptions(assistant, model, capabilities) + } + return {} +} + +/** + * Build Bedrock providerOptions + */ +function buildBedrockProviderOptions( + assistant: Assistant, + model: Model, + capabilities: { + enableReasoning: boolean + enableWebSearch: boolean + enableGenerateImage: boolean + } +): Record { + const { enableReasoning } = capabilities + let providerOptions: Record = {} + + if (enableReasoning) { + const reasoningParams = getBedrockReasoningParams(assistant, model) + providerOptions = { + ...providerOptions, + ...reasoningParams + } + } + + return providerOptions +} + /** * 构建通用的 providerOptions(用于其他 provider) */ diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 86a762897f..d0b6f1df25 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -30,9 +30,10 @@ import { import { isSupportEnableThinkingProvider } from '@renderer/config/providers' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' -import { SettingsState } from '@renderer/store/settings' -import { Assistant, EFFORT_RATIO, isSystemProvider, Model, SystemProviderIds } from '@renderer/types' -import { ReasoningEffortOptionalParams } from '@renderer/types/sdk' +import type { SettingsState } from '@renderer/store/settings' +import type { Assistant, Model } from '@renderer/types' +import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types' +import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk' import { toInteger } from 'lodash' const logger = loggerService.withContext('reasoning') @@ -97,7 +98,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin extra_body: { google: { thinking_config: { - thinkingBudget: 0 + thinking_budget: 0 } } } @@ -108,6 +109,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin // use thinking, doubao, zhipu, etc. if (isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenZhipuModel(model)) { + if (provider.id === SystemProviderIds.cerebras) { + return { + disable_reasoning: true + } + } return { thinking: { type: 'disabled' } } } @@ -258,8 +264,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin extra_body: { google: { thinking_config: { - thinkingBudget: -1, - includeThoughts: true + thinking_budget: -1, + include_thoughts: true } } } @@ -269,8 +275,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin extra_body: { google: { thinking_config: { - thinkingBudget: budgetTokens, - includeThoughts: true + thinking_budget: budgetTokens ?? -1, + include_thoughts: true } } } @@ -305,6 +311,9 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return {} } if (isSupportedThinkingTokenZhipuModel(model)) { + if (provider.id === SystemProviderIds.cerebras) { + return {} + } return { thinking: { type: 'enabled' } } } @@ -417,6 +426,8 @@ export function getAnthropicReasoningParams(assistant: Assistant, model: Model): /** * 获取 Gemini 推理参数 * 从 GeminiAPIClient 中提取的逻辑 + * 注意:Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递 + * 而在 Google 官方提供的 OpenAI 兼容端点中则使用蛇形命名法 thinking_budget */ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Record { if (!isReasoningModel(model)) { @@ -484,6 +495,34 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor } } +/** + * Get Bedrock reasoning parameters + */ +export function getBedrockReasoningParams(assistant: Assistant, model: Model): Record { + if (!isReasoningModel(model)) { + return {} + } + + const reasoningEffort = assistant?.settings?.reasoning_effort + + if (reasoningEffort === undefined) { + return {} + } + + // Only apply thinking budget for Claude reasoning models + if (!isSupportedThinkingTokenClaudeModel(model)) { + return {} + } + + const budgetTokens = getAnthropicThinkingBudget(assistant, model) + return { + reasoningConfig: { + type: 'enabled', + budgetTokens: budgetTokens + } + } +} + /** * 获取自定义参数 * 从 assistant 设置中提取自定义参数 diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index 0ab41d5ad3..02619b54cf 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -1,12 +1,12 @@ -import { +import type { AnthropicSearchConfig, OpenAISearchConfig, WebSearchPluginConfig } from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper' -import { BaseProviderId } from '@cherrystudio/ai-core/provider' +import type { BaseProviderId } from '@cherrystudio/ai-core/provider' import { isOpenAIDeepResearchModel, isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models' -import { CherryWebSearchConfig } from '@renderer/store/websearch' -import { Model } from '@renderer/types' +import type { CherryWebSearchConfig } from '@renderer/store/websearch' +import type { Model } from '@renderer/types' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' export function getWebSearchParams(model: Model): Record { @@ -107,6 +107,11 @@ export function buildProviderBuiltinWebSearchConfig( } } } + case 'cherryin': { + const _providerId = + { 'openai-response': 'openai', openai: 'openai-chat' }[model?.endpoint_type ?? ''] ?? model?.endpoint_type + return buildProviderBuiltinWebSearchConfig(_providerId, webSearchConfig, model) + } default: { return {} } diff --git a/src/renderer/src/api/agent.ts b/src/renderer/src/api/agent.ts index ecc772464d..7739a25440 100644 --- a/src/renderer/src/api/agent.ts +++ b/src/renderer/src/api/agent.ts @@ -1,37 +1,40 @@ import { loggerService } from '@logger' import { formatAgentServerError } from '@renderer/utils/error' -import { +import type { AddAgentForm, - AgentServerErrorSchema, ApiModelsFilter, ApiModelsResponse, - ApiModelsResponseSchema, CreateAgentRequest, CreateAgentResponse, - CreateAgentResponseSchema, CreateAgentSessionResponse, - CreateAgentSessionResponseSchema, CreateSessionForm, CreateSessionRequest, GetAgentResponse, - GetAgentResponseSchema, GetAgentSessionResponse, - GetAgentSessionResponseSchema, ListAgentSessionsResponse, - ListAgentSessionsResponseSchema, - type ListAgentsResponse, - ListAgentsResponseSchema, + ListAgentsResponse, ListOptions, - objectEntries, - objectKeys, UpdateAgentForm, UpdateAgentRequest, UpdateAgentResponse, - UpdateAgentResponseSchema, UpdateSessionForm, UpdateSessionRequest } from '@types' -import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios' +import { + AgentServerErrorSchema, + ApiModelsResponseSchema, + CreateAgentResponseSchema, + CreateAgentSessionResponseSchema, + GetAgentResponseSchema, + GetAgentSessionResponseSchema, + ListAgentSessionsResponseSchema, + ListAgentsResponseSchema, + objectEntries, + objectKeys, + UpdateAgentResponseSchema +} from '@types' +import type { Axios, AxiosRequestConfig } from 'axios' +import axios, { isAxiosError } from 'axios' import { ZodError } from 'zod' type ApiVersion = 'v1' diff --git a/src/renderer/src/assets/images/providers/cerebras.webp b/src/renderer/src/assets/images/providers/cerebras.webp new file mode 100644 index 0000000000..1c21b6ff26 Binary files /dev/null and b/src/renderer/src/assets/images/providers/cerebras.webp differ diff --git a/src/renderer/src/assets/images/providers/mcprouter.webp b/src/renderer/src/assets/images/providers/mcprouter.webp new file mode 100644 index 0000000000..7d6557fafc Binary files /dev/null and b/src/renderer/src/assets/images/providers/mcprouter.webp differ diff --git a/src/renderer/src/assets/images/providers/sophnet.svg b/src/renderer/src/assets/images/providers/sophnet.svg new file mode 100644 index 0000000000..aae1e03239 --- /dev/null +++ b/src/renderer/src/assets/images/providers/sophnet.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/images/providers/vercel.svg b/src/renderer/src/assets/images/providers/vercel.svg new file mode 100644 index 0000000000..486cb95787 --- /dev/null +++ b/src/renderer/src/assets/images/providers/vercel.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/src/renderer/src/assets/styles/color.css b/src/renderer/src/assets/styles/color.css index 5d625937e7..03c53b6424 100644 --- a/src/renderer/src/assets/styles/color.css +++ b/src/renderer/src/assets/styles/color.css @@ -1,3 +1,10 @@ +/* ================================================== + * 旧的 color.css 已被注释,等待完全移除 + * 项目正在迁移到 Tailwind CSS + * ================================================== + */ + +/* :root { --color-white: #ffffff; --color-white-soft: rgba(255, 255, 255, 0.8); @@ -19,7 +26,7 @@ --color-background-soft: var(--color-black-soft); --color-background-mute: var(--color-black-mute); --color-background-opacity: rgba(34, 34, 34, 0.7); - --inner-glow-opacity: 0.3; /* For the glassmorphism effect in the dropdown menu */ + --inner-glow-opacity: 0.3; --color-primary: #00b96b; --color-primary-soft: #00b96b99; @@ -145,3 +152,4 @@ --color-list-item: #252525; --color-list-item-hover: #1e1e1e; } +*/ diff --git a/src/renderer/src/assets/styles/index.css b/src/renderer/src/assets/styles/index.css index eaa984270f..8a361020fb 100644 --- a/src/renderer/src/assets/styles/index.css +++ b/src/renderer/src/assets/styles/index.css @@ -1,7 +1,6 @@ -@import './color.css'; +/* @import './color.css'; */ @import './font.css'; @import './markdown.css'; -@import './ant.css'; @import './scrollbar.css'; @import './container.css'; @import './animation.css'; @@ -12,12 +11,14 @@ @import '../fonts/country-flag-fonts/flag.css'; @layer base { - *, - *::before, - *::after { - box-sizing: border-box; - /* margin: 0; */ - font-weight: normal; + @layer base { + *, + *::before, + *::after { + box-sizing: border-box; + /* margin: 0; */ + font-weight: normal; + } } .lucide:not(.lucide-custom) { @@ -28,7 +29,6 @@ *:focus { outline-style: none; } - * { -webkit-tap-highlight-color: transparent; } @@ -41,11 +41,11 @@ body, margin: 0; } -/* #root { +#root { display: flex; flex-direction: row; flex: 1; -} */ +} body { display: flex; diff --git a/src/renderer/src/assets/styles/scrollbar.css b/src/renderer/src/assets/styles/scrollbar.css index 461384381e..62a4354f49 100644 --- a/src/renderer/src/assets/styles/scrollbar.css +++ b/src/renderer/src/assets/styles/scrollbar.css @@ -12,7 +12,7 @@ --scrollbar-thumb-radius: 10px; } -body[theme-mode='light'] { +body.light { --color-scrollbar-thumb: var(--color-scrollbar-thumb-light); --color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-light-hover); } diff --git a/src/renderer/src/assets/styles/selection-toolbar.css b/src/renderer/src/assets/styles/selection-toolbar.css index 6efcb57d1f..09be283630 100644 --- a/src/renderer/src/assets/styles/selection-toolbar.css +++ b/src/renderer/src/assets/styles/selection-toolbar.css @@ -56,7 +56,7 @@ html { --selection-toolbar-button-bgcolor-hover: #333333; } -[theme-mode='light'] { +html.light { --selection-toolbar-border: none; --selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.1); --selection-toolbar-background: rgba(245, 245, 245, 0.95); diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css index f05b01b65c..5981721b15 100644 --- a/src/renderer/src/assets/styles/tailwind.css +++ b/src/renderer/src/assets/styles/tailwind.css @@ -1,10 +1,8 @@ -@import 'tailwindcss' source('../../../../renderer'); +@import 'tailwindcss'; @import 'tw-animate-css'; -/* heroui */ -@plugin '../../hero.ts'; -@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'; - +@import '../../../../../packages/ui/src/styles/theme.css'; +@source '../../../../../packages/ui/src/components/**/*.{js,ts,jsx,tsx}'; @custom-variant dark (&:is(.dark *)); /* 如需自定义: @@ -21,116 +19,82 @@ */ :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); + --icon: #00000099; + --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); + --card-foreground: oklch(0.145 0 0); + --popover-foreground: oklch(0.145 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive-foreground: oklch(0.577 0.245 27.325); + --input: oklch(0.922 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.705 0.015 286.067); - --icon: #00000099; + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --icon: #ffffff99; + + --primary-foreground: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.92 0.004 286.32); - --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.708 0 0); --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.552 0.016 285.938); + --destructive-foreground: oklch(0.637 0.237 25.331); + --input: oklch(0.269 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.552 0.016 285.938); - --icon: #ffffff99; + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); } +/* shadcn中的默认变量(排除了与ui库冲突的变量,只映射ui库缺少的变量) */ @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); + --color-icon: var(--icon); --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); + --color-card-foreground: var(--card-foreground); + --color-popover-foreground: var(--popover-foreground); --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); + --color-input: var(--input); - --color-ring: var(--ring); + --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; - --color-icon: var(--icon); + @keyframes marquee { from { transform: translateX(0); @@ -150,19 +114,26 @@ } @layer base { - * { + /* * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; - } - + } */ + /* TODO: 迁移完成后删除 */ /* To disable drag title bar on toast. tailwind css doesn't provide such class name. */ .hero-toast { -webkit-app-region: no-drag; } } +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + :root { background-color: unset; } diff --git a/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts b/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts index 86ec67b760..d910a15796 100644 --- a/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts +++ b/src/renderer/src/components/ActionTools/__tests__/useToolManager.test.ts @@ -1,4 +1,5 @@ -import { ActionTool, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { useToolManager } from '@renderer/components/ActionTools' import { act, renderHook } from '@testing-library/react' import { useState } from 'react' import { describe, expect, it } from 'vitest' diff --git a/src/renderer/src/components/ActionTools/constants.ts b/src/renderer/src/components/ActionTools/constants.ts index c2b4966e5f..bade7d123a 100644 --- a/src/renderer/src/components/ActionTools/constants.ts +++ b/src/renderer/src/components/ActionTools/constants.ts @@ -1,4 +1,4 @@ -import { ActionToolSpec } from './types' +import type { ActionToolSpec } from './types' export const TOOL_SPECS: Record = { // Core tools diff --git a/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx b/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx index 3481b92797..f998470403 100644 --- a/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx +++ b/src/renderer/src/components/ActionTools/hooks/useImageTools.tsx @@ -3,7 +3,8 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { ImagePreviewService } from '@renderer/services/ImagePreviewService' import { download as downloadFile } from '@renderer/utils/download' import { svgToPngBlob, svgToSvgBlob } from '@renderer/utils/image' -import { RefObject, useCallback, useEffect, useRef } from 'react' +import type { RefObject } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('usePreviewToolHandlers') diff --git a/src/renderer/src/components/ActionTools/hooks/useToolManager.ts b/src/renderer/src/components/ActionTools/hooks/useToolManager.ts index ae73fcdb5d..b3d85aaa2f 100644 --- a/src/renderer/src/components/ActionTools/hooks/useToolManager.ts +++ b/src/renderer/src/components/ActionTools/hooks/useToolManager.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import { ActionTool, ToolRegisterProps } from '../types' +import type { ActionTool, ToolRegisterProps } from '../types' export const useToolManager = (setTools?: ToolRegisterProps['setTools']) => { // 注册工具,如果已存在同ID工具则替换 diff --git a/src/renderer/src/components/ApiModelLabel.tsx b/src/renderer/src/components/ApiModelLabel.tsx deleted file mode 100644 index c101689ff9..0000000000 --- a/src/renderer/src/components/ApiModelLabel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Avatar, cn } from '@heroui/react' -import { getModelLogoById } from '@renderer/config/models' -import { ApiModel } from '@renderer/types' -import React from 'react' - -import Ellipsis from './Ellipsis' - -export interface ModelLabelProps extends Omit, 'children'> { - model?: ApiModel - classNames?: { - container?: string - avatar?: string - modelName?: string - divider?: string - providerName?: string - } -} - -export const ApiModelLabel: React.FC = ({ model, className, classNames, ...props }) => { - return ( -
- - {model?.name} - | - {model?.provider_name} -
- ) -} diff --git a/src/renderer/src/components/Avatar/EmojiAvatar.tsx b/src/renderer/src/components/Avatar/EmojiAvatar.tsx deleted file mode 100644 index e01024735a..0000000000 --- a/src/renderer/src/components/Avatar/EmojiAvatar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { memo } from 'react' -import styled from 'styled-components' - -interface EmojiAvatarProps { - children: string - size?: number - fontSize?: number - onClick?: React.MouseEventHandler - className?: string - style?: React.CSSProperties -} - -const EmojiAvatar = ({ - ref, - children, - size = 31, - fontSize, - onClick, - className, - style -}: EmojiAvatarProps & { ref?: React.RefObject }) => ( - - {children} - -) - -EmojiAvatar.displayName = 'EmojiAvatar' - -const StyledEmojiAvatar = styled.div<{ $size: number; $fontSize: number }>` - display: flex; - align-items: center; - justify-content: center; - background-color: var(--color-background-soft); - border: 0.5px solid var(--color-border); - border-radius: 20%; - cursor: pointer; - width: ${(props) => props.$size}px; - height: ${(props) => props.$size}px; - font-size: ${(props) => props.$fontSize}px; - transition: opacity 0.3s ease; - - &:hover { - opacity: 0.8; - } -` - -export default memo(EmojiAvatar) diff --git a/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx b/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx index 6735d86a4e..649f619cba 100644 --- a/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx +++ b/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx @@ -1,4 +1,4 @@ -import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react' +import { Button, Popover } from 'antd' import React from 'react' import EmojiPicker from '../EmojiPicker' @@ -10,13 +10,10 @@ type Props = { export const EmojiAvatarWithPicker: React.FC = ({ emoji, onPick }) => { return ( - - - ) } diff --git a/src/renderer/src/components/Avatar/ModelAvatar.tsx b/src/renderer/src/components/Avatar/ModelAvatar.tsx index 04e8615fbb..9ce6d87c46 100644 --- a/src/renderer/src/components/Avatar/ModelAvatar.tsx +++ b/src/renderer/src/components/Avatar/ModelAvatar.tsx @@ -1,8 +1,9 @@ +import type { AvatarProps } from '@cherrystudio/ui' +import { Avatar, cn } from '@cherrystudio/ui' import { getModelLogo } from '@renderer/config/models' -import { Model } from '@renderer/types' -import { Avatar, AvatarProps } from 'antd' +import type { Model } from '@renderer/types' import { first } from 'lodash' -import { FC } from 'react' +import type { FC } from 'react' interface Props { model?: Model @@ -11,21 +12,14 @@ interface Props { className?: string } -const ModelAvatar: FC = ({ model, size, props, className }) => { +const ModelAvatar: FC = ({ model, size, className, ...props }) => { return ( + radius="lg" + className={cn('flex items-center justify-center', `${className || ''}`)} + style={{ width: size, height: size }} + {...props}> {first(model?.name)} ) diff --git a/src/renderer/src/components/Buttons/ActionIconButton.tsx b/src/renderer/src/components/Buttons/ActionIconButton.tsx index 1448008090..221a5eeb30 100644 --- a/src/renderer/src/components/Buttons/ActionIconButton.tsx +++ b/src/renderer/src/components/Buttons/ActionIconButton.tsx @@ -1,30 +1,32 @@ -import { cn } from '@heroui/react' -import { Button, ButtonProps } from 'antd' +import { Button, cn } from '@cherrystudio/ui' import React, { memo } from 'react' -interface ActionIconButtonProps extends ButtonProps { - children: React.ReactNode +interface ActionIconButtonProps extends Omit, 'ref'> { + icon: React.ReactNode active?: boolean + loading?: boolean } /** * A simple action button rendered as an icon */ -const ActionIconButton: React.FC = ({ children, active = false, className, ...props }) => { +const ActionIconButton: React.FC = ({ icon, active = false, className, ...props }) => { return ( ) } +ActionIconButton.displayName = 'ActionIconButton' + export default memo(ActionIconButton) diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index 9f5ab59988..e9d087b2fa 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -1,11 +1,12 @@ import { CodeOutlined } from '@ant-design/icons' +import { Button } from '@cherrystudio/ui' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' -import { ThemeMode } from '@renderer/types' import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' -import { Button } from 'antd' +import type { ThemeMode } from '@shared/data/preference/preferenceTypes' import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react' -import { FC, useState } from 'react' +import type { FC } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ClipLoader } from 'react-spinners' import styled, { keyframes } from 'styled-components' @@ -88,20 +89,24 @@ const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) => - ) : ( - - - diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 9453866f20..8e155d8be0 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,11 +1,14 @@ -import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' +import { CodeEditor, type CodeEditorHandles } from '@cherrystudio/ui' +import { Button, Tooltip } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' import { CopyIcon, FilePngIcon } from '@renderer/components/Icons' import { isMac } from '@renderer/config/constant' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { classNames } from '@renderer/utils' import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' import { captureScrollableIframeAsBlob, captureScrollableIframeAsDataURL } from '@renderer/utils/image' -import { Button, Dropdown, Modal, Splitter, Tooltip, Typography } from 'antd' +import { Dropdown, Modal, Splitter, Typography } from 'antd' import { Camera, Check, Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,6 +26,8 @@ type ViewMode = 'split' | 'code' | 'preview' const HtmlArtifactsPopup: React.FC = ({ open, title, html, onSave, onClose }) => { const { t } = useTranslation() + const [fontSize] = usePreference('chat.message.font_size') + const { activeCmTheme } = useCodeStyle() const [viewMode, setViewMode] = useState('split') const [isFullscreen, setIsFullscreen] = useState(false) const [saved, setSaved] = useTemporaryValue(false, 2000) @@ -79,24 +84,24 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht e.stopPropagation()}> } + size="sm" + variant={viewMode === 'split' ? 'default' : 'secondary'} onClick={() => setViewMode('split')}> + {t('html_artifacts.split')} } + size="sm" + variant={viewMode === 'code' ? 'default' : 'secondary'} onClick={() => setViewMode('code')}> + {t('html_artifacts.code')} } + size="sm" + variant={viewMode === 'preview' ? 'default' : 'secondary'} onClick={() => setViewMode('preview')}> + {t('html_artifacts.preview')} @@ -121,17 +126,18 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht } ] }}> - - - + ) @@ -141,6 +147,8 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht = ({ open, title, ht }} /> - - - ) : ( - - ) - } - onClick={handleSave} - /> + + + {saved ? ( + + ) : ( + + )} + diff --git a/src/renderer/src/components/CodeBlockView/StatusBar.tsx b/src/renderer/src/components/CodeBlockView/StatusBar.tsx index defd070ac8..6824ba5e42 100644 --- a/src/renderer/src/components/CodeBlockView/StatusBar.tsx +++ b/src/renderer/src/components/CodeBlockView/StatusBar.tsx @@ -1,5 +1,6 @@ -import { Flex } from 'antd' -import { FC, memo, ReactNode } from 'react' +import { Flex } from '@cherrystudio/ui' +import type { FC, ReactNode } from 'react' +import { memo } from 'react' import styled from 'styled-components' interface Props { diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 939095262e..cc978b3f8c 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -1,6 +1,7 @@ +import { CodeEditor, type CodeEditorHandles } from '@cherrystudio/ui' +import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' -import { ActionTool } from '@renderer/components/ActionTools' -import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' +import type { ActionTool } from '@renderer/components/ActionTools' import { CodeToolbar, useCopyTool, @@ -14,9 +15,9 @@ import { } from '@renderer/components/CodeToolbar' import CodeViewer from '@renderer/components/CodeViewer' import ImageViewer from '@renderer/components/ImageViewer' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' -import { useSettings } from '@renderer/hooks/useSettings' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { pyodideService } from '@renderer/services/PyodideService' import { getExtensionByLanguage } from '@renderer/utils/code-language' import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' @@ -27,7 +28,7 @@ import styled, { css } from 'styled-components' import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants' import StatusBar from './StatusBar' -import { ViewMode } from './types' +import type { ViewMode } from './types' const logger = loggerService.withContext('CodeBlockView') @@ -55,7 +56,25 @@ interface Props { */ export const CodeBlockView: React.FC = memo(({ children, language, onSave }) => { const { t } = useTranslation() - const { codeEditor, codeExecution, codeImageTools, codeCollapsible, codeWrappable } = useSettings() + + const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled') + const [codeExecutionTimeoutMinutes] = usePreference('chat.code.execution.timeout_minutes') + const [codeCollapsible] = usePreference('chat.code.collapsible') + const [codeWrappable] = usePreference('chat.code.wrappable') + const [codeImageTools] = usePreference('chat.code.image_tools') + const [fontSize] = usePreference('chat.message.font_size') + const [codeShowLineNumbers] = usePreference('chat.code.show_line_numbers') + const [codeEditor] = useMultiplePreferences({ + enabled: 'chat.code.editor.enabled', + autocompletion: 'chat.code.editor.autocompletion', + foldGutter: 'chat.code.editor.fold_gutter', + highlightActiveLine: 'chat.code.editor.highlight_active_line', + keymap: 'chat.code.editor.keymap', + themeLight: 'chat.code.editor.theme_light', + themeDark: 'chat.code.editor.theme_dark' + }) + + const { activeCmTheme } = useCodeStyle() const [viewState, setViewState] = useState({ mode: 'special' as ViewMode, @@ -87,8 +106,8 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave const [tools, setTools] = useState([]) const isExecutable = useMemo(() => { - return codeExecution.enabled && language === 'python' - }, [codeExecution.enabled, language]) + return codeExecutionEnabled && language === 'python' + }, [codeExecutionEnabled, language]) const sourceViewRef = useRef(null) const specialViewRef = useRef(null) @@ -153,7 +172,7 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave setExecutionResult(null) pyodideService - .runScript(children, {}, codeExecution.timeoutMinutes * 60000) + .runScript(children, {}, codeExecutionTimeoutMinutes * 60000) .then((result) => { setExecutionResult(result) }) @@ -166,7 +185,7 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave .finally(() => { setIsRunning(false) }) - }, [children, codeExecution.timeoutMinutes]) + }, [children, codeExecutionTimeoutMinutes]) const showPreviewTools = useMemo(() => { return viewMode !== 'source' && hasSpecialView @@ -245,12 +264,14 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave @@ -265,7 +286,18 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`} /> ), - [children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap] + [ + activeCmTheme, + children, + codeEditor, + codeShowLineNumbers, + fontSize, + handleHeightChange, + language, + onSave, + shouldExpand, + shouldWrap + ] ) // 特殊视图组件映射 diff --git a/src/renderer/src/components/CodeEditor/CodeEditor.tsx b/src/renderer/src/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000000..502e99cfc6 --- /dev/null +++ b/src/renderer/src/components/CodeEditor/CodeEditor.tsx @@ -0,0 +1,137 @@ +import type { BasicSetupOptions } from '@uiw/react-codemirror' +import CodeMirror, { Annotation, EditorView } from '@uiw/react-codemirror' +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import { memo } from 'react' + +import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap } from './hooks' +import type { CodeEditorProps } from './types' +import { prepareCodeChanges } from './utils' + +/** + * A code editor component based on CodeMirror. + * This is a wrapper of ReactCodeMirror. + * @deprecated Import CodeEditor from @cherrystudio/ui instead. + */ +const CodeEditor = ({ + ref, + value, + placeholder, + language, + onSave, + onChange, + onBlur, + onHeightChange, + height, + maxHeight, + minHeight, + options, + extensions, + theme = 'light', + fontSize = 16, + style, + className, + editable = true, + expanded = true, + wrapped = true +}: CodeEditorProps) => { + const basicSetup = useMemo(() => { + return { + dropCursor: true, + allowMultipleSelections: true, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + rectangularSelection: true, + crosshairCursor: true, + highlightActiveLineGutter: false, + highlightSelectionMatches: true, + closeBracketsKeymap: options?.keymap, + searchKeymap: options?.keymap, + foldKeymap: options?.keymap, + completionKeymap: options?.keymap, + lintKeymap: options?.keymap, + ...(options as BasicSetupOptions) + } + }, [options]) + + const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? '')) + const editorViewRef = useRef(null) + + const langExtensions = useLanguageExtensions(language, options?.lint) + + const handleSave = useCallback(() => { + const currentDoc = editorViewRef.current?.state.doc.toString() ?? '' + onSave?.(currentDoc) + }, [onSave]) + + // Calculate changes during streaming response to update EditorView + // Cannot handle user editing code during streaming response (and probably doesn't need to) + useEffect(() => { + if (!editorViewRef.current) return + + const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '') + const currentDoc = editorViewRef.current.state.doc.toString() + + const changes = prepareCodeChanges(currentDoc, newContent) + + if (changes && changes.length > 0) { + editorViewRef.current.dispatch({ + changes, + annotations: [Annotation.define().of(true)] + }) + } + }, [options?.stream, value]) + + const saveKeymapExtension = useSaveKeymap({ onSave, enabled: options?.keymap }) + const blurExtension = useBlurHandler({ onBlur }) + const heightListenerExtension = useHeightListener({ onHeightChange }) + + const customExtensions = useMemo(() => { + return [ + ...(extensions ?? []), + ...langExtensions, + ...(wrapped ? [EditorView.lineWrapping] : []), + saveKeymapExtension, + blurExtension, + heightListenerExtension + ].flat() + }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) + + useImperativeHandle(ref, () => ({ + save: handleSave + })) + + return ( + { + editorViewRef.current = view + onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0) + }} + onChange={(value, viewUpdate) => { + if (onChange && viewUpdate.docChanged) onChange(value) + }} + basicSetup={basicSetup} + style={{ + fontSize, + marginTop: 0, + borderRadius: 'inherit', + ...style + }} + className={`code-editor ${className ?? ''}`} + /> + ) +} + +CodeEditor.displayName = 'CodeEditor' + +export default memo(CodeEditor) diff --git a/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts b/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts index b02d10e152..ebc9a3d7d3 100644 --- a/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts +++ b/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts @@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getNormalizedExtension } from '../utils' -const mocks = vi.hoisted(() => ({ - getExtensionByLanguage: vi.fn() +const hoisted = vi.hoisted(() => ({ + languages: { + svg: { extensions: ['.svg'] }, + TypeScript: { extensions: ['.ts'] } + } })) -vi.mock('@renderer/utils/code-language', () => ({ - getExtensionByLanguage: mocks.getExtensionByLanguage +vi.mock('@shared/config/languages', () => ({ + languages: hoisted.languages })) describe('getNormalizedExtension', () => { @@ -16,28 +19,23 @@ describe('getNormalizedExtension', () => { }) it('should return custom mapping for custom language', async () => { - mocks.getExtensionByLanguage.mockReturnValue(undefined) await expect(getNormalizedExtension('svg')).resolves.toBe('xml') await expect(getNormalizedExtension('SVG')).resolves.toBe('xml') }) it('should prefer custom mapping when both custom and linguist exist', async () => { - mocks.getExtensionByLanguage.mockReturnValue('.svg') await expect(getNormalizedExtension('svg')).resolves.toBe('xml') }) it('should return linguist mapping when available (strip leading dot)', async () => { - mocks.getExtensionByLanguage.mockReturnValue('.ts') await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts') }) it('should return extension when input already looks like extension (leading dot)', async () => { - mocks.getExtensionByLanguage.mockReturnValue(undefined) await expect(getNormalizedExtension('.json')).resolves.toBe('json') }) it('should return language as-is when no rules matched', async () => { - mocks.getExtensionByLanguage.mockReturnValue(undefined) await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage') }) }) diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index 65c18a5a0f..b502c6e9da 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -1,7 +1,8 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup import { EditorView } from '@codemirror/view' import { loggerService } from '@logger' -import { Extension, keymap } from '@uiw/react-codemirror' +import type { Extension } from '@uiw/react-codemirror' +import { keymap } from '@uiw/react-codemirror' import { useCallback, useEffect, useMemo, useState } from 'react' import { getNormalizedExtension } from './utils' diff --git a/src/renderer/src/components/CodeEditor/index.ts b/src/renderer/src/components/CodeEditor/index.ts new file mode 100644 index 0000000000..4a2e55f9fb --- /dev/null +++ b/src/renderer/src/components/CodeEditor/index.ts @@ -0,0 +1,3 @@ +export { default } from './CodeEditor' +export * from './types' +export { getCmThemeByName, getCmThemeNames } from './utils' diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx deleted file mode 100644 index 31c4ce798c..0000000000 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { useCodeStyle } from '@renderer/context/CodeStyleProvider' -import { useSettings } from '@renderer/hooks/useSettings' -import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' -import diff from 'fast-diff' -import { useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' -import { memo } from 'react' - -import { useBlurHandler, useHeightListener, useLanguageExtensions, useSaveKeymap, useScrollToLine } from './hooks' - -// 标记非用户编辑的变更 -const External = Annotation.define() - -export interface CodeEditorHandles { - save?: () => void - scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void -} - -export interface CodeEditorProps { - ref?: React.RefObject - /** Value used in controlled mode, e.g., code blocks. */ - value: string - /** Placeholder when the editor content is empty. */ - placeholder?: string | HTMLElement - /** - * Code language string. - * - Case-insensitive. - * - Supports common names: javascript, json, python, etc. - * - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc. - * - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc. - */ - language: string - /** Fired when ref.save() is called or the save shortcut is triggered. */ - onSave?: (newContent: string) => void - /** Fired when the editor content changes. */ - onChange?: (newContent: string) => void - /** Fired when the editor loses focus. */ - onBlur?: (newContent: string) => void - /** Fired when the editor height changes. */ - onHeightChange?: (scrollHeight: number) => void - /** - * Fixed editor height, not exceeding maxHeight. - * Only works when expanded is false. - */ - height?: string - /** - * Maximum editor height. - * Only works when expanded is false. - */ - maxHeight?: string - /** Minimum editor height. */ - minHeight?: string - /** Editor options that extend BasicSetupOptions. */ - options?: { - /** - * Whether to enable special treatment for stream response. - * @default false - */ - stream?: boolean - /** - * Whether to enable linting. - * @default false - */ - lint?: boolean - /** - * Whether to enable keymap. - * @default false - */ - keymap?: boolean - } & BasicSetupOptions - /** Additional extensions for CodeMirror. */ - extensions?: Extension[] - /** Font size that overrides the app setting. */ - fontSize?: number - /** Style overrides for the editor, passed directly to CodeMirror's style property. */ - style?: React.CSSProperties - /** CSS class name appended to the default `code-editor` class. */ - className?: string - /** - * Whether the editor view is editable. - * @default true - */ - editable?: boolean - /** - * Set the editor state to read only but keep some user interactions, e.g., keymaps. - * @default false - */ - readOnly?: boolean - /** - * Whether the editor is expanded. - * If true, the height and maxHeight props are ignored. - * @default true - */ - expanded?: boolean - /** - * Whether the code lines are wrapped. - * @default true - */ - wrapped?: boolean -} - -/** - * A code editor component based on CodeMirror. - * This is a wrapper of ReactCodeMirror. - */ -const CodeEditor = ({ - ref, - value, - placeholder, - language, - onSave, - onChange, - onBlur, - onHeightChange, - height, - maxHeight, - minHeight, - options, - extensions, - fontSize: customFontSize, - style, - className, - editable = true, - readOnly = false, - expanded = true, - wrapped = true -}: CodeEditorProps) => { - const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings() - const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) - - // 合并 codeEditor 和 options 的 basicSetup,options 优先 - const basicSetup = useMemo(() => { - return { - lineNumbers: _lineNumbers, - ...(codeEditor as BasicSetupOptions), - ...(options as BasicSetupOptions) - } - }, [codeEditor, _lineNumbers, options]) - - const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize]) - - const { activeCmTheme } = useCodeStyle() - const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? '')) - const editorViewRef = useRef(null) - - const langExtensions = useLanguageExtensions(language, options?.lint) - - const handleSave = useCallback(() => { - const currentDoc = editorViewRef.current?.state.doc.toString() ?? '' - onSave?.(currentDoc) - }, [onSave]) - - // 流式响应过程中计算 changes 来更新 EditorView - // 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理) - useEffect(() => { - if (!editorViewRef.current) return - - const newContent = options?.stream ? (value ?? '').trimEnd() : (value ?? '') - const currentDoc = editorViewRef.current.state.doc.toString() - - const changes = prepareCodeChanges(currentDoc, newContent) - - if (changes && changes.length > 0) { - editorViewRef.current.dispatch({ - changes, - annotations: [External.of(true)] - }) - } - }, [options?.stream, value]) - - const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap }) - const blurExtension = useBlurHandler({ onBlur }) - const heightListenerExtension = useHeightListener({ onHeightChange }) - - const customExtensions = useMemo(() => { - return [ - ...(extensions ?? []), - ...langExtensions, - ...(wrapped ? [EditorView.lineWrapping] : []), - saveKeymapExtension, - blurExtension, - heightListenerExtension - ].flat() - }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) - - const scrollToLine = useScrollToLine(editorViewRef) - - useImperativeHandle(ref, () => ({ - save: handleSave, - scrollToLine - })) - - return ( - { - editorViewRef.current = view - onHeightChange?.(view.scrollDOM?.scrollHeight ?? 0) - }} - onChange={(value, viewUpdate) => { - if (onChange && viewUpdate.docChanged) onChange(value) - }} - basicSetup={{ - dropCursor: true, - allowMultipleSelections: true, - indentOnInput: true, - bracketMatching: true, - closeBrackets: true, - rectangularSelection: true, - crosshairCursor: true, - highlightActiveLineGutter: false, - highlightSelectionMatches: true, - closeBracketsKeymap: enableKeymap, - searchKeymap: enableKeymap, - foldKeymap: enableKeymap, - completionKeymap: enableKeymap, - lintKeymap: enableKeymap, - ...basicSetup // override basicSetup - }} - style={{ - fontSize, - marginTop: 0, - borderRadius: 'inherit', - ...style - }} - className={`code-editor ${className ?? ''}`} - /> - ) -} - -CodeEditor.displayName = 'CodeEditor' - -/** - * 使用 fast-diff 计算代码变更,再转换为 CodeMirror 的 changes。 - * 可以处理所有类型的变更,不过流式响应过程中多是插入操作。 - * @param oldCode 旧的代码内容 - * @param newCode 新的代码内容 - * @returns 用于 EditorView.dispatch 的 changes 数组 - */ -function prepareCodeChanges(oldCode: string, newCode: string) { - const diffResult = diff(oldCode, newCode) - - const changes: { from: number; to: number; insert: string }[] = [] - let offset = 0 - - // operation: 1=插入, -1=删除, 0=相等 - for (const [operation, text] of diffResult) { - if (operation === 1) { - changes.push({ - from: offset, - to: offset, - insert: text - }) - } else if (operation === -1) { - changes.push({ - from: offset, - to: offset + text.length, - insert: '' - }) - offset += text.length - } else { - offset += text.length - } - } - - return changes -} - -export default memo(CodeEditor) diff --git a/src/renderer/src/components/CodeEditor/types.ts b/src/renderer/src/components/CodeEditor/types.ts new file mode 100644 index 0000000000..f30056b108 --- /dev/null +++ b/src/renderer/src/components/CodeEditor/types.ts @@ -0,0 +1,94 @@ +import type { BasicSetupOptions, Extension } from '@uiw/react-codemirror' + +export type CodeMirrorTheme = 'light' | 'dark' | 'none' | Extension + +export interface CodeEditorHandles { + save?: () => void + scrollToLine?: (lineNumber: number, options?: { highlight?: boolean }) => void +} + +export interface CodeEditorProps { + ref?: React.RefObject + /** Value used in controlled mode, e.g., code blocks. */ + value: string + /** Placeholder when the editor content is empty. */ + placeholder?: string | HTMLElement + /** + * Code language string. + * - Case-insensitive. + * - Supports common names: javascript, json, python, etc. + * - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc. + * - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc. + */ + language: string + /** Fired when ref.save() is called or the save shortcut is triggered. */ + onSave?: (newContent: string) => void + /** Fired when the editor content changes. */ + onChange?: (newContent: string) => void + /** Fired when the editor loses focus. */ + onBlur?: (newContent: string) => void + /** Fired when the editor height changes. */ + onHeightChange?: (scrollHeight: number) => void + /** + * Fixed editor height, not exceeding maxHeight. + * Only works when expanded is false. + */ + height?: string + /** + * Maximum editor height. + * Only works when expanded is false. + */ + maxHeight?: string + /** Minimum editor height. */ + minHeight?: string + /** Editor options that extend BasicSetupOptions. */ + options?: { + /** + * Whether to enable special treatment for stream response. + * @default false + */ + stream?: boolean + /** + * Whether to enable linting. + * @default false + */ + lint?: boolean + /** + * Whether to enable keymap. + * @default false + */ + keymap?: boolean + } & BasicSetupOptions + /** Additional extensions for CodeMirror. */ + extensions?: Extension[] + /** + * CodeMirror theme name: 'light', 'dark', 'none', Extension. + * @default 'light' + */ + theme?: CodeMirrorTheme + /** + * Font size that overrides the app setting. + * @default 16 + */ + fontSize?: number + /** Style overrides for the editor, passed directly to CodeMirror's style property. */ + style?: React.CSSProperties + /** CSS class name appended to the default `code-editor` class. */ + className?: string + /** + * Whether the editor is editable. + * @default true + */ + editable?: boolean + /** + * Whether the editor is expanded. + * If true, the height and maxHeight props are ignored. + * @default true + */ + expanded?: boolean + /** + * Whether the code lines are wrapped. + * @default true + */ + wrapped?: boolean +} diff --git a/src/renderer/src/components/CodeEditor/utils.ts b/src/renderer/src/components/CodeEditor/utils.ts index ef5941720e..b46aad36c2 100644 --- a/src/renderer/src/components/CodeEditor/utils.ts +++ b/src/renderer/src/components/CodeEditor/utils.ts @@ -1,8 +1,49 @@ -import { getExtensionByLanguage } from '@renderer/utils/code-language' +import { languages } from '@shared/config/languages' +import * as cmThemes from '@uiw/codemirror-themes-all' +import type { Extension } from '@uiw/react-codemirror' +import diff from 'fast-diff' -// 自定义语言文件扩展名映射 -// key: 语言名小写 -// value: 扩展名 +import type { CodeMirrorTheme } from './types' + +/** + * Computes code changes using fast-diff and converts them to CodeMirror changes. + * Could handle all types of changes, though insertions are most common during streaming responses. + * @param oldCode The old code content + * @param newCode The new code content + * @returns An array of changes for EditorView.dispatch + */ +export function prepareCodeChanges(oldCode: string, newCode: string) { + const diffResult = diff(oldCode, newCode) + + const changes: { from: number; to: number; insert: string }[] = [] + let offset = 0 + + // operation: 1=insert, -1=delete, 0=equal + for (const [operation, text] of diffResult) { + if (operation === 1) { + changes.push({ + from: offset, + to: offset, + insert: text + }) + } else if (operation === -1) { + changes.push({ + from: offset, + to: offset + text.length, + insert: '' + }) + offset += text.length + } else { + offset += text.length + } + } + + return changes +} + +// Custom language file extension mapping +// key: language name in lowercase +// value: file extension const _customLanguageExtensions: Record = { svg: 'xml', vab: 'vb', @@ -10,31 +51,112 @@ const _customLanguageExtensions: Record = { } /** - * 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs - * - 先搜索自定义扩展名 - * - 再搜索 github linguist 扩展名 - * - 最后假定名称已经是扩展名 - * @param language 语言名称 - * @returns 扩展名(不包含 `.`) + * Get the file extension of the language, for @uiw/codemirror-extensions-langs + * - First, search for custom extensions + * - Then, search for github linguist extensions + * - Finally, assume the name is already an extension + * @param language language name + * @returns file extension (without `.` prefix) */ export async function getNormalizedExtension(language: string) { - const lowerLanguage = language.toLowerCase() + let lang = language + // If the language name looks like an extension, remove the dot + if (language.startsWith('.') && language.length > 1) { + lang = language.slice(1) + } + + const lowerLanguage = lang.toLowerCase() + + // 1. Search for custom extensions const customExt = _customLanguageExtensions[lowerLanguage] if (customExt) { return customExt } - const linguistExt = getExtensionByLanguage(language) + // 2. Search for github linguist extensions + const linguistExt = getExtensionByLanguage(lang) if (linguistExt) { return linguistExt.slice(1) } - // 如果语言名称像扩展名 - if (language.startsWith('.') && language.length > 1) { - return language.slice(1) + // Fallback to language name + return lang +} + +/** + * Get the file extension of the language, by language name + * - First, exact match + * - Then, case-insensitive match + * - Finally, match aliases + * If there are multiple file extensions, only the first one will be returned + * @param language language name + * @returns file extension + */ +export function getExtensionByLanguage(language: string): string { + const lowerLanguage = language.toLowerCase() + + // Exact match language name + const directMatch = languages[language] + if (directMatch?.extensions?.[0]) { + return directMatch.extensions[0] } - // 回退到语言名称 - return language + // Case-insensitive match language name + for (const [langName, data] of Object.entries(languages)) { + if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) { + return data.extensions[0] + } + } + + // Match aliases + for (const [, data] of Object.entries(languages)) { + if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) { + return data.extensions?.[0] || `.${language}` + } + } + + // Fallback to language name + return `.${language}` +} + +/** + * Get the list of CodeMirror theme names + * - Include auto, light, dark + * - Include all themes in @uiw/codemirror-themes-all + * + * A more robust approach might be to hardcode the theme list + * @returns theme name list + */ +export function getCmThemeNames(): string[] { + return ['auto', 'light', 'dark'] + .concat(Object.keys(cmThemes)) + .filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function') + .filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string)) +} + +/** + * Get the CodeMirror theme object by theme name + * @param name theme name + * @returns theme object + */ +export function getCmThemeByName(name: string): CodeMirrorTheme { + // 1. Search for the extension of the corresponding theme in @uiw/codemirror-themes-all + const candidate = (cmThemes as Record)[name] + if ( + Object.prototype.hasOwnProperty.call(cmThemes, name) && + typeof candidate !== 'function' && + !/^defaultSettings/i.test(name) && + !/(Style)$/.test(name) + ) { + return candidate as Extension + } + + // 2. Basic string theme + if (name === 'light' || name === 'dark' || name === 'none') { + return name + } + + // 3. If not found, fallback to light + return 'light' } diff --git a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx index 045d242158..2c68b936e6 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolButton.test.tsx @@ -1,4 +1,4 @@ -import { ActionTool } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -6,8 +6,8 @@ import CodeToolButton from '../button' // Mock Antd components const mocks = vi.hoisted(() => ({ - Tooltip: vi.fn(({ children, title }) => ( -
+ Tooltip: vi.fn(({ children, title, content }) => ( +
{children}
)), @@ -19,10 +19,13 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('antd', () => ({ - Tooltip: mocks.Tooltip, Dropdown: mocks.Dropdown })) +vi.mock('@cherrystudio/ui', () => ({ + Tooltip: mocks.Tooltip +})) + // Mock ToolWrapper vi.mock('../styles', () => ({ ToolWrapper: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( diff --git a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx index 5c38de461b..8b91ac3dd4 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/CodeToolbar.test.tsx @@ -1,4 +1,4 @@ -import { ActionTool } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,12 +14,12 @@ const mocks = vi.hoisted(() => ({ {tool.icon}
)), - Tooltip: vi.fn(({ children, title }) => ( -
+ Tooltip: vi.fn(({ children, title, content }) => ( +
{children}
)), - HStack: vi.fn(({ children, className }) => ( + RowFlex: vi.fn(({ children, className }) => (
{children}
@@ -39,12 +39,9 @@ vi.mock('../button', () => ({ default: mocks.CodeToolButton })) -vi.mock('antd', () => ({ - Tooltip: mocks.Tooltip -})) - -vi.mock('@renderer/components/Layout', () => ({ - HStack: mocks.HStack +vi.mock('@cherrystudio/ui', () => ({ + Tooltip: mocks.Tooltip, + RowFlex: mocks.RowFlex })) vi.mock('./styles', () => ({ diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx index 2b39950eab..67c8cc48f3 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useCopyTool.test.tsx @@ -1,5 +1,5 @@ import { useCopyTool } from '@renderer/components/CodeToolbar/hooks/useCopyTool' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx index 0181dfc5fe..0cc5d8c4ee 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useDownloadTool.test.tsx @@ -1,5 +1,5 @@ import { useDownloadTool } from '@renderer/components/CodeToolbar/hooks/useDownloadTool' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx index fbe52bbb35..ef408c562f 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useSplitViewTool.test.tsx @@ -1,4 +1,4 @@ -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { useSplitViewTool } from '@renderer/components/CodeToolbar/hooks/useSplitViewTool' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx index 9bac34c57a..a35a063426 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useViewSourceTool.test.tsx @@ -1,4 +1,4 @@ -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { useViewSourceTool } from '@renderer/components/CodeToolbar/hooks/useViewSourceTool' import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/CodeToolbar/button.tsx b/src/renderer/src/components/CodeToolbar/button.tsx index 1488752726..f891dd9a38 100644 --- a/src/renderer/src/components/CodeToolbar/button.tsx +++ b/src/renderer/src/components/CodeToolbar/button.tsx @@ -1,5 +1,6 @@ -import { ActionTool } from '@renderer/components/ActionTools' -import { Dropdown, Tooltip } from 'antd' +import { Tooltip } from '@cherrystudio/ui' +import type { ActionTool } from '@renderer/components/ActionTools' +import { Dropdown } from 'antd' import { memo, useMemo } from 'react' import { ToolWrapper } from './styles' @@ -11,7 +12,7 @@ interface CodeToolButtonProps { const CodeToolButton = ({ tool }: CodeToolButtonProps) => { const mainTool = useMemo( () => ( - + {tool.icon} ), diff --git a/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx index ea928df4fd..0f5d3f08e8 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useCopyTool.tsx @@ -1,6 +1,7 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { CopyIcon } from '@renderer/components/Icons' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { Check, Image } from 'lucide-react' import { useCallback, useEffect } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx index 397c51c921..835c01efbb 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useDownloadTool.tsx @@ -1,6 +1,7 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { FilePngIcon, FileSvgIcon } from '@renderer/components/Icons' -import { BasicPreviewHandles } from '@renderer/components/Preview' +import type { BasicPreviewHandles } from '@renderer/components/Preview' import { Download, FileCode } from 'lucide-react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx index 6428a9c543..0cf2e25008 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useExpandTool.tsx @@ -1,4 +1,5 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx index 4c46681a4d..0da586d4cf 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useRunTool.tsx @@ -1,4 +1,5 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { LoadingIcon } from '@renderer/components/Icons' import { CirclePlay } from 'lucide-react' import { useEffect } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx index c847b6ca90..f343a5a064 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useSaveTool.tsx @@ -1,5 +1,6 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' -import { CodeEditorHandles } from '@renderer/components/CodeEditor' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import { type CodeEditorHandles } from '@renderer/components/CodeEditor' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { Check, SaveIcon } from 'lucide-react' import { useCallback, useEffect } from 'react' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx index 63367d692f..64b3c0af44 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useSplitViewTool.tsx @@ -1,5 +1,6 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { Square, SquareSplitHorizontal } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx index a3a6da0152..fa6d71ea67 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useViewSourceTool.tsx @@ -1,5 +1,6 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' -import { ViewMode } from '@renderer/components/CodeBlockView/types' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ViewMode } from '@renderer/components/CodeBlockView/types' import { CodeXml, Eye, SquarePen } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx index bea1e4a5b5..d4bfa6e273 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx @@ -1,4 +1,5 @@ -import { ActionTool, TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' +import type { ActionTool } from '@renderer/components/ActionTools' +import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools' import { Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/components/CodeToolbar/toolbar.tsx b/src/renderer/src/components/CodeToolbar/toolbar.tsx index 7b17a6f0e8..9470e1ebec 100644 --- a/src/renderer/src/components/CodeToolbar/toolbar.tsx +++ b/src/renderer/src/components/CodeToolbar/toolbar.tsx @@ -1,6 +1,6 @@ -import { ActionTool } from '@renderer/components/ActionTools' -import { HStack } from '@renderer/components/Layout' -import { Tooltip } from 'antd' +import { RowFlex } from '@cherrystudio/ui' +import { Tooltip } from '@cherrystudio/ui' +import type { ActionTool } from '@renderer/components/ActionTools' import { EllipsisVertical } from 'lucide-react' import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -39,7 +39,7 @@ const CodeToolbar = ({ tools }: { tools: ActionTool[] }) => { {/* 有多个快捷工具时通过 more 按钮展示 */} {quickToolButtons} {quickTools.length > 1 && ( - + setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}> @@ -61,7 +61,7 @@ const StickyWrapper = styled.div` z-index: 10; ` -const ToolbarWrapper = styled(HStack)` +const ToolbarWrapper = styled(RowFlex)` position: absolute; align-items: center; bottom: 0.3rem; diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx index ac7a14e0ac..9c9547bc7c 100644 --- a/src/renderer/src/components/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer.tsx @@ -1,12 +1,12 @@ +import { usePreference } from '@data/hooks/usePreference' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight' -import { useSettings } from '@renderer/hooks/useSettings' import { uuid } from '@renderer/utils' import { getReactStyleFromToken } from '@renderer/utils/shiki' import { useVirtualizer } from '@tanstack/react-virtual' import { debounce } from 'lodash' import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { ThemedToken } from 'shiki/core' +import type { ThemedToken } from 'shiki/core' import styled from 'styled-components' interface CodeViewerProps { @@ -72,7 +72,8 @@ const CodeViewer = ({ expanded = true, wrapped = true }: CodeViewerProps) => { - const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings() + const [_lineNumbers] = usePreference('chat.code.show_line_numbers') + const [_fontSize] = usePreference('chat.message.font_size') const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const shikiThemeRef = useRef(null) const scrollerRef = useRef(null) diff --git a/src/renderer/src/components/CollapsibleSearchBar.tsx b/src/renderer/src/components/CollapsibleSearchBar.tsx index 04b838e37a..27d8b19325 100644 --- a/src/renderer/src/components/CollapsibleSearchBar.tsx +++ b/src/renderer/src/components/CollapsibleSearchBar.tsx @@ -1,5 +1,7 @@ +import { Tooltip } from '@cherrystudio/ui' import i18n from '@renderer/i18n' -import { Input, InputRef, Tooltip } from 'antd' +import type { InputRef } from 'antd' +import { Input } from 'antd' import { Search } from 'lucide-react' import { motion } from 'motion/react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react' @@ -92,7 +94,7 @@ const CollapsibleSearchBar = ({ }} style={{ cursor: 'pointer', display: 'flex' }} onClick={() => setSearchVisible(true)}> - + {icon} diff --git a/src/renderer/src/components/ConfirmDialog.tsx b/src/renderer/src/components/ConfirmDialog.tsx index 3f2313b178..e8749d819f 100644 --- a/src/renderer/src/components/ConfirmDialog.tsx +++ b/src/renderer/src/components/ConfirmDialog.tsx @@ -1,6 +1,6 @@ -import { Button } from '@heroui/react' +import { Button } from '@cherrystudio/ui' import { CheckIcon, XIcon } from 'lucide-react' -import { FC } from 'react' +import type { FC } from 'react' import { createPortal } from 'react-dom' interface Props { @@ -28,11 +28,13 @@ const ConfirmDialog: FC = ({ x, y, message, onConfirm, onCancel }) => {
{message}
- -
diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index d322f41616..ef1d31f7c6 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -1,6 +1,7 @@ +import { Tooltip } from '@cherrystudio/ui' import { ActionIconButton } from '@renderer/components/Buttons' import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' -import { Tooltip } from 'antd' +import { scrollElementIntoView } from '@renderer/utils' import { debounce } from 'lodash' import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' @@ -181,17 +182,14 @@ export const ContentSearch = React.forwardRef( // 3. 将当前项滚动到视图中 // 获取第一个文本节点的父元素来进行滚动 const parentElement = currentMatchRange.startContainer.parentElement - if (shouldScroll) { - parentElement?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'nearest' - }) + if (shouldScroll && parentElement) { + // 优先在指定的滚动容器内滚动,避免滚动整个页面导致索引错乱/看起来"跳到第一条" + scrollElementIntoView(parentElement, target) } } } }, - [allRanges, currentIndex] + [allRanges, currentIndex, target] ) const search = useCallback( @@ -363,24 +361,33 @@ export const ContentSearch = React.forwardRef( /> {showUserToggle && ( - - - - + + + } + />{' '} )} - - - - + + + } + />{' '} - - - - + + + } + /> @@ -397,15 +404,17 @@ export const ContentSearch = React.forwardRef( )} - - - - - - - - - + } + /> + } + /> + } /> diff --git a/src/renderer/src/components/CopyButton.tsx b/src/renderer/src/components/CopyButton.tsx index cfa80a02c5..278f3f1b2a 100644 --- a/src/renderer/src/components/CopyButton.tsx +++ b/src/renderer/src/components/CopyButton.tsx @@ -1,6 +1,6 @@ -import { Tooltip } from 'antd' +import { Tooltip } from '@cherrystudio/ui' import { Copy } from 'lucide-react' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -47,7 +47,7 @@ const CopyButton: FC = ({ ) if (tooltip) { - return {button} + return {button} } return button diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 8362d8a479..4e8e7a9494 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,7 +1,8 @@ import { Collapse } from 'antd' import { merge } from 'lodash' import { ChevronRight } from 'lucide-react' -import { FC, memo, useMemo, useState } from 'react' +import type { FC } from 'react' +import { memo, useMemo, useState } from 'react' interface CustomCollapseProps { label: React.ReactNode diff --git a/src/renderer/src/components/DividerWithText.tsx b/src/renderer/src/components/DividerWithText.tsx index 764550381f..30f287e9f8 100644 --- a/src/renderer/src/components/DividerWithText.tsx +++ b/src/renderer/src/components/DividerWithText.tsx @@ -1,4 +1,5 @@ -import React, { CSSProperties } from 'react' +import type { CSSProperties } from 'react' +import React from 'react' import styled from 'styled-components' interface DividerWithTextProps { diff --git a/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx index 87765a504b..d4319d4df8 100644 --- a/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx @@ -5,6 +5,16 @@ import { describe, expect, it, vi } from 'vitest' import { DraggableList } from '../' +vi.mock('@renderer/store', () => ({ + default: { + getState: () => ({ + llm: { + settings: {} + } + }) + } +})) + // mock @hello-pangea/dnd 组件 vi.mock('@hello-pangea/dnd', () => { return { diff --git a/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx index d931d961b8..610f6bb780 100644 --- a/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx @@ -3,6 +3,16 @@ import { describe, expect, it, vi } from 'vitest' import { DraggableVirtualList } from '../' +vi.mock('@renderer/store', () => ({ + default: { + getState: () => ({ + llm: { + settings: {} + } + }) + } +})) + // Mock 依赖项 vi.mock('@hello-pangea/dnd', () => ({ __esModule: true, diff --git a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts index a9ffd3d889..c2f566aad2 100644 --- a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts +++ b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts @@ -1,4 +1,4 @@ -import { DropResult } from '@hello-pangea/dnd' +import type { DropResult } from '@hello-pangea/dnd' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/DraggableList/list.tsx b/src/renderer/src/components/DraggableList/list.tsx index fbb5f29762..62a7c636c0 100644 --- a/src/renderer/src/components/DraggableList/list.tsx +++ b/src/renderer/src/components/DraggableList/list.tsx @@ -1,15 +1,14 @@ -import { - DragDropContext, - Draggable, - Droppable, +import type { DroppableProps, DropResult, OnDragEndResponder, OnDragStartResponder, ResponderProvided } from '@hello-pangea/dnd' +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' import { droppableReorder } from '@renderer/utils' -import { HTMLAttributes, Key, useCallback } from 'react' +import type { HTMLAttributes, Key } from 'react' +import { useCallback } from 'react' interface Props { list: T[] diff --git a/src/renderer/src/components/DraggableList/useDraggableReorder.ts b/src/renderer/src/components/DraggableList/useDraggableReorder.ts index 5438635cd6..7f7ed902cf 100644 --- a/src/renderer/src/components/DraggableList/useDraggableReorder.ts +++ b/src/renderer/src/components/DraggableList/useDraggableReorder.ts @@ -1,5 +1,6 @@ -import { DropResult } from '@hello-pangea/dnd' -import { Key, useCallback, useMemo } from 'react' +import type { DropResult } from '@hello-pangea/dnd' +import type { Key } from 'react' +import { useCallback, useMemo } from 'react' interface UseDraggableReorderParams { /** 原始的、完整的数据列表 */ diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx index b2efe7c247..6c4d7c29e7 100644 --- a/src/renderer/src/components/DraggableList/virtual-list.tsx +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -1,14 +1,12 @@ -import { - DragDropContext, - Draggable, - Droppable, +import { Scrollbar } from '@cherrystudio/ui' +import type { DroppableProps, DropResult, OnDragEndResponder, OnDragStartResponder, ResponderProvided } from '@hello-pangea/dnd' -import Scrollbar from '@renderer/components/Scrollbar' +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd' import { droppableReorder } from '@renderer/utils' import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react' diff --git a/src/renderer/src/components/EditableNumber/index.tsx b/src/renderer/src/components/EditableNumber/index.tsx index 220cf5fb57..6102a3a897 100644 --- a/src/renderer/src/components/EditableNumber/index.tsx +++ b/src/renderer/src/components/EditableNumber/index.tsx @@ -1,5 +1,6 @@ import { InputNumber } from 'antd' -import { FC, useEffect, useRef, useState } from 'react' +import type { FC } from 'react' +import { useEffect, useRef, useState } from 'react' import styled from 'styled-components' export interface EditableNumberProps { @@ -19,6 +20,7 @@ export interface EditableNumberProps { suffix?: string prefix?: string align?: 'start' | 'center' | 'end' + formatter?: (value: number | null) => string | number } const EditableNumber: FC = ({ @@ -35,7 +37,8 @@ const EditableNumber: FC = ({ style, className, size = 'middle', - align = 'end' + align = 'end', + formatter }) => { const [isEditing, setIsEditing] = useState(false) const [inputValue, setInputValue] = useState(value) @@ -89,7 +92,7 @@ const EditableNumber: FC = ({ changeOnBlur={changeOnBlur} /> - {value ?? placeholder} + {formatter ? formatter(value ?? null) : (value ?? placeholder)} ) diff --git a/src/renderer/src/components/EmojiIcon.tsx b/src/renderer/src/components/EmojiIcon.tsx index 6cd06b8715..e8a787c9b0 100644 --- a/src/renderer/src/components/EmojiIcon.tsx +++ b/src/renderer/src/components/EmojiIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import type { FC } from 'react' import styled from 'styled-components' interface EmojiIconProps { diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index 69ac7ccdaa..8ba9d3e967 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -1,7 +1,8 @@ import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url' import { useTheme } from '@renderer/context/ThemeProvider' import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill' -import { FC, useEffect, useRef } from 'react' +import type { FC } from 'react' +import { useEffect, useRef } from 'react' interface Props { onEmojiClick: (emoji: string) => void diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx index 1ee3967e9c..cd3b36ec2b 100644 --- a/src/renderer/src/components/ErrorBoundary.tsx +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -1,8 +1,9 @@ -import { Button } from '@heroui/button' +import { Button } from '@cherrystudio/ui' import { formatErrorMessage } from '@renderer/utils/error' import { Alert, Space } from 'antd' -import { ComponentType, ReactNode } from 'react' -import { ErrorBoundary, FallbackProps } from 'react-error-boundary' +import type { ComponentType, ReactNode } from 'react' +import type { FallbackProps } from 'react-error-boundary' +import { ErrorBoundary } from 'react-error-boundary' import { useTranslation } from 'react-i18next' import styled from 'styled-components' const DefaultFallback: ComponentType = (props: FallbackProps): ReactNode => { @@ -23,10 +24,10 @@ const DefaultFallback: ComponentType = (props: FallbackProps): Re type="error" action={ - - diff --git a/src/renderer/src/components/ExpandableText.tsx b/src/renderer/src/components/ExpandableText.tsx index 5df32bb9c6..a48054509a 100644 --- a/src/renderer/src/components/ExpandableText.tsx +++ b/src/renderer/src/components/ExpandableText.tsx @@ -1,5 +1,5 @@ -import { Button } from 'antd' -import { memo, useCallback, useMemo, useState } from 'react' +import { Button } from '@cherrystudio/ui' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -20,18 +20,12 @@ const ExpandableText = ({ setIsExpanded((prev) => !prev) }, []) - const button = useMemo(() => { - return ( - - ) - }, [isExpanded, t, toggleExpand]) - return ( {text} - {button} + ) } @@ -48,4 +42,4 @@ const TextContainer = styled.div<{ $expanded?: boolean }>` line-height: ${(props) => (props.$expanded ? 'unset' : '30px')}; ` -export default memo(ExpandableText) +export default ExpandableText diff --git a/src/renderer/src/components/FreeTrialModelTag.tsx b/src/renderer/src/components/FreeTrialModelTag.tsx index 2e294d9303..ad142ae0cf 100644 --- a/src/renderer/src/components/FreeTrialModelTag.tsx +++ b/src/renderer/src/components/FreeTrialModelTag.tsx @@ -1,8 +1,8 @@ import { getProviderLabel } from '@renderer/i18n/label' import NavigationService from '@renderer/services/NavigationService' -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { ArrowUpRight } from 'lucide-react' -import { FC, MouseEvent } from 'react' +import type { FC, MouseEvent } from 'react' import styled from 'styled-components' import IndicatorLight from './IndicatorLight' diff --git a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx index 9dce1c21be..a73ccccd6c 100644 --- a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx +++ b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx @@ -1,9 +1,11 @@ import { CheckCircleFilled, CloseCircleFilled, ExclamationCircleFilled, LoadingOutlined } from '@ant-design/icons' -import { Flex, Tooltip, Typography } from 'antd' +import { Flex } from '@cherrystudio/ui' +import { Tooltip } from '@cherrystudio/ui' +import { Typography } from 'antd' import React, { memo } from 'react' import styled from 'styled-components' -import { HealthResult } from './types' +import type { HealthResult } from './types' import { useHealthStatus } from './useHealthStatus' export interface HealthStatusIndicatorProps { @@ -48,9 +50,9 @@ const HealthStatusIndicator: React.FC = ({ } return ( - + {latencyText && {latencyText}} - + {icon} diff --git a/src/renderer/src/components/HealthStatusIndicator/types.ts b/src/renderer/src/components/HealthStatusIndicator/types.ts index f87376b781..87bff48c1c 100644 --- a/src/renderer/src/components/HealthStatusIndicator/types.ts +++ b/src/renderer/src/components/HealthStatusIndicator/types.ts @@ -1,4 +1,4 @@ -import { HealthStatus } from '@renderer/types/healthCheck' +import type { HealthStatus } from '@renderer/types/healthCheck' /** * 用于展示单个健康检查结果的必要数据 diff --git a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx index 456961c97f..4ecedb18c9 100644 --- a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx +++ b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx @@ -1,9 +1,9 @@ +import { Flex } from '@cherrystudio/ui' import { HealthStatus } from '@renderer/types/healthCheck' -import { Flex } from 'antd' import React from 'react' import { useTranslation } from 'react-i18next' -import { HealthResult } from './types' +import type { HealthResult } from './types' interface UseHealthStatusProps { results: HealthResult[] @@ -77,7 +77,7 @@ export const useHealthStatus = ({ results, showLatency = false }: UseHealthStatu return (
  • - + {statusText} {result.label} diff --git a/src/renderer/src/components/HighlightText.tsx b/src/renderer/src/components/HighlightText.tsx index debf02c924..d24b9c607c 100644 --- a/src/renderer/src/components/HighlightText.tsx +++ b/src/renderer/src/components/HighlightText.tsx @@ -1,4 +1,5 @@ -import { FC, memo, useMemo } from 'react' +import type { FC } from 'react' +import { memo, useMemo } from 'react' interface HighlightTextProps { text: string diff --git a/src/renderer/src/components/HorizontalScrollContainer/index.tsx b/src/renderer/src/components/HorizontalScrollContainer/index.tsx index fdc890d2e2..ec9f7a1043 100644 --- a/src/renderer/src/components/HorizontalScrollContainer/index.tsx +++ b/src/renderer/src/components/HorizontalScrollContainer/index.tsx @@ -1,5 +1,5 @@ -import { cn } from '@heroui/react' import Scrollbar from '@renderer/components/Scrollbar' +import { cn } from '@renderer/utils' import { ChevronRight } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import styled from 'styled-components' diff --git a/src/renderer/src/components/Icons/FileIcons.tsx b/src/renderer/src/components/Icons/FileIcons.tsx index 0fbfcdebf5..fd095335a8 100644 --- a/src/renderer/src/components/Icons/FileIcons.tsx +++ b/src/renderer/src/components/Icons/FileIcons.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, SVGProps } from 'react' +import type { CSSProperties, SVGProps } from 'react' interface BaseFileIconProps extends SVGProps { size?: string | number diff --git a/src/renderer/src/components/Icons/MinAppIcon.tsx b/src/renderer/src/components/Icons/MinAppIcon.tsx index 98974da745..58da46a723 100644 --- a/src/renderer/src/components/Icons/MinAppIcon.tsx +++ b/src/renderer/src/components/Icons/MinAppIcon.tsx @@ -1,6 +1,6 @@ import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' -import { MinAppType } from '@renderer/types' -import { FC } from 'react' +import type { MinAppType } from '@renderer/types' +import type { FC } from 'react' interface Props { app: MinAppType diff --git a/src/renderer/src/components/Icons/OcrIcon.tsx b/src/renderer/src/components/Icons/OcrIcon.tsx index 41367445a7..9f73867a0c 100644 --- a/src/renderer/src/components/Icons/OcrIcon.tsx +++ b/src/renderer/src/components/Icons/OcrIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import type { FC } from 'react' const OcrIcon: FC, HTMLElement>> = (props) => { return diff --git a/src/renderer/src/components/Icons/ReasoningIcon.tsx b/src/renderer/src/components/Icons/ReasoningIcon.tsx index 4f98f5735c..ae83187891 100644 --- a/src/renderer/src/components/Icons/ReasoningIcon.tsx +++ b/src/renderer/src/components/Icons/ReasoningIcon.tsx @@ -1,5 +1,6 @@ -import { Tooltip } from 'antd' -import React, { FC } from 'react' +import { Tooltip } from '@cherrystudio/ui' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -8,7 +9,7 @@ const ReasoningIcon: FC - + diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index b17f7397c0..f866ad21d5 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -1,6 +1,6 @@ import { lightbulbVariants } from '@renderer/utils/motionVariants' import { motion } from 'motion/react' -import { SVGProps } from 'react' +import type { SVGProps } from 'react' export const StreamlineGoodHealthAndWellBeing = ( props: SVGProps & { @@ -279,7 +279,7 @@ export function PoeLogo(props: SVGProps) { y1="7.303" y2="27.715"> - + ) { y1="23.511" y2="9.464"> - + diff --git a/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx b/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx index 6cebfa3332..7efa37a66f 100644 --- a/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx +++ b/src/renderer/src/components/Icons/SvgSpinners180Ring.tsx @@ -1,4 +1,4 @@ -import { SVGProps } from 'react' +import type { SVGProps } from 'react' export function SvgSpinners180Ring(props: SVGProps & { size?: number | string }) { const { size = '1em', ...svgProps } = props diff --git a/src/renderer/src/components/Icons/ToolIcon.tsx b/src/renderer/src/components/Icons/ToolIcon.tsx index 69f8da260c..2d7b5d6146 100644 --- a/src/renderer/src/components/Icons/ToolIcon.tsx +++ b/src/renderer/src/components/Icons/ToolIcon.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import type { FC } from 'react' const ToolIcon: FC, HTMLElement>> = (props) => { return diff --git a/src/renderer/src/components/Icons/ToolsCallingIcon.tsx b/src/renderer/src/components/Icons/ToolsCallingIcon.tsx index 7a591d2316..f8fe661e58 100644 --- a/src/renderer/src/components/Icons/ToolsCallingIcon.tsx +++ b/src/renderer/src/components/Icons/ToolsCallingIcon.tsx @@ -1,6 +1,7 @@ import { ToolOutlined } from '@ant-design/icons' -import { Tooltip } from 'antd' -import React, { FC } from 'react' +import { Tooltip } from '@cherrystudio/ui' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -9,7 +10,7 @@ const ToolsCallingIcon: FC - + diff --git a/src/renderer/src/components/Icons/VisionIcon.tsx b/src/renderer/src/components/Icons/VisionIcon.tsx index 4ab4c408c1..8ae435789b 100644 --- a/src/renderer/src/components/Icons/VisionIcon.tsx +++ b/src/renderer/src/components/Icons/VisionIcon.tsx @@ -1,6 +1,7 @@ -import { Tooltip } from 'antd' +import { Tooltip } from '@cherrystudio/ui' import { ImageIcon } from 'lucide-react' -import React, { FC } from 'react' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -9,7 +10,7 @@ const VisionIcon: FC, return ( - + diff --git a/src/renderer/src/components/Icons/WebSearchIcon.tsx b/src/renderer/src/components/Icons/WebSearchIcon.tsx index 6dc99000ae..2a32227196 100644 --- a/src/renderer/src/components/Icons/WebSearchIcon.tsx +++ b/src/renderer/src/components/Icons/WebSearchIcon.tsx @@ -1,6 +1,7 @@ import { GlobalOutlined } from '@ant-design/icons' -import { Tooltip } from 'antd' -import React, { FC } from 'react' +import { Tooltip } from '@cherrystudio/ui' +import type { FC } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -9,7 +10,7 @@ const WebSearchIcon: FC - + diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx index 179babeaf6..ac1050f78c 100644 --- a/src/renderer/src/components/ImageViewer.tsx +++ b/src/renderer/src/components/ImageViewer.tsx @@ -10,7 +10,8 @@ import { } from '@ant-design/icons' import { loggerService } from '@logger' import { download } from '@renderer/utils/download' -import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd' +import type { ImageProps as AntImageProps } from 'antd' +import { Dropdown, Image as AntImage, Space } from 'antd' import { Base64 } from 'js-base64' import { DownloadIcon, ImageIcon } from 'lucide-react' import mime from 'mime' @@ -38,7 +39,7 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => { if (!match) throw new Error('Invalid base64 image format') const mimeType = match[1] const byteArray = Base64.toUint8Array(match[2]) - const blob = new Blob([byteArray], { type: mimeType }) + const blob = new Blob([byteArray as unknown as BlobPart], { type: mimeType }) await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })]) } else if (src.startsWith('file://')) { // 处理本地文件路径 diff --git a/src/renderer/src/components/InfoPopover.tsx b/src/renderer/src/components/InfoPopover.tsx index 888aefc702..04ea1e7c3d 100644 --- a/src/renderer/src/components/InfoPopover.tsx +++ b/src/renderer/src/components/InfoPopover.tsx @@ -1,4 +1,5 @@ -import { Popover, PopoverProps } from 'antd' +import type { PopoverProps } from 'antd' +import { Popover } from 'antd' import { Info } from 'lucide-react' type InheritedPopoverProps = Omit diff --git a/src/renderer/src/components/InputEmbeddingDimension.tsx b/src/renderer/src/components/InputEmbeddingDimension.tsx index 056ebaea50..74a46a211c 100644 --- a/src/renderer/src/components/InputEmbeddingDimension.tsx +++ b/src/renderer/src/components/InputEmbeddingDimension.tsx @@ -1,10 +1,11 @@ +import { Button, Tooltip } from '@cherrystudio/ui' import { loggerService } from '@logger' import AiProvider from '@renderer/aiCore' import { RefreshIcon } from '@renderer/components/Icons' import { useProvider } from '@renderer/hooks/useProvider' -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { getErrorMessage } from '@renderer/utils' -import { Button, InputNumber, Space, Tooltip } from 'antd' +import { InputNumber, Space } from 'antd' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -73,14 +74,15 @@ const InputEmbeddingDimension = ({ onChange={onChange} disabled={disabled} /> - + ) diff --git a/src/renderer/src/components/LanguageSelect.tsx b/src/renderer/src/components/LanguageSelect.tsx index cc18294712..e9799096a7 100644 --- a/src/renderer/src/components/LanguageSelect.tsx +++ b/src/renderer/src/components/LanguageSelect.tsx @@ -1,8 +1,10 @@ import { UNKNOWN } from '@renderer/config/translate' import useTranslate from '@renderer/hooks/useTranslate' -import { TranslateLanguage, TranslateLanguageCode } from '@renderer/types' -import { Select, SelectProps, Space } from 'antd' -import { ReactNode, useCallback, useMemo } from 'react' +import type { TranslateLanguage, TranslateLanguageCode } from '@renderer/types' +import type { SelectProps } from 'antd' +import { Select, Space } from 'antd' +import type { ReactNode } from 'react' +import { useCallback, useMemo } from 'react' export type LanguageOption = { value: TranslateLanguageCode diff --git a/src/renderer/src/components/Layout/index.ts b/src/renderer/src/components/Layout/index.ts deleted file mode 100644 index 6ebc5788c5..0000000000 --- a/src/renderer/src/components/Layout/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -import styled from 'styled-components' - -interface ContainerProps { - padding?: string -} - -type PxValue = number | string - -export interface BoxProps { - width?: PxValue - height?: PxValue - w?: PxValue - h?: PxValue - color?: string - background?: string - flex?: string | number - position?: string - left?: PxValue - top?: PxValue - right?: PxValue - bottom?: PxValue - opacity?: string | number - borderRadius?: PxValue - border?: string - gap?: PxValue - mt?: PxValue - marginTop?: PxValue - mb?: PxValue - marginBottom?: PxValue - ml?: PxValue - marginLeft?: PxValue - mr?: PxValue - marginRight?: PxValue - m?: string - margin?: string - pt?: PxValue - paddingTop?: PxValue - pb?: PxValue - paddingBottom?: PxValue - pl?: PxValue - paddingLeft?: PxValue - pr?: PxValue - paddingRight?: PxValue - p?: string - padding?: string -} - -export interface StackProps extends BoxProps { - justifyContent?: 'center' | 'flex-start' | 'flex-end' | 'space-between' - alignItems?: 'center' | 'flex-start' | 'flex-end' | 'space-between' - flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse' -} - -export interface ButtonProps extends StackProps { - color?: string - isDisabled?: boolean - isLoading?: boolean - background?: string - border?: string - fontSize?: string -} - -const cssRegex = /(px|vw|vh|%|auto)$/g - -const getElementValue = (value?: PxValue) => { - if (!value) { - return value - } - - if (typeof value === 'number') { - return value + 'px' - } - - if (value.match(cssRegex)) { - return value - } - - return value + 'px' -} - -export const Box = styled.div` - width: ${(props) => (props.width || props.w ? getElementValue(props.width ?? props.w) : 'auto')}; - height: ${(props) => (props.height || props.h ? getElementValue(props.height || props.h) : 'auto')}; - color: ${(props) => props.color || 'default'}; - background: ${(props) => props.background || 'default'}; - flex: ${(props) => props.flex || 'none'}; - position: ${(props) => props.position || 'default'}; - left: ${(props) => getElementValue(props.left) || 'auto'}; - right: ${(props) => getElementValue(props.right) || 'auto'}; - bottom: ${(props) => getElementValue(props.bottom) || 'auto'}; - top: ${(props) => getElementValue(props.top) || 'auto'}; - gap: ${(p) => (p.gap ? getElementValue(p.gap) : 0)}; - opacity: ${(props) => props.opacity ?? 1}; - border-radius: ${(props) => getElementValue(props.borderRadius) || 0}; - box-sizing: border-box; - border: ${(props) => props?.border || 'none'}; - gap: ${(p) => (p.gap ? getElementValue(p.gap) : 0)}; - margin: ${(props) => (props.m || props.margin ? (props.m ?? props.margin) : 'none')}; - margin-top: ${(props) => (props.mt || props.marginTop ? getElementValue(props.mt || props.marginTop) : 'default')}; - margin-bottom: ${(props) => - props.mb || props.marginBottom ? getElementValue(props.mb ?? props.marginBottom) : 'default'}; - margin-left: ${(props) => (props.ml || props.marginLeft ? getElementValue(props.ml ?? props.marginLeft) : 'default')}; - margin-right: ${(props) => - props.mr || props.marginRight ? getElementValue(props.mr ?? props.marginRight) : 'default'}; - padding: ${(props) => (props.p || props.padding ? (props.p ?? props.padding) : 'none')}; - padding-top: ${(props) => (props.pt || props.paddingTop ? getElementValue(props.pt ?? props.paddingTop) : 'auto')}; - padding-bottom: ${(props) => - props.pb || props.paddingBottom ? getElementValue(props.pb ?? props.paddingBottom) : 'auto'}; - padding-left: ${(props) => (props.pl || props.paddingLeft ? getElementValue(props.pl ?? props.paddingLeft) : 'auto')}; - padding-right: ${(props) => - props.pr || props.paddingRight ? getElementValue(props.pr ?? props.paddingRight) : 'auto'}; -` - -export const Stack = styled(Box)` - display: flex; - justify-content: ${(props) => props.justifyContent ?? 'flex-start'}; - align-items: ${(props) => props.alignItems ?? 'flex-start'}; - flex-direction: ${(props) => props.flexDirection ?? 'row'}; -` - -export const Center = styled(Stack)` - justify-content: center; - align-items: center; -` - -export const HStack = styled(Stack)` - flex-direction: row; -` - -export const HSpaceBetweenStack = styled(HStack)` - justify-content: space-between; -` - -export const VStack = styled(Stack)` - flex-direction: column; -` - -export const BaseTypography = styled(Box)<{ - fontSize?: number - lineHeight?: string - fontWeigth?: number | string - color?: string - textAlign?: string -}>` - font-size: ${(props) => (props.fontSize ? getElementValue(props.fontSize) : '16px')}; - line-height: ${(props) => (props.lineHeight ? getElementValue(props.lineHeight) : 'normal')}; - font-weight: ${(props) => props.fontWeigth || 'normal'}; - color: ${(props) => props.color || '#fff'}; - text-align: ${(props) => props.textAlign || 'left'}; -` - -export const Container = styled.main` - display: flex; - flex-direction: column; - width: 100%; - box-sizing: border-box; - flex: 1; - padding: ${(p) => p.padding ?? '0 18px'}; -` diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index 2574573627..3ab38bcb41 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -1,5 +1,5 @@ import { Typography } from 'antd' -import { ReactNode } from 'react' +import type { ReactNode } from 'react' import styled from 'styled-components' interface ListItemProps { diff --git a/src/renderer/src/components/LocalBackupManager.tsx b/src/renderer/src/components/LocalBackupManager.tsx index fdc64222db..233e90f576 100644 --- a/src/renderer/src/components/LocalBackupManager.tsx +++ b/src/renderer/src/components/LocalBackupManager.tsx @@ -1,7 +1,8 @@ import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { Button, Flex, Tooltip } from '@cherrystudio/ui' import { restoreFromLocal } from '@renderer/services/BackupService' import { formatFileSize } from '@renderer/utils' -import { Button, message, Modal, Table, Tooltip } from 'antd' +import { Modal, Space, Table } from 'antd' import dayjs from 'dayjs' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -68,7 +69,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe const handleDeleteSelected = async () => { if (selectedRowKeys.length === 0) { - message.warning(t('settings.data.local.backup.manager.select.files.delete')) + window.toast.warning(t('settings.data.local.backup.manager.select.files.delete')) return } @@ -120,7 +121,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe setDeleting(true) try { await window.api.backup.deleteLocalBackupFile(fileName, localBackupDir) - message.success(t('settings.data.local.backup.manager.delete.success.single')) + window.toast.success(t('settings.data.local.backup.manager.delete.success.single')) await fetchBackupFiles() } catch (error: any) { window.toast.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`) @@ -147,7 +148,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe setRestoring(true) try { await (restoreMethod || restoreFromLocal)(fileName) - message.success(t('settings.data.local.backup.manager.restore.success')) + window.toast.success(t('settings.data.local.backup.manager.restore.success')) onClose() // Close the modal } catch (error: any) { window.toast.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`) @@ -167,7 +168,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe showTitle: false }, render: (fileName: string) => ( - + {fileName} ) @@ -191,18 +192,24 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe key: 'action', width: 160, render: (_: any, record: BackupFile) => ( - <> - - + ) } ] @@ -214,6 +221,26 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe } } + const footerContent = ( + + + + + + ) + return ( } onClick={fetchBackupFiles} disabled={loading}> - {t('settings.data.local.backup.manager.refresh')} - , - , - - ]}> + footer={footerContent}> {t('common.cancel')} , - ]}> diff --git a/src/renderer/src/components/MarkdownEditor/index.tsx b/src/renderer/src/components/MarkdownEditor/index.tsx index 427ff1ccc8..5b5ab445e9 100644 --- a/src/renderer/src/components/MarkdownEditor/index.tsx +++ b/src/renderer/src/components/MarkdownEditor/index.tsx @@ -1,6 +1,7 @@ import 'katex/dist/katex.min.css' -import React, { FC, useEffect, useState } from 'react' +import type { FC } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' import rehypeKatex from 'rehype-katex' diff --git a/src/renderer/src/components/MaxContextCount.tsx b/src/renderer/src/components/MaxContextCount.tsx index be9c9f293c..b07564da71 100644 --- a/src/renderer/src/components/MaxContextCount.tsx +++ b/src/renderer/src/components/MaxContextCount.tsx @@ -1,6 +1,6 @@ import { MAX_CONTEXT_COUNT } from '@renderer/config/constant' import { Infinity as InfinityIcon } from 'lucide-react' -import { CSSProperties } from 'react' +import type { CSSProperties } from 'react' type Props = { maxContext: number diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx index 5833ed9b1a..801db2b082 100644 --- a/src/renderer/src/components/MinApp/MinApp.tsx +++ b/src/renderer/src/components/MinApp/MinApp.tsx @@ -4,15 +4,12 @@ import IndicatorLight from '@renderer/components/IndicatorLight' import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' -import { useRuntime } from '@renderer/hooks/useRuntime' -import { useNavbarPosition } from '@renderer/hooks/useSettings' -import { setOpenedKeepAliveMinapps } from '@renderer/store/runtime' -import { MinAppType } from '@renderer/types' +import { useNavbarPosition } from '@renderer/hooks/useNavbar' +import type { MinAppType } from '@renderer/types' import type { MenuProps } from 'antd' import { Dropdown } from 'antd' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { useNavigate } from 'react-router-dom' import styled from 'styled-components' @@ -28,9 +25,18 @@ const logger = loggerService.withContext('App') const MinApp: FC = ({ app, onClick, size = 60, isLast }) => { const { openMinappKeepAlive } = useMinappPopup() const { t } = useTranslation() - const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps() - const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime() - const dispatch = useDispatch() + const { + minapps, + pinned, + disabled, + openedKeepAliveMinapps, + currentMinappId, + minappShow, + setOpenedKeepAliveMinapps, + updateMinapps, + updateDisabledMinapps, + updatePinnedMinapps + } = useMinapps() const navigate = useNavigate() const isPinned = pinned.some((p) => p.id === app.id) const isVisible = minapps.some((m) => m.id === app.id) @@ -76,7 +82,7 @@ const MinApp: FC = ({ app, onClick, size = 60, isLast }) => { updatePinnedMinapps(newPinned) // 更新 openedKeepAliveMinapps const newOpenedKeepAliveMinapps = openedKeepAliveMinapps.filter((item) => item.id !== app.id) - dispatch(setOpenedKeepAliveMinapps(newOpenedKeepAliveMinapps)) + setOpenedKeepAliveMinapps(newOpenedKeepAliveMinapps) } }, ...(app.type === 'Custom' diff --git a/src/renderer/src/components/MinApp/MinAppTabsPool.tsx b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx index af2c255f5f..1bef9a532a 100644 --- a/src/renderer/src/components/MinApp/MinAppTabsPool.tsx +++ b/src/renderer/src/components/MinApp/MinAppTabsPool.tsx @@ -1,9 +1,9 @@ import { loggerService } from '@logger' import WebviewContainer from '@renderer/components/MinApp/WebviewContainer' -import { useRuntime } from '@renderer/hooks/useRuntime' -import { useNavbarPosition } from '@renderer/hooks/useSettings' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { useNavbarPosition } from '@renderer/hooks/useNavbar' import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager' -import { WebviewTag } from 'electron' +import type { WebviewTag } from 'electron' import React, { useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import styled from 'styled-components' @@ -21,7 +21,7 @@ import styled from 'styled-components' const logger = loggerService.withContext('MinAppTabsPool') const MinAppTabsPool: React.FC = () => { - const { openedKeepAliveMinapps, currentMinappId } = useRuntime() + const { openedKeepAliveMinapps, currentMinappId } = useMinapps() const { isTopNavbar } = useNavbarPosition() const location = useLocation() diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 57e5141048..d7c2b40a26 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -10,6 +10,8 @@ import { PushpinOutlined, ReloadOutlined } from '@ant-design/icons' +import { Avatar, Button, Tooltip } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' import WindowControls from '@renderer/components/WindowControls' import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant' @@ -18,16 +20,13 @@ import { useBridge } from '@renderer/hooks/useBridge' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' -import { useRuntime } from '@renderer/hooks/useRuntime' -import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings' +import { useNavbarPosition } from '@renderer/hooks/useNavbar' import { useTimer } from '@renderer/hooks/useTimer' -import { useAppDispatch } from '@renderer/store' -import { setMinappsOpenLinkExternal } from '@renderer/store/settings' -import { MinAppType } from '@renderer/types' +import type { MinAppType } from '@renderer/types' import { delay } from '@renderer/utils' import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager' -import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd' -import { WebviewTag } from 'electron' +import { Alert, Drawer } from 'antd' +import type { WebviewTag } from 'electron' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BeatLoader from 'react-spinners/BeatLoader' @@ -131,7 +130,7 @@ const GoogleLoginTip = ({ banner onClose={handleClose} action={ - } @@ -142,13 +141,13 @@ const GoogleLoginTip = ({ /** The main container for MinApp popup */ const MinappPopupContainer: React.FC = () => { - const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime() + const [minappsOpenLinkExternal, setMinappsOpenLinkExternal] = usePreference('feature.minapp.open_link_external') const { closeMinapp, hideMinappPopup } = useMinappPopup() - const { pinned, updatePinnedMinapps } = useMinapps() + const { pinned, updatePinnedMinapps, openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = + useMinapps() const { t } = useTranslation() const backgroundColor = useNavBackgroundColor() const { isTopNavbar } = useNavbarPosition() - const dispatch = useAppDispatch() /** control the drawer open or close */ const [isPopupShow, setIsPopupShow] = useState(true) @@ -166,7 +165,6 @@ const MinappPopupContainer: React.FC = () => { const webviewRefs = useRef>(new Map()) /** Note: WebView loaded states now managed globally via webviewStateManager */ /** whether the minapps open link external is enabled */ - const { minappsOpenLinkExternal } = useSettings() const { isLeftNavbar } = useNavbarPosition() @@ -351,7 +349,7 @@ const MinappPopupContainer: React.FC = () => { /** set the open external status */ const handleToggleOpenExternal = () => { - dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal)) + setMinappsOpenLinkExternal(!minappsOpenLinkExternal) } /** navigate back in webview history */ @@ -402,24 +400,19 @@ const MinappPopupContainer: React.FC = () => { return ( {url ?? appInfo.url}
    {t('minapp.popup.rightclick_copyurl')} - } - mouseEnterDelay={0.8} - placement="rightBottom" - styles={{ - root: { - maxWidth: '400px' - } - }}> + }> handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}
    {appInfo.canOpenExternalLink && ( - + handleOpenLink(url ?? appInfo.url)}> @@ -430,24 +423,24 @@ const MinappPopupContainer: React.FC = () => { className={isWin || isLinux ? 'windows' : ''} style={{ marginRight: isWin || isLinux ? '140px' : 0 }} isTopNavbar={isTopNavbar}> - + handleGoBack(appInfo.id)}> - + handleGoForward(appInfo.id)}> - + handleReload(appInfo.id)}> {appInfo.canPinned && ( { ? t('minapp.add_to_launchpad') : t('minapp.add_to_sidebar') } - mouseEnterDelay={0.8} - placement="bottom"> + placement="bottom" + delay={800}> handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}> )} + placement="bottom" + delay={800}> {isDev && ( - + handleOpenDevTools(appInfo.id)}> )} {canMinimize && ( - + handlePopupMinimize()}> )} - + handlePopupClose(appInfo.id)}> @@ -552,7 +545,7 @@ const MinappPopupContainer: React.FC = () => { diff --git a/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx b/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx index 866f46ff8e..90fda92d8a 100644 --- a/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx +++ b/src/renderer/src/components/MinApp/TopViewMinappContainer.tsx @@ -1,9 +1,9 @@ import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer' -import { useRuntime } from '@renderer/hooks/useRuntime' -import { useNavbarPosition } from '@renderer/hooks/useSettings' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { useNavbarPosition } from '@renderer/hooks/useNavbar' const TopViewMinappContainer = () => { - const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime() + const { openedKeepAliveMinapps, openedOneOffMinapp } = useMinapps() const { isLeftNavbar } = useNavbarPosition() const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index 545772ef08..e291a1ecb3 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -1,6 +1,6 @@ +import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' -import { useSettings } from '@renderer/hooks/useSettings' -import { WebviewTag } from 'electron' +import type { WebviewTag } from 'electron' import { memo, useEffect, useRef } from 'react' const logger = loggerService.withContext('WebviewContainer') @@ -25,7 +25,8 @@ const WebviewContainer = memo( onNavigateCallback: (appid: string, url: string) => void }) => { const webviewRef = useRef(null) - const { enableSpellCheck, minappsOpenLinkExternal } = useSettings() + const [enableSpellCheck] = usePreference('app.spell_check.enabled') + const [minappsOpenLinkExternal] = usePreference('feature.minapp.open_link_external') const setRef = (appid: string) => { onSetRefCallback(appid, null) diff --git a/src/renderer/src/components/ModelIdWithTags.tsx b/src/renderer/src/components/ModelIdWithTags.tsx index bf902ae1c4..61d0f60104 100644 --- a/src/renderer/src/components/ModelIdWithTags.tsx +++ b/src/renderer/src/components/ModelIdWithTags.tsx @@ -1,5 +1,6 @@ -import { Model } from '@renderer/types' -import { Tooltip, Typography } from 'antd' +import { Tooltip } from '@cherrystudio/ui' +import type { Model } from '@renderer/types' +import { Typography } from 'antd' import { memo } from 'react' import styled from 'styled-components' @@ -20,20 +21,13 @@ const ModelIdWithTags = ({ return ( {model.id} } - mouseEnterDelay={0.5} - placement="top"> + className="w-auto max-w-125" + delay={500}> {model.name} diff --git a/src/renderer/src/components/ModelSelectButton.tsx b/src/renderer/src/components/ModelSelectButton.tsx index d803f5dbdb..9697453ac1 100644 --- a/src/renderer/src/components/ModelSelectButton.tsx +++ b/src/renderer/src/components/ModelSelectButton.tsx @@ -1,5 +1,6 @@ -import { Model } from '@renderer/types' -import { Button, Tooltip, TooltipProps } from 'antd' +import type { TooltipProps } from '@cherrystudio/ui' +import { Button, Tooltip } from '@cherrystudio/ui' +import type { Model } from '@renderer/types' import { useCallback, useMemo } from 'react' import ModelAvatar from './Avatar/ModelAvatar' @@ -22,14 +23,18 @@ const ModelSelectButton = ({ model, onSelectModel, modelFilter, noTooltip, toolt }, [model, modelFilter, onSelectModel]) const button = useMemo(() => { - return + ) }, [model, onClick]) if (noTooltip) { return button } else { return ( - + {button} ) diff --git a/src/renderer/src/components/ModelSelector.tsx b/src/renderer/src/components/ModelSelector.tsx index 98fa195fb6..ee06bb6525 100644 --- a/src/renderer/src/components/ModelSelector.tsx +++ b/src/renderer/src/components/ModelSelector.tsx @@ -1,11 +1,13 @@ +import { Avatar } from '@cherrystudio/ui' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { matchKeywordsInString } from '@renderer/utils' import { getFancyProviderName } from '@renderer/utils/naming' -import { Avatar, Select, SelectProps } from 'antd' +import type { SelectProps } from 'antd' +import { Select } from 'antd' import { sortBy } from 'lodash' -import { BaseSelectRef } from 'rc-select' +import type { BaseSelectRef } from 'rc-select' import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -106,7 +108,7 @@ const ModelSelector = ({ } else { return (
    - {showAvatar && } + {showAvatar && } {t('knowledge.error.model_invalid')}
    ) diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 263292dcad..93e52f6a0c 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -7,9 +7,10 @@ import { isWebSearchModel } from '@renderer/config/models' import i18n from '@renderer/i18n' -import { Model } from '@renderer/types' +import type { Model } from '@renderer/types' import { isFreeModel } from '@renderer/utils/model' -import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { FC } from 'react' +import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' import { diff --git a/src/renderer/src/components/NutstorePathSelector.tsx b/src/renderer/src/components/NutstorePathSelector.tsx index 6393bad4c1..52ef3232aa 100644 --- a/src/renderer/src/components/NutstorePathSelector.tsx +++ b/src/renderer/src/components/NutstorePathSelector.tsx @@ -1,12 +1,12 @@ +import { RowFlex } from '@cherrystudio/ui' +import { Button } from '@cherrystudio/ui' import { loggerService } from '@logger' import { FolderIcon as NutstoreFolderIcon } from '@renderer/components/Icons/NutstoreIcons' -import { Button, Input } from 'antd' +import { Input } from 'antd' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { HStack } from './Layout' - interface NewFolderProps { onConfirm: (name: string) => void onCancel: () => void @@ -36,10 +36,10 @@ function NewFolder(props: NewFolderProps) { setName(e.target.value)} /> - - @@ -215,7 +215,7 @@ export function NutstorePathSelector(props: Props) { ) } -const FooterContainer = styled(HStack)` +const FooterContainer = styled(RowFlex)` background: transparent; margin-top: 12px; padding: 0; @@ -233,21 +233,19 @@ interface FooterProps { export function NustorePathSelectorFooter(props: FooterProps) { const { t } = useTranslation() return ( - - + + - - - - - + - + ) } diff --git a/src/renderer/src/components/OAuth/OAuthButton.tsx b/src/renderer/src/components/OAuth/OAuthButton.tsx index 3368f60afe..6d786427bb 100644 --- a/src/renderer/src/components/OAuth/OAuthButton.tsx +++ b/src/renderer/src/components/OAuth/OAuthButton.tsx @@ -1,5 +1,6 @@ +import { Button } from '@cherrystudio/ui' import { getProviderLabel } from '@renderer/i18n/label' -import { Provider } from '@renderer/types' +import type { Provider } from '@renderer/types' import { oauthWith302AI, oauthWithAihubmix, @@ -8,11 +9,10 @@ import { oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth' -import { Button, ButtonProps } from 'antd' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' -interface Props extends ButtonProps { +interface Props extends React.ComponentProps { provider: Provider onSuccess?: (key: string) => void } @@ -54,7 +54,7 @@ const OAuthButton: FC = ({ provider, onSuccess, ...buttonProps }) => { } return ( - ) diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx index b55105c599..8d4a2fab3d 100644 --- a/src/renderer/src/components/ObsidianExportDialog.tsx +++ b/src/renderer/src/components/ObsidianExportDialog.tsx @@ -1,6 +1,7 @@ +import { Switch } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' import i18n from '@renderer/i18n' -import store from '@renderer/store' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { @@ -10,9 +11,8 @@ import { messageToMarkdownWithReasoning, topicToMarkdown } from '@renderer/utils/export' -import { Alert, Empty, Form, Input, Modal, Select, Spin, Switch, TreeSelect } from 'antd' +import { Alert, Empty, Form, Input, Modal, Select, Spin, TreeSelect } from 'antd' import React, { useEffect, useState } from 'react' - const logger = loggerService.withContext('ObsidianExportDialog') const { Option } = Select @@ -144,7 +144,7 @@ const PopupContainer: React.FC = ({ topic, rawContent }) => { - const defaultObsidianVault = store.getState().settings.defaultObsidianVault + const [defaultObsidianVault, setDefaultObsidianVault] = usePreference('data.integration.obsidian.default_vault') const [state, setState] = useState({ title, tags: obsidianTags || '', @@ -204,7 +204,7 @@ const PopupContainer: React.FC = ({ } } fetchVaults() - }, [defaultObsidianVault]) + }, [defaultObsidianVault, setDefaultObsidianVault]) useEffect(() => { if (selectedVault) { @@ -236,9 +236,9 @@ const PopupContainer: React.FC = ({ } else if (topic) { markdown = await topicToMarkdown(topic, exportReasoning) } else if (messages && messages.length > 0) { - markdown = messagesToMarkdown(messages, exportReasoning) + markdown = await messagesToMarkdown(messages, exportReasoning) } else if (message) { - markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message) + markdown = exportReasoning ? await messageToMarkdownWithReasoning(message) : await messageToMarkdown(message) } else { markdown = '' } @@ -415,7 +415,7 @@ const PopupContainer: React.FC = ({ {!rawContent && ( - + )} diff --git a/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx b/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx new file mode 100644 index 0000000000..4b3f969a26 --- /dev/null +++ b/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx @@ -0,0 +1,119 @@ +import { TopView } from '@renderer/components/TopView' +import { cn } from '@renderer/utils' +import { Modal } from 'antd' +import { Bot, MessageSquare } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +type OptionType = 'assistant' | 'agent' + +interface ShowParams { + onSelect: (type: OptionType) => void +} + +interface Props extends ShowParams { + resolve: (data: { type?: OptionType }) => void +} + +const PopupContainer: React.FC = ({ onSelect, resolve }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const [hoveredOption, setHoveredOption] = useState(null) + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + const handleSelect = (type: OptionType) => { + setOpen(false) + onSelect(type) + resolve({ type }) + } + + AddAssistantOrAgentPopup.hide = onCancel + + return ( + +
    + {/* Assistant Option */} + + + {/* Agent Option */} + +
    +
    + ) +} + +const TopViewKey = 'AddAssistantOrAgentPopup' + +export default class AddAssistantOrAgentPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise<{ type?: OptionType }>((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index e795de400f..e321cb8152 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,3 +1,4 @@ +import { RowFlex } from '@cherrystudio/ui' import { TopView } from '@renderer/components/TopView' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' @@ -5,9 +6,10 @@ import { useTimer } from '@renderer/hooks/useTimer' import { useSystemAssistantPresets } from '@renderer/pages/store/assistants/presets' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import { Assistant, AssistantPreset } from '@renderer/types' +import type { Assistant, AssistantPreset } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Divider, Input, InputRef, Modal, Tag } from 'antd' +import type { InputRef } from 'antd' +import { Divider, Input, Modal, Tag } from 'antd' import { take } from 'lodash' import { Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -15,7 +17,6 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import EmojiIcon from '../EmojiIcon' -import { HStack } from '../Layout' import Scrollbar from '../Scrollbar' interface Props { @@ -173,7 +174,7 @@ const PopupContainer: React.FC = ({ resolve }) => { }} closeIcon={null} footer={null}> - + @@ -190,7 +191,7 @@ const PopupContainer: React.FC = ({ resolve }) => { variant="borderless" size="middle" /> - + {take(presets, 100).map((preset, index) => ( @@ -199,10 +200,10 @@ const PopupContainer: React.FC = ({ resolve }) => { onClick={() => onCreateAssistant(preset)} className={`agent-item ${preset.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`} onMouseEnter={() => setSelectedIndex(index)}> - + {preset.name} - + {preset.id === 'default' && {t('assistants.presets.tag.system')}} {preset.type === 'agent' && {t('assistants.presets.tag.agent')}} {preset.id === 'new' && {t('assistants.presets.tag.new')}} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts index e69341a864..f28e034bda 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts @@ -3,23 +3,18 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup' import { checkApi } from '@renderer/services/ApiService' import WebSearchService from '@renderer/services/WebSearchService' -import { - isPreprocessProviderId, - isWebSearchProviderId, - Model, - PreprocessProvider, - Provider, - WebSearchProvider -} from '@renderer/types' -import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck' +import type { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import { isPreprocessProviderId, isWebSearchProviderId } from '@renderer/types' +import type { ApiKeyConnectivity, ApiKeyWithStatus } from '@renderer/types/healthCheck' +import { HealthStatus } from '@renderer/types/healthCheck' import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' import { formatErrorMessage } from '@renderer/utils/error' -import { TFunction } from 'i18next' +import type { TFunction } from 'i18next' import { isEmpty } from 'lodash' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types' +import type { ApiKeyValidity, ApiProvider, UpdateApiProviderFunc } from './types' interface UseApiKeysProps { provider: ApiProvider diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx index 83c9389935..d7f5f52907 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx @@ -1,15 +1,18 @@ +import { Button, Flex, Tooltip } from '@cherrystudio/ui' import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator' import { EditIcon } from '@renderer/components/Icons' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' -import { ApiKeyWithStatus } from '@renderer/types/healthCheck' +import type { ApiKeyWithStatus } from '@renderer/types/healthCheck' import { maskApiKey } from '@renderer/utils/api' -import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd' +import type { InputRef } from 'antd' +import { Input, List, Popconfirm, Typography } from 'antd' import { Check, Minus, X } from 'lucide-react' -import { FC, memo, useEffect, useRef, useState } from 'react' +import type { FC } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { ApiKeyValidity } from './types' +import type { ApiKeyValidity } from './types' export interface ApiKeyItemProps { keyStatus: ApiKeyWithStatus @@ -90,79 +93,82 @@ const ApiKeyItem: FC = ({ return ( - {isEditing ? ( - - setEditValue(e.target.value)} - onPressEnter={handleSave} - placeholder={t('settings.provider.api.key.new_key.placeholder')} - style={{ flex: 1, fontSize: '14px', marginLeft: '-10px' }} - spellCheck={false} - disabled={disabled} - /> - - - + + + - - - + + )} + + + + + + + + + + + + )} + ) } diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx b/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx index 86076b4ca8..cb12681390 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx @@ -1,3 +1,4 @@ +import { Button, Flex, Tooltip } from '@cherrystudio/ui' import { DeleteIcon } from '@renderer/components/Icons' import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon' import Scrollbar from '@renderer/components/Scrollbar' @@ -6,17 +7,19 @@ import { useProvider } from '@renderer/hooks/useProvider' import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import { SettingHelpText } from '@renderer/pages/settings' import { isProviderSupportAuth } from '@renderer/services/ProviderService' -import { PreprocessProviderId, WebSearchProviderId } from '@renderer/types' -import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck' -import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd' +import type { PreprocessProviderId, WebSearchProviderId } from '@renderer/types' +import type { ApiKeyWithStatus } from '@renderer/types/healthCheck' +import { HealthStatus } from '@renderer/types/healthCheck' +import { Card, List, Popconfirm, Space, Typography } from 'antd' import { Plus } from 'lucide-react' -import { FC, useState } from 'react' +import type { FC } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { isLlmProvider, useApiKeys } from './hook' import ApiKeyItem from './item' -import { ApiProvider, UpdateApiProviderFunc } from './types' +import type { ApiProvider, UpdateApiProviderFunc } from './types' interface ApiKeyListProps { provider: ApiProvider @@ -124,7 +127,7 @@ export const ApiKeyList: FC = ({ provider, updateProvider, show )} - + {/* 帮助文本 */} {t('settings.provider.api_key.tip')} @@ -138,25 +141,23 @@ export const ApiKeyList: FC = ({ provider, updateProvider, show onConfirm={removeInvalidKeys} okText={t('common.confirm')} cancelText={t('common.cancel')} - okButtonProps={{ danger: true }}> - - {/* 批量检查 */} - + )} @@ -164,11 +165,10 @@ export const ApiKeyList: FC = ({ provider, updateProvider, show {/* 添加新 key */} diff --git a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts index ad713b40fb..f90c4240d5 100644 --- a/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts +++ b/src/renderer/src/components/Popups/ApiKeyListPopup/types.ts @@ -1,4 +1,4 @@ -import { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' +import type { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' /** * API key 格式有效性 diff --git a/src/renderer/src/components/Popups/BackupPopup.tsx b/src/renderer/src/components/Popups/BackupPopup.tsx index c16cafa70a..4eef77c213 100644 --- a/src/renderer/src/components/Popups/BackupPopup.tsx +++ b/src/renderer/src/components/Popups/BackupPopup.tsx @@ -1,7 +1,7 @@ +import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' import { getBackupProgressLabel } from '@renderer/i18n/label' import { backup } from '@renderer/services/BackupService' -import store from '@renderer/store' import { IpcChannel } from '@shared/IpcChannel' import { Modal, Progress } from 'antd' import { useEffect, useState } from 'react' @@ -27,7 +27,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const [open, setOpen] = useState(true) const [progressData, setProgressData] = useState() const { t } = useTranslation() - const skipBackupFile = store.getState().settings.skipBackupFile + const [skipBackupFile] = usePreference('data.backup.general.skip_backup_file') useEffect(() => { const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => { diff --git a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx new file mode 100644 index 0000000000..cbe51ac614 --- /dev/null +++ b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx @@ -0,0 +1,553 @@ +import { loggerService } from '@logger' +import { AppLogo } from '@renderer/config/env' +import { SettingHelpText, SettingRow } from '@renderer/pages/settings' +import type { WebSocketCandidatesResponse } from '@shared/config/types' +import { Alert, Button, Modal, Progress, Spin } from 'antd' +import { QRCodeSVG } from 'qrcode.react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { TopView } from '../TopView' + +const logger = loggerService.withContext('ExportToPhoneLanPopup') + +interface Props { + resolve: (data: any) => void +} + +type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error' +type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error' + +const LoadingQRCode: React.FC = () => { + const { t } = useTranslation() + return ( +
    + + + {t('settings.data.export_to_phone.lan.generating_qr')} + +
    + ) +} + +const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => { + const { t } = useTranslation() + return ( +
    + + + {t('settings.data.export_to_phone.lan.scan_qr')} + +
    + ) +} + +const ConnectingAnimation: React.FC = () => { + const { t } = useTranslation() + return ( +
    +
    + + + {t('settings.data.export_to_phone.lan.status.connecting')} + +
    +
    + ) +} + +const ConnectedDisplay: React.FC = () => { + const { t } = useTranslation() + return ( +
    +
    + 📱 + + {t('settings.data.export_to_phone.lan.connected')} + +
    +
    + ) +} + +const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => { + const { t } = useTranslation() + return ( +
    + ⚠️ + + {t('settings.data.export_to_phone.lan.connection_failed')} + + {error && {error}} +
    + ) +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [isOpen, setIsOpen] = useState(true) + const [connectionPhase, setConnectionPhase] = useState('initializing') + const [transferPhase, setTransferPhase] = useState('idle') + const [qrCodeValue, setQrCodeValue] = useState('') + const [selectedFolderPath, setSelectedFolderPath] = useState(null) + const [sendProgress, setSendProgress] = useState(0) + const [error, setError] = useState(null) + const [autoCloseCountdown, setAutoCloseCountdown] = useState(null) + + const { t } = useTranslation() + + // 派生状态 + const isConnected = connectionPhase === 'connected' + const canSend = isConnected && selectedFolderPath && transferPhase === 'idle' + const isSending = transferPhase === 'preparing' || transferPhase === 'sending' + + // 状态文本映射 + const connectionStatusText = useMemo(() => { + const statusMap = { + initializing: t('settings.data.export_to_phone.lan.status.initializing'), + waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'), + connecting: t('settings.data.export_to_phone.lan.status.connecting'), + connected: t('settings.data.export_to_phone.lan.status.connected'), + disconnected: t('settings.data.export_to_phone.lan.status.disconnected'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[connectionPhase] + }, [connectionPhase, t]) + + const transferStatusText = useMemo(() => { + const statusMap = { + idle: '', + preparing: t('settings.data.export_to_phone.lan.status.preparing'), + sending: t('settings.data.export_to_phone.lan.status.sending'), + completed: t('settings.data.export_to_phone.lan.status.completed'), + error: t('settings.data.export_to_phone.lan.status.error') + } + return statusMap[transferPhase] + }, [transferPhase, t]) + + // 状态样式映射 + const connectionStatusStyles = useMemo(() => { + const styleMap = { + initializing: { + bg: 'var(--color-background-mute)', + border: 'var(--color-border-mute)' + }, + waiting_qr_scan: { + bg: 'var(--color-primary-mute)', + border: 'var(--color-primary-soft)' + }, + connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' }, + connected: { + bg: 'var(--color-status-success)', + border: 'var(--color-status-success)' + }, + disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' }, + error: { bg: 'var(--color-error)', border: 'var(--color-error)' } + } + return styleMap[connectionPhase] + }, [connectionPhase]) + + const initWebSocket = useCallback(async () => { + try { + setConnectionPhase('initializing') + await window.api.webSocket.start() + const { port, ip } = await window.api.webSocket.status() + + if (ip && port) { + const candidatesData = await window.api.webSocket.getAllCandidates() + + const optimizeConnectionInfo = () => { + const ipToNumber = (ip: string) => { + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) + } + + const compressedData = [ + 'CSA', + ipToNumber(ip), + candidatesData.map((candidate: WebSocketCandidatesResponse) => ipToNumber(candidate.host)), + port, // 端口号 + Date.now() % 86400000 + ] + + return compressedData + } + + const compressedData = optimizeConnectionInfo() + const qrCodeValue = JSON.stringify(compressedData) + setQrCodeValue(qrCodeValue) + setConnectionPhase('waiting_qr_scan') + } else { + setError(t('settings.data.export_to_phone.lan.error.no_ip')) + setConnectionPhase('error') + } + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setConnectionPhase('error') + logger.error('Failed to initialize WebSocket:', error as Error) + } + }, [t]) + + const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => { + logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`) + if (data.connected) { + setConnectionPhase('connected') + setError(null) + } else { + setConnectionPhase('disconnected') + } + }, []) + + const handleMessageReceived = useCallback((_event: any, data: any) => { + logger.info(`Received message from mobile: ${JSON.stringify(data)}`) + }, []) + + const handleSendProgress = useCallback( + (_event: any, data: { progress: number }) => { + const progress = data.progress + setSendProgress(progress) + + if (transferPhase === 'preparing' && progress > 0) { + setTransferPhase('sending') + } + + if (progress >= 100) { + setTransferPhase('completed') + // 启动 3 秒倒计时自动关闭 + setAutoCloseCountdown(3) + } + }, + [transferPhase] + ) + + const handleSelectZip = useCallback(async () => { + const result = await window.api.file.select() + if (result) { + setSelectedFolderPath(result[0].path) + } + }, []) + + const handleSendZip = useCallback(async () => { + if (!selectedFolderPath) { + setError(t('settings.data.export_to_phone.lan.error.no_file')) + return + } + + setTransferPhase('preparing') + setError(null) + setSendProgress(0) + + try { + logger.info(`Starting file transfer: ${selectedFolderPath}`) + await window.api.webSocket.sendFile(selectedFolderPath) + } catch (error) { + setError( + `${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}` + ) + setTransferPhase('error') + logger.error('Failed to send file:', error as Error) + } + }, [selectedFolderPath, t]) + + // 尝试关闭弹窗 - 如果正在传输则显示确认 + const handleCancel = useCallback(() => { + if (isSending) { + window.modal.confirm({ + title: t('settings.data.export_to_phone.lan.confirm_close_title'), + content: t('settings.data.export_to_phone.lan.confirm_close_message'), + centered: true, + okButtonProps: { + danger: true + }, + okText: t('settings.data.export_to_phone.lan.force_close'), + onOk: () => setIsOpen(false) + }) + } else { + setIsOpen(false) + } + }, [isSending, t]) + + // 清理并关闭 + const handleClose = useCallback(async () => { + try { + // 主动断开 WebSocket 连接 + if (isConnected || connectionPhase !== 'disconnected') { + logger.info('Closing popup, stopping WebSocket') + await window.api.webSocket.stop() + } + } catch (error) { + logger.error('Failed to stop WebSocket on close:', error as Error) + } + resolve({}) + }, [resolve, isConnected, connectionPhase]) + + useEffect(() => { + initWebSocket() + + const removeClientConnectedListener = window.electron.ipcRenderer.on( + 'websocket-client-connected', + handleClientConnected + ) + const removeMessageReceivedListener = window.electron.ipcRenderer.on( + 'websocket-message-received', + handleMessageReceived + ) + const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress) + + return () => { + removeClientConnectedListener() + removeMessageReceivedListener() + removeSendProgressListener() + window.api.webSocket.stop() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 自动关闭倒计时 + useEffect(() => { + if (autoCloseCountdown === null) return + + if (autoCloseCountdown <= 0) { + logger.debug('Auto-closing popup after transfer completion') + setIsOpen(false) + return + } + + const timer = setTimeout(() => { + setAutoCloseCountdown(autoCloseCountdown - 1) + }, 1000) + + return () => clearTimeout(timer) + }, [autoCloseCountdown]) + + // 状态指示器组件 + const StatusIndicator = useCallback( + () => ( +
    + {connectionStatusText} +
    + ), + [connectionStatusStyles, connectionStatusText] + ) + + // 二维码显示组件 - 使用显式条件渲染以避免类型不匹配 + const QRCodeDisplay = useCallback(() => { + switch (connectionPhase) { + case 'waiting_qr_scan': + case 'disconnected': + return + case 'initializing': + return + case 'connecting': + return + case 'connected': + return + case 'error': + return + default: + return null + } + }, [connectionPhase, qrCodeValue, error]) + + // 传输进度组件 + const TransferProgress = useCallback(() => { + if (!isSending && transferPhase !== 'completed') return null + + return ( +
    +
    +
    + + {t('settings.data.export_to_phone.lan.transfer_progress')} + + + {transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`} + +
    + + +
    +
    + ) + }, [isSending, transferPhase, sendProgress, t]) + + const AutoCloseCountdown = useCallback(() => { + if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null + + return ( +
    + {t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })} +
    + ) + }, [transferPhase, autoCloseCountdown, t]) + + // 错误显示组件 + const ErrorDisplay = useCallback(() => { + if (!error || transferPhase !== 'error') return null + + return ( +
    + ❌ {error} +
    + ) + }, [error, transferPhase]) + + return ( + + + + + + + + + + + + +
    + + +
    +
    + + + {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} + + + + + +
    + ) +} + +const TopViewKey = 'ExportToPhoneLanPopup' + +export default class ExportToPhoneLanPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/GeneralPopup.tsx b/src/renderer/src/components/Popups/GeneralPopup.tsx index 3307b10162..f68d132a1d 100644 --- a/src/renderer/src/components/Popups/GeneralPopup.tsx +++ b/src/renderer/src/components/Popups/GeneralPopup.tsx @@ -1,6 +1,8 @@ import { TopView } from '@renderer/components/TopView' -import { Modal, ModalProps } from 'antd' -import { ReactNode, useState } from 'react' +import type { ModalProps } from 'antd' +import { Modal } from 'antd' +import type { ReactNode } from 'react' +import { useState } from 'react' interface ShowParams extends ModalProps { content: ReactNode diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx index 4560ac74e2..a707aa5798 100644 --- a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -1,9 +1,9 @@ +import { Button, Tooltip } from '@cherrystudio/ui' import { CopyIcon, DeleteIcon } from '@renderer/components/Icons' import { useChatContext } from '@renderer/hooks/useChatContext' -import { Topic } from '@renderer/types' -import { Button, Tooltip } from 'antd' +import type { Topic } from '@renderer/types' import { Save, X } from 'lucide-react' -import { FC } from 'react' +import type { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -35,39 +35,36 @@ const MultiSelectActionPopup: FC = ({ topic }) => { {t('common.selectedMessages', { count: selectedMessageIds.length })} - + - + - - - -
    @@ -91,7 +88,7 @@ const ActionBar = styled.div` background-color: var(--color-background); padding: 4px 4px; border-radius: 99px; - box-shadow: 0px 2px 8px 0px rgb(128 128 128 / 20%); + box-shadow: 0 2px 8px 0 rgb(128 128 128 / 20%); border: 0.5px solid var(--color-border); gap: 16px; ` diff --git a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx index 9a00e311cf..f84fcb96bb 100644 --- a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx +++ b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx @@ -1,4 +1,5 @@ -import { ObsidianProcessingMethod, PopupContainer } from '@renderer/components/ObsidianExportDialog' +import type { ObsidianProcessingMethod } from '@renderer/components/ObsidianExportDialog' +import { PopupContainer } from '@renderer/components/ObsidianExportDialog' import { TopView } from '@renderer/components/TopView' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' diff --git a/src/renderer/src/components/Popups/PrivacyPopup.tsx b/src/renderer/src/components/Popups/PrivacyPopup.tsx index 1ec9639bfc..08da177215 100644 --- a/src/renderer/src/components/Popups/PrivacyPopup.tsx +++ b/src/renderer/src/components/Popups/PrivacyPopup.tsx @@ -1,8 +1,9 @@ +import { Button } from '@cherrystudio/ui' 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 { ThemeMode } from '@shared/data/preference/preferenceTypes' +import { Modal } from 'antd' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -96,7 +97,7 @@ const PopupContainer: React.FC = ({ title, showDeclineButton = true, reso {i18n.language.startsWith('zh') ? '拒绝' : 'Decline'} ), - ].filter(Boolean)}> diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx index 0d254d3fb9..cfc13ca4ae 100644 --- a/src/renderer/src/components/Popups/PromptPopup.tsx +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -1,8 +1,9 @@ +import { Box } from '@cherrystudio/ui' import { Input, Modal } from 'antd' -import { TextAreaProps } from 'antd/es/input' -import { ReactNode, useRef, useState } from 'react' +import type { TextAreaProps } from 'antd/es/input' +import type { ReactNode } from 'react' +import { useRef, useState } from 'react' -import { Box } from '../Layout' import { TopView } from '../TopView' interface PromptPopupShowParams { @@ -68,7 +69,7 @@ const PromptPopupContainer: React.FC = ({ afterOpenChange={handleAfterOpenChange} transitionName="animation-move-down" centered> - {message} + {message} = ({ source, title, resolve }) => { ? 'chat.save.topic.knowledge.select.content.label' : 'chat.save.knowledge.select.content.title' )}> - + {contentTypeOptions.map((option) => ( handleContentTypeToggle(option.type)}> - + {option.count} {option.label} - - - + {selectedTypes.includes(option.type) && } ))} - + )} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx index aec91bd803..e627fb03a0 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx @@ -1,3 +1,4 @@ +import { Flex } from '@cherrystudio/ui' import { loggerService } from '@logger' import { EmbeddingTag, @@ -8,8 +9,7 @@ import { VisionTag, WebSearchTag } from '@renderer/components/Tags/Model' -import { ModelTag } from '@renderer/types' -import { Flex } from 'antd' +import type { ModelTag } from '@renderer/types' import React, { startTransition, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -48,7 +48,7 @@ const TagFilterSection: React.FC = ({ availableTags, tagS return ( - + {t('models.filter.by_tag')} {availableTags.map((tag) => { const TagElement = tagComponents[tag] diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap index 297987423c..359de10115 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap +++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap @@ -15,60 +15,65 @@ exports[`TagFilterSection > rendering > should match snapshot 1`] = `
    - - models.filter.by_tag - - - - - - - - + + models.filter.by_tag + + + + + + + + +
    `; diff --git a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx index 3871e593d7..72c5554fbf 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx @@ -1,5 +1,5 @@ +import { RowFlex } from '@cherrystudio/ui' 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' @@ -7,7 +7,8 @@ import { getModelLogoById } from '@renderer/config/models' import { useApiModels } from '@renderer/hooks/agents/useModels' import { getModelUniqId } from '@renderer/services/ModelService' import { getProviderNameById } from '@renderer/services/ProviderService' -import { AdaptedApiModel, ApiModel, ApiModelsFilter, Model, ModelType, objectEntries } from '@renderer/types' +import type { AdaptedApiModel, ApiModel, ApiModelsFilter, Model, ModelType } from '@renderer/types' +import { objectEntries } from '@renderer/types' import { classNames, filterModelsByKeywords } from '@renderer/utils' import { apiModelAdapter, getModelTags } from '@renderer/utils/model' import { Avatar, Divider, Empty, Modal } from 'antd' @@ -27,7 +28,7 @@ import styled from 'styled-components' import { useModelTagFilter } from './filters' import SelectModelSearchBar from './searchbar' import TagFilterSection from './TagFilterSection' -import { FlatListApiItem, FlatListApiModel } from './types' +import type { FlatListApiItem, FlatListApiModel } from './types' const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 @@ -104,7 +105,7 @@ const PopupContainer: React.FC = ({ model, apiFilter, modelFilter, showTa type: 'model', name: ( - {model.name} + {model.name} {isCherryAi && } ), diff --git a/src/renderer/src/components/Popups/SelectModelPopup/filters.ts b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts index d2ee6c7742..f43d86f79e 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/filters.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts @@ -6,7 +6,8 @@ import { isVisionModel, isWebSearchModel } from '@renderer/config/models' -import { Model, ModelTag, objectEntries } from '@renderer/types' +import type { Model, ModelTag } from '@renderer/types' +import { objectEntries } from '@renderer/types' import { isFreeModel } from '@renderer/utils/model' import { useCallback, useMemo, useState } from 'react' diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index 60ebc3fe77..1aa7859a99 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,6 +1,8 @@ import { PushpinOutlined } from '@ant-design/icons' +import { Tooltip } from '@cherrystudio/ui' +import { Flex } from '@cherrystudio/ui' +import { Avatar } from '@cherrystudio/ui' 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' @@ -8,10 +10,11 @@ import { getModelLogo } from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, ModelType, objectEntries, Provider } from '@renderer/types' +import type { Model, ModelType, Provider } from '@renderer/types' +import { objectEntries } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' import { getModelTags } from '@renderer/utils/model' -import { Avatar, Divider, Empty, Modal, Tooltip } from 'antd' +import { Divider, Empty, Modal } from 'antd' import { first, sortBy } from 'lodash' import { Settings2 } from 'lucide-react' import React, { @@ -30,7 +33,7 @@ import styled from 'styled-components' import { useModelTagFilter } from './filters' import SelectModelSearchBar from './searchbar' import TagFilterSection from './TagFilterSection' -import { FlatListItem, FlatListModel } from './types' +import type { FlatListItem, FlatListModel } from './types' const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 @@ -110,10 +113,10 @@ const PopupContainer: React.FC = ({ model, filter: baseFilter, showTagFil type: 'model', name: ( - + {model.name} {isPinned && | {groupName}} - +
    {isCherryAi && } ), @@ -123,7 +126,7 @@ const PopupContainer: React.FC = ({ model, filter: baseFilter, showTagFil ), icon: ( - + {first(model.name) || 'M'} ), @@ -181,7 +184,7 @@ const PopupContainer: React.FC = ({ model, filter: baseFilter, showTagFil type: 'group', name: getFancyProviderName(p), actions: p.id !== 'cherryai' && ( - + = ({ onSearch }) }, []) return ( - + @@ -58,7 +59,7 @@ const SelectModelSearchBar: React.FC = ({ onSearch }) } }} /> - + ) } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 6c6e3c2cac..a811930fc3 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -1,5 +1,5 @@ -import { AdaptedApiModel, Model } from '@renderer/types' -import { ReactNode } from 'react' +import type { AdaptedApiModel, Model } from '@renderer/types' +import type { ReactNode } from 'react' /** * 滚动触发来源类型 diff --git a/src/renderer/src/components/Popups/TemplatePopup.tsx b/src/renderer/src/components/Popups/TemplatePopup.tsx index 67dc50febf..1c5b880e26 100644 --- a/src/renderer/src/components/Popups/TemplatePopup.tsx +++ b/src/renderer/src/components/Popups/TemplatePopup.tsx @@ -1,4 +1,4 @@ -import { Box } from '@renderer/components/Layout' +import { Box } from '@cherrystudio/ui' import { TopView } from '@renderer/components/TopView' import { Modal } from 'antd' import { useState } from 'react' @@ -37,7 +37,7 @@ const PopupContainer: React.FC = ({ title, resolve }) => { afterClose={onClose} transitionName="animation-move-down" centered> - Name + Name ) } diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx index 49dca0254a..60c07eb487 100644 --- a/src/renderer/src/components/Popups/TextEditPopup.tsx +++ b/src/renderer/src/components/Popups/TextEditPopup.tsx @@ -1,12 +1,13 @@ import { LoadingOutlined } from '@ant-design/icons' +import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' -import { useSettings } from '@renderer/hooks/useSettings' import useTranslate from '@renderer/hooks/useTranslate' import { translateText } from '@renderer/services/TranslateService' -import { Modal, ModalProps } from 'antd' +import type { ModalProps } from 'antd' +import { Modal } from 'antd' import TextArea from 'antd/es/input/TextArea' -import { TextAreaProps } from 'antd/lib/input' -import { TextAreaRef } from 'antd/lib/input/TextArea' +import type { TextAreaProps } from 'antd/lib/input' +import type { TextAreaRef } from 'antd/lib/input/TextArea' import { Languages } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -42,7 +43,8 @@ const PopupContainer: React.FC = ({ const [textValue, setTextValue] = useState(text) const [isTranslating, setIsTranslating] = useState(false) const textareaRef = useRef(null) - const { targetLanguage, showTranslateConfirm } = useSettings() + const [targetLanguage] = usePreference('feature.translate.target_language') + const [showTranslateConfirm] = usePreference('chat.input.translate.show_confirm') const isMounted = useRef(true) useEffect(() => { diff --git a/src/renderer/src/components/Popups/TextFilePreview.tsx b/src/renderer/src/components/Popups/TextFilePreview.tsx index 584929401c..229622a250 100644 --- a/src/renderer/src/components/Popups/TextFilePreview.tsx +++ b/src/renderer/src/components/Popups/TextFilePreview.tsx @@ -1,8 +1,10 @@ +import { CodeEditor } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' +import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { Modal } from 'antd' import { useState } from 'react' import styled from 'styled-components' -import CodeEditor from '../CodeEditor' import { TopView } from '../TopView' interface Props { @@ -14,6 +16,8 @@ interface Props { const PopupContainer: React.FC = ({ text, title, extension, resolve }) => { const [open, setOpen] = useState(true) + const [fontSize] = usePreference('chat.message.font_size') + const { activeCmTheme } = useCodeStyle() const onOk = () => { setOpen(false) @@ -55,6 +59,8 @@ const PopupContainer: React.FC = ({ text, title, extension, resolve }) => footer={null}> {extension !== undefined ? ( void +} + +const PopupContainer: React.FC = ({ releaseInfo, resolve }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const [isInstalling, setIsInstalling] = useState(false) + + useEffect(() => { + if (releaseInfo) { + logger.info('Update dialog opened', { version: releaseInfo.version }) + } + }, [releaseInfo]) + + const handleInstall = async () => { + setIsInstalling(true) + try { + await handleSaveData() + await window.api.quitAndInstall() + setOpen(false) + } catch (error) { + logger.error('Failed to save data before update', error as Error) + setIsInstalling(false) + window.toast.error(t('update.saveDataError')) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + UpdateDialogPopup.hide = onCancel + + const releaseNotes = releaseInfo?.releaseNotes + + return ( + +

    {t('update.title')}

    +

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

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