Compare commits

..

12 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 984eb7ac5f 🎨 style: Fix formatting issues
- Remove trailing whitespace in WebviewContainer.tsx
- Run biome format and lint to ensure code style compliance

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 20:56:45 +00:00
SuYao 0ae9c12cb1 Merge branch 'main' into copilot/add-webpage-printing-feature 2025-11-03 04:42:23 +08:00
copilot-swe-agent[bot] 92d558feff enhance: Use page title for default filename and improve PDF pagination
- Get page title from webview and use it as default filename for both PDF and HTML export
- Sanitize filename by removing invalid characters and limiting length
- Add preferCSSPageSize option to printToPDF for better multi-page support
- Fallback to timestamp-based filename if title is unavailable

Addresses reviewer feedback and fixes PDF export only capturing first page issue.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 20:16:17 +00:00
GitHub Action ac8d28687d fix(i18n): Auto update translations for PR #11104 2025-11-02 15:13:09 +00:00
copilot-swe-agent[bot] 6ec0985ae5 🐛 fix: Correct JavaScript string escaping for quotes
- Fix quote escaping from \\\\\' to \\'
- Ensures proper backslash escaping in JavaScript template string
- Produces correct \' in the final DOCTYPE string

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:28:58 +00:00
copilot-swe-agent[bot] f47673a153 🐛 fix: Correct DOCTYPE declaration format and quote escaping
- Use single quotes and proper escaping for DOCTYPE attributes
- Add SYSTEM keyword for systemId-only cases
- Ensure PUBLIC is only used with publicId
- Fix quote escaping to use backslashes instead of HTML entities
- Properly handle all DOCTYPE declaration formats per HTML/SGML spec

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:27:27 +00:00
copilot-swe-agent[bot] 5545921b8b 🔒 security: Add input validation and improve code structure
- Extract shortcut checking logic into isHandledShortcut helper
- Add try-catch in executeJavaScript for safety
- Escape quotes in DOCTYPE publicId and systemId
- Add optional chaining for documentElement
- Provide fallback if doctype properties are unexpected

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:25:40 +00:00
copilot-swe-agent[bot] 70d8a8ac28 enhance: Complete DOCTYPE support with publicId and systemId
- Add full DOCTYPE construction including publicId and systemId
- Fix grammar in comment (custom -> with custom logic)
- Ensures proper HTML5 and XHTML document types are preserved

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:23:45 +00:00
copilot-swe-agent[bot] 345b7cf231 🐛 fix: Improve doctype serialization for better cross-browser compatibility
- Use direct DOCTYPE construction instead of XMLSerializer
- More reliable across different browser contexts
- Based on code review feedback

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:22:20 +00:00
copilot-swe-agent[bot] 71d924854e ♻️ refactor: Improve webview ID handling and remove unused import
- Get webviewId dynamically when shortcuts are triggered instead of caching
- Remove unused 'join' import from WebviewService
- Add null check for webviewId in shortcut handler

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:17:54 +00:00
copilot-swe-agent[bot] 2a51ec628e feat: Add print to PDF and save as HTML for mini program webviews
- Add IPC channels for Webview_PrintToPDF and Webview_SaveAsHTML
- Implement printWebviewToPDF and saveWebviewAsHTML functions in WebviewService
- Add keyboard shortcuts handlers (Cmd/Ctrl+P for print, Cmd/Ctrl+S for save)
- Update WebviewContainer to handle keyboard shortcuts and trigger print/save actions
- Add preload API methods for webview print and save operations

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-02 06:12:46 +00:00
copilot-swe-agent[bot] 9b8402da0c Initial plan 2025-11-02 06:02:46 +00:00
295 changed files with 11500 additions and 14481 deletions
+31 -56
View File
@@ -1,4 +1,4 @@
name: Auto I18N Weekly
name: Auto I18N
env:
TRANSLATION_API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
@@ -7,15 +7,14 @@ env:
TRANSLATION_BASE_LOCALE: ${{ vars.AUTO_I18N_BASE_LOCALE || 'en-us'}}
on:
schedule:
# Runs at 00:00 UTC every Sunday.
# This corresponds to 08:00 AM UTC+8 (Beijing time) every Sunday.
- cron: "0 0 * * 0"
pull_request:
types: [opened, synchronize, reopened]
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
@@ -25,69 +24,45 @@ jobs:
- name: 🐈‍⬛ Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.ref }}
- name: 📦 Setting Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 20
package-manager-cache: false
- 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
- name: 📦 Install dependencies in isolated directory
run: |
yarn install
# 在临时目录安装依赖
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
- name: 🏃‍♀️ Translate
run: yarn sync:i18n && yarn auto:i18n
run: npx tsx scripts/sync-i18n.ts && npx tsx scripts/auto-translate-i18n.ts
- name: 🔍 Format
run: yarn 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/
- name: 🔍 Check for changes
id: git_status
- name: 🔄 Commit changes
run: |
# Check if there are any uncommitted changes
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add .
git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改
git diff --exit-code --quiet || echo "::set-output name=has_changes::true"
git status --porcelain
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
- 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
- name: 🚀 Push changes
uses: ad-m/github-push-action@master
with:
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."
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.event.pull_request.head.ref }}
+3 -3
View File
@@ -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: 22
node-version: '20'
- 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: 22
node-version: '20'
- name: Process pending issues with Claude
uses: anthropics/claude-code-action@main
+3 -3
View File
@@ -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: 22
node-version: 20
- 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.9.1 --activate
run: corepack enable && corepack prepare yarn@4.6.0 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
+2 -2
View File
@@ -26,10 +26,10 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 20
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
run: corepack enable && corepack prepare yarn@4.6.0 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
+5 -5
View File
@@ -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: 22
node-version: 20
- 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.9.1 --activate
run: corepack enable && corepack prepare yarn@4.6.0 --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 }}
+75 -17
View File
@@ -22,6 +22,7 @@
"eslint.config.mjs"
],
"overrides": [
// set different env
{
"env": {
"node": true
@@ -35,7 +36,8 @@
"files": [
"src/renderer/**/*.{ts,tsx}",
"packages/aiCore/**",
"packages/extension-table-plus/**"
"packages/extension-table-plus/**",
"resources/js/**"
]
},
{
@@ -51,24 +53,76 @@
"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-unused-expressions": "off",
"no-undef": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": ["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",
@@ -80,17 +134,19 @@
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "off",
"oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future
"oxc/uninvoked-array-callback": "warn",
"require-yield": "error",
"typescript/await-thenable": "warn",
"typescript/consistent-type-imports": "error",
// "typescript/ban-ts-comment": "error",
"typescript/no-array-constructor": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-empty-object-type": "off",
"typescript/no-explicit-any": "off",
"typescript/no-explicit-any": "off", // not safe but too many errors
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
@@ -99,7 +155,7 @@
"typescript/no-misused-new": "error",
"typescript/no-misused-spread": "warn",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "off",
"typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on.
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-require-imports": "off",
"typescript/no-this-alias": "error",
@@ -117,18 +173,20 @@
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "off",
"unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "off",
"unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "off",
"unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "off",
"unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn"
"unicorn/prefer-string-starts-ends-with": "warn",
"use-isnan": "error",
"valid-typeof": "error"
},
"settings": {
"jsdoc": {
@@ -1,5 +1,5 @@
diff --git a/dist/index.js b/dist/index.js
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644
index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
@@ -12,7 +12,7 @@ index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644
index a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 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 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa96b52ac0d 100644
index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
@@ -18,29 +18,30 @@ index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa9
tool_calls: import_v42.z.array(
import_v42.z.object({
index: import_v42.z.number(),
@@ -785,6 +787,13 @@ var OpenAIChatLanguageModel = class {
@@ -785,6 +787,14 @@ 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 +875,7 @@ var OpenAIChatLanguageModel = class {
@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class {
};
let metadataExtracted = false;
let isFirstChunk = true;
let isActiveText = false;
+ let isActiveReasoning = false;
const providerMetadata = { openai: {} };
return {
stream: response.pipeThrough(
@@ -923,6 +933,21 @@ var OpenAIChatLanguageModel = class {
@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class {
return;
}
const delta = choice.delta;
@@ -53,6 +54,7 @@ index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa9
+ });
+ isActiveReasoning = true;
+ }
+
+ controller.enqueue({
+ type: 'reasoning-delta',
+ id: 'reasoning-0',
@@ -62,7 +64,7 @@ index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa9
if (delta.content != null) {
if (!isActiveText) {
controller.enqueue({ type: "text-start", id: "0" });
@@ -1035,6 +1060,9 @@ var OpenAIChatLanguageModel = class {
@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class {
}
},
flush(controller) {
@@ -1,5 +1,5 @@
diff --git a/sdk.mjs b/sdk.mjs
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
@@ -11,7 +11,7 @@ index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f79205830
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -6505,14 +6505,11 @@ class ProcessTransport {
@@ -6487,14 +6487,11 @@ class ProcessTransport {
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
throw new ReferenceError(errorMessage);
}
+4 -1
View File
@@ -7,10 +7,12 @@ 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**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `feat:`, `🐛 fix:`, `♻️ refactor:`, `
📝 docs:`).
## Development Commands
@@ -39,6 +41,7 @@ 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
+6 -6
View File
@@ -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](https://poe.com/), and others
- 🔗 AI Web Service Integration: Claude, Perplexity, Poe, and others
- 💻 Local Model Support with Ollama, LM Studio
2. **AI Assistants & Conversations**:
@@ -238,6 +238,10 @@ 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
@@ -245,7 +249,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** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee |
| **Cost** | Free for Personal Use / Commercial License | 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 |
@@ -258,12 +262,8 @@ 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
+21
View File
@@ -0,0 +1,21 @@
{
"$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
}
+2 -2
View File
@@ -18,13 +18,13 @@ yarn
### Setup Node.js
Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
Download and install [Node.js v20.x.x](https://nodejs.org/en/download)
### Setup Yarn
```bash
corepack enable
corepack prepare yarn@4.9.1 --activate
corepack prepare yarn@4.6.0 --activate
```
### Install Dependencies
-2
View File
@@ -11,8 +11,6 @@ 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
-2
View File
@@ -11,8 +11,6 @@
用户可以在软件的`设置`-`关于`中,开启“测试计划”并选择版本通道。请注意“测试计划”的版本无法保证数据的一致性,请使用前一定要备份数据。
用户选择RC版通道或Beta版通道后,若发布了正式版,仍旧会升级到正式版。
用户在测试过程中发现的BUG,欢迎提交issue或通过其他渠道反馈。用户的反馈对我们非常重要。
## 开发者指南
+110 -26
View File
@@ -21,8 +21,6 @@ files:
- "**/*"
- "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}"
- "!electron.vite.config.{js,ts,mjs,cjs}}"
- "!.*"
- "!components.json"
- "!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md,biome.jsonc}"
- "!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
@@ -135,42 +133,128 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.7.0-beta.6
What's New in v1.7.0-beta.3
New Features:
- 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
- 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
Improvements:
- 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
- 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
Bug Fixes:
- 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
- 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
<!--LANG:zh-CN-->
v1.7.0-beta.6 新特性
v1.7.0-beta.3 新特性
新功能:
- 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大
- 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档
- 能工具建议:增强的快速面板,更好的项目选择和键盘快捷键
- 增强工具权限系统:实时工具审批界面,改进用户体验
- 插件管理系统:支持 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 集成小程序
改进:
- 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳
- 增强 AI 性能:改进连接稳定性和响应速度
- 更可靠的文件上传:更好地支持各种文件类型和上传场景
- 更简洁的界面:优化 UI 元素,视觉一致性更好
- Agent 创建:新创建的 Agent 现在会自动激活
- 懒加载:通过路由懒加载优化页面加载性能
- UI 增强:改进 Agent 项目样式和布局一致性
- 导航:改进 macOS 全屏模式下的导航栏布局
- 设置选项卡:增强上下文滑块一致性
- 备份管理器:统一本地和 S3 备份管理器的页脚布局
- 菜单系统:增强应用菜单,改进帮助部分
- 代理规则:全面的代理绕过规则匹配
- 德语支持:添加德语语言支持
- MCP 确认:添加激活协议安装的 MCP 服务器时的确认模态框
- 翻译:增强翻译脚本的并发和验证功能
- Electron & Vite:更新至 Electron 38 和 Vite 4.0.1
- 二维码生成:优化手机局域网导出性能
- 企业设置:在关于设置中添加企业部分
- 助手/Agent 弹窗:增强添加助手和 Agent 的界面
Claude Code 工具改进:
- GlobTool:现在计算行数而不是文件数,提供更清晰的输出
- ReadTool:自动从输出中移除系统提醒标签
- TodoWriteTool:改进渲染行为
- 环境变量:更新模型相关的环境变量名称
问题修复:
- 修复添加自定义 AI 提供商时的图片选择问题
- 修复某些 API 配置下的文件上传问题
- 修复输入栏响应性问题
- 修复快速面板在某些情况下无法正常工作的问题
- 修复发送消息时未使用会话模型
- 修复工具审批 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 编辑模态框加载竞态条件
- 修复文件路径更新时防抖保存取消问题
<!--LANG:END-->
+1 -2
View File
@@ -95,8 +95,7 @@ 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/ai-sdk-provider': resolve('packages/ai-sdk-provider/src')
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
}
},
optimizeDeps: {
+14 -18
View File
@@ -78,11 +78,10 @@
"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.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@paymoapp/electron-shutdown-handler": "^1.1.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"express": "^5.1.0",
"font-list": "^2.0.0",
@@ -106,17 +105,17 @@
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.53",
"@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",
"@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",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
"@aws-sdk/client-bedrock": "^3.910.0",
"@aws-sdk/client-bedrock-runtime": "^3.910.0",
"@aws-sdk/client-s3": "^3.910.0",
"@aws-sdk/client-bedrock": "^3.840.0",
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
"@aws-sdk/client-s3": "^3.840.0",
"@biomejs/biome": "2.2.4",
"@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18",
"@cherrystudio/embedjs": "^0.1.31",
@@ -147,6 +146,7 @@
"@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",
@@ -231,7 +231,7 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "^5.0.90",
"ai": "^5.0.76",
"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",
@@ -241,7 +241,7 @@
"check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
"chokidar": "^4.0.3",
"claude-code-plugins": "1.0.3",
"claude-code-plugins": "1.0.1",
"cli-progress": "^3.12.0",
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
@@ -348,7 +348,6 @@
"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,7 +373,6 @@
"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",
@@ -394,6 +392,7 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.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,10 +403,7 @@
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch"
"@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"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {
-39
View File
@@ -1,39 +0,0 @@
# @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
View File
@@ -1,64 +0,0 @@
{
"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"
}
}
}
@@ -1,319 +0,0 @@
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
View File
@@ -1 +0,0 @@
export * from './cherryin-provider'
-19
View File
@@ -1,19 +0,0 @@
{
"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
View File
@@ -1,12 +0,0 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: 'src/index.ts'
},
outDir: 'dist',
format: ['esm', 'cjs'],
clean: true,
dts: true,
tsconfig: 'tsconfig.json'
})
+7 -9
View File
@@ -36,16 +36,14 @@
"ai": "^5.0.26"
},
"dependencies": {
"@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/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/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.16",
"@ai-sdk/xai": "^2.0.31",
"@cherrystudio/ai-sdk-provider": "workspace:*",
"@ai-sdk/provider-utils": "^3.0.12",
"@ai-sdk/xai": "^2.0.26",
"zod": "^4.1.5"
},
"devDependencies": {
@@ -1,10 +1,9 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import type { anthropic } from '@ai-sdk/anthropic'
import type { google } from '@ai-sdk/google'
import type { 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'
@@ -96,56 +95,3 @@ 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,11 +2,15 @@
* 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, switchWebSearchTool } from './helper'
import { DEFAULT_WEB_SEARCH_CONFIG } from './helper'
/**
* 网络搜索插件
@@ -20,13 +24,56 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
transformParams: async (params: any, context: AiRequestContext) => {
const { providerId } = context
switchWebSearchTool(providerId, config, params)
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
}
if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
// cherryin.gemini
const _providerId = params.model.provider.split('.')[1]
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
}
}
return params
}
})
@@ -12,7 +12,6 @@ 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'
@@ -32,8 +31,6 @@ export const baseProviderIds = [
'azure-responses',
'deepseek',
'openrouter',
'cherryin',
'cherryin-chat',
'huggingface'
] as const
@@ -139,26 +136,6 @@ 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',
+2 -1
View File
@@ -54,6 +54,8 @@ export enum IpcChannel {
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
Webview_SearchHotkey = 'webview:search-hotkey',
Webview_PrintToPDF = 'webview:print-to-pdf',
Webview_SaveAsHTML = 'webview:save-as-html',
// Open
Open_Path = 'open:path',
@@ -189,7 +191,6 @@ 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',
-3
View File
@@ -470,6 +470,3 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
})
}
]
// resources/scripts should be maintained manually
export const HOME_CHERRY_DIR = '.cherrystudio'
@@ -1 +0,0 @@
ALTER TABLE `sessions` ADD `slash_commands` text;
@@ -1,346 +0,0 @@
{
"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,13 +15,6 @@
"when": 1758187378775,
"tag": "0001_woozy_captain_flint",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1762526423527,
"tag": "0002_wealthy_naoko",
"breakpoints": true
}
]
}
+36
View File
@@ -0,0 +1,36 @@
;(() => {
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)
}
}
)
})()
+5
View File
@@ -0,0 +1,5 @@
export function getQueryParam(paramName) {
const url = new URL(window.location.href)
const params = new URLSearchParams(url.search)
return params.get(paramName)
}
+1 -1
View File
@@ -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.3.1' // Default fallback version
const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version
// Mapping of platform+arch to binary package name
const BUN_PACKAGES = {
+35 -61
View File
@@ -7,29 +7,28 @@ 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.9.5'
const DEFAULT_UV_VERSION = '0.7.13'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
'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.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',
'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',
// MUSL variants
'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'
'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'
}
/**
@@ -57,7 +56,6 @@ 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}...`)
@@ -67,58 +65,34 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
console.log(`Extracting ${packageName} to ${binDir}...`)
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 })
const zip = new StreamZip.async({ file: tempFilename })
execSync(`tar -xzf "${tempFilename}" -C "${tempExtractDir}"`, { stdio: 'inherit' })
// Get all entries in the zip file
const entries = await zip.entries()
// 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
// 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 {
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
+88
View File
@@ -0,0 +1,88 @@
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
}
+2 -4
View File
@@ -18,10 +18,8 @@ import { sortedObjectByKeys } from './sort'
// ========== SCRIPT CONFIGURATION AREA - MODIFY SETTINGS HERE ==========
const SCRIPT_CONFIG = {
// 🔧 Concurrency Control Configuration
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)
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)
// 🔑 API Configuration
API_KEY: process.env.TRANSLATION_API_KEY || '', // API key from environment variable
+1 -1
View File
@@ -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) {
-7
View File
@@ -21,7 +21,6 @@ import { appMenuService } from './services/AppMenuService'
import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
import { nodeTraceService } from './services/NodeTraceService'
import powerMonitorService from './services/PowerMonitorService'
import {
CHERRY_STUDIO_PROTOCOL,
handleProtocolUrl,
@@ -31,7 +30,6 @@ import {
import selectionService, { initSelectionService } from './services/SelectionService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { versionService } from './services/VersionService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
@@ -112,10 +110,6 @@ if (!app.requestSingleInstanceLock()) {
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
// Record current version for tracking
// A preparation for v2 data refactoring
versionService.recordCurrentVersion()
initWebviewHotkeys()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
@@ -133,7 +127,6 @@ if (!app.requestSingleInstanceLock()) {
appMenuService?.setupApplicationMenu()
nodeTraceService.init()
powerMonitorService.init()
app.on('activate', function () {
const mainWindow = windowService.getMainWindow()
+13 -13
View File
@@ -50,7 +50,6 @@ import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import OvmsManager from './services/OvmsManager'
import powerMonitorService from './services/PowerMonitorService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
@@ -116,17 +115,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
const notificationService = new NotificationService()
// Register shutdown handlers
powerMonitorService.registerShutdownHandler(() => {
appUpdater.setAutoUpdate(false)
})
powerMonitorService.registerShutdownHandler(() => {
const mw = windowService.getMainWindow()
if (mw && !mw.isDestroyed()) {
mw.webContents.send(IpcChannel.App_SaveData)
}
})
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
const checkMainWindow = () => {
if (!mainWindow || mainWindow.isDestroyed()) {
@@ -551,7 +541,6 @@ 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))
@@ -820,6 +809,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
webview.session.setSpellCheckerEnabled(isEnable)
})
// Webview print and save handlers
ipcMain.handle(IpcChannel.Webview_PrintToPDF, async (_, webviewId: number) => {
const { printWebviewToPDF } = await import('./services/WebviewService')
return await printWebviewToPDF(webviewId)
})
ipcMain.handle(IpcChannel.Webview_SaveAsHTML, async (_, webviewId: number) => {
const { saveWebviewAsHTML } = await import('./services/WebviewService')
return await saveWebviewAsHTML(webviewId)
})
// store sync
storeSyncService.registerIpcHandler()
@@ -275,10 +275,15 @@ 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
body: fileBuffer,
headers: {
'Content-Type': 'application/pdf'
}
// headers: {
// 'Content-Length': fileBuffer.length.toString()
// }
})
if (!response.ok) {
+16 -61
View File
@@ -7,33 +7,16 @@ 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 { appMenu } = locale.translation
const { common } = locale.translation
const template: MenuItemConstructorOptions[] = [
{
label: app.name,
submenu: [
{
label: appMenu.about + ' ' + app.name,
label: common.about + ' ' + app.name,
click: () => {
// Emit event to navigate to About page
const mainWindow = windowService.getMainWindow()
@@ -44,78 +27,50 @@ export class AppMenuService {
}
},
{ type: 'separator' },
{ role: 'services', label: appMenu.services },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide', label: `${appMenu.hide} ${app.name}` },
{ role: 'hideOthers', label: appMenu.hideOthers },
{ role: 'unhide', label: appMenu.unhide },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit', label: `${appMenu.quit} ${app.name}` }
{ role: 'quit' }
]
},
{
label: appMenu.file,
submenu: [{ role: 'close', label: appMenu.close }]
role: 'fileMenu'
},
{
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: 'editMenu'
},
{
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: 'viewMenu'
},
{
label: appMenu.window,
submenu: [
{ role: 'minimize', label: appMenu.minimize },
{ role: 'zoom', label: appMenu.zoom },
{ type: 'separator' },
{ role: 'front', label: appMenu.front }
]
role: 'windowMenu'
},
{
label: appMenu.help,
role: 'help',
submenu: [
{
label: appMenu.website,
label: 'Website',
click: () => {
shell.openExternal('https://cherry-ai.com')
}
},
{
label: appMenu.documentation,
label: 'Documentation',
click: () => {
shell.openExternal('https://cherry-ai.com/docs')
}
},
{
label: appMenu.feedback,
label: 'Feedback',
click: () => {
shell.openExternal('https://github.com/CherryHQ/cherry-studio/issues/new/choose')
}
},
{
label: appMenu.releases,
label: 'Releases',
click: () => {
shell.openExternal('https://github.com/CherryHQ/cherry-studio/releases')
}
+6 -7
View File
@@ -10,7 +10,6 @@ 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,
@@ -67,7 +66,7 @@ class CodeToolsService {
}
public async getBunPath() {
const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const bunName = await getBinaryName('bun')
const bunPath = path.join(dir, bunName)
return bunPath
@@ -363,7 +362,7 @@ class CodeToolsService {
private async isPackageInstalled(cliTool: string): Promise<boolean> {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
// Ensure bin directory exists
@@ -390,7 +389,7 @@ class CodeToolsService {
logger.info(`${cliTool} is installed, getting current version`)
try {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, {
@@ -501,7 +500,7 @@ class CodeToolsService {
try {
const packageName = await this.getPackageName(cliTool)
const bunPath = await this.getBunPath()
const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR)
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
const registryUrl = await this.getNpmRegistryUrl()
const installEnvPrefix = isWin
@@ -551,7 +550,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(), HOME_CHERRY_DIR, 'bin')
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
logger.debug(`Package name: ${packageName}`)
@@ -653,7 +652,7 @@ class CodeToolsService {
baseCommand = `${baseCommand} ${configParams}`
}
const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR)
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
if (isInstalled) {
// If already installed, run executable directly (with optional update message)
-366
View File
@@ -16,7 +16,6 @@ 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'
@@ -31,73 +30,6 @@ 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)[]
@@ -122,26 +54,6 @@ 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()
@@ -836,284 +748,6 @@ 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') {
+1 -2
View File
@@ -30,7 +30,6 @@ 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'
@@ -716,7 +715,7 @@ class McpService {
}
public async getInstallInfo() {
const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const uvName = await getBinaryName('uv')
const bunName = await getBinaryName('bun')
const uvPath = path.join(dir, uvName)
+8 -9
View File
@@ -3,7 +3,6 @@ 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'
@@ -146,7 +145,7 @@ class OvmsManager {
*/
public async runOvms(): Promise<{ success: boolean; message?: string }> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
const runBatPath = path.join(ovmsDir, 'run.bat')
@@ -196,7 +195,7 @@ class OvmsManager {
*/
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
const homeDir = homedir()
const ovmsPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'ovms.exe')
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
try {
// Check if OVMS executable exists
@@ -274,7 +273,7 @@ class OvmsManager {
}
const homeDir = homedir()
const configPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'models', 'config.json')
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
@@ -305,7 +304,7 @@ class OvmsManager {
private async applyModelPath(modelDirPath: string): Promise<boolean> {
const homeDir = homedir()
const patchDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'patch')
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
if (!(await fs.pathExists(patchDir))) {
return true
}
@@ -356,7 +355,7 @@ class OvmsManager {
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
const homeDir = homedir()
const ovdndDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const pathModel = path.join(ovdndDir, 'models', modelId)
try {
@@ -469,7 +468,7 @@ class OvmsManager {
*/
public async checkModelExists(modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
@@ -496,7 +495,7 @@ class OvmsManager {
*/
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
@@ -549,7 +548,7 @@ class OvmsManager {
*/
public async getModels(): Promise<ModelConfig[]> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
-112
View File
@@ -1,112 +0,0 @@
import { loggerService } from '@logger'
import { isLinux, isMac, isWin } from '@main/constant'
import ElectronShutdownHandler from '@paymoapp/electron-shutdown-handler'
import { BrowserWindow } from 'electron'
import { powerMonitor } from 'electron'
const logger = loggerService.withContext('PowerMonitorService')
type ShutdownHandler = () => void | Promise<void>
export class PowerMonitorService {
private static instance: PowerMonitorService
private initialized = false
private shutdownHandlers: ShutdownHandler[] = []
private constructor() {
// Private constructor to prevent direct instantiation
}
public static getInstance(): PowerMonitorService {
if (!PowerMonitorService.instance) {
PowerMonitorService.instance = new PowerMonitorService()
}
return PowerMonitorService.instance
}
/**
* Register a shutdown handler to be called when system shutdown is detected
* @param handler - The handler function to be called on shutdown
*/
public registerShutdownHandler(handler: ShutdownHandler): void {
this.shutdownHandlers.push(handler)
logger.info('Shutdown handler registered', { totalHandlers: this.shutdownHandlers.length })
}
/**
* Initialize power monitor to listen for shutdown events
*/
public init(): void {
if (this.initialized) {
logger.warn('PowerMonitorService already initialized')
return
}
if (isWin) {
this.initWindowsShutdownHandler()
} else if (isMac || isLinux) {
this.initElectronPowerMonitor()
}
this.initialized = true
logger.info('PowerMonitorService initialized', { platform: process.platform })
}
/**
* Execute all registered shutdown handlers
*/
private async executeShutdownHandlers(): Promise<void> {
logger.info('Executing shutdown handlers', { count: this.shutdownHandlers.length })
for (const handler of this.shutdownHandlers) {
try {
await handler()
} catch (error) {
logger.error('Error executing shutdown handler', error as Error)
}
}
}
/**
* Initialize shutdown handler for Windows using @paymoapp/electron-shutdown-handler
*/
private initWindowsShutdownHandler(): void {
try {
const zeroMemoryWindow = new BrowserWindow({ show: false })
// Set the window handle for the shutdown handler
ElectronShutdownHandler.setWindowHandle(zeroMemoryWindow.getNativeWindowHandle())
// Listen for shutdown event
ElectronShutdownHandler.on('shutdown', async () => {
logger.info('System shutdown event detected (Windows)')
// Execute all registered shutdown handlers
await this.executeShutdownHandlers()
// Release the shutdown block to allow the system to shut down
ElectronShutdownHandler.releaseShutdown()
})
logger.info('Windows shutdown handler registered')
} catch (error) {
logger.error('Failed to initialize Windows shutdown handler', error as Error)
}
}
/**
* Initialize power monitor for macOS and Linux using Electron's powerMonitor
*/
private initElectronPowerMonitor(): void {
try {
powerMonitor.on('shutdown', async () => {
logger.info('System shutdown event detected', { platform: process.platform })
// Execute all registered shutdown handlers
await this.executeShutdownHandlers()
})
logger.info('Electron powerMonitor shutdown listener registered')
} catch (error) {
logger.error('Failed to initialize Electron powerMonitor', error as Error)
}
}
}
// Default export as singleton instance
export default PowerMonitorService.getInstance()
+9 -5
View File
@@ -1,9 +1,8 @@
import { randomUUID } from 'node:crypto'
import type { BrowserWindow } from 'electron'
import { ipcMain } from 'electron'
import { windowService } from './WindowService'
interface PythonExecutionRequest {
id: string
script: string
@@ -22,6 +21,7 @@ interface PythonExecutionResponse {
*/
export class PythonService {
private static instance: PythonService | null = null
private mainWindow: BrowserWindow | null = null
private pendingRequests = new Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>()
private constructor() {
@@ -51,6 +51,10 @@ export class PythonService {
})
}
public setMainWindow(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow
}
/**
* Execute Python code by sending request to renderer PyodideService
*/
@@ -59,8 +63,8 @@ export class PythonService {
context: Record<string, any> = {},
timeout: number = 60000
): Promise<string> {
if (!windowService.getMainWindow()) {
throw new Error('Main window not found')
if (!this.mainWindow) {
throw new Error('Main window not set in PythonService')
}
return new Promise((resolve, reject) => {
@@ -91,7 +95,7 @@ export class PythonService {
// Send request to renderer
const request: PythonExecutionRequest = { id: requestId, script, context, timeout }
windowService.getMainWindow()?.webContents.send('python-execution-request', request)
this.mainWindow?.webContents.send('python-execution-request', request)
})
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ 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'
@@ -19,7 +18,7 @@ class SpanCacheService implements TraceCache {
pri
constructor() {
this.fileDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'trace')
this.fileDir = path.join(os.homedir(), '.cherrystudio', 'trace')
}
createSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => {
-285
View File
@@ -1,285 +0,0 @@
import { loggerService } from '@logger'
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
const logger = loggerService.withContext('VersionService')
type OS = 'win' | 'mac' | 'linux' | 'unknown'
type Environment = 'prod' | 'dev'
type Packaged = 'packaged' | 'unpackaged'
type Mode = 'install' | 'portable'
/**
* Version record stored in version.log
*/
interface VersionRecord {
version: string
os: OS
environment: Environment
packaged: Packaged
mode: Mode
timestamp: string
}
/**
* Service for tracking application version history
* Stores version information in userData/version.log for data migration and diagnostics
*/
class VersionService {
private readonly VERSION_LOG_FILE = 'version.log'
private versionLogPath: string | null = null
constructor() {
// Lazy initialization of path since app.getPath may not be available during construction
}
/**
* Gets the full path to version.log file
* @returns {string} Full path to version log file
*/
private getVersionLogPath(): string {
if (!this.versionLogPath) {
this.versionLogPath = path.join(app.getPath('userData'), this.VERSION_LOG_FILE)
}
return this.versionLogPath
}
/**
* Gets current operating system identifier
* @returns {OS} OS identifier
*/
private getCurrentOS(): OS {
switch (process.platform) {
case 'win32':
return 'win'
case 'darwin':
return 'mac'
case 'linux':
return 'linux'
default:
return 'unknown'
}
}
/**
* Gets current environment (production or development)
* @returns {Environment} Environment identifier
*/
private getCurrentEnvironment(): Environment {
return import.meta.env.MODE === 'production' ? 'prod' : 'dev'
}
/**
* Gets packaging status
* @returns {Packaged} Packaging status
*/
private getPackagedStatus(): Packaged {
return app.isPackaged ? 'packaged' : 'unpackaged'
}
/**
* Gets installation mode (install or portable)
* @returns {Mode} Installation mode
*/
private getInstallMode(): Mode {
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined ? 'portable' : 'install'
}
/**
* Generates version log line for current application state
* @returns {string} Pipe-separated version record line
*/
private generateCurrentVersionLine(): string {
const version = app.getVersion()
const os = this.getCurrentOS()
const environment = this.getCurrentEnvironment()
const packaged = this.getPackagedStatus()
const mode = this.getInstallMode()
const timestamp = new Date().toISOString()
return `${version}|${os}|${environment}|${packaged}|${mode}|${timestamp}`
}
/**
* Parses a version log line into a VersionRecord object
* @param {string} line - Pipe-separated version record line
* @returns {VersionRecord | null} Parsed version record or null if invalid
*/
private parseVersionLine(line: string): VersionRecord | null {
try {
const parts = line.trim().split('|')
if (parts.length !== 6) {
return null
}
const [version, os, environment, packaged, mode, timestamp] = parts
// Validate data
if (
!version ||
!['win', 'mac', 'linux', 'unknown'].includes(os) ||
!['prod', 'dev'].includes(environment) ||
!['packaged', 'unpackaged'].includes(packaged) ||
!['install', 'portable'].includes(mode) ||
!timestamp
) {
return null
}
return {
version,
os: os as OS,
environment: environment as Environment,
packaged: packaged as Packaged,
mode: mode as Mode,
timestamp
}
} catch (error) {
logger.warn(`Failed to parse version line: ${line}`, error as Error)
return null
}
}
/**
* Reads the last 1KB from version.log and returns all lines
* Uses reverse reading from file end to avoid reading the entire file
* @returns {string[]} Array of version lines from the last 1KB
*/
private readLastVersionLines(): string[] {
const logPath = this.getVersionLogPath()
try {
if (!fs.existsSync(logPath)) {
return []
}
const stats = fs.statSync(logPath)
const fileSize = stats.size
if (fileSize === 0) {
return []
}
// Read from the end of the file, 1KB is enough to find previous version
// Typical line: "1.7.0-beta.3|win|prod|packaged|install|2025-01-15T08:30:00.000Z\n" (~70 bytes)
// 1KB can store ~14 lines, which is more than enough
const bufferSize = Math.min(1024, fileSize)
const buffer = Buffer.alloc(bufferSize)
const fd = fs.openSync(logPath, 'r')
try {
const startPosition = Math.max(0, fileSize - bufferSize)
fs.readSync(fd, buffer, 0, bufferSize, startPosition)
const content = buffer.toString('utf-8')
const lines = content
.trim()
.split('\n')
.filter((line) => line.trim())
return lines
} finally {
fs.closeSync(fd)
}
} catch (error) {
logger.error('Failed to read version log:', error as Error)
return []
}
}
/**
* Appends a version record line to version.log
* @param {string} line - Version record line to append
*/
private appendVersionLine(line: string): void {
const logPath = this.getVersionLogPath()
try {
fs.appendFileSync(logPath, line + '\n', 'utf-8')
logger.debug(`Version recorded: ${line}`)
} catch (error) {
logger.error('Failed to append version log:', error as Error)
}
}
/**
* Records the current version on application startup
* Only adds a new record if the version has changed since the last run
*/
recordCurrentVersion(): void {
try {
const currentLine = this.generateCurrentVersionLine()
const lines = this.readLastVersionLines()
// Add new record if this is the first run or version has changed
if (lines.length === 0) {
logger.info('First run detected, creating version log')
this.appendVersionLine(currentLine)
return
}
const lastLine = lines[lines.length - 1]
const lastRecord = this.parseVersionLine(lastLine)
const currentVersion = app.getVersion()
// Check if any meaningful field has changed (version, os, environment, packaged, mode)
const currentOS = this.getCurrentOS()
const currentEnvironment = this.getCurrentEnvironment()
const currentPackaged = this.getPackagedStatus()
const currentMode = this.getInstallMode()
const hasMeaningfulChange =
!lastRecord ||
lastRecord.version !== currentVersion ||
lastRecord.os !== currentOS ||
lastRecord.environment !== currentEnvironment ||
lastRecord.packaged !== currentPackaged ||
lastRecord.mode !== currentMode
if (hasMeaningfulChange) {
logger.info(`Version information changed, recording new entry`)
this.appendVersionLine(currentLine)
} else {
logger.debug(`Version information not changed, skip recording`)
}
} catch (error) {
logger.error('Failed to record current version:', error as Error)
}
}
/**
* Gets the previous version record (last record with different version than current)
* Reads from the last 1KB of version.log to find the most recent different version
* Useful for detecting version upgrades and running migrations
* @returns {VersionRecord | null} Previous version record or null if not available
*/
getPreviousVersion(): VersionRecord | null {
try {
const lines = this.readLastVersionLines()
if (lines.length === 0) {
return null
}
const currentVersion = app.getVersion()
// Read from the end backwards to find the first different version
for (let i = lines.length - 1; i >= 0; i--) {
const record = this.parseVersionLine(lines[i])
if (record && record.version !== currentVersion) {
return record
}
}
return null
} catch (error) {
logger.error('Failed to get previous version:', error as Error)
return null
}
}
}
/**
* Singleton instance of VersionService
*/
export const versionService = new VersionService()
+147 -5
View File
@@ -1,5 +1,6 @@
import { IpcChannel } from '@shared/IpcChannel'
import { app, session, shell, webContents } from 'electron'
import { app, dialog, session, shell, webContents } from 'electron'
import { promises as fs } from 'fs'
/**
* init the useragent of the webview session
@@ -53,11 +54,17 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isEscape = key === 'escape'
const isEnter = key === 'enter'
// Helper to check if this is a shortcut we handle
const isHandledShortcut = (k: string) => {
const isFindShortcut = (input.control || input.meta) && k === 'f'
const isPrintShortcut = (input.control || input.meta) && k === 'p'
const isSaveShortcut = (input.control || input.meta) && k === 's'
const isEscape = k === 'escape'
const isEnter = k === 'enter'
return isFindShortcut || isPrintShortcut || isSaveShortcut || isEscape || isEnter
}
if (!isFindShortcut && !isEscape && !isEnter) {
if (!isHandledShortcut(key)) {
return
}
@@ -66,11 +73,20 @@ const attachKeyboardHandler = (contents: Electron.WebContents) => {
return
}
const isFindShortcut = (input.control || input.meta) && key === 'f'
const isPrintShortcut = (input.control || input.meta) && key === 'p'
const isSaveShortcut = (input.control || input.meta) && key === 's'
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
if (isFindShortcut) {
event.preventDefault()
}
// Prevent default print/save dialogs and handle them with custom logic
if (isPrintShortcut || isSaveShortcut) {
event.preventDefault()
}
// Send the hotkey event to the renderer
// The renderer will decide whether to preventDefault for Escape and Enter
// based on whether the search bar is visible
@@ -100,3 +116,129 @@ export function initWebviewHotkeys() {
attachKeyboardHandler(contents)
})
}
/**
* Print webview content to PDF
* @param webviewId The webview webContents id
* @returns Path to saved PDF file or null if user cancelled
*/
export async function printWebviewToPDF(webviewId: number): Promise<string | null> {
const webview = webContents.fromId(webviewId)
if (!webview) {
throw new Error('Webview not found')
}
try {
// Get the page title for default filename
const pageTitle = await webview.executeJavaScript('document.title || "webpage"').catch(() => 'webpage')
// Sanitize filename by removing invalid characters
const sanitizedTitle = pageTitle.replace(/[<>:"/\\|?*]/g, '-').substring(0, 100)
const defaultFilename = sanitizedTitle ? `${sanitizedTitle}.pdf` : `webpage-${Date.now()}.pdf`
// Show save dialog
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save as PDF',
defaultPath: defaultFilename,
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
})
if (canceled || !filePath) {
return null
}
// Generate PDF with settings to capture full page
const pdfData = await webview.printToPDF({
marginsType: 0,
printBackground: true,
printSelectionOnly: false,
landscape: false,
pageSize: 'A4',
preferCSSPageSize: true
})
// Save PDF to file
await fs.writeFile(filePath, pdfData)
return filePath
} catch (error) {
throw new Error(`Failed to print to PDF: ${(error as Error).message}`)
}
}
/**
* Save webview content as HTML
* @param webviewId The webview webContents id
* @returns Path to saved HTML file or null if user cancelled
*/
export async function saveWebviewAsHTML(webviewId: number): Promise<string | null> {
const webview = webContents.fromId(webviewId)
if (!webview) {
throw new Error('Webview not found')
}
try {
// Get the page title for default filename
const pageTitle = await webview.executeJavaScript('document.title || "webpage"').catch(() => 'webpage')
// Sanitize filename by removing invalid characters
const sanitizedTitle = pageTitle.replace(/[<>:"/\\|?*]/g, '-').substring(0, 100)
const defaultFilename = sanitizedTitle ? `${sanitizedTitle}.html` : `webpage-${Date.now()}.html`
// Show save dialog
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save as HTML',
defaultPath: defaultFilename,
filters: [
{ name: 'HTML Files', extensions: ['html', 'htm'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (canceled || !filePath) {
return null
}
// Get the HTML content with safe error handling
const html = await webview.executeJavaScript(`
(() => {
try {
// Build complete DOCTYPE string if present
let doctype = '';
if (document.doctype) {
const dt = document.doctype;
doctype = '<!DOCTYPE ' + (dt.name || 'html');
// Add PUBLIC identifier if publicId is present
if (dt.publicId) {
// Escape single quotes in publicId
const escapedPublicId = String(dt.publicId).replace(/'/g, "\\'");
doctype += " PUBLIC '" + escapedPublicId + "'";
// Add systemId if present (required when publicId is present)
if (dt.systemId) {
const escapedSystemId = String(dt.systemId).replace(/'/g, "\\'");
doctype += " '" + escapedSystemId + "'";
}
} else if (dt.systemId) {
// SYSTEM identifier (without PUBLIC)
const escapedSystemId = String(dt.systemId).replace(/'/g, "\\'");
doctype += " SYSTEM '" + escapedSystemId + "'";
}
doctype += '>';
}
return doctype + (document.documentElement?.outerHTML || '');
} catch (error) {
// Fallback: just return the HTML without DOCTYPE if there's an error
return document.documentElement?.outerHTML || '';
}
})()
`)
// Save HTML to file
await fs.writeFile(filePath, html, 'utf-8')
return filePath
} catch (error) {
throw new Error(`Failed to save as HTML: ${(error as Error).message}`)
}
}
+1 -8
View File
@@ -36,14 +36,7 @@ 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',
'slash_commands'
]
protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
/**
* Initialize database with retry logic and proper error handling
@@ -22,7 +22,6 @@ 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,5 +1,4 @@
import { loggerService } from '@logger'
import type { SlashCommand, UpdateSessionResponse } from '@types'
import type { UpdateSessionResponse } from '@types'
import {
AgentBaseSchema,
type AgentEntity,
@@ -14,10 +13,6 @@ 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
@@ -34,52 +29,6 @@ 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> = {}
@@ -129,7 +78,6 @@ 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
@@ -162,13 +110,7 @@ export class SessionService extends BaseService {
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
// 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)
}
session.slash_commands = await this.listSlashCommands(session.agent_type)
return session
}
@@ -1,7 +1,7 @@
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { describe, expect, it } from 'vitest'
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
const baseStreamMetadata = {
parent_tool_use_id: null,
@@ -10,19 +10,6 @@ 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,12 +1,25 @@
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: '/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' }
{ 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' }
]
@@ -12,7 +12,6 @@ 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'
@@ -20,7 +19,6 @@ 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'
@@ -199,7 +197,7 @@ class ClaudeCodeService implements AgentServiceInterface {
options.strictMcpConfig = true
}
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
if (lastAgentSessionId) {
options.resume = lastAgentSessionId
// TODO: use fork session when we support branching sessions
// options.forkSession = true
@@ -222,15 +220,7 @@ class ClaudeCodeService implements AgentServiceInterface {
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
this.processSDKQuery(
userInputStream,
closeUserStream,
options,
aiStream,
errorChunks,
session.agent_id,
session.id
).catch((error) => {
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
logger.error('Unhandled Claude Code stream error', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
@@ -339,9 +329,7 @@ class ClaudeCodeService implements AgentServiceInterface {
closePromptStream: () => void,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[],
agentId: string,
sessionId: string
errorChunks: string[]
): Promise<void> {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
@@ -354,62 +342,6 @@ 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,
@@ -433,19 +365,10 @@ 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,21 +73,13 @@ 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 => {
const withoutLocalCommandTags = stripLocalCommandTags(text)
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
}
/**
@@ -110,7 +102,6 @@ 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)
@@ -144,8 +135,7 @@ function handleAssistantMessage(
const isStreamingActive = state.hasActiveStep()
if (typeof content === 'string') {
const sanitizedContent = stripLocalCommandTags(content)
if (!sanitizedContent) {
if (!content) {
return chunks
}
@@ -167,7 +157,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id: textId,
text: sanitizedContent,
text: content,
providerMetadata
})
chunks.push({
@@ -188,10 +178,7 @@ function handleAssistantMessage(
switch (block.type) {
case 'text':
if (!isStreamingActive) {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
textBlocks.push(block.text)
}
break
case 'tool_use':
@@ -550,10 +537,6 @@ 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,
-81
View File
@@ -1,6 +1,4 @@
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'
@@ -9,36 +7,6 @@ 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
@@ -60,55 +28,6 @@ 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,6 +1,5 @@
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'
@@ -14,7 +13,7 @@ import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr', 'run.npu.bat')
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
export class OvOcrService extends OcrBaseService {
constructor() {
@@ -31,7 +30,7 @@ export class OvOcrService extends OcrBaseService {
}
private getOvOcrPath(): string {
return path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr')
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
}
private getImgDir(): string {
+3 -3
View File
@@ -5,7 +5,7 @@ import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { audioExts, documentExts, HOME_CHERRY_DIR, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { audioExts, documentExts, 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(), HOME_CHERRY_DIR, 'config')
return path.join(os.homedir(), '.cherrystudio', 'config')
}
export function getCacheDir() {
@@ -172,7 +172,7 @@ export function getAppConfigDir(name: string) {
}
export function getMcpDir() {
return path.join(os.homedir(), HOME_CHERRY_DIR, 'mcp')
return path.join(os.homedir(), '.cherrystudio', 'mcp')
}
/**
+1 -2
View File
@@ -3,7 +3,6 @@ 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
@@ -18,7 +17,7 @@ function hasWritePermission(path: string) {
}
function getConfigDir() {
return path.join(os.homedir(), HOME_CHERRY_DIR, 'config')
return path.join(os.homedir(), '.cherrystudio', 'config')
}
export function initAppDataDir() {
+2 -3
View File
@@ -1,5 +1,4 @@
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'
@@ -47,11 +46,11 @@ export async function getBinaryName(name: string): Promise<string> {
export async function getBinaryPath(name?: string): Promise<string> {
if (!name) {
return path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
return path.join(os.homedir(), '.cherrystudio', 'bin')
}
const binaryName = await getBinaryName(name)
const binariesDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binariesDirExists = fs.existsSync(binariesDir)
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
}
+2 -12
View File
@@ -48,16 +48,6 @@ 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 }
@@ -211,8 +201,6 @@ 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),
@@ -418,6 +406,8 @@ const api = {
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
printToPDF: (webviewId: number) => ipcRenderer.invoke(IpcChannel.Webview_PrintToPDF, webviewId),
saveAsHTML: (webviewId: number) => ipcRenderer.invoke(IpcChannel.Webview_SaveAsHTML, webviewId),
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
callback(payload)
+20 -15
View File
@@ -6,9 +6,11 @@ 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'
@@ -32,21 +34,24 @@ function App(): React.ReactElement {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<HeroUIProvider>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<ToastPortal />
</HeroUIProvider>
</QueryClientProvider>
</Provider>
)
@@ -30,22 +30,18 @@ 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,
getSessionWasCleared?: () => boolean
onSessionUpdate?: (sessionId: string) => void
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
this.enableWebSearch = enableWebSearch || false
this.onSessionUpdate = onSessionUpdate
this.getSessionWasCleared = getSessionWasCleared
}
private markFirstTokenIfNeeded() {
@@ -88,9 +84,8 @@ export class AiSdkToChunkAdapter {
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset state at the start of stream
// Reset link converter state at the start of stream
this.isFirstChunk = true
this.hasTextContent = false
try {
while (true) {
@@ -134,8 +129,6 @@ 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,
@@ -150,7 +143,6 @@ export class AiSdkToChunkAdapter {
})
break
case 'text-delta': {
this.hasTextContent = true
const processedText = chunk.text || ''
let finalText: string
@@ -309,25 +301,6 @@ 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,
@@ -1,7 +1,6 @@
import { BedrockClient, ListFoundationModelsCommand, ListInferenceProfilesCommand } from '@aws-sdk/client-bedrock'
import {
BedrockRuntimeClient,
type BedrockRuntimeClientConfig,
ConverseCommand,
InvokeModelCommand,
InvokeModelWithResponseStreamCommand
@@ -12,8 +11,6 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { findTokenLimit, isReasoningModel } from '@renderer/config/models'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockApiKey,
getAwsBedrockAuthType,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
@@ -78,48 +75,32 @@ export class AwsBedrockAPIClient extends BaseApiClient<
}
const region = getAwsBedrockRegion()
const authType = getAwsBedrockAuthType()
const accessKeyId = getAwsBedrockAccessKeyId()
const secretAccessKey = getAwsBedrockSecretAccessKey()
if (!region) {
throw new Error('AWS region is required. Please configure AWS region in settings.')
throw new Error('AWS region is required. Please configure AWS-Region in extra headers.')
}
// Build client configuration based on auth type
let clientConfig: BedrockRuntimeClientConfig
if (authType === 'iam') {
// IAM credentials authentication
const accessKeyId = getAwsBedrockAccessKeyId()
const secretAccessKey = getAwsBedrockSecretAccessKey()
if (!accessKeyId || !secretAccessKey) {
throw new Error('AWS credentials are required. Please configure Access Key ID and Secret Access Key.')
}
clientConfig = {
region,
credentials: {
accessKeyId,
secretAccessKey
}
}
} else {
// API Key authentication
const awsBedrockApiKey = getAwsBedrockApiKey()
if (!awsBedrockApiKey) {
throw new Error('AWS Bedrock API Key is required. Please configure API Key in settings.')
}
clientConfig = {
region,
token: { token: awsBedrockApiKey },
authSchemePreference: ['httpBearerAuth']
}
if (!accessKeyId || !secretAccessKey) {
throw new Error('AWS credentials are required. Please configure AWS-Access-Key-ID and AWS-Secret-Access-Key.')
}
const client = new BedrockRuntimeClient(clientConfig)
const bedrockClient = new BedrockClient(clientConfig)
const client = new BedrockRuntimeClient({
region,
credentials: {
accessKeyId,
secretAccessKey
}
})
const bedrockClient = new BedrockClient({
region,
credentials: {
accessKeyId,
secretAccessKey
}
})
this.sdkInstance = { client, bedrockClient, region }
return this.sdkInstance
@@ -85,19 +85,6 @@ export function supportsLargeFileUpload(model: Model): boolean {
})
}
/**
* TopP
*/
export function supportsTopP(model: Model): boolean {
const provider = getProviderByModel(model)
if (provider?.type === 'anthropic' || model?.endpoint_type === 'anthropic') {
return false
}
return true
}
/**
*
*/
@@ -34,7 +34,6 @@ import { setupToolsConfig } from '../utils/mcp'
import { buildProviderOptions } from '../utils/options'
import { getAnthropicThinkingBudget } from '../utils/reasoning'
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
import { supportsTopP } from './modelCapabilities'
import { getTemperature, getTopP } from './modelParameters'
const logger = loggerService.withContext('parameterBuilder')
@@ -177,27 +176,20 @@ export async function buildStreamTextParams(
messages: sdkMessages,
maxOutputTokens: maxTokens,
temperature: getTemperature(assistant, model),
topP: getTopP(assistant, model),
abortSignal: options.requestOptions?.signal,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(20),
maxRetries: 0
}
if (supportsTopP(model)) {
params.topP = getTopP(assistant, model)
}
if (tools) {
params.tools = tools
}
if (assistant.prompt) {
params.system = await replacePromptVariables(assistant.prompt, model.name)
}
logger.debug('params', params)
return {
params,
modelId: model.id,
@@ -21,45 +21,10 @@ vi.mock('@renderer/store', () => ({
}
}))
vi.mock('@renderer/utils/api', () => ({
formatApiHost: vi.fn((host, isSupportedAPIVersion = true) => {
if (isSupportedAPIVersion === false) {
return host // Return host as-is when isSupportedAPIVersion is false
}
return `${host}/v1` // Default behavior when isSupportedAPIVersion is true
}),
routeToEndpoint: vi.fn((host) => ({
baseURL: host,
endpoint: '/chat/completions'
}))
}))
vi.mock('@renderer/config/providers', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
isCherryAIProvider: vi.fn(),
isPerplexityProvider: vi.fn(),
isAnthropicProvider: vi.fn(() => false),
isAzureOpenAIProvider: vi.fn(() => false),
isGeminiProvider: vi.fn(() => false),
isNewApiProvider: vi.fn(() => false)
}
})
vi.mock('@renderer/hooks/useVertexAI', () => ({
isVertexProvider: vi.fn(() => false),
isVertexAIConfigured: vi.fn(() => false),
createVertexProvider: vi.fn()
}))
import { isCherryAIProvider, isPerplexityProvider } from '@renderer/config/providers'
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { Model, Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants'
import { getActualProvider, providerToAiSdkConfig } from '../providerConfig'
import { providerToAiSdkConfig } from '../providerConfig'
const createWindowKeyv = () => {
const store = new Map<string, string>()
@@ -81,31 +46,11 @@ const createCopilotProvider = (): Provider => ({
isSystem: true
})
const createModel = (id: string, name = id, provider = 'copilot'): Model => ({
const createModel = (id: string, name = id): Model => ({
id,
name,
provider,
group: provider
})
const createCherryAIProvider = (): Provider => ({
id: 'cherryai',
type: 'openai',
name: 'CherryAI',
apiKey: 'test-key',
apiHost: 'https://api.cherryai.com',
models: [],
isSystem: false
})
const createPerplexityProvider = (): Provider => ({
id: 'perplexity',
type: 'openai',
name: 'Perplexity',
apiKey: 'test-key',
apiHost: 'https://api.perplexity.ai',
models: [],
isSystem: false
provider: 'copilot',
group: 'copilot'
})
describe('Copilot responses routing', () => {
@@ -142,134 +87,3 @@ describe('Copilot responses routing', () => {
expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id'])
})
})
describe('CherryAI provider configuration', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
vi.clearAllMocks()
})
it('formats CherryAI provider apiHost with false parameter', () => {
const provider = createCherryAIProvider()
const model = createModel('gpt-4', 'GPT-4', 'cherryai')
// Mock the functions to simulate CherryAI provider detection
vi.mocked(isCherryAIProvider).mockReturnValue(true)
vi.mocked(getProviderByModel).mockReturnValue(provider)
// Call getActualProvider which should trigger formatProviderApiHost
const actualProvider = getActualProvider(model)
// Verify that formatApiHost was called with false as the second parameter
expect(formatApiHost).toHaveBeenCalledWith('https://api.cherryai.com', false)
expect(actualProvider.apiHost).toBe('https://api.cherryai.com')
})
it('does not format non-CherryAI provider with false parameter', () => {
const provider = {
id: 'openai',
type: 'openai',
name: 'OpenAI',
apiKey: 'test-key',
apiHost: 'https://api.openai.com',
models: [],
isSystem: false
} as Provider
const model = createModel('gpt-4', 'GPT-4', 'openai')
// Mock the functions to simulate non-CherryAI provider
vi.mocked(isCherryAIProvider).mockReturnValue(false)
vi.mocked(getProviderByModel).mockReturnValue(provider)
// Call getActualProvider
const actualProvider = getActualProvider(model)
// Verify that formatApiHost was called with default parameters (true)
expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com')
expect(actualProvider.apiHost).toBe('https://api.openai.com/v1')
})
it('handles CherryAI provider with empty apiHost', () => {
const provider = createCherryAIProvider()
provider.apiHost = ''
const model = createModel('gpt-4', 'GPT-4', 'cherryai')
vi.mocked(isCherryAIProvider).mockReturnValue(true)
vi.mocked(getProviderByModel).mockReturnValue(provider)
const actualProvider = getActualProvider(model)
expect(formatApiHost).toHaveBeenCalledWith('', false)
expect(actualProvider.apiHost).toBe('')
})
})
describe('Perplexity provider configuration', () => {
beforeEach(() => {
;(globalThis as any).window = {
...(globalThis as any).window,
keyv: createWindowKeyv()
}
vi.clearAllMocks()
})
it('formats Perplexity provider apiHost with false parameter', () => {
const provider = createPerplexityProvider()
const model = createModel('sonar', 'Sonar', 'perplexity')
// Mock the functions to simulate Perplexity provider detection
vi.mocked(isCherryAIProvider).mockReturnValue(false)
vi.mocked(isPerplexityProvider).mockReturnValue(true)
vi.mocked(getProviderByModel).mockReturnValue(provider)
// Call getActualProvider which should trigger formatProviderApiHost
const actualProvider = getActualProvider(model)
// Verify that formatApiHost was called with false as the second parameter
expect(formatApiHost).toHaveBeenCalledWith('https://api.perplexity.ai', false)
expect(actualProvider.apiHost).toBe('https://api.perplexity.ai')
})
it('does not format non-Perplexity provider with false parameter', () => {
const provider = {
id: 'openai',
type: 'openai',
name: 'OpenAI',
apiKey: 'test-key',
apiHost: 'https://api.openai.com',
models: [],
isSystem: false
} as Provider
const model = createModel('gpt-4', 'GPT-4', 'openai')
// Mock the functions to simulate non-Perplexity provider
vi.mocked(isCherryAIProvider).mockReturnValue(false)
vi.mocked(isPerplexityProvider).mockReturnValue(false)
vi.mocked(getProviderByModel).mockReturnValue(provider)
// Call getActualProvider
const actualProvider = getActualProvider(model)
// Verify that formatApiHost was called with default parameters (true)
expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com')
expect(actualProvider.apiHost).toBe('https://api.openai.com/v1')
})
it('handles Perplexity provider with empty apiHost', () => {
const provider = createPerplexityProvider()
provider.apiHost = ''
const model = createModel('sonar', 'Sonar', 'perplexity')
vi.mocked(isCherryAIProvider).mockReturnValue(false)
vi.mocked(isPerplexityProvider).mockReturnValue(true)
vi.mocked(getProviderByModel).mockReturnValue(provider)
const actualProvider = getActualProvider(model)
expect(formatApiHost).toHaveBeenCalledWith('', false)
expect(actualProvider.apiHost).toBe('')
})
})
@@ -84,8 +84,6 @@ 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)
@@ -9,15 +9,11 @@ import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models'
import {
isAnthropicProvider,
isAzureOpenAIProvider,
isCherryAIProvider,
isGeminiProvider,
isNewApiProvider,
isPerplexityProvider
isNewApiProvider
} from '@renderer/config/providers'
import {
getAwsBedrockAccessKeyId,
getAwsBedrockApiKey,
getAwsBedrockAuthType,
getAwsBedrockRegion,
getAwsBedrockSecretAccessKey
} from '@renderer/hooks/useAwsBedrock'
@@ -102,10 +98,6 @@ function formatProviderApiHost(provider: Provider): Provider {
formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost)
} else if (isVertexProvider(formatted)) {
formatted.apiHost = formatVertexApiHost(formatted)
} else if (isCherryAIProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else if (isPerplexityProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else {
formatted.apiHost = formatApiHost(formatted.apiHost)
}
@@ -171,7 +163,7 @@ export function providerToAiSdkConfig(
extraOptions.endpoint = endpoint
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
extraOptions.mode = 'responses'
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
} else if (aiSdkProviderId === 'openai') {
extraOptions.mode = 'chat'
}
@@ -200,15 +192,9 @@ export function providerToAiSdkConfig(
// bedrock
if (aiSdkProviderId === 'bedrock') {
const authType = getAwsBedrockAuthType()
extraOptions.region = getAwsBedrockRegion()
if (authType === 'apiKey') {
extraOptions.apiKey = getAwsBedrockApiKey()
} else {
extraOptions.accessKeyId = getAwsBedrockAccessKeyId()
extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey()
}
extraOptions.accessKeyId = getAwsBedrockAccessKeyId()
extraOptions.secretAccessKey = getAwsBedrockSecretAccessKey()
}
// google-vertex
if (aiSdkProviderId === 'google-vertex' || aiSdkProviderId === 'google-vertex-anthropic') {
-61
View File
@@ -17,7 +17,6 @@ import { getAiSdkProviderId } from '../provider/factory'
import { buildGeminiGenerateImageParams } from './image'
import {
getAnthropicReasoningParams,
getBedrockReasoningParams,
getCustomParameters,
getGeminiReasoningParams,
getOpenAIReasoningParams,
@@ -113,9 +112,6 @@ export function buildProviderOptions(
}
break
}
case 'cherryin':
providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider)
break
default:
throw new Error(`Unsupported base provider ${baseProviderId}`)
}
@@ -131,9 +127,6 @@ export function buildProviderOptions(
case 'google-vertex-anthropic':
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
break
case 'bedrock':
providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities)
break
default:
// 对于其他 provider,使用通用的构建逻辑
providerSpecificOptions = {
@@ -273,60 +266,6 @@ 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
*/
function buildBedrockProviderOptions(
assistant: Assistant,
model: Model,
capabilities: {
enableReasoning: boolean
enableWebSearch: boolean
enableGenerateImage: boolean
}
): Record<string, any> {
const { enableReasoning } = capabilities
let providerOptions: Record<string, any> = {}
if (enableReasoning) {
const reasoningParams = getBedrockReasoningParams(assistant, model)
providerOptions = {
...providerOptions,
...reasoningParams
}
}
return providerOptions
}
/**
* providerOptions provider
*/
+5 -35
View File
@@ -418,8 +418,6 @@ 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)) {
@@ -433,8 +431,8 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
if (reasoningEffort === undefined) {
return {
thinkingConfig: {
includeThoughts: false,
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinkingBudget: 0 } : {})
include_thoughts: false,
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinking_budget: 0 } : {})
}
}
}
@@ -444,7 +442,7 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
if (effortRatio > 1) {
return {
thinkingConfig: {
includeThoughts: true
include_thoughts: true
}
}
}
@@ -454,8 +452,8 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {
thinkingConfig: {
...(budget > 0 ? { thinkingBudget: budget } : {}),
includeThoughts: true
...(budget > 0 ? { thinking_budget: budget } : {}),
include_thoughts: true
}
}
}
@@ -487,34 +485,6 @@ export function getXAIReasoningParams(assistant: Assistant, model: Model): Recor
}
}
/**
* Get Bedrock reasoning parameters
*/
export function getBedrockReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isReasoningModel(model)) {
return {}
}
const reasoningEffort = assistant?.settings?.reasoning_effort
if (reasoningEffort === undefined) {
return {}
}
// Only apply thinking budget for Claude reasoning models
if (!isSupportedThinkingTokenClaudeModel(model)) {
return {}
}
const budgetTokens = getAnthropicThinkingBudget(assistant, model)
return {
reasoningConfig: {
type: 'enabled',
budgetTokens: budgetTokens
}
}
}
/**
*
* assistant
@@ -107,11 +107,6 @@ 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 {}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="38" viewBox="0 0 33 38" fill="none">
<g clip-path="url(#clip0_4321_9943)">
<path d="M1.51221 6.59813C1.51221 4.09263 3.54331 2.06152 6.04881 2.06152H27.9757C30.4812 2.06152 32.5123 4.09263 32.5123 6.59813C32.5123 9.10362 30.4812 11.1347 27.9757 11.1347H6.04881C3.54331 11.1347 1.51221 9.10362 1.51221 6.59813Z" fill="#6200EE"/>
<path d="M3.38905 3.56467C5.26076 1.89906 8.12831 2.06615 9.79391 3.93785L22.1493 17.8221C23.8149 19.6938 23.6478 22.5614 21.7761 24.227C19.9044 25.8926 17.0369 25.7255 15.3713 23.8538L3.01586 9.96953C1.35026 8.09782 1.51734 5.23027 3.38905 3.56467Z" fill="#6200EE"/>
<path d="M1.51221 20.9643C1.51221 18.4588 3.54331 16.4277 6.04881 16.4277H18.9025C21.408 16.4277 23.4391 18.4588 23.4391 20.9643C23.4391 23.4698 21.408 25.5009 18.9025 25.5009H6.04881C3.54331 25.5009 1.51221 23.4698 1.51221 20.9643Z" fill="#6200EE"/>
<path d="M10.5854 32.3052C10.5854 34.8107 8.55431 36.8418 6.04881 36.8418C3.54331 36.8418 1.51221 34.8107 1.51221 32.3052C1.51221 29.7997 3.54331 27.7686 6.04881 27.7686C8.55431 27.7686 10.5854 29.7997 10.5854 32.3052Z" fill="#BF7AFF"/>
</g>
<defs>
<clipPath id="clip0_4321_9943">
<rect width="32.5124" height="36.9029" fill="white" transform="translate(0 0.548828)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+2 -2
View File
@@ -41,11 +41,11 @@ body,
margin: 0;
}
#root {
/* #root {
display: flex;
flex-direction: row;
flex: 1;
}
} */
body {
display: flex;
@@ -1,6 +1,10 @@
@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 *));
/* 如需自定义:
@@ -152,6 +156,11 @@
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 {
@@ -0,0 +1,31 @@
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 } from 'antd'
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import React from 'react'
import EmojiPicker from '../EmojiPicker'
@@ -10,10 +10,13 @@ type Props = {
export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return (
<Popover content={<EmojiPicker onEmojiClick={onPick} />} trigger="click">
<Button type="text" style={{ width: 32, height: 32, fontSize: 18 }}>
{emoji}
</Button>
<Popover>
<PopoverTrigger>
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly />
</PopoverTrigger>
<PopoverContent>
<EmojiPicker onEmojiClick={onPick}></EmojiPicker>
</PopoverContent>
</Popover>
)
}
@@ -1,4 +1,4 @@
import { cn } from '@renderer/utils'
import { cn } from '@heroui/react'
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
import React, { memo } from 'react'
+8 -18
View File
@@ -1,5 +1,5 @@
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
import { Button } from 'antd'
import { Button } from '@heroui/react'
import { CheckIcon, XIcon } from 'lucide-react'
import type { FC } from 'react'
import { createPortal } from 'react-dom'
@@ -28,22 +28,12 @@ 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
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' }}
/>
<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>
</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="small" onClick={debug}>
<Button size="sm" onPress={debug}>
{t('error.boundary.default.devtools')}
</Button>
<Button size="small" onClick={reload}>
<Button size="sm" onPress={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'
@@ -106,6 +106,51 @@ const WebviewContainer = memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appid, url])
// Setup keyboard shortcuts handler for print and save
useEffect(() => {
if (!webviewRef.current) return
const unsubscribe = window.api?.webview?.onFindShortcut?.(async (payload) => {
// Get webviewId when event is triggered
const webviewId = webviewRef.current?.getWebContentsId()
// Only handle events for this webview
if (!webviewId || payload.webviewId !== webviewId) return
const key = payload.key?.toLowerCase()
const isModifier = payload.control || payload.meta
if (!isModifier || !key) return
try {
if (key === 'p') {
// Print to PDF
logger.info(`Printing webview ${appid} to PDF`)
const filePath = await window.api.webview.printToPDF(webviewId)
if (filePath) {
window.toast?.success?.(`PDF saved to: ${filePath}`)
logger.info(`PDF saved to: ${filePath}`)
}
} else if (key === 's') {
// Save as HTML
logger.info(`Saving webview ${appid} as HTML`)
const filePath = await window.api.webview.saveAsHTML(webviewId)
if (filePath) {
window.toast?.success?.(`HTML saved to: ${filePath}`)
logger.info(`HTML saved to: ${filePath}`)
}
}
} catch (error) {
logger.error(`Failed to handle shortcut for webview ${appid}:`, error as Error)
window.toast?.error?.(`Failed: ${(error as Error).message}`)
}
})
return () => {
unsubscribe?.()
}
}, [appid])
// Update webview settings when they change
useEffect(() => {
if (!webviewRef.current) return
@@ -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 cursor-pointer 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 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 cursor-pointer 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 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,8 +1,11 @@
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'
@@ -22,7 +25,7 @@ const LoadingQRCode: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<Spin />
<Spinner />
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.generating_qr')}
</span>
@@ -41,8 +44,8 @@ const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
size={200}
imageSettings={{
src: AppLogo,
width: 40,
height: 40,
width: 60,
height: 60,
excavate: true
}}
/>
@@ -69,7 +72,7 @@ const ConnectingAnimation: React.FC = () => {
borderRadius: '12px',
backgroundColor: 'var(--color-status-warning)'
}}>
<Spin size="large" />
<Spinner size="lg" color="warning" />
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
{t('settings.data.export_to_phone.lan.status.connecting')}
</span>
@@ -134,6 +137,7 @@ 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()
@@ -295,20 +299,22 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
// 尝试关闭弹窗 - 如果正在传输则显示确认
const handleCancel = useCallback(() => {
if (isSending) {
window.modal.confirm({
title: t('settings.data.export_to_phone.lan.confirm_close_title'),
content: t('settings.data.export_to_phone.lan.confirm_close_message'),
centered: true,
okButtonProps: {
danger: true
},
okText: t('settings.data.export_to_phone.lan.force_close'),
onOk: () => setIsOpen(false)
})
setShowCloseConfirm(true)
} else {
setIsOpen(false)
}
}, [isSending, t])
}, [isSending])
// 确认强制关闭
const handleForceClose = useCallback(() => {
logger.info('Force closing popup during transfer')
setIsOpen(false)
}, [])
// 取消关闭确认
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false)
}, [])
// 清理并关闭
const handleClose = useCallback(async () => {
@@ -370,13 +376,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '5px 12px',
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
backgroundColor: connectionStatusStyles.bg,
border: `1px solid ${connectionStatusStyles.border}`,
marginBottom: 10
border: `1px solid ${connectionStatusStyles.border}`
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
</div>
@@ -408,7 +412,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
if (!isSending && transferPhase !== 'completed') return null
return (
<div style={{ paddingTop: '20px' }}>
<div style={{ paddingTop: '8px' }}>
<div
style={{
display: 'flex',
@@ -437,9 +441,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</div>
<Progress
percent={Math.round(sendProgress)}
status={transferPhase === 'completed' ? 'success' : 'active'}
showInfo={false}
value={Math.round(sendProgress)}
size="md"
color={transferPhase === 'completed' ? 'success' : 'primary'}
showValueLabel={false}
aria-label="Send progress"
/>
</div>
</div>
@@ -482,50 +488,95 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return (
<Modal
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>
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>
<Alert message={t('settings.data.export_to_phone.lan.content')} type="info" style={{ borderRadius: 0 }} />
<SettingRow>
<div>{t('settings.data.export_to_phone.lan.content')}</div>
</SettingRow>
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px', marginBlock: 25 }}>
<QRCodeDisplay />
</SettingRow>
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
<QRCodeDisplay />
</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>
<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>
<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 />
<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>
</Modal>
)
}
@@ -1,205 +0,0 @@
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,32 +1,44 @@
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 type { BaseOption } from './shared'
const { TextArea } = Input
import { ErrorBoundary } from '../../ErrorBoundary'
import type { BaseOption, ModelOption } from './shared'
import { Option, renderOption } from './shared'
const logger = loggerService.withContext('AddAgentPopup')
@@ -36,6 +48,8 @@ interface AgentTypeOption extends BaseOption {
name: AgentEntity['name']
}
type Option = AgentTypeOption | ModelOption
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
@@ -50,37 +64,58 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
})
interface ShowParams {
type Props = {
agent?: AgentWithTools
isOpen: boolean
onClose: () => void
afterSubmit?: (a: AgentEntity) => void
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
/**
* 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 })
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 (open) {
if (isOpen) {
setForm(buildAgentForm(agent))
}
}, [agent, open])
}, [agent, isOpen])
const selectedPermissionMode = form.configuration?.permission_mode ?? 'default'
const onPermissionModeChange = useCallback((value: PermissionMode) => {
const onPermissionModeChange = useCallback((keys: Selection) => {
if (keys === 'all') {
return
}
const [first] = Array.from(keys)
if (!first) {
return
}
setForm((prev) => {
const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {})
if (parsedConfiguration.permission_mode === value) {
const nextMode = first as PermissionMode
if (parsedConfiguration.permission_mode === nextMode) {
if (!prev.configuration) {
return {
...prev,
@@ -94,7 +129,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
...prev,
configuration: {
...parsedConfiguration,
permission_mode: value
permission_mode: nextMode
}
}
})
@@ -115,57 +150,55 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
[]
)
const agentOptions = useMemo(
const agentOptions: AgentTypeOption[] = useMemo(
() =>
agentConfig.map((option) => ({
value: option.key,
label: (
<OptionWrapper>
<Avatar src={option.avatar} size={24} />
<span>{option.label}</span>
</OptionWrapper>
)
})),
agentConfig.map(
(option) =>
({
...option,
rendered: <Option option={option} />
}) as const satisfies SelectedItemProps
),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(value: AgentType) => {
(e: ChangeEvent<HTMLSelectElement>) => {
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 === value)
const newConfig = agentConfig.find((config) => config.key === e.target.value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: value,
type: e.target.value as AgentType,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const onNameChange = useCallback((name: string) => {
setForm((prev) => ({
...prev,
name: e.target.value
name
}))
}, [])
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const onDescChange = useCallback((description: string) => {
setForm((prev) => ({
...prev,
description: e.target.value
description
}))
}, [])
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const onInstChange = useCallback((instructions: string) => {
setForm((prev) => ({
...prev,
instructions: e.target.value
instructions
}))
}, [])
@@ -198,36 +231,34 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
}))
}, [])
// 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 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])
const handleModelSelect = useCallback(async (model: ApiModel) => {
setForm((prev) => ({ ...prev, model: model.id }))
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setForm((prev) => ({
...prev,
model: e.target.value
}))
}, [])
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -299,7 +330,9 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
afterSubmit?.(result.data)
}
loadingRef.current = false
setOpen(false)
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
},
[
form.type,
@@ -311,6 +344,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
form.allowed_tools,
form.configuration,
agent,
onClose,
t,
updateAgent,
afterSubmit,
@@ -318,312 +352,138 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
]
)
AgentModalPopup.hide = onCancel
return (
<ErrorBoundary>
<Modal
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>
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>
)}
</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')}
</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}
</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')}
</Button>
</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>
</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>
</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;
}
}
`
@@ -0,0 +1,320 @@
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,3 +1,8 @@
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
@@ -5,3 +10,43 @@ 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>
)
}
@@ -1,104 +0,0 @@
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,4 +1,3 @@
export * from './defaultStrategies'
export * from './hook'
export * from './provider'
export * from './types'
@@ -4,12 +4,11 @@ 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 }) => {
@@ -18,39 +17,19 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const [list, setList] = useState<QuickPanelListItem[]>([])
const [title, setTitle] = useState<string | undefined>()
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
const [defaultIndex, setDefaultIndex] = useState<number>(0)
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) => {
// 先尝试引用匹配(快速路径)
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
})
})
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
// 添加更新整个列表的方法
@@ -64,23 +43,17 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
clearTimer.current = null
}
setLastCloseAction(undefined)
setTitle(options.title)
setList(options.list)
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
setDefaultIndex(nextDefaultIndex)
setDefaultIndex(options.defaultIndex ?? 0)
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)
}, [])
@@ -88,8 +61,6 @@ 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(() => {
@@ -97,13 +68,9 @@ 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]
@@ -133,15 +100,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
manageListExternally,
triggerInfo,
lastCloseAction,
filterFn,
sortFn,
onClose,
beforeAction,
afterAction,
onSearchChange
afterAction
}),
[
open,
@@ -155,15 +117,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
manageListExternally,
triggerInfo,
lastCloseAction,
filterFn,
sortFn,
onClose,
beforeAction,
afterAction,
onSearchChange
afterAction
]
)
@@ -10,8 +10,7 @@ export enum QuickPanelReservedSymbol {
WebSearch = '?',
Mcp = 'mcp',
McpPrompt = 'mcp-prompt',
McpResource = 'mcp-resource',
SlashCommands = 'slash-commands'
McpResource = 'mcp-resource'
}
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
@@ -28,29 +27,6 @@ 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
@@ -72,14 +48,6 @@ 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 = {
@@ -96,7 +64,6 @@ export type QuickPanelListItem = {
isSelected?: boolean
isMenu?: boolean
disabled?: boolean
hidden?: boolean
/**
*
* alwaysVisible
@@ -120,15 +87,10 @@ 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'
+79 -220
View File
@@ -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,50 +62,21 @@ 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
@@ -115,35 +86,52 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
// 拆分:固定显示项(不参与过滤)与普通项
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
const normalItems = baseList.filter((item) => !item.alwaysVisible)
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
// Filter normal items using injected filter function
const filteredNormalItems = normalItems.filter((item) => {
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
})
if (!_searchText) return true
// Sort filtered items using injected sort function
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
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())
}
})
// 只有在搜索文本变化或面板符号变化时才重置index
const isSearchChanged = prevSearchTextRef.current !== searchText
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
if (isSearchChanged || isSymbolChanged) {
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) // 搜索文本变化时不默认高亮
}
setIndex(-1) // 不默认高亮任何项,让用户主动选择
} else {
// 如果当前index超出范围,调整到有效范围内
setIndex((prevIndex) => {
const combinedLength = pinnedItems.length + sortedNormalItems.length
const combinedLength = pinnedItems.length + filteredNormalItems.length
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
@@ -154,9 +142,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
// 固定项置顶 + 排序后的普通项
return [...pinnedItems, ...sortedNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
// 固定项置顶 + 过滤后的普通项
return [...pinnedItems, ...filteredNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
@@ -190,64 +178,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (deleteStart >= deleteEnd) return
const activeSearchText = searchTextRef.current ?? ''
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
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
})
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus()
textArea.setSelectionRange(deleteStart, deleteStart)
},
0
)
setSearchText('')
},
@@ -267,21 +210,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (textArea) {
setInputText(textArea.value)
}
} else if (
action &&
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
ctx.triggerInfo?.type === 'input'
) {
setTimeoutTimer(
'quickpanel_deferred_clear',
() => {
clearSearchText(true)
},
0
)
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
clearSearchText(true)
}
},
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
[ctx, clearSearchText, setInputText, searchText]
)
const handleItemAction = useCallback(
@@ -351,86 +284,12 @@ 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
@@ -445,8 +304,6 @@ 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')
@@ -470,17 +327,16 @@ 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
) // 等待面板关闭动画结束后,再清空搜索词
}
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
useEffect(() => {
if (ctx.isVisible) return
const timer = setTimeout(() => {
setSearchText('')
}, 200)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
useLayoutEffect(() => {
@@ -688,7 +544,19 @@ 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 = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
const collapsed = hasSearchText && visibleNonPinnedCount === 0
useEffect(() => {
if (!ctx.isVisible) return
if (!collapsed) return
if (ctx.triggerInfo?.type !== 'input') return
if (ctx.multiple) return
const trimmedSearch = searchText.replace(/^[/@]/, '').trim()
if (!trimmedSearch) return
handleClose('no_result')
}, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText])
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@@ -747,9 +615,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
{collapsed ? (
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
) : (
{!collapsed && (
<DynamicVirtualList
ref={listRef}
list={list}
@@ -859,13 +725,6 @@ 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%;

Some files were not shown because too many files have changed in this diff Show More