Compare commits
44 Commits
copilot/fi
...
fix/openmi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61dd97bd0e | ||
|
|
d6e7ce330e | ||
|
|
4f7d8731ea | ||
|
|
2b5ac5ab51 | ||
|
|
060fcd2ce6 | ||
|
|
a6182eaf85 | ||
|
|
649f9420a4 | ||
|
|
2552d97ea7 | ||
|
|
803f4b5a64 | ||
|
|
31f8fff6e2 | ||
|
|
2663cb19ce | ||
|
|
ce5d46bfc7 | ||
|
|
c1fa24522d | ||
|
|
2f66f5b511 | ||
|
|
2d8555c326 | ||
|
|
e2c8edab61 | ||
|
|
5e0a66fa1f | ||
|
|
bc8b0a8d53 | ||
|
|
e43562423e | ||
|
|
120ac122eb | ||
|
|
9013fcba14 | ||
|
|
c32f4badbd | ||
|
|
66f66fe08e | ||
|
|
d5826c2dc7 | ||
|
|
85a628f8dd | ||
|
|
ed453750fe | ||
|
|
57d9a31c0f | ||
|
|
58afbe8a79 | ||
|
|
9a10516b52 | ||
|
|
e268e69597 | ||
|
|
10e78ac60e | ||
|
|
44b2b859da | ||
|
|
bfef0c5580 | ||
|
|
1e8055031a | ||
|
|
8e33ff8d90 | ||
|
|
a619000340 | ||
|
|
78278ce96d | ||
|
|
76483d828e | ||
|
|
816a92c609 | ||
|
|
83e4d4363f | ||
|
|
1103449a4f | ||
|
|
56c7a7f066 | ||
|
|
caa59c4c50 | ||
|
|
2546dfbe5d |
87
.github/workflows/auto-i18n.yml
vendored
87
.github/workflows/auto-i18n.yml
vendored
@@ -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@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."
|
||||
|
||||
6
.github/workflows/github-issue-tracker.yml
vendored
6
.github/workflows/github-issue-tracker.yml
vendored
@@ -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:
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
if: steps.check_time.outputs.should_delay == 'false'
|
||||
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'
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: 22
|
||||
|
||||
- name: Process pending issues with Claude
|
||||
uses: anthropics/claude-code-action@main
|
||||
|
||||
6
.github/workflows/nightly-build.yml
vendored
6
.github/workflows/nightly-build.yml
vendored
@@ -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
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
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
|
||||
|
||||
4
.github/workflows/pr-ci.yml
vendored
4
.github/workflows/pr-ci.yml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
- name: Install Node.js
|
||||
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
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -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*.*.*
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
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 }}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"eslint.config.mjs"
|
||||
],
|
||||
"overrides": [
|
||||
// set different env
|
||||
{
|
||||
"env": {
|
||||
"node": true
|
||||
@@ -36,8 +35,7 @@
|
||||
"files": [
|
||||
"src/renderer/**/*.{ts,tsx}",
|
||||
"packages/aiCore/**",
|
||||
"packages/extension-table-plus/**",
|
||||
"resources/js/**"
|
||||
"packages/extension-table-plus/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -53,76 +51,24 @@
|
||||
"node": true
|
||||
},
|
||||
"files": ["src/preload/**"]
|
||||
},
|
||||
{
|
||||
"files": ["packages/ai-sdk-provider/**"],
|
||||
"globals": {
|
||||
"fetch": "readonly"
|
||||
}
|
||||
}
|
||||
],
|
||||
// We don't use the React plugin here because its behavior differs slightly from that of ESLint's React plugin.
|
||||
"plugins": ["unicorn", "typescript", "oxc", "import"],
|
||||
"rules": {
|
||||
"constructor-super": "error",
|
||||
"for-direction": "error",
|
||||
"getter-return": "error",
|
||||
"no-array-constructor": "off",
|
||||
// "import/no-cycle": "error", // tons of error, bro
|
||||
"no-async-promise-executor": "error",
|
||||
"no-caller": "warn",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-args": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-eval": "warn",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "warn",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-misleading-character-class": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-octal": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-unassigned-vars": "warn",
|
||||
"no-undef": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-vars": ["warn", { "caughtErrors": "none" }],
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-useless-rename": "warn",
|
||||
"no-with": "error",
|
||||
"oxc/bad-array-method-on-arguments": "warn",
|
||||
"oxc/bad-char-at-comparison": "warn",
|
||||
"oxc/bad-comparison-sequence": "warn",
|
||||
@@ -134,19 +80,17 @@
|
||||
"oxc/erasing-op": "warn",
|
||||
"oxc/missing-throw": "warn",
|
||||
"oxc/number-arg-out-of-range": "warn",
|
||||
"oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future
|
||||
"oxc/only-used-in-recursion": "off",
|
||||
"oxc/uninvoked-array-callback": "warn",
|
||||
"require-yield": "error",
|
||||
"typescript/await-thenable": "warn",
|
||||
// "typescript/ban-ts-comment": "error",
|
||||
"typescript/no-array-constructor": "error",
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/no-array-constructor": "error",
|
||||
"typescript/no-array-delete": "warn",
|
||||
"typescript/no-base-to-string": "warn",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-duplicate-type-constituents": "warn",
|
||||
"typescript/no-empty-object-type": "off",
|
||||
"typescript/no-explicit-any": "off", // not safe but too many errors
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-floating-promises": "warn",
|
||||
"typescript/no-for-in-array": "warn",
|
||||
@@ -155,7 +99,7 @@
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-misused-spread": "warn",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on.
|
||||
"typescript/no-non-null-asserted-optional-chain": "off",
|
||||
"typescript/no-redundant-type-constituents": "warn",
|
||||
"typescript/no-require-imports": "off",
|
||||
"typescript/no-this-alias": "error",
|
||||
@@ -173,20 +117,18 @@
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"typescript/unbound-method": "warn",
|
||||
"unicorn/no-await-in-promise-methods": "warn",
|
||||
"unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future
|
||||
"unicorn/no-empty-file": "off",
|
||||
"unicorn/no-invalid-fetch-options": "warn",
|
||||
"unicorn/no-invalid-remove-event-listener": "warn",
|
||||
"unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "warn",
|
||||
"unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future
|
||||
"unicorn/no-thenable": "off",
|
||||
"unicorn/no-unnecessary-await": "warn",
|
||||
"unicorn/no-useless-fallback-in-spread": "warn",
|
||||
"unicorn/no-useless-length-check": "warn",
|
||||
"unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future
|
||||
"unicorn/no-useless-spread": "off",
|
||||
"unicorn/prefer-set-size": "warn",
|
||||
"unicorn/prefer-string-starts-ends-with": "warn",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error"
|
||||
"unicorn/prefer-string-starts-ends-with": "warn"
|
||||
},
|
||||
"settings": {
|
||||
"jsdoc": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 100644
|
||||
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -12,7 +12,7 @@ index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 100644
|
||||
index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644
|
||||
index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa96b52ac0d 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
|
||||
@@ -18,30 +18,29 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31
|
||||
tool_calls: import_v42.z.array(
|
||||
import_v42.z.object({
|
||||
index: import_v42.z.number(),
|
||||
@@ -785,6 +787,14 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -785,6 +787,13 @@ var OpenAIChatLanguageModel = class {
|
||||
if (text != null && text.length > 0) {
|
||||
content.push({ type: "text", text });
|
||||
}
|
||||
+ const reasoning =
|
||||
+ choice.message.reasoning_content;
|
||||
+ const reasoning = choice.message.reasoning_content;
|
||||
+ if (reasoning != null && reasoning.length > 0) {
|
||||
+ content.push({
|
||||
+ type: 'reasoning',
|
||||
+ text: reasoning,
|
||||
+ text: reasoning
|
||||
+ });
|
||||
+ }
|
||||
for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -866,6 +875,7 @@ var OpenAIChatLanguageModel = class {
|
||||
};
|
||||
let isFirstChunk = true;
|
||||
let metadataExtracted = false;
|
||||
let isActiveText = false;
|
||||
+ let isActiveReasoning = false;
|
||||
const providerMetadata = { openai: {} };
|
||||
return {
|
||||
stream: response.pipeThrough(
|
||||
@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -923,6 +933,21 @@ var OpenAIChatLanguageModel = class {
|
||||
return;
|
||||
}
|
||||
const delta = choice.delta;
|
||||
@@ -54,7 +53,6 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31
|
||||
+ });
|
||||
+ isActiveReasoning = true;
|
||||
+ }
|
||||
+
|
||||
+ controller.enqueue({
|
||||
+ type: 'reasoning-delta',
|
||||
+ id: 'reasoning-0',
|
||||
@@ -64,7 +62,7 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31
|
||||
if (delta.content != null) {
|
||||
if (!isActiveText) {
|
||||
controller.enqueue({ type: "text-start", id: "0" });
|
||||
@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -1035,6 +1060,9 @@ var OpenAIChatLanguageModel = class {
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
||||
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
@@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8
|
||||
import { createInterface } from "readline";
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6487,14 +6487,11 @@ class ProcessTransport {
|
||||
@@ -6505,14 +6505,11 @@ class ProcessTransport {
|
||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
||||
throw new ReferenceError(errorMessage);
|
||||
}
|
||||
@@ -7,12 +7,10 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
- **Keep it clear**: Write code that is easy to read, maintain, and explain.
|
||||
- **Match the house style**: Reuse existing patterns, naming, and conventions.
|
||||
- **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed.
|
||||
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
|
||||
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
|
||||
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
|
||||
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
|
||||
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
|
||||
📝 docs:`).
|
||||
- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -41,7 +39,6 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
|
||||
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
|
||||
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
|
||||
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
|
||||
|
||||
### Logging
|
||||
```typescript
|
||||
|
||||
12
README.md
12
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<br>● **Employee** Management<br>● Shared **Knowledge Base**<br>● **Access** Control<br>● **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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"aliases": {
|
||||
"components": "@renderer/ui/third-party",
|
||||
"hooks": "@renderer/hooks",
|
||||
"lib": "@renderer/lib",
|
||||
"ui": "@renderer/ui",
|
||||
"utils": "@renderer/utils"
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rsc": false,
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"baseColor": "zinc",
|
||||
"config": "",
|
||||
"css": "src/renderer/src/assets/styles/tailwind.css",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"tsx": true
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
用户可以在软件的`设置`-`关于`中,开启“测试计划”并选择版本通道。请注意“测试计划”的版本无法保证数据的一致性,请使用前一定要备份数据。
|
||||
|
||||
用户选择RC版通道或Beta版通道后,若发布了正式版,仍旧会升级到正式版。
|
||||
|
||||
用户在测试过程中发现的BUG,欢迎提交issue或通过其他渠道反馈。用户的反馈对我们非常重要。
|
||||
|
||||
## 开发者指南
|
||||
|
||||
@@ -135,128 +135,42 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
What's New in v1.7.0-beta.3
|
||||
What's New in v1.7.0-beta.6
|
||||
|
||||
New Features:
|
||||
- Enhanced Tool Permission System: Real-time tool approval interface with improved UX
|
||||
- Plugin Management System: Support for Claude Agent plugins (agents, commands, skills)
|
||||
- Skill Tool: Add skill execution capabilities for agents
|
||||
- Mobile App Data Restore: Support restoring data to mobile applications
|
||||
- OpenMinerU Preprocessor: Knowledge base now supports open-source MinerU for document processing
|
||||
- HuggingFace Provider: Added HuggingFace as AI provider
|
||||
- Claude Haiku 4.5: Support for the latest Claude Haiku 4.5 model
|
||||
- Ling Series Models: Added support for Ling-1T and related models
|
||||
- Intel OVMS Painting: New painting provider using Intel OpenVINO Model Server
|
||||
- Automatic Update Checks: Implement periodic update checking with configurable intervals
|
||||
- HuggingChat Mini App: New mini app for HuggingChat integration
|
||||
- Enhanced Input Bar: Completely redesigned input bar with improved responsiveness and functionality
|
||||
- Better File Handling: Improved drag-and-drop and paste support for images and documents
|
||||
- Smart Tool Suggestions: Enhanced quick panel with better item selection and keyboard shortcuts
|
||||
|
||||
Improvements:
|
||||
- Agent Creation: New agents are now automatically activated upon creation
|
||||
- Lazy Loading: Optimize page load performance with route lazy loading
|
||||
- UI Enhancements: Improved agent item styling and layout consistency
|
||||
- Navigation: Better navbar layout for fullscreen mode on macOS
|
||||
- Settings Tab: Enhanced context slider consistency
|
||||
- Backup Manager: Unified footer layout for local and S3 backup managers
|
||||
- Menu System: Enhanced application menu with improved help section
|
||||
- Proxy Rules: Comprehensive proxy bypass rule matching
|
||||
- German Language: Added German language support
|
||||
- MCP Confirmation: Added confirmation modal when activating protocol-installed MCP servers
|
||||
- Translation: Enhanced translation script with concurrency and validation
|
||||
- Electron & Vite: Updated to Electron 38 and Vite 4.0.1
|
||||
- QR Code Generation: Optimized performance for phone LAN export
|
||||
- Enterprise Settings: Added enterprise section in About settings
|
||||
- Assistant/Agent Popup: Enhanced UI for adding assistants and agents
|
||||
|
||||
Claude Code Tool Improvements:
|
||||
- GlobTool: Now counts lines instead of files in output for better clarity
|
||||
- ReadTool: Automatically removes system reminder tags from output
|
||||
- TodoWriteTool: Improved rendering behavior
|
||||
- Environment Variables: Updated model-related environment variable names
|
||||
- Smoother Input Experience: Better auto-resizing and text handling in chat input
|
||||
- Enhanced AI Performance: Improved connection stability and response speed
|
||||
- More Reliable File Uploads: Better support for various file types and upload scenarios
|
||||
- Cleaner Interface: Optimized UI elements for better visual consistency
|
||||
|
||||
Bug Fixes:
|
||||
- Fixed session model not being used when sending messages
|
||||
- Fixed tool approval UI and shared workspace plugin inconsistencies
|
||||
- Fixed API server readiness notification to renderer
|
||||
- Fixed grouped items not respecting saved tag order
|
||||
- Fixed assistant/agent activation when creating new ones
|
||||
- Fixed Dashscope Anthropic API host and migrated old configs
|
||||
- Fixed Qwen3 thinking mode control for Ollama
|
||||
- Fixed disappeared MCP button
|
||||
- Fixed create assistant causing blank screen
|
||||
- Fixed up-down button visibility in some cases
|
||||
- Fixed hooks preventing save on composing enter key
|
||||
- Fixed Azure GPT-image-1 and OpenRouter Gemini-image
|
||||
- Fixed Silicon reasoning issues
|
||||
- Fixed topic branch incomplete copy with two-pass ID mapping
|
||||
- Fixed deep research model search context restrictions
|
||||
- Fixed model capability checking logic
|
||||
- Fixed reranker API error response capture
|
||||
- Fixed right-click paste file content into inputbar
|
||||
- Fixed minimax-m2 support in aiCore
|
||||
- Fixed Azure embedding issues
|
||||
- Fixed agent edit modal loading race condition
|
||||
- Fixed debounced save cancellation on file path update
|
||||
- Fixed image selection issue when adding custom AI providers
|
||||
- Fixed file upload problems with certain API configurations
|
||||
- Fixed input bar responsiveness issues
|
||||
- Fixed quick panel not working properly in some situations
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
v1.7.0-beta.3 新特性
|
||||
v1.7.0-beta.6 新特性
|
||||
|
||||
新功能:
|
||||
- 增强工具权限系统:实时工具审批界面,改进用户体验
|
||||
- 插件管理系统:支持 Claude Agent 插件(agents、commands、skills)
|
||||
- 技能工具:为 Agent 添加技能执行能力
|
||||
- 移动应用数据恢复:支持将数据恢复到移动应用程序
|
||||
- OpenMinerU 预处理器:知识库现支持使用开源 MinerU 处理文档
|
||||
- HuggingFace 提供商:添加 HuggingFace 作为 AI 提供商
|
||||
- Claude Haiku 4.5:支持最新的 Claude Haiku 4.5 模型
|
||||
- Ling 系列模型:添加 Ling-1T 及相关模型支持
|
||||
- Intel OVMS 绘图:使用 Intel OpenVINO 模型服务器的新绘图提供商
|
||||
- 自动更新检查:实现可配置间隔的定期更新检查
|
||||
- HuggingChat 小程序:新增 HuggingChat 集成小程序
|
||||
- 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大
|
||||
- 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档
|
||||
- 智能工具建议:增强的快速面板,更好的项目选择和键盘快捷键
|
||||
|
||||
改进:
|
||||
- Agent 创建:新创建的 Agent 现在会自动激活
|
||||
- 懒加载:通过路由懒加载优化页面加载性能
|
||||
- UI 增强:改进 Agent 项目样式和布局一致性
|
||||
- 导航:改进 macOS 全屏模式下的导航栏布局
|
||||
- 设置选项卡:增强上下文滑块一致性
|
||||
- 备份管理器:统一本地和 S3 备份管理器的页脚布局
|
||||
- 菜单系统:增强应用菜单,改进帮助部分
|
||||
- 代理规则:全面的代理绕过规则匹配
|
||||
- 德语支持:添加德语语言支持
|
||||
- MCP 确认:添加激活协议安装的 MCP 服务器时的确认模态框
|
||||
- 翻译:增强翻译脚本的并发和验证功能
|
||||
- Electron & Vite:更新至 Electron 38 和 Vite 4.0.1
|
||||
- 二维码生成:优化手机局域网导出性能
|
||||
- 企业设置:在关于设置中添加企业部分
|
||||
- 助手/Agent 弹窗:增强添加助手和 Agent 的界面
|
||||
|
||||
Claude Code 工具改进:
|
||||
- GlobTool:现在计算行数而不是文件数,提供更清晰的输出
|
||||
- ReadTool:自动从输出中移除系统提醒标签
|
||||
- TodoWriteTool:改进渲染行为
|
||||
- 环境变量:更新模型相关的环境变量名称
|
||||
- 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳
|
||||
- 增强 AI 性能:改进连接稳定性和响应速度
|
||||
- 更可靠的文件上传:更好地支持各种文件类型和上传场景
|
||||
- 更简洁的界面:优化 UI 元素,视觉一致性更好
|
||||
|
||||
问题修复:
|
||||
- 修复发送消息时未使用会话模型
|
||||
- 修复工具审批 UI 和共享工作区插件不一致
|
||||
- 修复 API 服务器就绪通知到渲染器
|
||||
- 修复分组项目不遵守已保存标签顺序
|
||||
- 修复创建新的助手/Agent 时的激活问题
|
||||
- 修复 Dashscope Anthropic API 主机并迁移旧配置
|
||||
- 修复 Ollama 的 Qwen3 思考模式控制
|
||||
- 修复 MCP 按钮消失
|
||||
- 修复创建助手导致空白屏幕
|
||||
- 修复某些情况下上下按钮可见性
|
||||
- 修复钩子在输入法输入时阻止保存
|
||||
- 修复 Azure GPT-image-1 和 OpenRouter Gemini-image
|
||||
- 修复 Silicon 推理问题
|
||||
- 修复主题分支不完整复制,采用两阶段 ID 映射
|
||||
- 修复深度研究模型搜索上下文限制
|
||||
- 修复模型能力检查逻辑
|
||||
- 修复 reranker API 错误响应捕获
|
||||
- 修复右键粘贴文件内容到输入栏
|
||||
- 修复 aiCore 中的 minimax-m2 支持
|
||||
- 修复 Azure embedding 问题
|
||||
- 修复 Agent 编辑模态框加载竞态条件
|
||||
- 修复文件路径更新时防抖保存取消问题
|
||||
- 修复添加自定义 AI 提供商时的图片选择问题
|
||||
- 修复某些 API 配置下的文件上传问题
|
||||
- 修复输入栏响应性问题
|
||||
- 修复快速面板在某些情况下无法正常工作的问题
|
||||
<!--LANG:END-->
|
||||
|
||||
@@ -95,7 +95,8 @@ export default defineConfig({
|
||||
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
|
||||
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
|
||||
'@cherrystudio/ai-core': resolve('packages/aiCore/src'),
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
27
package.json
27
package.json
@@ -78,7 +78,7 @@
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
@@ -106,11 +106,13 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.42",
|
||||
"@ai-sdk/google-vertex": "^3.0.48",
|
||||
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch",
|
||||
"@ai-sdk/mistral": "^2.0.19",
|
||||
"@ai-sdk/perplexity": "^2.0.13",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
||||
"@ai-sdk/cerebras": "^1.0.31",
|
||||
"@ai-sdk/gateway": "^2.0.9",
|
||||
"@ai-sdk/google-vertex": "^3.0.62",
|
||||
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch",
|
||||
"@ai-sdk/mistral": "^2.0.23",
|
||||
"@ai-sdk/perplexity": "^2.0.17",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.41.0",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
@@ -147,7 +149,6 @@
|
||||
"@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": "^1.0.0",
|
||||
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
|
||||
@@ -232,7 +233,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.76",
|
||||
"ai": "^5.0.90",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
@@ -242,7 +243,7 @@
|
||||
"check-disk-space": "3.4.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"claude-code-plugins": "1.0.1",
|
||||
"claude-code-plugins": "1.0.3",
|
||||
"cli-progress": "^3.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
@@ -349,6 +350,7 @@
|
||||
"striptags": "^3.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
@@ -374,6 +376,7 @@
|
||||
"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",
|
||||
@@ -393,7 +396,6 @@
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@7.1.5",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
@@ -404,7 +406,10 @@
|
||||
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch"
|
||||
"@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": {
|
||||
|
||||
39
packages/ai-sdk-provider/README.md
Normal file
39
packages/ai-sdk-provider/README.md
Normal file
@@ -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.
|
||||
64
packages/ai-sdk-provider/package.json
Normal file
64
packages/ai-sdk-provider/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
319
packages/ai-sdk-provider/src/cherryin-provider.ts
Normal file
319
packages/ai-sdk-provider/src/cherryin-provider.ts
Normal file
@@ -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<string, HeaderValue> | (() => Record<string, HeaderValue>)
|
||||
|
||||
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<string>
|
||||
textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
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<string, HeaderValue> => {
|
||||
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<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
'Content-Type': 'application/json',
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
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()
|
||||
1
packages/ai-sdk-provider/src/index.ts
Normal file
1
packages/ai-sdk-provider/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './cherryin-provider'
|
||||
19
packages/ai-sdk-provider/tsconfig.json
Normal file
19
packages/ai-sdk-provider/tsconfig.json
Normal file
@@ -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/**/*"]
|
||||
}
|
||||
12
packages/ai-sdk-provider/tsdown.config.ts
Normal file
12
packages/ai-sdk-provider/tsdown.config.ts
Normal file
@@ -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'
|
||||
})
|
||||
@@ -36,14 +36,16 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.32",
|
||||
"@ai-sdk/azure": "^2.0.53",
|
||||
"@ai-sdk/deepseek": "^1.0.23",
|
||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
||||
"@ai-sdk/openai-compatible": "^1.0.22",
|
||||
"@ai-sdk/anthropic": "^2.0.43",
|
||||
"@ai-sdk/azure": "^2.0.66",
|
||||
"@ai-sdk/deepseek": "^1.0.27",
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch",
|
||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||
"@ai-sdk/openai-compatible": "^1.0.26",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.12",
|
||||
"@ai-sdk/xai": "^2.0.26",
|
||||
"@ai-sdk/provider-utils": "^3.0.16",
|
||||
"@ai-sdk/xai": "^2.0.31",
|
||||
"@cherrystudio/ai-sdk-provider": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { anthropic } from '@ai-sdk/anthropic'
|
||||
import type { google } from '@ai-sdk/google'
|
||||
import type { openai } from '@ai-sdk/openai'
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import type { InferToolInput, InferToolOutput } from 'ai'
|
||||
import { type Tool } from 'ai'
|
||||
|
||||
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import type { ProviderOptionsMap } from '../../../options/types'
|
||||
import type { OpenRouterSearchConfig } from './openrouter'
|
||||
|
||||
@@ -95,3 +96,56 @@ export type WebSearchToolInputSchema = {
|
||||
google: InferToolInput<GoogleWebSearchTool>
|
||||
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,15 +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 type { WebSearchPluginConfig } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper'
|
||||
|
||||
/**
|
||||
* 网络搜索插件
|
||||
@@ -24,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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
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 type { Provider } from 'ai'
|
||||
import { customProvider } from 'ai'
|
||||
@@ -31,6 +32,8 @@ export const baseProviderIds = [
|
||||
'azure-responses',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'cherryin',
|
||||
'cherryin-chat',
|
||||
'huggingface'
|
||||
] as const
|
||||
|
||||
@@ -136,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',
|
||||
|
||||
@@ -189,6 +189,7 @@ export enum IpcChannel {
|
||||
Fs_ReadText = 'fs:readText',
|
||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||
File_IsTextFile = 'file:isTextFile',
|
||||
File_ListDirectory = 'file:listDirectory',
|
||||
File_GetDirectoryStructure = 'file:getDirectoryStructure',
|
||||
File_CheckFileName = 'file:checkFileName',
|
||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||
|
||||
@@ -470,3 +470,6 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
// resources/scripts should be maintained manually
|
||||
export const HOME_CHERRY_DIR = '.cherrystudio'
|
||||
|
||||
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `sessions` ADD `slash_commands` text;
|
||||
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
const https = require('https')
|
||||
const { loggerService } = require('@logger')
|
||||
|
||||
const logger = loggerService.withContext('IpService')
|
||||
|
||||
/**
|
||||
* 获取用户的IP地址所在国家
|
||||
* @returns {Promise<string>} 返回国家代码,默认为'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<boolean>} 如果用户在中国返回true,否则返回false
|
||||
*/
|
||||
async function isUserInChina() {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户位置获取适合的npm镜像URL
|
||||
* @returns {Promise<string>} 返回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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -551,6 +551,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||
|
||||
@@ -275,15 +275,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
// https://mineru.net/apiManage/docs
|
||||
const response = await net.fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: fileBuffer,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf'
|
||||
}
|
||||
// headers: {
|
||||
// 'Content-Length': fileBuffer.length.toString()
|
||||
// }
|
||||
body: fileBuffer
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -124,7 +124,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
||||
const formData = new FormData()
|
||||
formData.append('return_md', 'true')
|
||||
formData.append('response_format_zip', 'true')
|
||||
formData.append('files', fileBuffer, {
|
||||
formData.append('file', fileBuffer, {
|
||||
filename: file.origin_name
|
||||
})
|
||||
|
||||
|
||||
@@ -7,16 +7,33 @@ import { app, Menu, shell } from 'electron'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
export class AppMenuService {
|
||||
private languageChangeCallback?: (newLanguage: string) => void
|
||||
|
||||
constructor() {
|
||||
// Subscribe to language change events
|
||||
this.languageChangeCallback = () => {
|
||||
this.setupApplicationMenu()
|
||||
}
|
||||
configManager.subscribe('language', this.languageChangeCallback)
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
// Clean up subscription to prevent memory leaks
|
||||
if (this.languageChangeCallback) {
|
||||
configManager.unsubscribe('language', this.languageChangeCallback)
|
||||
}
|
||||
}
|
||||
|
||||
public setupApplicationMenu(): void {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { common } = locale.translation
|
||||
const { appMenu } = locale.translation
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{
|
||||
label: common.about + ' ' + app.name,
|
||||
label: appMenu.about + ' ' + app.name,
|
||||
click: () => {
|
||||
// Emit event to navigate to About page
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
@@ -27,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')
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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,
|
||||
@@ -66,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
|
||||
@@ -362,7 +363,7 @@ class CodeToolsService {
|
||||
|
||||
private async isPackageInstalled(cliTool: string): Promise<boolean> {
|
||||
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
|
||||
@@ -389,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`, {
|
||||
@@ -500,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
|
||||
@@ -550,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}`)
|
||||
@@ -652,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)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar'
|
||||
import chokidar from 'chokidar'
|
||||
import * as crypto from 'crypto'
|
||||
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'
|
||||
@@ -30,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)[]
|
||||
@@ -54,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
|
||||
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<DirectoryListOptions> = {
|
||||
recursive: true,
|
||||
maxDepth: 3,
|
||||
includeHidden: false,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntries: 10,
|
||||
searchPattern: '.'
|
||||
}
|
||||
|
||||
class FileStorage {
|
||||
private storageDir = getFilesDir()
|
||||
private notesDir = getNotesDir()
|
||||
@@ -748,6 +836,284 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectory = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
dirPath: string,
|
||||
options?: DirectoryListOptions
|
||||
): Promise<string[]> => {
|
||||
const mergedOptions: Required<DirectoryListOptions> = {
|
||||
...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<DirectoryListOptions>,
|
||||
currentDepth: number = 0
|
||||
): Promise<string[]> {
|
||||
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<DirectoryListOptions>): Promise<string[]> {
|
||||
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<DirectoryListOptions>): Promise<string[]> {
|
||||
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<DirectoryListOptions>
|
||||
): Promise<string[]> {
|
||||
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<boolean> => {
|
||||
try {
|
||||
if (!dirPath || typeof dirPath !== 'string') {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ToolListChangedNotificationSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
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'
|
||||
@@ -715,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)
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<ModelConfig[]> {
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Attributes, SpanEntity, TokenUsage, TraceCache } from '@mcp-trace/
|
||||
import { convertSpanToSpanEntity } from '@mcp-trace/trace-core'
|
||||
import { SpanStatusCode } from '@opentelemetry/api'
|
||||
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'
|
||||
@@ -18,7 +19,7 @@ class SpanCacheService implements TraceCache {
|
||||
pri
|
||||
|
||||
constructor() {
|
||||
this.fileDir = path.join(os.homedir(), '.cherrystudio', 'trace')
|
||||
this.fileDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'trace')
|
||||
}
|
||||
|
||||
createSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => {
|
||||
|
||||
@@ -36,7 +36,14 @@ export abstract class BaseService {
|
||||
protected static db: LibSQLDatabase<typeof schema> | null = null
|
||||
protected static isInitialized = false
|
||||
protected static initializationPromise: Promise<void> | 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UpdateSessionResponse } from '@types'
|
||||
import { loggerService } from '@logger'
|
||||
import type { SlashCommand, UpdateSessionResponse } from '@types'
|
||||
import {
|
||||
AgentBaseSchema,
|
||||
type AgentEntity,
|
||||
@@ -13,6 +14,10 @@ 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 type { AgentModelField } from '../errors'
|
||||
import { pluginService } from '../plugins/PluginService'
|
||||
import { builtinSlashCommands } from './claudecode/commands'
|
||||
|
||||
const logger = loggerService.withContext('SessionService')
|
||||
|
||||
export class SessionService extends BaseService {
|
||||
private static instance: SessionService | null = null
|
||||
@@ -29,6 +34,52 @@ export class SessionService extends BaseService {
|
||||
await BaseService.initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Override BaseService.listSlashCommands to merge builtin and plugin commands
|
||||
*/
|
||||
async listSlashCommands(agentType: string, agentId?: string): Promise<SlashCommand[]> {
|
||||
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<CreateSessionRequest> = {}
|
||||
@@ -78,6 +129,7 @@ export class SessionService extends BaseService {
|
||||
plan_model: serializedData.plan_model || null,
|
||||
small_model: serializedData.small_model || null,
|
||||
mcps: serializedData.mcps || null,
|
||||
allowed_tools: serializedData.allowed_tools || null,
|
||||
configuration: serializedData.configuration || null,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
@@ -110,7 +162,13 @@ export class SessionService extends BaseService {
|
||||
|
||||
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
|
||||
session.slash_commands = await this.listSlashCommands(session.agent_type)
|
||||
|
||||
// If slash_commands is not in database yet (e.g., first invoke before init message),
|
||||
// fall back to builtin + local commands. Otherwise, use the merged commands from database.
|
||||
if (!session.slash_commands || session.slash_commands.length === 0) {
|
||||
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <local-command-stdout>echo "hi"</local-command-stdout> after'
|
||||
expect(stripLocalCommandTags(input)).toBe('before echo "hi" after')
|
||||
})
|
||||
|
||||
it('strips multiple stdout/stderr blocks and leaves other content intact', () => {
|
||||
const input =
|
||||
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
|
||||
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude → AiSDK transform', () => {
|
||||
it('handles tool call streaming lifecycle', () => {
|
||||
const state = new ClaudeStreamState()
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
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' }
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import { app } from 'electron'
|
||||
|
||||
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'
|
||||
|
||||
@@ -19,6 +20,7 @@ const require_ = createRequire(import.meta.url)
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
const NO_RESUME_COMMANDS = ['/clear']
|
||||
|
||||
type UserInputMessage = {
|
||||
type: 'user'
|
||||
@@ -197,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
|
||||
@@ -220,7 +222,15 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
// Start async processing on the next tick so listeners can subscribe first
|
||||
setImmediate(() => {
|
||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
||||
this.processSDKQuery(
|
||||
userInputStream,
|
||||
closeUserStream,
|
||||
options,
|
||||
aiStream,
|
||||
errorChunks,
|
||||
session.agent_id,
|
||||
session.id
|
||||
).catch((error) => {
|
||||
logger.error('Unhandled Claude Code stream error', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
@@ -329,7 +339,9 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
closePromptStream: () => void,
|
||||
options: Options,
|
||||
stream: ClaudeCodeStream,
|
||||
errorChunks: string[]
|
||||
errorChunks: string[],
|
||||
agentId: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const jsonOutput: SDKMessage[] = []
|
||||
let hasCompleted = false
|
||||
@@ -342,6 +354,62 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
jsonOutput.push(message)
|
||||
|
||||
// Handle init message - merge builtin and SDK slash_commands
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
const sdkSlashCommands = message.slash_commands || []
|
||||
logger.info('Received init message with slash commands', {
|
||||
sessionId,
|
||||
commands: sdkSlashCommands
|
||||
})
|
||||
|
||||
try {
|
||||
// Get builtin + local slash commands from BaseService
|
||||
const existingCommands = await sessionService.listSlashCommands('claude-code', agentId)
|
||||
|
||||
// Convert SDK slash_commands (string[]) to SlashCommand[] format
|
||||
// Ensure all commands start with '/'
|
||||
const sdkCommands = sdkSlashCommands.map((cmd) => {
|
||||
const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}`
|
||||
return {
|
||||
command: normalizedCmd,
|
||||
description: undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name
|
||||
const commandMap = new Map<string, { command: string; description?: string }>()
|
||||
|
||||
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,
|
||||
@@ -365,10 +433,19 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
type: 'chunk',
|
||||
chunk
|
||||
})
|
||||
|
||||
// Close prompt stream when SDK signals completion or error
|
||||
if (chunk.type === 'finish' || chunk.type === 'error') {
|
||||
logger.info('Closing prompt stream as SDK signaled completion', {
|
||||
chunkType: chunk.type,
|
||||
reason: chunk.type === 'finish' ? 'finished' : 'error_occurred'
|
||||
})
|
||||
closePromptStream()
|
||||
logger.info('Prompt stream closed successfully')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasCompleted = true
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.debug('SDK query completed successfully', {
|
||||
|
||||
@@ -73,13 +73,21 @@ 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-(stdout|stderr)>(.*?)<\/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 <command-message>...</command-message> and <command-name>...</command-name>
|
||||
*/
|
||||
const filterCommandTags = (text: string): string => {
|
||||
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
const withoutLocalCommandTags = stripLocalCommandTags(text)
|
||||
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,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)
|
||||
@@ -135,7 +144,8 @@ function handleAssistantMessage(
|
||||
const isStreamingActive = state.hasActiveStep()
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (!content) {
|
||||
const sanitizedContent = stripLocalCommandTags(content)
|
||||
if (!sanitizedContent) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
@@ -157,7 +167,7 @@ function handleAssistantMessage(
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: textId,
|
||||
text: content,
|
||||
text: sanitizedContent,
|
||||
providerMetadata
|
||||
})
|
||||
chunks.push({
|
||||
@@ -178,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':
|
||||
@@ -537,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,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { configManager } from '@main/services/ConfigManager'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import type EventEmitter from 'events'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
@@ -7,6 +9,36 @@ import type { OAuthCallbackServerOptions } from './types'
|
||||
|
||||
const logger = loggerService.withContext('MCP:OAuthCallbackServer')
|
||||
|
||||
function getTranslation(key: string): string {
|
||||
const language = configManager.getLanguage()
|
||||
const localeData = locales[language]
|
||||
|
||||
if (!localeData) {
|
||||
logger.warn(`No locale data found for language: ${language}`)
|
||||
return key
|
||||
}
|
||||
|
||||
const translations = localeData.translation as any
|
||||
if (!translations) {
|
||||
logger.warn(`No translations found for language: ${language}`)
|
||||
return key
|
||||
}
|
||||
|
||||
const keys = key.split('.')
|
||||
let value = translations
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k]
|
||||
} else {
|
||||
logger.warn(`Translation key not found: ${key} (failed at: ${k})`)
|
||||
return key // fallback to key if translation not found
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : key
|
||||
}
|
||||
|
||||
export class CallBackServer {
|
||||
private server: Promise<http.Server>
|
||||
private events: EventEmitter
|
||||
@@ -28,6 +60,55 @@ export class CallBackServer {
|
||||
if (code) {
|
||||
// Emit the code event
|
||||
this.events.emit('auth-code-received', code)
|
||||
// Send success response to browser
|
||||
const title = getTranslation('settings.mcp.oauth.callback.title')
|
||||
const message = getTranslation('settings.mcp.oauth.callback.message')
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
color: #2d3748;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
color: #718096;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${title}</h1>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||||
res.end('Missing authorization code')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing OAuth callback:', error as Error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { HOME_CHERRY_DIR } from '@shared/config/constant'
|
||||
import type { OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
|
||||
import { isImageFileMetadata } from '@types'
|
||||
import { exec } from 'child_process'
|
||||
@@ -13,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() {
|
||||
@@ -30,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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { 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'
|
||||
@@ -160,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() {
|
||||
@@ -172,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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
export async function getBinaryPath(name?: string): Promise<string> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -48,6 +48,16 @@ import type {
|
||||
} from '../renderer/src/types/plugin'
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
|
||||
type DirectoryListOptions = {
|
||||
recursive?: boolean
|
||||
maxDepth?: number
|
||||
includeHidden?: boolean
|
||||
includeFiles?: boolean
|
||||
includeDirectories?: boolean
|
||||
maxEntries?: number
|
||||
searchPattern?: string
|
||||
}
|
||||
|
||||
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
|
||||
if (spanContext) {
|
||||
const data = { type: 'trace', context: spanContext }
|
||||
@@ -201,6 +211,8 @@ const api = {
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
|
||||
isTextFile: (filePath: string): Promise<boolean> => 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),
|
||||
|
||||
@@ -6,11 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Provider } from 'react-redux'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import { ToastPortal } from './components/ToastPortal'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
import { HeroUIProvider } from './context/HeroUIProvider'
|
||||
import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
@@ -34,24 +32,21 @@ function App(): React.ReactElement {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HeroUIProvider>
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<Router />
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
<ToastPortal />
|
||||
</HeroUIProvider>
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<Router />
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
@@ -30,18 +30,22 @@ export class AiSdkToChunkAdapter {
|
||||
private onSessionUpdate?: (sessionId: string) => void
|
||||
private responseStartTimestamp: number | null = null
|
||||
private firstTokenTimestamp: number | null = null
|
||||
private hasTextContent = false
|
||||
private getSessionWasCleared?: () => boolean
|
||||
|
||||
constructor(
|
||||
private onChunk: (chunk: Chunk) => void,
|
||||
mcpTools: MCPTool[] = [],
|
||||
accumulate?: boolean,
|
||||
enableWebSearch?: boolean,
|
||||
onSessionUpdate?: (sessionId: string) => void
|
||||
onSessionUpdate?: (sessionId: string) => void,
|
||||
getSessionWasCleared?: () => boolean
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
this.onSessionUpdate = onSessionUpdate
|
||||
this.getSessionWasCleared = getSessionWasCleared
|
||||
}
|
||||
|
||||
private markFirstTokenIfNeeded() {
|
||||
@@ -84,8 +88,9 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
this.resetTimingState()
|
||||
this.responseStartTimestamp = Date.now()
|
||||
// Reset link converter state at the start of stream
|
||||
// Reset state at the start of stream
|
||||
this.isFirstChunk = true
|
||||
this.hasTextContent = false
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -129,6 +134,8 @@ export class AiSdkToChunkAdapter {
|
||||
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
|
||||
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
|
||||
this.onSessionUpdate?.(agentRawMessage.session_id)
|
||||
} else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) {
|
||||
this.onSessionUpdate?.(agentRawMessage.session_id)
|
||||
}
|
||||
this.onChunk({
|
||||
type: ChunkType.RAW,
|
||||
@@ -143,6 +150,7 @@ export class AiSdkToChunkAdapter {
|
||||
})
|
||||
break
|
||||
case 'text-delta': {
|
||||
this.hasTextContent = true
|
||||
const processedText = chunk.text || ''
|
||||
let finalText: string
|
||||
|
||||
@@ -301,6 +309,25 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
|
||||
case 'finish': {
|
||||
// Check if session was cleared (e.g., /clear command) and no text was output
|
||||
const sessionCleared = this.getSessionWasCleared?.() ?? false
|
||||
if (sessionCleared && !this.hasTextContent) {
|
||||
// Inject a "context cleared" message for the user
|
||||
const clearMessage = '✨ Context cleared. Starting fresh conversation.'
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: clearMessage
|
||||
})
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
text: clearMessage
|
||||
})
|
||||
final.text = clearMessage
|
||||
}
|
||||
|
||||
const usage = {
|
||||
completion_tokens: chunk.totalUsage?.outputTokens || 0,
|
||||
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
* 2. 暂时保持接口兼容性
|
||||
*/
|
||||
|
||||
import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway'
|
||||
import { createExecutor } from '@cherrystudio/ai-core'
|
||||
import { loggerService } from '@logger'
|
||||
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
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'
|
||||
@@ -439,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock('@renderer/config/providers', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
isCherryAIProvider: vi.fn(),
|
||||
isPerplexityProvider: vi.fn(),
|
||||
isAnthropicProvider: vi.fn(() => false),
|
||||
isAzureOpenAIProvider: vi.fn(() => false),
|
||||
isGeminiProvider: vi.fn(() => false),
|
||||
@@ -52,7 +53,7 @@ vi.mock('@renderer/hooks/useVertexAI', () => ({
|
||||
createVertexProvider: vi.fn()
|
||||
}))
|
||||
|
||||
import { isCherryAIProvider } from '@renderer/config/providers'
|
||||
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'
|
||||
@@ -97,6 +98,16 @@ const createCherryAIProvider = (): Provider => ({
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as any).window = {
|
||||
@@ -195,3 +206,70 @@ describe('CherryAI provider configuration', () => {
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
isAzureOpenAIProvider,
|
||||
isCherryAIProvider,
|
||||
isGeminiProvider,
|
||||
isNewApiProvider
|
||||
isNewApiProvider,
|
||||
isPerplexityProvider
|
||||
} from '@renderer/config/providers'
|
||||
import {
|
||||
getAwsBedrockAccessKeyId,
|
||||
@@ -103,6 +104,8 @@ function formatProviderApiHost(provider: Provider): Provider {
|
||||
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)
|
||||
}
|
||||
@@ -168,7 +171,7 @@ export function providerToAiSdkConfig(
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai') {
|
||||
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -113,6 +113,9 @@ export function buildProviderOptions(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'cherryin':
|
||||
providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported base provider ${baseProviderId}`)
|
||||
}
|
||||
@@ -148,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 }
|
||||
@@ -270,6 +274,34 @@ function buildXAIProviderOptions(
|
||||
return providerOptions
|
||||
}
|
||||
|
||||
function buildCherryInProviderOptions(
|
||||
assistant: Assistant,
|
||||
model: Model,
|
||||
capabilities: {
|
||||
enableReasoning: boolean
|
||||
enableWebSearch: boolean
|
||||
enableGenerateImage: boolean
|
||||
},
|
||||
actualProvider: Provider
|
||||
): Record<string, any> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -109,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' } }
|
||||
}
|
||||
|
||||
@@ -306,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' } }
|
||||
}
|
||||
|
||||
@@ -418,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<string, any> {
|
||||
if (!isReasoningModel(model)) {
|
||||
@@ -431,8 +441,8 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
||||
if (reasoningEffort === undefined) {
|
||||
return {
|
||||
thinkingConfig: {
|
||||
include_thoughts: false,
|
||||
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinking_budget: 0 } : {})
|
||||
includeThoughts: false,
|
||||
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinkingBudget: 0 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,7 +452,7 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
||||
if (effortRatio > 1) {
|
||||
return {
|
||||
thinkingConfig: {
|
||||
include_thoughts: true
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,8 +462,8 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
||||
|
||||
return {
|
||||
thinkingConfig: {
|
||||
...(budget > 0 ? { thinking_budget: budget } : {}),
|
||||
include_thoughts: true
|
||||
...(budget > 0 ? { thinkingBudget: budget } : {}),
|
||||
includeThoughts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
BIN
src/renderer/src/assets/images/providers/cerebras.webp
Normal file
BIN
src/renderer/src/assets/images/providers/cerebras.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
src/renderer/src/assets/images/providers/mcprouter.webp
Normal file
BIN
src/renderer/src/assets/images/providers/mcprouter.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
1
src/renderer/src/assets/images/providers/vercel.svg
Normal file
1
src/renderer/src/assets/images/providers/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Vercel</title><path d="M12 0l12 20.785H0L12 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -41,11 +41,11 @@ body,
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* #root {
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
} */
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
@import 'tailwindcss' source('../../../../renderer');
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* heroui */
|
||||
@plugin '../../hero.ts';
|
||||
@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* 如需自定义:
|
||||
@@ -156,11 +152,6 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* To disable drag title bar on toast. tailwind css doesn't provide such class name. */
|
||||
.hero-toast {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Avatar, cn } from '@heroui/react'
|
||||
import { getModelLogoById } from '@renderer/config/models'
|
||||
import type { ApiModel } from '@renderer/types'
|
||||
import React from 'react'
|
||||
|
||||
import Ellipsis from './Ellipsis'
|
||||
|
||||
export interface ModelLabelProps extends Omit<React.ComponentPropsWithRef<'div'>, 'children'> {
|
||||
model?: ApiModel
|
||||
classNames?: {
|
||||
container?: string
|
||||
avatar?: string
|
||||
modelName?: string
|
||||
divider?: string
|
||||
providerName?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiModelLabel: React.FC<ModelLabelProps> = ({ model, className, classNames, ...props }) => {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className, classNames?.container)} {...props}>
|
||||
<Avatar
|
||||
src={model ? (getModelLogoById(model.id) ?? getModelLogoById(model.name)) : undefined}
|
||||
className={cn('h-4 w-4', classNames?.avatar)}
|
||||
/>
|
||||
<Ellipsis className={classNames?.modelName}>{model?.name}</Ellipsis>
|
||||
<span className={classNames?.divider}> | </span>
|
||||
<Ellipsis className={classNames?.providerName}>{model?.provider_name}</Ellipsis>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Props> = ({ emoji, onPick }) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<EmojiPicker onEmojiClick={onPick}></EmojiPicker>
|
||||
</PopoverContent>
|
||||
<Popover content={<EmojiPicker onEmojiClick={onPick} />} trigger="click">
|
||||
<Button type="text" style={{ width: 32, height: 32, fontSize: 18 }}>
|
||||
{emoji}
|
||||
</Button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from '@heroui/react'
|
||||
import { cn } from '@renderer/utils'
|
||||
import type { ButtonProps } from 'antd'
|
||||
import { Button } from 'antd'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@heroui/react'
|
||||
import { CheckIcon, XIcon } from 'lucide-react'
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
|
||||
import { Button } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
@@ -28,12 +28,22 @@ const ConfirmDialog: FC<Props> = ({ x, y, message, onConfirm, onCancel }) => {
|
||||
<div className="flex min-w-[160px] items-center rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] p-3 shadow-[0_4px_12px_rgba(0,0,0,0.15)]">
|
||||
<div className="mr-2 text-sm leading-[1.4]">{message}</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button onPress={onCancel} radius="full" className="h-6 w-6 min-w-0 p-1" color="danger">
|
||||
<XIcon className="text-danger-foreground" size={16} />
|
||||
</Button>
|
||||
<Button onPress={onConfirm} radius="full" className="h-6 w-6 min-w-0 p-1" color="success">
|
||||
<CheckIcon className="text-success-foreground" size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
shape="circle"
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
style={{ width: 24, height: 24, minWidth: 24 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
shape="circle"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
style={{ width: 24, height: 24, minWidth: 24, backgroundColor: '#52c41a' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Button } from 'antd'
|
||||
import { Alert, Space } from 'antd'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { FallbackProps } from 'react-error-boundary'
|
||||
@@ -24,10 +24,10 @@ const DefaultFallback: ComponentType<FallbackProps> = (props: FallbackProps): Re
|
||||
type="error"
|
||||
action={
|
||||
<Space>
|
||||
<Button size="sm" onPress={debug}>
|
||||
<Button size="small" onClick={debug}>
|
||||
{t('error.boundary.default.devtools')}
|
||||
</Button>
|
||||
<Button size="sm" onPress={reload}>
|
||||
<Button size="small" onClick={reload}>
|
||||
{t('error.boundary.default.reload')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '@heroui/react'
|
||||
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'
|
||||
@@ -51,7 +51,7 @@ const PopupContainer: React.FC<Props> = ({ onSelect, resolve }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect('assistant')}
|
||||
className="group flex flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
|
||||
className="group flex cursor-pointer flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
|
||||
onMouseEnter={() => setHoveredOption('assistant')}
|
||||
onMouseLeave={() => setHoveredOption(null)}>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors">
|
||||
@@ -73,7 +73,7 @@ const PopupContainer: React.FC<Props> = ({ onSelect, resolve }) => {
|
||||
<button
|
||||
onClick={() => handleSelect('agent')}
|
||||
type="button"
|
||||
className="group flex flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
|
||||
className="group flex cursor-pointer flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
|
||||
onMouseEnter={() => setHoveredOption('agent')}
|
||||
onMouseLeave={() => setHoveredOption(null)}>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors">
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
|
||||
import { Progress } from '@heroui/progress'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { loggerService } from '@logger'
|
||||
import { 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'
|
||||
@@ -25,7 +22,7 @@ const LoadingQRCode: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<Spinner />
|
||||
<Spin />
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
|
||||
{t('settings.data.export_to_phone.lan.generating_qr')}
|
||||
</span>
|
||||
@@ -44,8 +41,8 @@ const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
|
||||
size={200}
|
||||
imageSettings={{
|
||||
src: AppLogo,
|
||||
width: 60,
|
||||
height: 60,
|
||||
width: 40,
|
||||
height: 40,
|
||||
excavate: true
|
||||
}}
|
||||
/>
|
||||
@@ -72,7 +69,7 @@ const ConnectingAnimation: React.FC = () => {
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'var(--color-status-warning)'
|
||||
}}>
|
||||
<Spinner size="lg" color="warning" />
|
||||
<Spin size="large" />
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
|
||||
{t('settings.data.export_to_phone.lan.status.connecting')}
|
||||
</span>
|
||||
@@ -137,7 +134,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
|
||||
const [sendProgress, setSendProgress] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
|
||||
|
||||
const { t } = useTranslation()
|
||||
@@ -299,22 +295,20 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
// 尝试关闭弹窗 - 如果正在传输则显示确认
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSending) {
|
||||
setShowCloseConfirm(true)
|
||||
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])
|
||||
|
||||
// 确认强制关闭
|
||||
const handleForceClose = useCallback(() => {
|
||||
logger.info('Force closing popup during transfer')
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
// 取消关闭确认
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseConfirm(false)
|
||||
}, [])
|
||||
}, [isSending, t])
|
||||
|
||||
// 清理并关闭
|
||||
const handleClose = useCallback(async () => {
|
||||
@@ -376,11 +370,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
padding: '5px 12px',
|
||||
width: '100%',
|
||||
backgroundColor: connectionStatusStyles.bg,
|
||||
border: `1px solid ${connectionStatusStyles.border}`
|
||||
border: `1px solid ${connectionStatusStyles.border}`,
|
||||
marginBottom: 10
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
|
||||
</div>
|
||||
@@ -412,7 +408,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
if (!isSending && transferPhase !== 'completed') return null
|
||||
|
||||
return (
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -441,11 +437,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={Math.round(sendProgress)}
|
||||
size="md"
|
||||
color={transferPhase === 'completed' ? 'success' : 'primary'}
|
||||
showValueLabel={false}
|
||||
aria-label="Send progress"
|
||||
percent={Math.round(sendProgress)}
|
||||
status={transferPhase === 'completed' ? 'success' : 'active'}
|
||||
showInfo={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,95 +482,50 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
isDismissable={false}
|
||||
isKeyboardDismissDisabled={false}
|
||||
placement="center"
|
||||
onClose={handleClose}>
|
||||
<ModalContent>
|
||||
{() => (
|
||||
<>
|
||||
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<SettingRow>
|
||||
<StatusIndicator />
|
||||
</SettingRow>
|
||||
open={isOpen}
|
||||
onCancel={handleCancel}
|
||||
afterClose={handleClose}
|
||||
title={t('settings.data.export_to_phone.lan.title')}
|
||||
centered
|
||||
closable={!isSending}
|
||||
maskClosable={false}
|
||||
keyboard={true}
|
||||
footer={null}
|
||||
styles={{ body: { paddingBottom: 10 } }}>
|
||||
<SettingRow>
|
||||
<StatusIndicator />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow>
|
||||
<div>{t('settings.data.export_to_phone.lan.content')}</div>
|
||||
</SettingRow>
|
||||
<Alert message={t('settings.data.export_to_phone.lan.content')} type="info" style={{ borderRadius: 0 }} />
|
||||
|
||||
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
|
||||
<QRCodeDisplay />
|
||||
</SettingRow>
|
||||
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px', marginBlock: 25 }}>
|
||||
<QRCodeDisplay />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
|
||||
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
|
||||
{t('settings.data.export_to_phone.lan.selectZip')}
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
|
||||
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow style={{ display: 'flex', alignItems: 'center', marginBlock: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
|
||||
<Button onClick={handleSelectZip} disabled={isSending}>
|
||||
{t('settings.data.export_to_phone.lan.selectZip')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSendZip} disabled={!canSend} loading={isSending}>
|
||||
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingHelpText
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
|
||||
</SettingHelpText>
|
||||
<SettingHelpText
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
|
||||
</SettingHelpText>
|
||||
|
||||
<TransferProgress />
|
||||
<AutoCloseCountdown />
|
||||
<ErrorDisplay />
|
||||
</ModalBody>
|
||||
|
||||
{showCloseConfirm && (
|
||||
<ModalFooter>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-status-warning)',
|
||||
border: '1px solid var(--color-status-warning)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '20px' }}>⚠️</span>
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
|
||||
{t('settings.data.export_to_phone.lan.confirm_close_title')}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
|
||||
{t('settings.data.export_to_phone.lan.confirm_close_message')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
|
||||
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="danger" onPress={handleForceClose}>
|
||||
{t('settings.data.export_to_phone.lan.force_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
<TransferProgress />
|
||||
<AutoCloseCountdown />
|
||||
<ErrorDisplay />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
205
src/renderer/src/components/Popups/UpdateDialogPopup.tsx
Normal file
205
src/renderer/src/components/Popups/UpdateDialogPopup.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
import { Button, Modal } from 'antd'
|
||||
import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Markdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const logger = loggerService.withContext('UpdateDialog')
|
||||
|
||||
interface ShowParams {
|
||||
releaseInfo: UpdateInfo | null
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ 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 (
|
||||
<Modal
|
||||
title={
|
||||
<ModalHeaderWrapper>
|
||||
<h3>{t('update.title')}</h3>
|
||||
<p>{t('update.message').replace('{{version}}', releaseInfo?.version || '')}</p>
|
||||
</ModalHeaderWrapper>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
width={720}
|
||||
footer={[
|
||||
<Button key="later" onClick={onCancel} disabled={isInstalling}>
|
||||
{t('update.later')}
|
||||
</Button>,
|
||||
<Button key="install" type="primary" onClick={handleInstall} loading={isInstalling}>
|
||||
{t('update.install')}
|
||||
</Button>
|
||||
]}>
|
||||
<ModalBodyWrapper>
|
||||
<ReleaseNotesWrapper className="markdown">
|
||||
<Markdown>
|
||||
{typeof releaseNotes === 'string'
|
||||
? releaseNotes
|
||||
: Array.isArray(releaseNotes)
|
||||
? releaseNotes
|
||||
.map((note: ReleaseNoteInfo) => note.note)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
: t('update.noReleaseNotes')}
|
||||
</Markdown>
|
||||
</ReleaseNotesWrapper>
|
||||
</ModalBodyWrapper>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'UpdateDialogPopup'
|
||||
|
||||
export default class UpdateDialogPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -1,44 +1,32 @@
|
||||
import type { SelectedItemProps } from '@heroui/react'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Select,
|
||||
SelectItem,
|
||||
Textarea,
|
||||
useDisclosure
|
||||
} from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { permissionModeCards } from '@renderer/config/agent'
|
||||
import { agentModelFilter, getModelLogoById } from '@renderer/config/models'
|
||||
import { useAgents } from '@renderer/hooks/agents/useAgents'
|
||||
import { useApiModels } from '@renderer/hooks/agents/useModels'
|
||||
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
|
||||
import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton'
|
||||
import type {
|
||||
AddAgentForm,
|
||||
AgentEntity,
|
||||
AgentType,
|
||||
ApiModel,
|
||||
BaseAgentForm,
|
||||
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 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 type { BaseOption, ModelOption } from './shared'
|
||||
import { Option, renderOption } from './shared'
|
||||
import type { BaseOption } from './shared'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
const logger = loggerService.withContext('AddAgentPopup')
|
||||
|
||||
@@ -48,8 +36,6 @@ interface AgentTypeOption extends BaseOption {
|
||||
name: AgentEntity['name']
|
||||
}
|
||||
|
||||
type Option = AgentTypeOption | ModelOption
|
||||
|
||||
type AgentWithTools = AgentEntity & { tools?: Tool[] }
|
||||
|
||||
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
|
||||
@@ -64,58 +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<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose, afterSubmit }) => {
|
||||
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ 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<BaseAgentForm>(() => 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,
|
||||
@@ -129,7 +94,7 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
|
||||
...prev,
|
||||
configuration: {
|
||||
...parsedConfiguration,
|
||||
permission_mode: nextMode
|
||||
permission_mode: value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -150,55 +115,57 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
|
||||
[]
|
||||
)
|
||||
|
||||
const agentOptions: AgentTypeOption[] = useMemo(
|
||||
const agentOptions = useMemo(
|
||||
() =>
|
||||
agentConfig.map(
|
||||
(option) =>
|
||||
({
|
||||
...option,
|
||||
rendered: <Option option={option} />
|
||||
}) as const satisfies SelectedItemProps
|
||||
),
|
||||
agentConfig.map((option) => ({
|
||||
value: option.key,
|
||||
label: (
|
||||
<OptionWrapper>
|
||||
<Avatar src={option.avatar} size={24} />
|
||||
<span>{option.label}</span>
|
||||
</OptionWrapper>
|
||||
)
|
||||
})),
|
||||
[agentConfig]
|
||||
)
|
||||
|
||||
const onAgentTypeChange = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
(value: AgentType) => {
|
||||
const prevConfig = agentConfig.find((config) => config.key === form.type)
|
||||
let newName: string | undefined = form.name
|
||||
if (prevConfig && prevConfig.name === form.name) {
|
||||
const newConfig = agentConfig.find((config) => config.key === e.target.value)
|
||||
const newConfig = agentConfig.find((config) => config.key === value)
|
||||
if (newConfig) {
|
||||
newName = newConfig.name
|
||||
}
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
type: e.target.value as AgentType,
|
||||
type: value,
|
||||
name: newName
|
||||
}))
|
||||
},
|
||||
[agentConfig, form.name, form.type]
|
||||
)
|
||||
|
||||
const onNameChange = useCallback((name: string) => {
|
||||
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name
|
||||
name: e.target.value
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onDescChange = useCallback((description: string) => {
|
||||
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
description
|
||||
description: e.target.value
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onInstChange = useCallback((instructions: string) => {
|
||||
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
instructions
|
||||
instructions: e.target.value
|
||||
}))
|
||||
}, [])
|
||||
|
||||
@@ -231,34 +198,36 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
// mocked data. not final version
|
||||
return (models ?? [])
|
||||
.filter((m) =>
|
||||
agentModelFilter({
|
||||
id: m.id,
|
||||
provider: m.provider || '',
|
||||
name: m.name,
|
||||
group: ''
|
||||
})
|
||||
)
|
||||
.map((model) => ({
|
||||
type: 'model',
|
||||
key: model.id,
|
||||
label: model.name,
|
||||
avatar: getModelLogoById(model.id),
|
||||
providerId: model.provider,
|
||||
providerName: model.provider_name
|
||||
})) satisfies ModelOption[]
|
||||
}, [models])
|
||||
// Create a temporary agentBase object for SelectAgentBaseModelButton
|
||||
const tempAgentBase: AgentEntity = useMemo(
|
||||
() => ({
|
||||
id: agent?.id ?? 'temp-creating',
|
||||
type: form.type,
|
||||
name: form.name,
|
||||
model: form.model,
|
||||
accessible_paths: form.accessible_paths.length > 0 ? form.accessible_paths : ['/'],
|
||||
allowed_tools: form.allowed_tools ?? [],
|
||||
description: form.description,
|
||||
instructions: form.instructions,
|
||||
configuration: form.configuration,
|
||||
created_at: agent?.created_at ?? new Date().toISOString(),
|
||||
updated_at: agent?.updated_at ?? new Date().toISOString()
|
||||
}),
|
||||
[form, agent?.id, agent?.created_at, agent?.updated_at]
|
||||
)
|
||||
|
||||
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
model: e.target.value
|
||||
}))
|
||||
const handleModelSelect = useCallback(async (model: ApiModel) => {
|
||||
setForm((prev) => ({ ...prev, model: model.id }))
|
||||
}, [])
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
@@ -330,9 +299,7 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
|
||||
afterSubmit?.(result.data)
|
||||
}
|
||||
loadingRef.current = false
|
||||
|
||||
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
onClose()
|
||||
setOpen(false)
|
||||
},
|
||||
[
|
||||
form.type,
|
||||
@@ -344,7 +311,6 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
|
||||
form.allowed_tools,
|
||||
form.configuration,
|
||||
agent,
|
||||
onClose,
|
||||
t,
|
||||
updateAgent,
|
||||
afterSubmit,
|
||||
@@ -352,138 +318,312 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
|
||||
]
|
||||
)
|
||||
|
||||
AgentModalPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
classNames={{
|
||||
base: 'max-h-[90vh]',
|
||||
wrapper: 'overflow-hidden'
|
||||
}}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader>
|
||||
<Form onSubmit={onSubmit} className="min-h-0 w-full shrink overflow-auto">
|
||||
<ModalBody className="min-h-0 w-full flex-1 shrink overflow-auto">
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
isRequired
|
||||
isDisabled={isEditing(agent)}
|
||||
selectionMode="single"
|
||||
selectedKeys={[form.type]}
|
||||
disallowEmptySelection
|
||||
onChange={onAgentTypeChange}
|
||||
items={agentOptions}
|
||||
label={t('agent.type.label')}
|
||||
placeholder={t('agent.add.type.placeholder')}
|
||||
renderValue={renderOption}>
|
||||
{(option) => (
|
||||
<SelectItem key={option.key} textValue={option.label}>
|
||||
<Option option={option} />
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
|
||||
</div>
|
||||
<Select
|
||||
isRequired
|
||||
selectionMode="single"
|
||||
selectedKeys={form.model ? [form.model] : []}
|
||||
disallowEmptySelection
|
||||
onChange={onModelChange}
|
||||
items={modelOptions}
|
||||
label={t('common.model')}
|
||||
placeholder={t('common.placeholders.select.model')}
|
||||
renderValue={renderOption}>
|
||||
{(option) => (
|
||||
<SelectItem key={option.key} textValue={option.label}>
|
||||
<Option option={option} />
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<Select
|
||||
isRequired
|
||||
selectionMode="single"
|
||||
selectedKeys={[selectedPermissionMode]}
|
||||
onSelectionChange={onPermissionModeChange}
|
||||
label={t('agent.settings.tooling.permissionMode.title', 'Permission mode')}
|
||||
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
|
||||
description={t(
|
||||
'agent.settings.tooling.permissionMode.helper',
|
||||
'Choose how the agent handles tool approvals.'
|
||||
)}
|
||||
items={permissionModeCards}>
|
||||
{(item) => (
|
||||
<SelectItem key={item.mode} textValue={t(item.titleKey, item.titleFallback)}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-sm">{t(item.titleKey, item.titleFallback)}</span>
|
||||
<span className="text-foreground-500 text-xs">
|
||||
{t(item.descriptionKey, item.descriptionFallback)}
|
||||
</span>
|
||||
<span className="text-foreground-400 text-xs">
|
||||
{t(item.behaviorKey, item.behaviorFallback)}
|
||||
</span>
|
||||
{item.caution ? (
|
||||
<span className="flex items-center gap-1 text-danger-500 text-xs">
|
||||
<AlertTriangleIcon size={12} className="text-danger" />
|
||||
{t(
|
||||
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
|
||||
'Use with caution — all tools will run without asking for approval.'
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
title={isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
centered
|
||||
width={500}
|
||||
footer={null}>
|
||||
<StyledForm onSubmit={onSubmit}>
|
||||
<FormContent>
|
||||
<FormRow>
|
||||
<FormItem style={{ flex: 1 }}>
|
||||
<Label>{t('agent.type.label')}</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onChange={onAgentTypeChange}
|
||||
options={agentOptions}
|
||||
disabled={isEditing(agent)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem style={{ flex: 1 }}>
|
||||
<Label>
|
||||
{t('common.name')} <RequiredMark>*</RequiredMark>
|
||||
</Label>
|
||||
<Input value={form.name} onChange={onNameChange} required />
|
||||
</FormItem>
|
||||
</FormRow>
|
||||
|
||||
<FormItem>
|
||||
<Label>
|
||||
{t('common.model')} <RequiredMark>*</RequiredMark>
|
||||
</Label>
|
||||
<SelectAgentBaseModelButton
|
||||
agentBase={tempAgentBase}
|
||||
onSelect={handleModelSelect}
|
||||
fontSize={14}
|
||||
avatarSize={24}
|
||||
iconSize={16}
|
||||
buttonStyle={{
|
||||
padding: '8px 12px',
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
height: 'auto'
|
||||
}}
|
||||
containerClassName="flex items-center justify-between w-full"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<Label>
|
||||
{t('agent.settings.tooling.permissionMode.title', 'Permission mode')} <RequiredMark>*</RequiredMark>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPermissionMode}
|
||||
onChange={onPermissionModeChange}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
|
||||
dropdownStyle={{ minWidth: '500px' }}
|
||||
optionLabelProp="label">
|
||||
{permissionModeCards.map((item) => (
|
||||
<Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
|
||||
<PermissionOptionWrapper>
|
||||
<div className="title">{t(item.titleKey, item.titleFallback)}</div>
|
||||
<div className="description">{t(item.descriptionKey, item.descriptionFallback)}</div>
|
||||
<div className="behavior">{t(item.behaviorKey, item.behaviorFallback)}</div>
|
||||
{item.caution && (
|
||||
<div className="caution">
|
||||
<AlertTriangleIcon size={12} />
|
||||
{t(
|
||||
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
|
||||
'Use with caution — all tools will run without asking for approval.'
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{t('agent.session.accessible_paths.label')}
|
||||
</span>
|
||||
<Button size="sm" variant="flat" onPress={addAccessiblePath}>
|
||||
{t('agent.session.accessible_paths.add')}
|
||||
)}
|
||||
</PermissionOptionWrapper>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<HelpText>
|
||||
{t('agent.settings.tooling.permissionMode.helper', 'Choose how the agent handles tool approvals.')}
|
||||
</HelpText>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<LabelWithButton>
|
||||
<Label>
|
||||
{t('agent.session.accessible_paths.label')} <RequiredMark>*</RequiredMark>
|
||||
</Label>
|
||||
<Button size="small" onClick={addAccessiblePath}>
|
||||
{t('agent.session.accessible_paths.add')}
|
||||
</Button>
|
||||
</LabelWithButton>
|
||||
{form.accessible_paths.length > 0 ? (
|
||||
<PathList>
|
||||
{form.accessible_paths.map((path) => (
|
||||
<PathItem key={path}>
|
||||
<PathText title={path}>{path}</PathText>
|
||||
<Button size="small" danger onClick={() => removeAccessiblePath(path)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{form.accessible_paths.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{form.accessible_paths.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
|
||||
<span className="truncate text-sm" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-400 text-sm">{t('agent.session.accessible_paths.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||
<Textarea
|
||||
label={t('common.description')}
|
||||
value={form.description ?? ''}
|
||||
onValueChange={onDescChange}
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter className="w-full">
|
||||
<Button onPress={onClose}>{t('common.close')}</Button>
|
||||
<Button color="primary" type="submit" isLoading={loadingRef.current}>
|
||||
{isEditing(agent) ? t('common.confirm') : t('common.add')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</PathItem>
|
||||
))}
|
||||
</PathList>
|
||||
) : (
|
||||
<EmptyText>{t('agent.session.accessible_paths.empty')}</EmptyText>
|
||||
)}
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<Label>{t('common.prompt')}</Label>
|
||||
<TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<Label>{t('common.description')}</Label>
|
||||
<TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
|
||||
</FormItem>
|
||||
</FormContent>
|
||||
|
||||
<FormFooter>
|
||||
<Button onClick={onCancel}>{t('common.close')}</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loadingRef.current}>
|
||||
{isEditing(agent) ? t('common.confirm') : t('common.add')}
|
||||
</Button>
|
||||
</FormFooter>
|
||||
</StyledForm>
|
||||
</Modal>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'AgentModalPopup'
|
||||
|
||||
export default class AgentModalPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the old export for backward compatibility during migration
|
||||
export const AgentModal = AgentModalPopup
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const FormContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const FormRow = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const FormItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const Label = styled.label`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
const RequiredMark = styled.span`
|
||||
color: #ff4d4f;
|
||||
margin-left: 4px;
|
||||
`
|
||||
|
||||
const HelpText = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const LabelWithButton = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const PathList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const PathItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-bg-1);
|
||||
`
|
||||
|
||||
const PathText = styled.span`
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const EmptyText = styled.p`
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
const FormFooter = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const OptionWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const PermissionOptionWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.behavior {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.caution {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #ff4d4f;
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
background-color: rgba(255, 77, 79, 0.1);
|
||||
border-radius: 4px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
useDisclosure
|
||||
} from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { AllowedToolsSelect } from '@renderer/components/agent'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
|
||||
import type {
|
||||
AgentEntity,
|
||||
AgentSessionEntity,
|
||||
BaseSessionForm,
|
||||
CreateSessionForm,
|
||||
Tool,
|
||||
UpdateSessionForm
|
||||
} from '@renderer/types'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ErrorBoundary } from '../../ErrorBoundary'
|
||||
|
||||
const logger = loggerService.withContext('SessionAgentPopup')
|
||||
|
||||
type AgentWithTools = AgentEntity & { tools?: Tool[] }
|
||||
type SessionWithTools = AgentSessionEntity & { tools?: Tool[] }
|
||||
|
||||
const buildSessionForm = (existing?: SessionWithTools, agent?: AgentWithTools): BaseSessionForm => ({
|
||||
name: existing?.name ?? agent?.name ?? 'Claude Code',
|
||||
description: existing?.description ?? agent?.description,
|
||||
instructions: existing?.instructions ?? agent?.instructions,
|
||||
model: existing?.model ?? agent?.model ?? '',
|
||||
accessible_paths: existing?.accessible_paths
|
||||
? [...existing.accessible_paths]
|
||||
: agent?.accessible_paths
|
||||
? [...agent.accessible_paths]
|
||||
: [],
|
||||
allowed_tools: existing?.allowed_tools
|
||||
? [...existing.allowed_tools]
|
||||
: agent?.allowed_tools
|
||||
? [...agent.allowed_tools]
|
||||
: [],
|
||||
mcps: existing?.mcps ? [...existing.mcps] : agent?.mcps ? [...agent.mcps] : []
|
||||
})
|
||||
|
||||
interface BaseProps {
|
||||
agentId: string
|
||||
session?: SessionWithTools
|
||||
onSessionCreated?: (session: AgentSessionEntity) => void
|
||||
}
|
||||
|
||||
interface TriggerProps extends BaseProps {
|
||||
trigger: { content: ReactNode; className?: string }
|
||||
isOpen?: never
|
||||
onClose?: never
|
||||
}
|
||||
|
||||
interface StateProps extends BaseProps {
|
||||
trigger?: never
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type Props = TriggerProps | StateProps
|
||||
|
||||
/**
|
||||
* Modal component for creating or editing a Session.
|
||||
* @deprecated may as a reference when migrating to v2
|
||||
*
|
||||
* Either trigger or isOpen and onClose is given.
|
||||
* @param agentId - The ID of agent which the session is related.
|
||||
* @param session - Optional session entity for editing mode.
|
||||
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
|
||||
* @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 SessionModal: React.FC<Props> = ({
|
||||
agentId,
|
||||
session,
|
||||
trigger,
|
||||
isOpen: _isOpen,
|
||||
onClose: _onClose,
|
||||
onSessionCreated
|
||||
}) => {
|
||||
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
|
||||
const { t } = useTranslation()
|
||||
const loadingRef = useRef(false)
|
||||
// const { setTimeoutTimer } = useTimer()
|
||||
const { createSession } = useSessions(agentId)
|
||||
const { updateSession } = useUpdateSession(agentId)
|
||||
const { agent } = useAgent(agentId)
|
||||
const isEditing = (session?: AgentSessionEntity) => session !== undefined
|
||||
|
||||
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session, agent ?? undefined))
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setForm(buildSessionForm(session, agent ?? undefined))
|
||||
}
|
||||
}, [session, agent, isOpen])
|
||||
|
||||
const availableTools = useMemo(() => session?.tools ?? agent?.tools ?? [], [agent?.tools, session?.tools])
|
||||
const selectedToolKeys = useMemo(() => new Set(form.allowed_tools ?? []), [form.allowed_tools])
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTools.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setForm((prev) => {
|
||||
const allowed = prev.allowed_tools ?? []
|
||||
const validTools = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
|
||||
if (validTools.length === allowed.length) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
allowed_tools: validTools
|
||||
}
|
||||
})
|
||||
}, [availableTools])
|
||||
|
||||
const onNameChange = useCallback((name: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
name
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onDescChange = useCallback((description: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
description
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onInstChange = useCallback((instructions: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
instructions
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onAllowedToolsChange = useCallback(
|
||||
(keys: Selection) => {
|
||||
setForm((prev) => {
|
||||
const existing = prev.allowed_tools ?? []
|
||||
if (keys === 'all') {
|
||||
return {
|
||||
...prev,
|
||||
allowed_tools: availableTools.map((tool) => tool.id)
|
||||
}
|
||||
}
|
||||
|
||||
const next = Array.from(keys).map(String)
|
||||
const filtered = availableTools.length
|
||||
? next.filter((id) => availableTools.some((tool) => tool.id === id))
|
||||
: next
|
||||
|
||||
if (existing.length === filtered.length && existing.every((id) => filtered.includes(id))) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
allowed_tools: filtered
|
||||
}
|
||||
})
|
||||
},
|
||||
[availableTools]
|
||||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (loadingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingRef.current = true
|
||||
|
||||
// Additional validation check besides native HTML validation to ensure security
|
||||
if (!form.model) {
|
||||
window.toast.error(t('error.model.not_exists'))
|
||||
loadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (form.accessible_paths.length === 0) {
|
||||
window.toast.error(t('agent.session.accessible_paths.error.at_least_one'))
|
||||
loadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing(session)) {
|
||||
if (!session) {
|
||||
throw new Error('Agent is required for editing mode')
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
id: session.id,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
instructions: form.instructions,
|
||||
model: form.model,
|
||||
accessible_paths: [...form.accessible_paths],
|
||||
allowed_tools: [...(form.allowed_tools ?? [])],
|
||||
mcps: [...(form.mcps ?? [])]
|
||||
} satisfies UpdateSessionForm
|
||||
|
||||
updateSession(updatePayload)
|
||||
logger.debug('Updated agent', updatePayload)
|
||||
} else {
|
||||
const newSession = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
instructions: form.instructions,
|
||||
model: form.model,
|
||||
accessible_paths: [...form.accessible_paths],
|
||||
allowed_tools: [...(form.allowed_tools ?? [])],
|
||||
mcps: [...(form.mcps ?? [])]
|
||||
} satisfies CreateSessionForm
|
||||
const createdSession = await createSession(newSession)
|
||||
if (createdSession) {
|
||||
onSessionCreated?.(createdSession)
|
||||
}
|
||||
logger.debug('Added agent', newSession)
|
||||
}
|
||||
|
||||
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
onClose()
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
}
|
||||
},
|
||||
[
|
||||
form.model,
|
||||
form.name,
|
||||
form.description,
|
||||
form.instructions,
|
||||
form.accessible_paths,
|
||||
form.allowed_tools,
|
||||
form.mcps,
|
||||
session,
|
||||
onClose,
|
||||
onSessionCreated,
|
||||
t,
|
||||
updateSession,
|
||||
createSession
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
|
||||
encapsulated component. This is because the Modal component needs to bind the onOpen
|
||||
event handler to the Button for proper focus management.
|
||||
|
||||
Or just use external isOpen/onOpen/onClose to control modal state.
|
||||
*/}
|
||||
|
||||
{trigger && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpen()
|
||||
}}
|
||||
className={cn('w-full', trigger.className)}>
|
||||
{trigger.content}
|
||||
</div>
|
||||
)}
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>
|
||||
{isEditing(session) ? t('agent.session.edit.title') : t('agent.session.add.title')}
|
||||
</ModalHeader>
|
||||
<Form onSubmit={onSubmit} className="w-full">
|
||||
<ModalBody className="w-full">
|
||||
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
|
||||
<Textarea
|
||||
label={t('common.description')}
|
||||
value={form.description ?? ''}
|
||||
onValueChange={onDescChange}
|
||||
/>
|
||||
<AllowedToolsSelect
|
||||
items={availableTools}
|
||||
selectedKeys={selectedToolKeys}
|
||||
onSelectionChange={onAllowedToolsChange}
|
||||
/>
|
||||
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter className="w-full">
|
||||
<Button onPress={onClose}>{t('common.close')}</Button>
|
||||
<Button color="primary" type="submit" isLoading={loadingRef.current}>
|
||||
{isEditing(session) ? t('common.confirm') : t('common.add')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
import type { SelectedItemProps, SelectedItems } from '@heroui/react'
|
||||
import { Avatar } from '@heroui/react'
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface BaseOption {
|
||||
type: 'type' | 'model'
|
||||
key: string
|
||||
@@ -10,43 +5,3 @@ export interface BaseOption {
|
||||
// img src
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface ModelOption extends BaseOption {
|
||||
providerId?: string
|
||||
providerName?: string
|
||||
}
|
||||
|
||||
export function isModelOption(option: BaseOption): option is ModelOption {
|
||||
return option.type === 'model'
|
||||
}
|
||||
|
||||
export const Item = ({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />
|
||||
|
||||
export const renderOption = (items: SelectedItems<BaseOption>) =>
|
||||
items.map((item) => <Item key={item.key} item={item} />)
|
||||
|
||||
export const Option = ({ option }: { option?: BaseOption | null }) => {
|
||||
const { t } = useTranslation()
|
||||
if (!option) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Avatar name="?" className="h-5 w-5" />
|
||||
{t('common.invalid_value')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const providerLabel = (() => {
|
||||
if (!isModelOption(option)) return null
|
||||
if (option.providerName) return option.providerName
|
||||
if (option.providerId) return getProviderLabel(option.providerId)
|
||||
return null
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Avatar src={option.avatar} className="h-5 w-5" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
{providerLabel ? <span className="truncate text-foreground-500">| {providerLabel}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
|
||||
|
||||
/**
|
||||
* Default filter function
|
||||
* Implements standard filtering logic with pinyin support
|
||||
*/
|
||||
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
|
||||
if (!searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Direct substring match
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match for Chinese characters
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
let pinyinText = pinyinCache.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCache.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score for sorting
|
||||
* Higher score = better match
|
||||
*/
|
||||
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Exact match (highest priority)
|
||||
if (lowerFilterText === lowerSearchText) {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Label exact match (very high priority)
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) {
|
||||
return 900
|
||||
}
|
||||
|
||||
// Starts with search text (high priority)
|
||||
if (lowerFilterText.startsWith(lowerSearchText)) {
|
||||
return 800
|
||||
}
|
||||
|
||||
// Label starts with search text
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) {
|
||||
return 700
|
||||
}
|
||||
|
||||
// Contains search text (medium priority)
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
// Earlier position = higher score
|
||||
const position = lowerFilterText.indexOf(lowerSearchText)
|
||||
return 600 - position
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match (lower priority)
|
||||
return 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort function
|
||||
* Sorts items by match score in descending order
|
||||
*/
|
||||
export const defaultSortFn: QuickPanelSortFn = (items, searchText) => {
|
||||
if (!searchText) return items
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const scoreA = calculateMatchScore(a, searchText)
|
||||
const scoreB = calculateMatchScore(b, searchText)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './defaultStrategies'
|
||||
export * from './hook'
|
||||
export * from './provider'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,11 +4,12 @@ import type {
|
||||
QuickPanelCallBackOptions,
|
||||
QuickPanelCloseAction,
|
||||
QuickPanelContextType,
|
||||
QuickPanelFilterFn,
|
||||
QuickPanelListItem,
|
||||
QuickPanelOpenOptions,
|
||||
QuickPanelSortFn,
|
||||
QuickPanelTriggerInfo
|
||||
} from './types'
|
||||
|
||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||
|
||||
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
@@ -17,19 +18,39 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
|
||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||
const [title, setTitle] = useState<string | undefined>()
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
|
||||
const [pageSize, setPageSize] = useState<number>(7)
|
||||
const [multiple, setMultiple] = useState<boolean>(false)
|
||||
const [manageListExternally, setManageListExternally] = useState<boolean>(false)
|
||||
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
|
||||
const [filterFn, setFilterFn] = useState<QuickPanelFilterFn | undefined>()
|
||||
const [sortFn, setSortFn] = useState<QuickPanelSortFn | undefined>()
|
||||
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [onSearchChange, setOnSearchChange] = useState<((searchText: string) => void) | undefined>()
|
||||
const [lastCloseAction, setLastCloseAction] = useState<QuickPanelCloseAction | undefined>(undefined)
|
||||
|
||||
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 添加更新item选中状态的方法
|
||||
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
|
||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
setList((prevList) => {
|
||||
// 先尝试引用匹配(快速路径)
|
||||
const refIndex = prevList.findIndex((item) => item === targetItem)
|
||||
if (refIndex !== -1) {
|
||||
return prevList.map((item, idx) => (idx === refIndex ? { ...item, isSelected } : item))
|
||||
}
|
||||
|
||||
// 如果引用匹配失败,使用内容匹配(兜底方案)
|
||||
// 通过 label 和 filterText 来识别同一个item
|
||||
return prevList.map((item) => {
|
||||
const isSameItem =
|
||||
(item.label === targetItem.label || item.filterText === targetItem.filterText) &&
|
||||
(!targetItem.filterText || item.filterText === targetItem.filterText)
|
||||
return isSameItem ? { ...item, isSelected } : item
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 添加更新整个列表的方法
|
||||
@@ -43,17 +64,23 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
clearTimer.current = null
|
||||
}
|
||||
|
||||
setLastCloseAction(undefined)
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
|
||||
setDefaultIndex(nextDefaultIndex)
|
||||
setPageSize(options.pageSize ?? 7)
|
||||
setMultiple(options.multiple ?? false)
|
||||
setManageListExternally(options.manageListExternally ?? false)
|
||||
setSymbol(options.symbol)
|
||||
setTriggerInfo(options.triggerInfo)
|
||||
|
||||
setOnClose(() => options.onClose)
|
||||
setBeforeAction(() => options.beforeAction)
|
||||
setAfterAction(() => options.afterAction)
|
||||
setOnSearchChange(() => options.onSearchChange)
|
||||
setFilterFn(() => options.filterFn)
|
||||
setSortFn(() => options.sortFn)
|
||||
|
||||
setIsVisible(true)
|
||||
}, [])
|
||||
@@ -61,6 +88,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction, searchText?: string) => {
|
||||
setIsVisible(false)
|
||||
setManageListExternally(false)
|
||||
setLastCloseAction(action)
|
||||
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
|
||||
|
||||
clearTimer.current = setTimeout(() => {
|
||||
@@ -68,9 +97,13 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setOnClose(undefined)
|
||||
setBeforeAction(undefined)
|
||||
setAfterAction(undefined)
|
||||
setOnSearchChange(undefined)
|
||||
setFilterFn(undefined)
|
||||
setSortFn(undefined)
|
||||
setTitle(undefined)
|
||||
setSymbol('')
|
||||
setTriggerInfo(undefined)
|
||||
setManageListExternally(false)
|
||||
}, 200)
|
||||
},
|
||||
[onClose]
|
||||
@@ -100,10 +133,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
afterAction,
|
||||
onSearchChange
|
||||
}),
|
||||
[
|
||||
open,
|
||||
@@ -117,10 +155,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
afterAction,
|
||||
onSearchChange
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export enum QuickPanelReservedSymbol {
|
||||
WebSearch = '?',
|
||||
Mcp = 'mcp',
|
||||
McpPrompt = 'mcp-prompt',
|
||||
McpResource = 'mcp-resource'
|
||||
McpResource = 'mcp-resource',
|
||||
SlashCommands = 'slash-commands'
|
||||
}
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
@@ -27,6 +28,29 @@ export type QuickPanelCallBackOptions = {
|
||||
searchText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter function type
|
||||
* @param item - The item to check
|
||||
* @param searchText - The search text (without leading symbol)
|
||||
* @param fuzzyRegex - Fuzzy matching regex
|
||||
* @param pinyinCache - Cache for pinyin conversions
|
||||
* @returns true if item matches the search
|
||||
*/
|
||||
export type QuickPanelFilterFn = (
|
||||
item: QuickPanelListItem,
|
||||
searchText: string,
|
||||
fuzzyRegex: RegExp,
|
||||
pinyinCache: WeakMap<QuickPanelListItem, string>
|
||||
) => boolean
|
||||
|
||||
/**
|
||||
* Sort function type
|
||||
* @param items - The filtered items to sort
|
||||
* @param searchText - The search text (without leading symbol)
|
||||
* @returns sorted items
|
||||
*/
|
||||
export type QuickPanelSortFn = (items: QuickPanelListItem[], searchText: string) => QuickPanelListItem[]
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
/** 显示在底部左边,类似于Placeholder */
|
||||
title?: string
|
||||
@@ -48,6 +72,14 @@ export type QuickPanelOpenOptions = {
|
||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||
/** Callback when search text changes (called with debounced search text) */
|
||||
onSearchChange?: (searchText: string) => void
|
||||
/** Tool manages list + collapse behavior externally (skip filtering/auto-close) */
|
||||
manageListExternally?: boolean
|
||||
/** Custom filter function for items (follows open-closed principle) */
|
||||
filterFn?: QuickPanelFilterFn
|
||||
/** Custom sort function for filtered items (follows open-closed principle) */
|
||||
sortFn?: QuickPanelSortFn
|
||||
}
|
||||
|
||||
export type QuickPanelListItem = {
|
||||
@@ -88,10 +120,15 @@ export interface QuickPanelContextType {
|
||||
readonly pageSize: number
|
||||
readonly multiple: boolean
|
||||
readonly triggerInfo?: QuickPanelTriggerInfo
|
||||
readonly manageListExternally?: boolean
|
||||
readonly lastCloseAction?: QuickPanelCloseAction
|
||||
readonly filterFn?: QuickPanelFilterFn
|
||||
readonly sortFn?: QuickPanelSortFn
|
||||
|
||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly onSearchChange?: (searchText: string) => void
|
||||
}
|
||||
|
||||
export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none'
|
||||
|
||||
@@ -10,8 +10,8 @@ import { debounce } from 'lodash'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import { defaultFilterFn, defaultSortFn } from './defaultStrategies'
|
||||
import { QuickPanelContext } from './provider'
|
||||
import type {
|
||||
QuickPanelCallBackOptions,
|
||||
@@ -62,21 +62,50 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 缓存:按 item 缓存拼音文本,避免重复转换
|
||||
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
|
||||
|
||||
// 轻量防抖:减少高频输入时的过滤调用
|
||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||
|
||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
// Use injected filter and sort functions, or fall back to defaults
|
||||
const filterFn = ctx.filterFn || defaultFilterFn
|
||||
const sortFn = ctx.sortFn || defaultSortFn
|
||||
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
|
||||
const baseList = (ctx.list || []).filter((item) => !item.hidden)
|
||||
|
||||
if (ctx.manageListExternally) {
|
||||
const combinedLength = baseList.length
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
if (isSymbolChanged) {
|
||||
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
|
||||
const desiredIndex =
|
||||
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
|
||||
setIndex(desiredIndex)
|
||||
} else {
|
||||
setIndex((prevIndex) => {
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
return prevIndex
|
||||
})
|
||||
}
|
||||
|
||||
prevSearchTextRef.current = ''
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
return baseList
|
||||
}
|
||||
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
const lowerSearchText = _searchText.toLowerCase()
|
||||
const fuzzyPattern = lowerSearchText
|
||||
@@ -86,52 +115,35 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
||||
|
||||
// 拆分:固定显示项(不参与过滤)与普通项
|
||||
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
|
||||
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
|
||||
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
|
||||
const normalItems = baseList.filter((item) => !item.alwaysVisible)
|
||||
|
||||
// Filter normal items using injected filter function
|
||||
const filteredNormalItems = normalItems.filter((item) => {
|
||||
if (!_searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
let pinyinText = pinyinCacheRef.current.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCacheRef.current.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
|
||||
})
|
||||
|
||||
// Sort filtered items using injected sort function
|
||||
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
|
||||
|
||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
|
||||
if (isSearchChanged || isSymbolChanged) {
|
||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
||||
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||
if (isSymbolChanged) {
|
||||
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
|
||||
const desiredIndex =
|
||||
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
|
||||
setIndex(desiredIndex)
|
||||
} else {
|
||||
setIndex(-1) // 搜索文本变化时不默认高亮
|
||||
}
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
||||
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
@@ -142,10 +154,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
// 固定项置顶 + 过滤后的普通项
|
||||
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
|
||||
return pinnedFiltered.filter((item) => !item.hidden)
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
||||
// 固定项置顶 + 排序后的普通项
|
||||
return [...pinnedItems, ...sortedNormalItems]
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
@@ -179,19 +190,64 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
if (deleteStart >= deleteEnd) return
|
||||
|
||||
// 删除文本
|
||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
||||
setInputText(newText)
|
||||
const activeSearchText = searchTextRef.current ?? ''
|
||||
|
||||
// 设置光标位置
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
setInputText((currentText) => {
|
||||
const safeText = currentText ?? ''
|
||||
const expectedSegment = includeSymbol ? symbolSegment : symbolSegment.slice(1)
|
||||
const typedSearch = activeSearchText
|
||||
const normalizedTyped = includeSymbol
|
||||
? typedSearch
|
||||
: typedSearch.startsWith(symbolSegment[0] ?? '')
|
||||
? typedSearch.slice(1)
|
||||
: typedSearch
|
||||
|
||||
if (normalizedTyped && expectedSegment !== normalizedTyped) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const segmentStart = includeSymbol ? symbolStart : symbolStart + 1
|
||||
const segmentEnd = segmentStart + expectedSegment.length
|
||||
|
||||
if (segmentStart < 0 || segmentStart > safeText.length) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
if (segmentEnd > safeText.length) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const actualSegment = safeText.slice(segmentStart, segmentEnd)
|
||||
if (actualSegment !== expectedSegment) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const clampedDeleteStart = Math.max(0, Math.min(deleteStart, safeText.length))
|
||||
const clampedDeleteEnd = Math.max(clampedDeleteStart, Math.min(deleteEnd, safeText.length))
|
||||
|
||||
if (clampedDeleteStart >= clampedDeleteEnd) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const updatedText = safeText.slice(0, clampedDeleteStart) + safeText.slice(clampedDeleteEnd)
|
||||
|
||||
if (updatedText === safeText) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
const textareaEl = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
if (!textareaEl) return
|
||||
textareaEl.focus()
|
||||
textareaEl.setSelectionRange(clampedDeleteStart, clampedDeleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
return updatedText
|
||||
})
|
||||
|
||||
setSearchText('')
|
||||
},
|
||||
@@ -211,11 +267,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
} else if (
|
||||
action &&
|
||||
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
|
||||
ctx.triggerInfo?.type === 'input'
|
||||
) {
|
||||
setTimeoutTimer(
|
||||
'quickpanel_deferred_clear',
|
||||
() => {
|
||||
clearSearchText(true)
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText, searchText]
|
||||
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
@@ -285,12 +351,86 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
searchTextRef.current = searchText
|
||||
}, [searchText])
|
||||
|
||||
// Track onSearchChange callback and search state for debouncing
|
||||
const prevSearchCallbackTextRef = useRef('')
|
||||
const isFirstSearchRef = useRef(true)
|
||||
const searchCallbackTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const onSearchChangeRef = useRef(ctx.onSearchChange)
|
||||
|
||||
// Keep onSearchChange ref up to date
|
||||
useEffect(() => {
|
||||
onSearchChangeRef.current = ctx.onSearchChange
|
||||
}, [ctx.onSearchChange])
|
||||
|
||||
// Reset search history when panel closes
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) {
|
||||
prevSearchCallbackTextRef.current = ''
|
||||
isFirstSearchRef.current = true
|
||||
if (searchCallbackTimerRef.current) {
|
||||
clearTimeout(searchCallbackTimerRef.current)
|
||||
searchCallbackTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [ctx.isVisible])
|
||||
|
||||
// Trigger onSearchChange with debounce (called from handleInput)
|
||||
const triggerSearchChange = useCallback((searchText: string) => {
|
||||
if (!onSearchChangeRef.current) return
|
||||
|
||||
// Clean search text: remove leading symbol (/ or @) and trim
|
||||
const cleanSearchText = searchText.replace(/^[/@]/, '').trim()
|
||||
|
||||
// Don't trigger if search text hasn't changed
|
||||
if (cleanSearchText === prevSearchCallbackTextRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't trigger callback for empty search text
|
||||
if (!cleanSearchText) {
|
||||
prevSearchCallbackTextRef.current = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Clear previous timer
|
||||
if (searchCallbackTimerRef.current) {
|
||||
clearTimeout(searchCallbackTimerRef.current)
|
||||
}
|
||||
|
||||
// First search triggers immediately (0ms), subsequent searches have 300ms debounce
|
||||
const delay = isFirstSearchRef.current ? 0 : 300
|
||||
|
||||
searchCallbackTimerRef.current = setTimeout(() => {
|
||||
prevSearchCallbackTextRef.current = cleanSearchText
|
||||
isFirstSearchRef.current = false
|
||||
onSearchChangeRef.current?.(cleanSearchText)
|
||||
searchCallbackTimerRef.current = null
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchCallbackTimerRef.current) {
|
||||
clearTimeout(searchCallbackTimerRef.current)
|
||||
searchCallbackTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取当前输入的搜索词
|
||||
const isComposing = useRef(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSearchTextDebounced.cancel()
|
||||
}
|
||||
}, [setSearchTextDebounced])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
if (!textArea) return
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
if (isComposing.current) return
|
||||
@@ -305,6 +445,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (lastSymbolIndex !== -1) {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchTextDebounced(newSearchText)
|
||||
// Trigger server-side search callback immediately (with its own debounce)
|
||||
triggerSearchChange(newSearchText)
|
||||
} else {
|
||||
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
|
||||
handleClose('delete-symbol')
|
||||
@@ -328,16 +470,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||
setSearchTextDebounced.cancel()
|
||||
setTimeoutTimer(
|
||||
'quickpanel_clear_search',
|
||||
() => {
|
||||
setSearchText('')
|
||||
},
|
||||
200
|
||||
) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (ctx.isVisible) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [ctx.isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -545,19 +688,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
|
||||
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
|
||||
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
|
||||
const collapsed = hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
if (!collapsed) return
|
||||
if (ctx.triggerInfo?.type !== 'input') return
|
||||
if (ctx.multiple) return
|
||||
|
||||
const trimmedSearch = searchText.replace(/^[/@]/, '').trim()
|
||||
if (!trimmedSearch) return
|
||||
|
||||
handleClose('no_result')
|
||||
}, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText])
|
||||
const collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||
|
||||
@@ -616,7 +747,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
{!collapsed && (
|
||||
{collapsed ? (
|
||||
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
|
||||
) : (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
@@ -726,6 +859,13 @@ const QuickPanelBody = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelEmpty = styled.div`
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const QuickPanelFooter = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ToastProvider } from '@heroui/toast'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export const ToastPortal = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
return () => setMounted(false)
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<ToastProvider
|
||||
placement="top-center"
|
||||
regionProps={{
|
||||
className: 'z-[1001]'
|
||||
}}
|
||||
toastOffset={20}
|
||||
toastProps={{
|
||||
timeout: 3000,
|
||||
classNames: {
|
||||
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
|
||||
base: 'hero-toast'
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { Modal } from 'antd'
|
||||
import { message, Modal } from 'antd'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Box } from '../Layout'
|
||||
import { getToastUtilities } from './toast'
|
||||
import { getToastUtilities, initMessageApi } from './toast'
|
||||
|
||||
let onPop = () => {}
|
||||
let onShow = ({ element, id }: { element: React.FC | React.ReactNode; id: string }) => {
|
||||
@@ -36,6 +36,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
elementsRef.current = elements
|
||||
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const [messageApi, messageContextHolder] = message.useMessage()
|
||||
const { shortcuts } = useShortcuts()
|
||||
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
|
||||
|
||||
@@ -43,8 +44,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
window.modal = modal
|
||||
initMessageApi(messageApi)
|
||||
window.toast = getToastUtilities()
|
||||
}, [modal])
|
||||
}, [messageApi, modal])
|
||||
|
||||
onPop = () => {
|
||||
const views = [...elementsRef.current]
|
||||
@@ -97,6 +99,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<TopViewMinappContainer />
|
||||
{elements.map(({ element: Element, id }) => (
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
|
||||
import type { RequireSome } from '@renderer/types'
|
||||
|
||||
type AddToastProps = Parameters<typeof addToast>[0]
|
||||
type ToastPropsColored = Omit<AddToastProps, 'color'>
|
||||
|
||||
const createToast = (color: 'danger' | 'success' | 'warning' | 'default') => {
|
||||
return (arg: ToastPropsColored | string): string | null => {
|
||||
if (typeof arg === 'string') {
|
||||
return addToast({ color, title: arg })
|
||||
} else {
|
||||
return addToast({ color, ...arg })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syntatic sugar, oh yeah
|
||||
|
||||
/**
|
||||
* Display an error toast notification with red color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const error = createToast('danger')
|
||||
|
||||
/**
|
||||
* Display a success toast notification with green color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const success = createToast('success')
|
||||
|
||||
/**
|
||||
* Display a warning toast notification with yellow color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const warning = createToast('warning')
|
||||
|
||||
/**
|
||||
* Display an info toast notification with default color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const info = createToast('default')
|
||||
|
||||
/**
|
||||
* Display a loading toast notification that resolves with a promise
|
||||
* @param args - Toast options object containing a promise to resolve
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
|
||||
// Disappear immediately by default
|
||||
if (args.timeout === undefined) {
|
||||
args.timeout = 1
|
||||
}
|
||||
return addToast(args)
|
||||
}
|
||||
|
||||
export const getToastUtilities = () =>
|
||||
({
|
||||
getToastQueue,
|
||||
addToast,
|
||||
closeToast,
|
||||
closeAll,
|
||||
isToastClosing,
|
||||
error,
|
||||
success,
|
||||
warning,
|
||||
info,
|
||||
loading
|
||||
}) as const
|
||||
231
src/renderer/src/components/TopView/toast.tsx
Normal file
231
src/renderer/src/components/TopView/toast.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { RequireSome } from '@renderer/types'
|
||||
import { message as antdMessage } from 'antd'
|
||||
import type { MessageInstance } from 'antd/es/message/interface'
|
||||
import type React from 'react'
|
||||
|
||||
// Global message instance for static usage
|
||||
let messageApi: MessageInstance | null = null
|
||||
|
||||
// Initialize message API - should be called once the App component is mounted
|
||||
export const initMessageApi = (api: MessageInstance) => {
|
||||
messageApi = api
|
||||
}
|
||||
|
||||
// Get message API instance
|
||||
const getMessageApi = (): MessageInstance => {
|
||||
if (!messageApi) {
|
||||
// Fallback to static method if hook API is not available
|
||||
return antdMessage
|
||||
}
|
||||
return messageApi
|
||||
}
|
||||
|
||||
type ToastColor = 'danger' | 'success' | 'warning' | 'default'
|
||||
type MessageType = 'error' | 'success' | 'warning' | 'info'
|
||||
|
||||
interface ToastConfig {
|
||||
title?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
timeout?: number
|
||||
key?: string | number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
onClick?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface LoadingToastConfig extends ToastConfig {
|
||||
promise: Promise<any>
|
||||
}
|
||||
|
||||
const colorToType = (color: ToastColor): MessageType => {
|
||||
switch (color) {
|
||||
case 'danger':
|
||||
return 'error'
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'default':
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
// Toast content component
|
||||
const ToastContent: React.FC<{ title?: React.ReactNode; description?: React.ReactNode; icon?: React.ReactNode }> = ({
|
||||
title,
|
||||
description,
|
||||
icon
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{(icon || title) && (
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && <div className="text-sm">{description}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const createToast = (color: ToastColor) => {
|
||||
return (arg: ToastConfig | string): string | null => {
|
||||
const api = getMessageApi()
|
||||
const type = colorToType(color) as 'error' | 'success' | 'warning' | 'info'
|
||||
|
||||
if (typeof arg === 'string') {
|
||||
// antd message methods return a function to close the message
|
||||
api[type](arg)
|
||||
return null
|
||||
}
|
||||
|
||||
const { title, description, icon, timeout, ...restConfig } = arg
|
||||
|
||||
// Convert timeout from milliseconds to seconds (antd uses seconds)
|
||||
const duration = timeout !== undefined ? timeout / 1000 : 3
|
||||
|
||||
return (
|
||||
(api.open({
|
||||
type: type as 'error' | 'success' | 'warning' | 'info',
|
||||
content: <ToastContent title={title} description={description} icon={icon} />,
|
||||
duration,
|
||||
...restConfig
|
||||
}) as any) || null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an error toast notification with red color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const error = createToast('danger')
|
||||
|
||||
/**
|
||||
* Display a success toast notification with green color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const success = createToast('success')
|
||||
|
||||
/**
|
||||
* Display a warning toast notification with yellow color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const warning = createToast('warning')
|
||||
|
||||
/**
|
||||
* Display an info toast notification with default color
|
||||
* @param arg - Toast content (string) or toast options object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const info = createToast('default')
|
||||
|
||||
/**
|
||||
* Display a loading toast notification that resolves with a promise
|
||||
* @param args - Toast options object containing a promise to resolve
|
||||
*/
|
||||
export const loading = (args: RequireSome<LoadingToastConfig, 'promise'>): string | null => {
|
||||
const api = getMessageApi()
|
||||
const { title, description, icon, promise, timeout, ...restConfig } = args
|
||||
|
||||
// Generate unique key for this loading message
|
||||
const key = args.key || `loading-${Date.now()}-${Math.random()}`
|
||||
|
||||
// Show loading message
|
||||
api.loading({
|
||||
content: <ToastContent title={title || 'Loading...'} description={description} icon={icon} />,
|
||||
duration: 0, // Don't auto-close
|
||||
key,
|
||||
...restConfig
|
||||
})
|
||||
|
||||
// Handle promise resolution
|
||||
promise
|
||||
.then((result) => {
|
||||
api.success({
|
||||
content: <ToastContent title={title || 'Success'} description={description} />,
|
||||
duration: timeout !== undefined ? timeout / 1000 : 2,
|
||||
key,
|
||||
...restConfig
|
||||
})
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
api.error({
|
||||
content: (
|
||||
<ToastContent title={title || 'Error'} description={err?.message || description || 'An error occurred'} />
|
||||
),
|
||||
duration: timeout !== undefined ? timeout / 1000 : 3,
|
||||
key,
|
||||
...restConfig
|
||||
})
|
||||
throw err
|
||||
})
|
||||
|
||||
return key as string
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a toast notification
|
||||
* @param config - Toast configuration object
|
||||
* @returns Toast ID or null
|
||||
*/
|
||||
export const addToast = (config: ToastConfig) => info(config)
|
||||
|
||||
/**
|
||||
* Close a specific toast notification by its key
|
||||
* @param key - Toast key (string)
|
||||
*/
|
||||
export const closeToast = (key: string) => {
|
||||
getMessageApi().destroy(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all toast notifications
|
||||
*/
|
||||
export const closeAll = () => {
|
||||
getMessageApi().destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub functions for compatibility with previous toast API
|
||||
* These are no-ops since antd message doesn't expose a queue
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated This function is a no-op stub for backward compatibility only.
|
||||
* Antd message doesn't expose a queue. Do not rely on this function.
|
||||
* @returns Empty toast queue stub
|
||||
*/
|
||||
export const getToastQueue = (): any => ({ toasts: [] })
|
||||
|
||||
/**
|
||||
* @deprecated This function is a no-op stub for backward compatibility only.
|
||||
* Antd message doesn't track closing state. Do not rely on this function.
|
||||
* @param key - Toast key (unused)
|
||||
* @returns Always returns false
|
||||
*/
|
||||
export const isToastClosing = (key?: string): boolean => {
|
||||
key // unused
|
||||
return false
|
||||
}
|
||||
|
||||
export const getToastUtilities = () =>
|
||||
({
|
||||
getToastQueue,
|
||||
addToast,
|
||||
closeToast,
|
||||
closeAll,
|
||||
isToastClosing,
|
||||
error,
|
||||
success,
|
||||
warning,
|
||||
info,
|
||||
loading
|
||||
}) as const
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { handleSaveData } from '@renderer/store'
|
||||
import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
const logger = loggerService.withContext('UpdateDialog')
|
||||
|
||||
interface UpdateDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
releaseInfo: UpdateInfo | null
|
||||
}
|
||||
|
||||
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isInstalling, setIsInstalling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && releaseInfo) {
|
||||
logger.info('Update dialog opened', { version: releaseInfo.version })
|
||||
}
|
||||
}, [isOpen, releaseInfo])
|
||||
|
||||
const handleInstall = async () => {
|
||||
setIsInstalling(true)
|
||||
try {
|
||||
await handleSaveData()
|
||||
await window.api.quitAndInstall()
|
||||
} catch (error) {
|
||||
logger.error('Failed to save data before update', error as Error)
|
||||
setIsInstalling(false)
|
||||
window.toast.error(t('update.saveDataError'))
|
||||
}
|
||||
}
|
||||
|
||||
const releaseNotes = releaseInfo?.releaseNotes
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="2xl"
|
||||
scrollBehavior="inside"
|
||||
classNames={{
|
||||
base: 'max-h-[85vh]',
|
||||
header: 'border-b border-divider',
|
||||
footer: 'border-t border-divider'
|
||||
}}>
|
||||
<ModalContent>
|
||||
{(onModalClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
|
||||
<p className="text-default-500 text-small">
|
||||
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
|
||||
</p>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<ScrollShadow className="max-h-[450px]" hideScrollBar>
|
||||
<div className="markdown rounded-lg bg-default-50 p-4">
|
||||
<Markdown>
|
||||
{typeof releaseNotes === 'string'
|
||||
? releaseNotes
|
||||
: Array.isArray(releaseNotes)
|
||||
? releaseNotes
|
||||
.map((note: ReleaseNoteInfo) => note.note)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
: t('update.noReleaseNotes')}
|
||||
</Markdown>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
|
||||
{t('update.later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={async () => {
|
||||
await handleInstall()
|
||||
onModalClose()
|
||||
}}
|
||||
isLoading={isInstalling}>
|
||||
{t('update.install')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateDialog
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user