Merge branch 'main' into feat/mcp-hub

This commit is contained in:
Vaayne 2026-01-06 21:44:06 +08:00
commit bbeb052390
141 changed files with 34412 additions and 28006 deletions

View File

@ -32,38 +32,37 @@ jobs:
with:
node-version: 22
- name: 📦 Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: 📦 Install pnpm
uses: pnpm/action-setup@v4
- name: 📂 Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: 📂 Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: 💾 Cache yarn dependencies
- name: 💾 Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: 📦 Install dependencies
run: |
yarn install
pnpm install
- name: 🏃‍♀️ Translate
run: yarn i18n:sync && yarn i18n:translate
run: pnpm i18n:sync && pnpm i18n:translate
- name: 🔍 Format
run: yarn format
run: pnpm format
- name: 🔍 Check for changes
id: git_status
run: |
# Check if there are any uncommitted changes
git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改
git reset -- package.json pnpm-lock.yaml # 不提交 package.json 和 pnpm-lock.yaml 的更改
git diff --exit-code --quiet || echo "::set-output name=has_changes::true"
git status --porcelain
@ -73,7 +72,7 @@ jobs:
- name: 🚀 Create Pull Request if changes exist
if: steps.git_status.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions
commit-message: "feat(bot): Weekly automated script run"

View File

@ -65,25 +65,24 @@ jobs:
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: Install Dependencies
run: yarn install
run: pnpm install
- name: Generate date tag
id: date
@ -94,7 +93,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:linux
pnpm build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
@ -106,7 +105,7 @@ jobs:
- name: Build Mac
if: matrix.os == 'macos-latest'
run: |
yarn build:mac
pnpm build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@ -123,7 +122,7 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:win
pnpm build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192

View File

@ -28,37 +28,36 @@ jobs:
with:
node-version: 22
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: Install Dependencies
run: yarn install
run: pnpm install
- name: Lint Check
run: yarn test:lint
run: pnpm test:lint
- name: Format Check
run: yarn format:check
run: pnpm format:check
- name: Type Check
run: yarn typecheck
run: pnpm typecheck
- name: i18n Check
run: yarn i18n:check
run: pnpm i18n:check
- name: Test
run: yarn test
run: pnpm test

View File

@ -56,31 +56,30 @@ jobs:
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
- name: Cache pnpm dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-yarn-
${{ runner.os }}-pnpm-
- name: Install Dependencies
run: yarn install
run: pnpm install
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:linux
pnpm build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -94,7 +93,7 @@ jobs:
if: matrix.os == 'macos-latest'
run: |
sudo -H pip install setuptools
yarn build:mac
pnpm build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
@ -111,7 +110,7 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:win
pnpm build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192

View File

@ -48,9 +48,8 @@ jobs:
with:
node-version: 22
- name: Install corepack
shell: bash
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Clean node_modules
if: ${{ github.event.inputs.clean == 'true' }}
@ -59,11 +58,11 @@ jobs:
- name: Install Dependencies
shell: bash
run: yarn install
run: pnpm install
- name: Build Windows with code signing
shell: bash
run: yarn build:win
run: pnpm build:win
env:
WIN_SIGN: true
CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }}

View File

@ -154,14 +154,14 @@ jobs:
with:
node-version: 22
- name: Enable Corepack
- name: Install pnpm
if: steps.check.outputs.should_run == 'true'
run: corepack enable && corepack prepare yarn@4.9.1 --activate
uses: pnpm/action-setup@v4
- name: Install dependencies
if: steps.check.outputs.should_run == 'true'
working-directory: main
run: yarn install --immutable
run: pnpm install --frozen-lockfile
- name: Update upgrade config
if: steps.check.outputs.should_run == 'true'
@ -170,7 +170,7 @@ jobs:
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }}
run: |
yarn tsx scripts/update-app-upgrade-config.ts \
pnpm tsx scripts/update-app-upgrade-config.ts \
--tag "$RELEASE_TAG" \
--config ../cs/app-upgrade-config.json \
--is-prerelease "$IS_PRERELEASE"

View File

@ -1 +1 @@
yarn lint-staged
pnpm lint-staged

2
.npmrc
View File

@ -1 +1 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_mirror=https://npmmirror.com/mirrors/electron/

Binary file not shown.

View File

@ -1,9 +0,0 @@
enableImmutableInstalls: false
httpTimeout: 300000
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
npmRegistryServer: https://registry.npmjs.org
npmPublishRegistry: https://registry.npmjs.org

View File

@ -10,7 +10,7 @@ This file provides guidance to AI coding assistants when working with code in th
- **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.
- **Lint, test, and format before completion**: Coding tasks are only complete after running `yarn lint`, `yarn test`, and `yarn format` successfully.
- **Lint, test, and format before completion**: Coding tasks are only complete after running `pnpm lint`, `pnpm test`, and `pnpm format` successfully.
- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
## Pull Request Workflow (CRITICAL)
@ -24,18 +24,18 @@ When creating a Pull Request, you MUST:
## Development Commands
- **Install**: `yarn install` - Install all project dependencies
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck)
- If having i18n sort issues, run `yarn i18n:sync` first to sync template
- If having formatting issues, run `yarn format` first
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes
- **Install**: `pnpm install` - Install all project dependencies
- **Development**: `pnpm dev` - Runs Electron app in development mode with hot reload
- **Debug**: `pnpm debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
- **Build Check**: `pnpm build:check` - **REQUIRED** before commits (lint + test + typecheck)
- If having i18n sort issues, run `pnpm i18n:sync` first to sync template
- If having formatting issues, run `pnpm format` first
- **Test**: `pnpm test` - Run all tests (Vitest) across main and renderer processes
- **Single Test**:
- `yarn test:main` - Run tests for main process only
- `yarn test:renderer` - Run tests for renderer process only
- **Lint**: `yarn lint` - Fix linting issues and run TypeScript type checking
- **Format**: `yarn format` - Auto-format code using Biome
- `pnpm test:main` - Run tests for main process only
- `pnpm test:renderer` - Run tests for renderer process only
- **Lint**: `pnpm lint` - Fix linting issues and run TypeScript type checking
- **Format**: `pnpm format` - Auto-format code using Biome
## Project Architecture
@ -49,7 +49,7 @@ When creating a Pull Request, you MUST:
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
- **Build System**: Electron-Vite with experimental rolldown-vite, pnpm workspaces.
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
### Logging

View File

@ -34,7 +34,7 @@
</a>
</h1>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<p align="center">English | <a href="./docs/zh/README.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/docs/en-us">Documents</a> | <a href="./docs/en/guides/development.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
<div align="center">
@ -242,12 +242,12 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
## Version Comparison
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| 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 |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
## Get the Enterprise Edition
@ -275,7 +275,7 @@ We believe the Enterprise Edition will become your team's AI productivity engine
# 📊 GitHub Stats
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star History

View File

@ -50,7 +50,8 @@
"!*.json",
"!src/main/integration/**",
"!**/tailwind.css",
"!**/package.json"
"!**/package.json",
"!.zed/**"
],
"indentStyle": "space",
"indentWidth": 2,

View File

@ -11,7 +11,7 @@
### Install
```bash
yarn
pnpm install
```
### Development
@ -20,17 +20,17 @@ yarn
Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
### Setup Yarn
### Setup pnpm
```bash
corepack enable
corepack prepare yarn@4.9.1 --activate
corepack prepare pnpm@10.27.0 --activate
```
### Install Dependencies
```bash
yarn install
pnpm install
```
### ENV
@ -42,13 +42,13 @@ cp .env.example .env
### Start
```bash
yarn dev
pnpm dev
```
### Debug
```bash
yarn debug
pnpm debug
```
Then input chrome://inspect in browser
@ -56,18 +56,18 @@ Then input chrome://inspect in browser
### Test
```bash
yarn test
pnpm test
```
### Build
```bash
# For windows
$ yarn build:win
$ pnpm build:win
# For macOS
$ yarn build:mac
$ pnpm build:mac
# For Linux
$ yarn build:linux
$ pnpm build:linux
```

View File

@ -116,7 +116,7 @@ This script checks:
- Whether keys are properly sorted
```bash
yarn i18n:check
pnpm i18n:check
```
### `i18n:sync` - Synchronize JSON Structure and Sort Order
@ -128,7 +128,7 @@ This script uses `zh-cn.json` as the source of truth to sync structure across al
3. Sorting keys automatically
```bash
yarn i18n:sync
pnpm i18n:sync
```
### `i18n:translate` - Automatically Translate Pending Texts
@ -148,20 +148,20 @@ MODEL="qwen-plus-latest"
Alternatively, add these variables directly to your `.env` file.
```bash
yarn i18n:translate
pnpm i18n:translate
```
### Workflow
1. During development, first add the required text in `zh-cn.json`
2. Confirm it displays correctly in the Chinese environment
3. Run `yarn i18n:sync` to propagate the keys to other language files
4. Run `yarn i18n:translate` to perform machine translation
3. Run `pnpm i18n:sync` to propagate the keys to other language files
4. Run `pnpm i18n:translate` to perform machine translation
5. Grab a coffee and let the magic happen!
## Best Practices
1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages.
2. **Run Check Script Before Commit**: Use `yarn i18n:check` to catch i18n issues early.
2. **Run Check Script Before Commit**: Use `pnpm i18n:check` to catch i18n issues early.
3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content.
4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error`

View File

@ -37,8 +37,8 @@ The `x-files/app-upgrade-config/app-upgrade-config.json` file is synchronized by
1. **Guard + metadata preparation** the `Check if should proceed` and `Prepare metadata` steps compute the target tag, prerelease flag, whether the tag is the newest release, and a `safe_tag` slug used for branch names. When any rule fails, the workflow stops without touching the config.
2. **Checkout source branches** the default branch is checked out into `main/`, while the long-lived `x-files/app-upgrade-config` branch lives in `cs/`. All modifications happen in the latter directory.
3. **Install toolchain** Node.js 22, Corepack, and frozen Yarn dependencies are installed inside `main/`.
4. **Run the update script** `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>` updates the JSON in-place.
3. **Install toolchain** Node.js 22, Corepack, and frozen pnpm dependencies are installed inside `main/`.
4. **Run the update script** `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>` updates the JSON in-place.
- The script normalizes the tag (e.g., strips `v` prefix), detects the release channel (`latest`, `rc`, `beta`), and loads segment rules from `config/app-upgrade-segments.json`.
- It validates that prerelease flags and semantic suffixes agree, enforces locked segments, builds mirror feed URLs, and performs release-availability checks (GitHub HEAD request for every channel; GitCode GET for latest channels, falling back to `https://releases.cherry-ai.com` when gitcode is delayed).
- After updating the relevant channel entry, the script rewrites the config with semver-sort order and a new `lastUpdated` timestamp.
@ -223,10 +223,10 @@ interface ChannelConfig {
Starting from this change, `.github/workflows/update-app-upgrade-config.yml` listens to GitHub release events (published + prerelease). The workflow:
1. Checks out the default branch (for scripts) and the `x-files/app-upgrade-config` branch (where the config is hosted).
2. Runs `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `x-files/app-upgrade-config` working tree.
2. Runs `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `x-files/app-upgrade-config` working tree.
3. If the file changed, it opens a PR against `x-files/app-upgrade-config` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`.
You can run the same script locally via `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages arent published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
You can run the same script locally via `pnpm update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages aren't published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
## Version Matching Logic

View File

@ -0,0 +1,129 @@
# Fuzzy Search for File List
This document describes the fuzzy search implementation for file listing in Cherry Studio.
## Overview
The fuzzy search feature allows users to find files by typing partial or approximate file names/paths. It uses a two-tier file filtering strategy (ripgrep glob pre-filtering with greedy substring fallback) combined with subsequence-based scoring for optimal performance and flexibility.
## Features
- **Ripgrep Glob Pre-filtering**: Primary filtering using glob patterns for fast native-level filtering
- **Greedy Substring Matching**: Fallback file filtering strategy when ripgrep glob pre-filtering returns no results
- **Subsequence-based Segment Scoring**: During scoring, path segments gain additional weight when query characters appear in order
- **Relevance Scoring**: Results are sorted by a relevance score derived from multiple factors
## Matching Strategies
### 1. Ripgrep Glob Pre-filtering (Primary)
The query is converted to a glob pattern for ripgrep to do initial filtering:
```
Query: "updater"
Glob: "*u*p*d*a*t*e*r*"
```
This leverages ripgrep's native performance for the initial file filtering.
### 2. Greedy Substring Matching (Fallback)
When the glob pre-filter returns no results, the system falls back to greedy substring matching. This allows more flexible matching:
```
Query: "updatercontroller"
File: "packages/update/src/node/updateController.ts"
Matching process:
1. Find "update" (longest match from start)
2. Remaining "rcontroller" → find "r" then "controller"
3. All parts matched → Success
```
## Scoring Algorithm
Results are ranked by a relevance score based on named constants defined in `FileStorage.ts`:
| Constant | Value | Description |
|----------|-------|-------------|
| `SCORE_FILENAME_STARTS` | 100 | Filename starts with query (highest priority) |
| `SCORE_FILENAME_CONTAINS` | 80 | Filename contains exact query substring |
| `SCORE_SEGMENT_MATCH` | 60 | Per path segment that matches query |
| `SCORE_WORD_BOUNDARY` | 20 | Query matches start of a word |
| `SCORE_CONSECUTIVE_CHAR` | 15 | Per consecutive character match |
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | Logarithmic penalty for longer paths |
### Scoring Strategy
The scoring prioritizes:
1. **Filename matches** (highest): Files where the query appears in the filename are most relevant
2. **Path segment matches**: Multiple matching segments indicate stronger relevance
3. **Word boundaries**: Matching at word starts (e.g., "upd" matching "update") is preferred
4. **Consecutive matches**: Longer consecutive character sequences score higher
5. **Path length**: Shorter paths are preferred (logarithmic penalty prevents long paths from dominating)
### Example Scoring
For query `updater`:
| File | Score Factors |
|------|---------------|
| `RCUpdater.js` | Short path + filename contains "updater" |
| `updateController.ts` | Multiple segment matches |
| `UpdaterHelper.plist` | Long path penalty |
## Configuration
### DirectoryListOptions
```typescript
interface DirectoryListOptions {
recursive?: boolean // Default: true
maxDepth?: number // Default: 10
includeHidden?: boolean // Default: false
includeFiles?: boolean // Default: true
includeDirectories?: boolean // Default: true
maxEntries?: number // Default: 20
searchPattern?: string // Default: '.'
fuzzy?: boolean // Default: true
}
```
## Usage
```typescript
// Basic fuzzy search
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'updater',
fuzzy: true,
maxEntries: 20
})
// Disable fuzzy search (exact glob matching)
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'update',
fuzzy: false
})
```
## Performance Considerations
1. **Ripgrep Pre-filtering**: Most queries are handled by ripgrep's native glob matching, which is extremely fast
2. **Fallback Only When Needed**: Greedy substring matching (which loads all files) only runs when glob matching returns empty results
3. **Result Limiting**: Only top 20 results are returned by default
4. **Excluded Directories**: Common large directories are automatically excluded:
- `node_modules`
- `.git`
- `dist`, `build`
- `.next`, `.nuxt`
- `coverage`, `.cache`
## Implementation Details
The implementation is located in `src/main/services/FileStorage.ts`:
- `queryToGlobPattern()`: Converts query to ripgrep glob pattern
- `isFuzzyMatch()`: Subsequence matching algorithm
- `isGreedySubstringMatch()`: Greedy substring matching fallback
- `getFuzzyMatchScore()`: Calculates relevance score
- `listDirectoryWithRipgrep()`: Main search orchestration

View File

@ -34,7 +34,7 @@
</a>
</h1>
<p align="center">
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/zh-cn">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
<a href="https://github.com/CherryHQ/cherry-studio">English</a> | 中文 | <a href="https://cherry-ai.com">官方网站</a> | <a href="https://docs.cherry-ai.com">文档</a> | <a href="./guides/development.md">开发</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">反馈</a><br>
</p>
<!-- 题头徽章组合 -->
@ -281,7 +281,7 @@ https://docs.cherry-ai.com
# 📊 GitHub 统计
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image")
# ⭐️ Star 记录

View File

@ -11,7 +11,7 @@
### Install
```bash
yarn
pnpm install
```
### Development
@ -20,17 +20,17 @@ yarn
Download and install [Node.js v22.x.x](https://nodejs.org/en/download)
### Setup Yarn
### Setup pnpm
```bash
corepack enable
corepack prepare yarn@4.9.1 --activate
corepack prepare pnpm@10.27.0 --activate
```
### Install Dependencies
```bash
yarn install
pnpm install
```
### ENV
@ -42,13 +42,13 @@ cp .env.example .env
### Start
```bash
yarn dev
pnpm dev
```
### Debug
```bash
yarn debug
pnpm debug
```
Then input chrome://inspect in browser
@ -56,18 +56,18 @@ Then input chrome://inspect in browser
### Test
```bash
yarn test
pnpm test
```
### Build
```bash
# For windows
$ yarn build:win
$ pnpm build:win
# For macOS
$ yarn build:mac
$ pnpm build:mac
# For Linux
$ yarn build:linux
$ pnpm build:linux
```

View File

@ -111,7 +111,7 @@ export const getThemeModeLabel = (key: string): string => {
- 是否已经有序
```bash
yarn i18n:check
pnpm i18n:check
```
### `i18n:sync` - 同步 json 结构与排序
@ -123,7 +123,7 @@ yarn i18n:check
3. 自动排序
```bash
yarn i18n:sync
pnpm i18n:sync
```
### `i18n:translate` - 自动翻译待翻译文本
@ -143,19 +143,19 @@ MODEL="qwen-plus-latest"
你也可以通过直接编辑`.env`文件来添加环境变量。
```bash
yarn i18n:translate
pnpm i18n:translate
```
### 工作流
1. 开发阶段,先在`zh-cn.json`中添加所需文案
2. 确认在中文环境下显示无误后,使用`yarn i18n:sync`将文案同步到其他语言文件
3. 使用`yarn i18n:translate`进行自动翻译
2. 确认在中文环境下显示无误后,使用`pnpm i18n:sync`将文案同步到其他语言文件
3. 使用`pnpm i18n:translate`进行自动翻译
4. 喝杯咖啡,等翻译完成吧!
## 最佳实践
1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言
2. **提交前运行检查脚本**:使用`yarn i18n:check`检查 i18n 是否有问题
2. **提交前运行检查脚本**:使用`pnpm i18n:check`检查 i18n 是否有问题
3. **小步提交翻译**:避免积累大量未翻译文本
4. **保持 key 语义明确**key 应能清晰表达其用途,如`user.profile.avatar.upload.error`

View File

@ -37,8 +37,8 @@
1. **检查与元数据准备**`Check if should proceed` 和 `Prepare metadata` 步骤会计算 tag、prerelease 标志、是否最新版本以及用于分支名的 `safe_tag`。若任意校验失败,工作流立即退出。
2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `cs/` 中,所有改动都发生在 `cs/`
3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `yarn install --immutable`。
4. **运行更新脚本**:执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`。
3. **安装工具链**:安装 Node.js 22、启用 Corepack并在 `main/` 目录执行 `pnpm install --frozen-lockfile`。
4. **运行更新脚本**:执行 `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`。
- 脚本会标准化 tag去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。
- 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。
- 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON并刷新 `lastUpdated`
@ -223,10 +223,10 @@ interface ChannelConfig {
`.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release包含正常发布与 Pre Release触发
1. 同时 Checkout 仓库默认分支(用于脚本)和 `x-files/app-upgrade-config` 分支(真实托管配置的分支)。
2. 在默认分支目录执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。
2. 在默认分支目录执行 `pnpm tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。
3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PRDiff 仅包含该文件。
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
如需本地调试,可执行 `pnpm update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
## 版本匹配逻辑

View File

@ -0,0 +1,129 @@
# 文件列表模糊搜索
本文档描述了 Cherry Studio 中文件列表的模糊搜索实现。
## 概述
模糊搜索功能允许用户通过输入部分或近似的文件名/路径来查找文件。它使用两层文件过滤策略ripgrep glob 预过滤 + 贪婪子串匹配回退),结合基于子序列的评分,以获得最佳性能和灵活性。
## 功能特性
- **Ripgrep Glob 预过滤**:使用 glob 模式进行快速原生级过滤的主要过滤策略
- **贪婪子串匹配**:当 ripgrep glob 预过滤无结果时的回退文件过滤策略
- **基于子序列的段评分**:评分时,当查询字符按顺序出现时,路径段获得额外权重
- **相关性评分**:结果按多因素相关性分数排序
## 匹配策略
### 1. Ripgrep Glob 预过滤(主要)
查询被转换为 glob 模式供 ripgrep 进行初始过滤:
```
查询: "updater"
Glob: "*u*p*d*a*t*e*r*"
```
这利用了 ripgrep 的原生性能进行初始文件过滤。
### 2. 贪婪子串匹配(回退)
当 glob 预过滤无结果时,系统回退到贪婪子串匹配。这允许更灵活的匹配:
```
查询: "updatercontroller"
文件: "packages/update/src/node/updateController.ts"
匹配过程:
1. 找到 "update"(从开头的最长匹配)
2. 剩余 "rcontroller" → 找到 "r" 然后 "controller"
3. 所有部分都匹配 → 成功
```
## 评分算法
结果根据 `FileStorage.ts` 中定义的命名常量进行相关性分数排名:
| 常量 | 值 | 描述 |
|------|-----|------|
| `SCORE_FILENAME_STARTS` | 100 | 文件名以查询开头(最高优先级)|
| `SCORE_FILENAME_CONTAINS` | 80 | 文件名包含精确查询子串 |
| `SCORE_SEGMENT_MATCH` | 60 | 每个匹配查询的路径段 |
| `SCORE_WORD_BOUNDARY` | 20 | 查询匹配单词开头 |
| `SCORE_CONSECUTIVE_CHAR` | 15 | 每个连续字符匹配 |
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | 较长路径的对数惩罚 |
### 评分策略
评分优先级:
1. **文件名匹配**(最高):查询出现在文件名中的文件最相关
2. **路径段匹配**:多个匹配段表示更强的相关性
3. **词边界**:在单词开头匹配(如 "upd" 匹配 "update")更优先
4. **连续匹配**:更长的连续字符序列得分更高
5. **路径长度**:较短路径更优先(对数惩罚防止长路径主导评分)
### 评分示例
对于查询 `updater`
| 文件 | 评分因素 |
|------|----------|
| `RCUpdater.js` | 短路径 + 文件名包含 "updater" |
| `updateController.ts` | 多个路径段匹配 |
| `UpdaterHelper.plist` | 长路径惩罚 |
## 配置
### DirectoryListOptions
```typescript
interface DirectoryListOptions {
recursive?: boolean // 默认: true
maxDepth?: number // 默认: 10
includeHidden?: boolean // 默认: false
includeFiles?: boolean // 默认: true
includeDirectories?: boolean // 默认: true
maxEntries?: number // 默认: 20
searchPattern?: string // 默认: '.'
fuzzy?: boolean // 默认: true
}
```
## 使用方法
```typescript
// 基本模糊搜索
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'updater',
fuzzy: true,
maxEntries: 20
})
// 禁用模糊搜索(精确 glob 匹配)
const files = await window.api.file.listDirectory(dirPath, {
searchPattern: 'update',
fuzzy: false
})
```
## 性能考虑
1. **Ripgrep 预过滤**:大多数查询由 ripgrep 的原生 glob 匹配处理,速度极快
2. **仅在需要时回退**:贪婪子串匹配(加载所有文件)仅在 glob 匹配返回空结果时运行
3. **结果限制**:默认只返回前 20 个结果
4. **排除目录**:自动排除常见的大型目录:
- `node_modules`
- `.git`
- `dist`、`build`
- `.next`、`.nuxt`
- `coverage`、`.cache`
## 实现细节
实现位于 `src/main/services/FileStorage.ts`
- `queryToGlobPattern()`:将查询转换为 ripgrep glob 模式
- `isFuzzyMatch()`:子序列匹配算法
- `isGreedySubstringMatch()`:贪婪子串匹配回退
- `getFuzzyMatchScore()`:计算相关性分数
- `listDirectoryWithRipgrep()`:主搜索协调

View File

@ -28,6 +28,12 @@ files:
- "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}"
- "!**/{.editorconfig,.jekyll-metadata}"
- "!src"
- "!config"
- "!patches"
- "!app-upgrade-config.json"
- "!**/node_modules/**/*.cpp"
- "!**/node_modules/node-addon-api/**"
- "!**/node_modules/prebuild-install/**"
- "!scripts"
- "!local"
- "!docs"
@ -134,38 +140,44 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
Cherry Studio 1.7.8 - Bug Fixes & Performance Improvements
Cherry Studio 1.7.9 - New Features & Bug Fixes
This release focuses on bug fixes and performance optimizations.
⚡ Performance
- [ModelList] Improve model list loading performance
✨ New Features
- [Agent] Add 302.AI provider support
- [Browser] Browser data now persists and supports multiple tabs
- [Language] Add Romanian language support
- [Search] Add fuzzy search for file list
- [Models] Add latest Zhipu models
- [Image] Improve text-to-image functionality
🐛 Bug Fixes
- [Ollama] Fix new users unable to use Ollama models
- [Ollama] Improve reasoningEffort handling
- [Assistants] Prevent deleting last assistant and add error message
- [Shortcut] Fix shortcut icons sorting disorder
- [Memory] Fix global memory settings submit failure
- [Windows] Fix remember size not working for SelectionAction window
- [Anthropic] Fix API base URL handling
- [Files] Allow more file extensions
- [Mac] Fix mini window unexpected closing issue
- [Preview] Fix HTML preview controls not working in fullscreen
- [Translate] Fix translation duplicate execution issue
- [Zoom] Fix page zoom reset issue during navigation
- [Agent] Fix crash when switching between agent and assistant
- [Agent] Fix navigation in agent mode
- [Copy] Fix markdown copy button issue
- [Windows] Fix compatibility issues on non-Windows systems
<!--LANG:zh-CN-->
Cherry Studio 1.7.8 - 问题修复与性能优化
Cherry Studio 1.7.9 - 新功能与问题修复
本次更新专注于问题修复和性能优化。
⚡ 性能优化
- [模型列表] 提升模型列表加载性能
✨ 新功能
- [Agent] 新增 302.AI 服务商支持
- [浏览器] 浏览器数据现在可以保存,支持多标签页
- [语言] 新增罗马尼亚语支持
- [搜索] 文件列表新增模糊搜索功能
- [模型] 新增最新智谱模型
- [图片] 优化文生图功能
🐛 问题修复
- [Ollama] 修复新用户无法使用 Ollama 模型的问题
- [Ollama] 改进推理参数处理
- [助手] 防止删除最后一个助手并添加错误提示
- [快捷方式] 修复快捷方式图标排序混乱
- [记忆] 修复全局记忆设置提交失败
- [窗口] 修复 SelectionAction 窗口记住尺寸不生效
- [Anthropic] 修复 API 地址处理
- [文件] 允许更多文件扩展名
- [Mac] 修复迷你窗口意外关闭的问题
- [预览] 修复全屏模式下 HTML 预览控件无法使用的问题
- [翻译] 修复翻译重复执行的问题
- [缩放] 修复页面导航时缩放被重置的问题
- [智能体] 修复在智能体和助手间切换时崩溃的问题
- [智能体] 修复智能体模式下的导航问题
- [复制] 修复 Markdown 复制按钮问题
- [兼容性] 修复非 Windows 系统的兼容性问题
<!--LANG:END-->

View File

@ -67,18 +67,7 @@ export default defineConfig({
plugins: [
(async () => (await import('@tailwindcss/vite')).default())(),
react({
tsDecorators: true,
plugins: [
[
'@swc/plugin-styled-components',
{
displayName: true, // 开发环境下启用组件名称
fileName: false, // 不在类名中包含文件名
pure: true, // 优化性能
ssr: false // 不需要服务端渲染
}
]
]
tsDecorators: true
}),
...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin
...visualizerPlugin('renderer')

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.8",
"version": "1.7.9",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -9,28 +9,13 @@
"engines": {
"node": ">=22.0.0"
},
"workspaces": {
"packages": [
"local",
"packages/*"
],
"installConfig": {
"hoistingLimits": [
"packages/database",
"packages/mcp-trace/trace-core",
"packages/mcp-trace/trace-node",
"packages/mcp-trace/trace-web",
"packages/extension-table-plus"
]
}
},
"scripts": {
"start": "electron-vite preview",
"dev": "dotenv electron-vite dev",
"dev:watch": "dotenv electron-vite dev -- -w",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build",
"build:check": "yarn lint && yarn test",
"build:check": "pnpm lint && pnpm test",
"build:unpack": "dotenv npm run build && electron-builder --dir",
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
@ -42,107 +27,116 @@
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
"publish": "pnpm build:check && pnpm release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
"agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"analyze:renderer": "VISUALIZER_RENDERER=true pnpm build",
"analyze:main": "VISUALIZER_MAIN=true pnpm build",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
"i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
"i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
"i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"i18n:all": "yarn i18n:check && yarn i18n:sync && yarn i18n:translate",
"i18n:all": "pnpm i18n:check && pnpm i18n:sync && pnpm i18n:translate",
"update:languages": "tsx scripts/update-languages.ts",
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
"test": "vitest run --silent",
"test:main": "vitest run --project main",
"test:renderer": "vitest run --project renderer",
"test:aicore": "vitest run --project aiCore",
"test:update": "yarn test:renderer --update",
"test:update": "pnpm test:renderer --update",
"test:coverage": "vitest run --coverage --silent",
"test:ui": "vitest --ui",
"test:watch": "vitest",
"test:e2e": "yarn playwright test",
"test:e2e": "pnpm playwright test",
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
"test:scripts": "vitest scripts",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn i18n:check && yarn format:check",
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && pnpm typecheck && pnpm i18n:check && pnpm format:check",
"format": "biome format --write && biome lint --write",
"format:check": "biome format && biome lint",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"claude": "dotenv -e .env -- claude",
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public",
"release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public"
"release:aicore:alpha": "pnpm --filter @cherrystudio/ai-core version prerelease --preid alpha && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag alpha --access public",
"release:aicore:beta": "pnpm --filter @cherrystudio/ai-core version prerelease --preid beta && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag beta --access public",
"release:aicore": "pnpm --filter @cherrystudio/ai-core version patch && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --access public",
"release:ai-sdk-provider": "pnpm --filter @cherrystudio/ai-sdk-provider version patch && pnpm --filter @cherrystudio/ai-sdk-provider build && pnpm --filter @cherrystudio/ai-sdk-provider publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
"@anthropic-ai/claude-agent-sdk": "0.1.62",
"@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",
"bonjour-service": "^1.3.0",
"emoji-picker-element-data": "^1",
"express": "^5.1.0",
"font-list": "^2.0.0",
"graceful-fs": "^4.2.11",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"@napi-rs/system-ocr": "1.0.2",
"@paymoapp/electron-shutdown-handler": "1.1.2",
"express": "5.1.0",
"font-list": "2.0.0",
"graceful-fs": "4.2.11",
"gray-matter": "4.0.3",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",
"os-proxy-config": "^1.1.2",
"selection-hook": "^1.0.12",
"sharp": "^0.34.3",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"node-stream-zip": "1.15.0",
"officeparser": "4.2.0",
"os-proxy-config": "1.1.2",
"selection-hook": "1.0.12",
"sharp": "0.34.3",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"tesseract.js": "6.0.1",
"turndown": "7.2.0"
},
"devDependencies": {
"js-yaml": "4.1.0",
"bonjour-service": "1.3.0",
"emoji-picker-element-data": "1",
"@agentic/exa": "^7.3.3",
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.61",
"@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "2.0.87",
"@ai-sdk/cerebras": "^1.0.31",
"@ai-sdk/gateway": "^2.0.15",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/google": "2.0.49",
"@ai-sdk/google-vertex": "^3.0.94",
"@ai-sdk/huggingface": "^0.0.10",
"@ai-sdk/mistral": "^2.0.24",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
"@ai-sdk/openai": "2.0.85",
"@ai-sdk/perplexity": "^2.0.20",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.17",
"@ai-sdk/test-server": "^0.0.1",
"@ai-sdk/xai": "2.0.36",
"@ant-design/cssinjs": "1.23.0",
"@ant-design/icons": "5.6.1",
"@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",
"@anthropic-ai/vertex-sdk": "0.11.4",
"@aws-sdk/client-bedrock": "^3.910.0",
"@aws-sdk/client-bedrock-runtime": "^3.910.0",
"@aws-sdk/client-s3": "^3.910.0",
"@biomejs/biome": "2.2.4",
"@cherrystudio/ai-core": "workspace:^1.0.9",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
"@cherrystudio/embedjs-loader-image": "^0.1.31",
"@cherrystudio/embedjs-loader-markdown": "^0.1.31",
"@cherrystudio/embedjs-loader-msoffice": "^0.1.31",
"@cherrystudio/embedjs-loader-pdf": "^0.1.31",
"@cherrystudio/embedjs-loader-sitemap": "^0.1.31",
"@cherrystudio/embedjs-loader-web": "^0.1.31",
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/embedjs": "0.1.31",
"@cherrystudio/embedjs-interfaces": "0.1.31",
"@cherrystudio/embedjs-libsql": "0.1.31",
"@cherrystudio/embedjs-loader-csv": "0.1.31",
"@cherrystudio/embedjs-loader-image": "0.1.31",
"@cherrystudio/embedjs-loader-markdown": "0.1.31",
"@cherrystudio/embedjs-loader-msoffice": "0.1.31",
"@cherrystudio/embedjs-loader-pdf": "0.1.31",
"@cherrystudio/embedjs-loader-sitemap": "0.1.31",
"@cherrystudio/embedjs-loader-web": "0.1.31",
"@cherrystudio/embedjs-loader-xml": "0.1.31",
"@cherrystudio/embedjs-ollama": "0.1.31",
"@cherrystudio/embedjs-openai": "0.1.31",
"@cherrystudio/embedjs-utils": "0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "^6.12.0",
"@cherrystudio/openai": "6.15.0",
"@codemirror/lang-json": "6.0.1",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -155,18 +149,21 @@
"@emotion/is-prop-valid": "^1.3.1",
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@floating-ui/dom": "1.7.3",
"@google/genai": "1.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@kangfenmao/keyv-storage": "^0.1.0",
"@kangfenmao/keyv-storage": "^0.1.3",
"@langchain/community": "^1.0.0",
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@langchain/core": "1.0.2",
"@langchain/openai": "1.0.0",
"@langchain/textsplitters": "0.1.0",
"@mistralai/mistralai": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"@modelcontextprotocol/sdk": "1.23.0",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@openrouter/ai-sdk-provider": "^1.2.8",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "2.0.1",
"@opentelemetry/core": "2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
@ -177,6 +174,7 @@
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.12.0",
"@swc/core": "^1.15.8",
"@swc/plugin-styled-components": "^8.0.4",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.85.5",
@ -185,21 +183,25 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tiptap/extension-collaboration": "^3.2.0",
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
"@tiptap/extension-drag-handle-react": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-list": "^3.2.0",
"@tiptap/extension-mathematics": "^3.2.0",
"@tiptap/extension-mention": "^3.2.0",
"@tiptap/extension-node-range": "^3.2.0",
"@tiptap/extension-table-of-contents": "^3.2.0",
"@tiptap/extension-typography": "^3.2.0",
"@tiptap/extension-underline": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/react": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",
"@tiptap/suggestion": "^3.2.0",
"@tiptap/core": "3.2.0",
"@tiptap/extension-code-block": "3.2.0",
"@tiptap/extension-collaboration": "3.2.0",
"@tiptap/extension-drag-handle": "3.2.0",
"@tiptap/extension-drag-handle-react": "3.2.0",
"@tiptap/extension-heading": "3.2.0",
"@tiptap/extension-image": "3.2.0",
"@tiptap/extension-link": "3.2.0",
"@tiptap/extension-list": "3.2.0",
"@tiptap/extension-mathematics": "3.2.0",
"@tiptap/extension-mention": "3.2.0",
"@tiptap/extension-node-range": "3.2.0",
"@tiptap/extension-table-of-contents": "3.2.0",
"@tiptap/extension-typography": "3.2.0",
"@tiptap/extension-underline": "3.2.0",
"@tiptap/pm": "3.2.0",
"@tiptap/react": "3.2.0",
"@tiptap/starter-kit": "3.2.0",
"@tiptap/suggestion": "3.2.0",
"@tiptap/y-tiptap": "^3.0.0",
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
@ -210,14 +212,17 @@
"@types/dotenv": "^8.2.3",
"@types/express": "^5",
"@types/fs-extra": "^11",
"@types/hast": "^3.0.4",
"@types/he": "^1",
"@types/html-to-text": "^9",
"@types/js-yaml": "^4.0.9",
"@types/json-schema": "7.0.15",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
"@types/mdast": "4.0.4",
"@types/mime-types": "^3",
"@types/node": "^22.17.1",
"@types/node": "22.17.2",
"@types/pako": "^1.0.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -228,9 +233,10 @@
"@types/swagger-ui-express": "^4.1.8",
"@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5",
"@types/unist": "3.0.3",
"@types/uuid": "^10.0.0",
"@types/word-extractor": "^1",
"@typescript/native-preview": "latest",
"@typescript/native-preview": "7.0.0-dev.20250915.1",
"@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1",
"@uiw/react-codemirror": "^4.25.1",
@ -242,12 +248,15 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "0.4.16",
"ai": "^5.0.98",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"antd": "5.27.0",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"builder-util-runtime": "9.5.0",
"chalk": "4.1.2",
"chardet": "^2.1.0",
"check-disk-space": "3.4.0",
"cheerio": "^1.1.2",
@ -256,8 +265,10 @@
"cli-progress": "^3.12.0",
"clsx": "^2.1.1",
"code-inspector-plugin": "^0.20.14",
"codemirror-lang-mermaid": "0.5.0",
"color": "^5.0.0",
"concurrently": "^9.2.1",
"cors": "2.8.5",
"country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
@ -265,6 +276,7 @@
"diff": "^8.0.2",
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv": "16.6.1",
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
@ -273,12 +285,12 @@
"electron-devtools-installer": "^3.2.0",
"electron-reload": "^2.0.0-alpha.1",
"electron-store": "^8.2.0",
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
"electron-updater": "6.7.0",
"electron-vite": "5.0.0",
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"epub": "1.3.0",
"eslint": "^9.22.0",
"eslint-plugin-import-zod": "^1.2.0",
"eslint-plugin-oxlint": "^1.15.0",
@ -289,6 +301,7 @@
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "1.3.2",
"form-data": "4.0.4",
"framer-motion": "^12.23.12",
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
@ -305,6 +318,10 @@
"isbinaryfile": "5.0.4",
"jaison": "^2.0.2",
"jest-styled-components": "^7.2.0",
"js-base64": "3.7.7",
"json-schema": "0.4.0",
"katex": "0.16.22",
"ky": "1.8.1",
"linguist-languages": "^8.1.0",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
@ -312,19 +329,27 @@
"lucide-react": "^0.525.0",
"macos-release": "^3.4.0",
"markdown-it": "^14.1.0",
"md5": "2.3.0",
"mermaid": "^11.10.1",
"mime": "^4.0.4",
"mime-types": "^3.0.1",
"motion": "^12.10.5",
"nanoid": "3.3.11",
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"ollama-ai-provider-v2": "patch:ollama-ai-provider-v2@npm%3A1.5.5#~/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch",
"ollama-ai-provider-v2": "1.5.5",
"open": "^8.4.2",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pako": "1.0.11",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"prosemirror-model": "1.25.2",
"proxy-agent": "^6.5.0",
"rc-input": "1.8.0",
"rc-select": "14.16.6",
"rc-virtual-list": "3.18.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
@ -351,8 +376,11 @@
"remark-gfm": "^4.0.1",
"remark-github-blockquote-alert": "^2.0.0",
"remark-math": "^6.0.0",
"remark-parse": "11.0.0",
"remark-stringify": "11.0.0",
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
"semver": "7.7.1",
"shiki": "^3.12.0",
"strict-url-sanitise": "^0.0.1",
"string-width": "^7.2.0",
@ -367,9 +395,10 @@
"tsx": "^4.20.3",
"turndown-plugin-gfm": "^1.0.2",
"tw-animate-css": "^1.3.8",
"typescript": "~5.8.2",
"typescript": "~5.8.3",
"undici": "6.21.2",
"unified": "^11.0.5",
"unist-util-visit": "5.0.0",
"uuid": "^13.0.0",
"vite": "npm:rolldown-vite@7.3.0",
"vitest": "^3.2.4",
@ -384,44 +413,66 @@
"zipread": "^1.3.3",
"zod": "^4.1.5"
},
"resolutions": {
"@smithy/types": "4.7.1",
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
"esbuild": "^0.25.0",
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.24.0",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0",
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.3.0",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
"@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.42": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
"@ai-sdk/google@npm:^2.0.40": "patch:@ai-sdk/google@npm%3A2.0.40#~/.yarn/patches/@ai-sdk-google-npm-2.0.40-47e0eeee83.patch",
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch",
"@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/openai-compatible@npm:1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible@npm:^1.0.19": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
"pnpm": {
"overrides": {
"@smithy/types": "4.7.1",
"@codemirror/language": "6.11.3",
"@codemirror/lint": "6.8.5",
"@codemirror/view": "6.38.1",
"esbuild": "^0.25.0",
"node-abi": "4.24.0",
"openai": "npm:@cherrystudio/openai@6.15.0",
"tar-fs": "^2.1.4",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.3.0",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3",
"@langchain/core": "1.0.2",
"@ai-sdk/openai-compatible@1.0.27": "1.0.28"
},
"patchedDependencies": {
"@anthropic-ai/claude-agent-sdk@0.1.62": "patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
"@napi-rs/system-ocr@1.0.2": "patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"tesseract.js@6.0.1": "patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@2.0.49": "patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
"@ai-sdk/openai@2.0.85": "patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
"@anthropic-ai/vertex-sdk@0.11.4": "patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
"@google/genai@1.0.1": "patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@langchain/core@1.0.2": "patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
"@langchain/openai@1.0.0": "patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
"@tiptap/extension-drag-handle@3.2.0": "patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
"antd@5.27.0": "patches/antd-npm-5.27.0-aa91c36546.patch",
"electron-updater@6.7.0": "patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
"epub@1.3.0": "patches/epub-npm-1.3.0-8325494ffe.patch",
"ollama-ai-provider-v2@1.5.5": "patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch",
"atomically@1.7.0": "patches/atomically-npm-1.7.0-e742e5293b.patch",
"file-stream-rotator@0.6.1": "patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@0.4.7": "patches/libsql-npm-0.4.7-444e260fb1.patch",
"pdf-parse@1.1.1": "patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
},
"onlyBuiltDependencies": [
"@kangfenmao/keyv-storage",
"@paymoapp/electron-shutdown-handler",
"@scarf/scarf",
"@swc/core",
"electron",
"electron-winstaller",
"esbuild",
"msw",
"protobufjs",
"registry-js",
"selection-hook",
"sharp",
"tesseract.js",
"zipfile"
]
},
"packageManager": "yarn@4.9.1",
"packageManager": "pnpm@10.27.0",
"lint-staged": {
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
"biome format --write --no-errors-on-unmatched",

View File

@ -8,7 +8,7 @@ It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Ant
```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
pnpm 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.

View File

@ -41,7 +41,7 @@
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible": "1.0.28",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17"
},

View File

@ -42,7 +42,7 @@
"@ai-sdk/anthropic": "^2.0.49",
"@ai-sdk/azure": "^2.0.87",
"@ai-sdk/deepseek": "^1.0.31",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
"@ai-sdk/openai-compatible": "1.0.28",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.17",
"@ai-sdk/xai": "^2.0.36",

View File

@ -68,8 +68,8 @@
],
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/core": "3.2.0",
"@tiptap/pm": "3.2.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
@ -89,5 +89,5 @@
"build": "tsdown",
"lint": "biome format ./src/ --write && eslint --fix ./src/"
},
"packageManager": "yarn@4.9.1"
"packageManager": "pnpm@10.27.0"
}

View File

@ -364,6 +364,7 @@ export enum IpcChannel {
OCR_ListProviders = 'ocr:list-providers',
// OVMS
Ovms_IsSupported = 'ovms:is-supported',
Ovms_AddModel = 'ovms:add-model',
Ovms_StopAddModel = 'ovms:stop-addmodel',
Ovms_GetModels = 'ovms:get-models',

View File

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { isBase64ImageDataUrl, isDataUrl, parseDataUrl } from '../utils'
describe('parseDataUrl', () => {
it('parses a standard base64 image data URL', () => {
const result = parseDataUrl('data:image/png;base64,iVBORw0KGgo=')
expect(result).toEqual({
mediaType: 'image/png',
isBase64: true,
data: 'iVBORw0KGgo='
})
})
it('parses a base64 data URL with additional parameters', () => {
const result = parseDataUrl('data:image/jpeg;name=foo;base64,/9j/4AAQ')
expect(result).toEqual({
mediaType: 'image/jpeg',
isBase64: true,
data: '/9j/4AAQ'
})
})
it('parses a plain text data URL (non-base64)', () => {
const result = parseDataUrl('data:text/plain,Hello%20World')
expect(result).toEqual({
mediaType: 'text/plain',
isBase64: false,
data: 'Hello%20World'
})
})
it('parses a data URL with empty media type', () => {
const result = parseDataUrl('data:;base64,SGVsbG8=')
expect(result).toEqual({
mediaType: undefined,
isBase64: true,
data: 'SGVsbG8='
})
})
it('returns null for non-data URLs', () => {
const result = parseDataUrl('https://example.com/image.png')
expect(result).toBeNull()
})
it('returns null for malformed data URL without comma', () => {
const result = parseDataUrl('data:image/png;base64')
expect(result).toBeNull()
})
it('handles empty string', () => {
const result = parseDataUrl('')
expect(result).toBeNull()
})
it('handles large base64 data without performance issues', () => {
// Simulate a 4K image base64 string (about 1MB)
const largeData = 'A'.repeat(1024 * 1024)
const dataUrl = `data:image/png;base64,${largeData}`
const start = performance.now()
const result = parseDataUrl(dataUrl)
const duration = performance.now() - start
expect(result).not.toBeNull()
expect(result?.mediaType).toBe('image/png')
expect(result?.isBase64).toBe(true)
expect(result?.data).toBe(largeData)
// Should complete in under 10ms (string operations are fast)
expect(duration).toBeLessThan(10)
})
it('parses SVG data URL', () => {
const result = parseDataUrl('data:image/svg+xml;base64,PHN2Zz4=')
expect(result).toEqual({
mediaType: 'image/svg+xml',
isBase64: true,
data: 'PHN2Zz4='
})
})
it('parses JSON data URL', () => {
const result = parseDataUrl('data:application/json,{"key":"value"}')
expect(result).toEqual({
mediaType: 'application/json',
isBase64: false,
data: '{"key":"value"}'
})
})
})
describe('isDataUrl', () => {
it('returns true for valid data URLs', () => {
expect(isDataUrl('data:image/png;base64,ABC')).toBe(true)
expect(isDataUrl('data:text/plain,hello')).toBe(true)
expect(isDataUrl('data:,simple')).toBe(true)
})
it('returns false for non-data URLs', () => {
expect(isDataUrl('https://example.com')).toBe(false)
expect(isDataUrl('file:///path/to/file')).toBe(false)
expect(isDataUrl('')).toBe(false)
})
it('returns false for malformed data URLs', () => {
expect(isDataUrl('data:')).toBe(false)
expect(isDataUrl('data:image/png')).toBe(false)
})
})
describe('isBase64ImageDataUrl', () => {
it('returns true for base64 image data URLs', () => {
expect(isBase64ImageDataUrl('data:image/png;base64,ABC')).toBe(true)
expect(isBase64ImageDataUrl('data:image/jpeg;base64,/9j/')).toBe(true)
expect(isBase64ImageDataUrl('data:image/gif;base64,R0lG')).toBe(true)
expect(isBase64ImageDataUrl('data:image/webp;base64,UklG')).toBe(true)
})
it('returns false for non-base64 image data URLs', () => {
expect(isBase64ImageDataUrl('data:image/svg+xml,<svg></svg>')).toBe(false)
})
it('returns false for non-image data URLs', () => {
expect(isBase64ImageDataUrl('data:text/plain;base64,SGVsbG8=')).toBe(false)
expect(isBase64ImageDataUrl('data:application/json,{}')).toBe(false)
})
it('returns false for regular URLs', () => {
expect(isBase64ImageDataUrl('https://example.com/image.png')).toBe(false)
expect(isBase64ImageDataUrl('file:///image.png')).toBe(false)
})
it('returns false for malformed data URLs', () => {
expect(isBase64ImageDataUrl('data:image/png')).toBe(false)
expect(isBase64ImageDataUrl('')).toBe(false)
})
})

View File

@ -4,7 +4,7 @@
*
*
* THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY!
* Run `yarn update:languages` to update this file.
* Run `pnpm update:languages` to update this file.
*
*
*/

View File

@ -88,3 +88,81 @@ const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
export function withoutTrailingApiVersion(url: string): string {
return url.replace(TRAILING_VERSION_REGEX, '')
}
export interface DataUrlParts {
/** The media type (e.g., 'image/png', 'text/plain') */
mediaType?: string
/** Whether the data is base64 encoded */
isBase64: boolean
/** The data portion (everything after the comma). This is the raw string, not decoded. */
data: string
}
/**
* Parses a data URL into its component parts without using regex on the data portion.
* This is memory-safe for large data URLs (e.g., 4K images) as it uses indexOf instead of regex.
*
* Data URL format: data:[<mediatype>][;base64],<data>
*
* @param url - The data URL string to parse
* @returns DataUrlParts if valid, null if invalid
*
* @example
* parseDataUrl('data:image/png;base64,iVBORw0KGgo...')
* // { mediaType: 'image/png', isBase64: true, data: 'iVBORw0KGgo...' }
*
* parseDataUrl('data:text/plain,Hello')
* // { mediaType: 'text/plain', isBase64: false, data: 'Hello' }
*
* parseDataUrl('invalid-url')
* // null
*/
export function parseDataUrl(url: string): DataUrlParts | null {
if (!url.startsWith('data:')) {
return null
}
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
return null
}
const header = url.slice(5, commaIndex)
const isBase64 = header.includes(';base64')
const semicolonIndex = header.indexOf(';')
const mediaType = (semicolonIndex === -1 ? header : header.slice(0, semicolonIndex)).trim() || undefined
const data = url.slice(commaIndex + 1)
return { mediaType, isBase64, data }
}
/**
* Checks if a string is a data URL.
*
* @param url - The string to check
* @returns true if the string is a valid data URL
*/
export function isDataUrl(url: string): boolean {
return url.startsWith('data:') && url.includes(',')
}
/**
* Checks if a data URL contains base64-encoded image data.
*
* @param url - The data URL to check
* @returns true if the URL is a base64-encoded image data URL
*/
export function isBase64ImageDataUrl(url: string): boolean {
if (!url.startsWith('data:image/')) {
return false
}
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
return false
}
const header = url.slice(5, commaIndex)
return header.includes(';base64')
}

View File

@ -9,7 +9,7 @@ index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b2
+ sendReasoning: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>;
diff --git a/dist/index.js b/dist/index.js
index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644
--- a/dist/index.js
@ -48,7 +48,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text ?? undefined,
+ reasoning_content: reasoning_text || undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@ -60,7 +60,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
+ textVerbosity: import_v4.z.string().optional(),
+ sendReasoning: import_v4.z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,
@ -175,7 +175,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
messages.push({
role: "assistant",
content: text,
+ reasoning_content: reasoning_text ?? undefined,
+ reasoning_content: reasoning_text || undefined,
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
...metadata
});
@ -187,7 +187,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
+ textVerbosity: z.string().optional(),
+ sendReasoning: z.boolean().optional()
});
// src/openai-compatible-error.ts
@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class {
reasoning_effort: compatibleOptions.reasoningEffort,

25417
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- 'packages/*'

View File

@ -50,7 +50,7 @@ Usage Instructions:
- pt-pt (Portuguese)
Run Command:
yarn i18n:translate
pnpm i18n:translate
Performance Optimization Recommendations:
- For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50
@ -152,7 +152,8 @@ const languageMap = {
'es-es': 'Spanish',
'fr-fr': 'French',
'pt-pt': 'Portuguese',
'de-de': 'German'
'de-de': 'German',
'ro-ro': 'Romanian'
}
const PROMPT = `

View File

@ -2,14 +2,14 @@ const { Arch } = require('electron-builder')
const { downloadNpmPackage } = require('./utils')
// if you want to add new prebuild binaries packages with different architectures, you can add them here
// please add to allX64 and allArm64 from yarn.lock
// please add to allX64 and allArm64 from pnpm-lock.yaml
const allArm64 = {
'@img/sharp-darwin-arm64': '0.34.3',
'@img/sharp-win32-arm64': '0.34.3',
'@img/sharp-linux-arm64': '0.34.3',
'@img/sharp-libvips-darwin-arm64': '1.2.0',
'@img/sharp-libvips-linux-arm64': '1.2.0',
'@img/sharp-libvips-darwin-arm64': '1.2.4',
'@img/sharp-libvips-linux-arm64': '1.2.4',
'@libsql/darwin-arm64': '0.4.7',
'@libsql/linux-arm64-gnu': '0.4.7',
@ -24,8 +24,8 @@ const allX64 = {
'@img/sharp-linux-x64': '0.34.3',
'@img/sharp-win32-x64': '0.34.3',
'@img/sharp-libvips-darwin-x64': '1.2.0',
'@img/sharp-libvips-linux-x64': '1.2.0',
'@img/sharp-libvips-darwin-x64': '1.2.4',
'@img/sharp-libvips-linux-x64': '1.2.4',
'@libsql/darwin-x64': '0.4.7',
'@libsql/linux-x64-gnu': '0.4.7',

View File

@ -145,7 +145,7 @@ export function main() {
console.log('i18n 检查已通过')
} catch (e) {
console.error(e)
throw new Error(`检查未通过。尝试运行 yarn i18n:sync 以解决问题。`)
throw new Error(`检查未通过。尝试运行 pnpm i18n:sync 以解决问题。`)
}
}

View File

@ -57,7 +57,7 @@ function generateLanguagesFileContent(languages: Record<string, LanguageData>):
*
*
* THIS FILE IS AUTOMATICALLY GENERATED BY A SCRIPT. DO NOT EDIT IT MANUALLY!
* Run \`yarn update:languages\` to update this file.
* Run \`pnpm update:languages\` to update this file.
*
*
*/
@ -81,7 +81,7 @@ export const languages: Record<string, LanguageData> = ${languagesObjectString};
async function format(filePath: string): Promise<void> {
console.log('🎨 Formatting file with Biome...')
try {
await execAsync(`yarn biome format --write ${filePath}`)
await execAsync(`pnpm biome format --write ${filePath}`)
console.log('✅ Biome formatting complete.')
} catch (e: any) {
console.error('❌ Biome formatting failed:', e.stdout || e.stderr)
@ -96,7 +96,7 @@ async function format(filePath: string): Promise<void> {
async function checkTypeScript(filePath: string): Promise<void> {
console.log('🧐 Checking file with TypeScript compiler...')
try {
await execAsync(`yarn tsc --noEmit --skipLibCheck ${filePath}`)
await execAsync(`pnpm tsc --noEmit --skipLibCheck ${filePath}`)
console.log('✅ TypeScript check passed.')
} catch (e: any) {
console.error('❌ TypeScript check failed:', e.stdout || e.stderr)

View File

@ -18,7 +18,7 @@ if (!['patch', 'minor', 'major'].includes(versionType)) {
}
// 更新版本
exec(`yarn version ${versionType} --immediate`)
exec(`pnpm version ${versionType}`)
// 读取更新后的 package.json 获取新版本号
const updatedPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'))

View File

@ -37,7 +37,7 @@ import { versionService } from './services/VersionService'
import { windowService } from './services/WindowService'
import { initWebviewHotkeys } from './services/WebviewService'
import { runAsyncFunction } from './utils'
import { ovmsManager } from './services/OvmsManager'
import { isOvmsSupported } from './services/OvmsManager'
const logger = loggerService.withContext('MainEntry')
@ -158,7 +158,7 @@ if (!app.requestSingleInstanceLock()) {
registerShortcuts(mainWindow)
registerIpc(mainWindow, app)
await registerIpc(mainWindow, app)
localTransferService.startDiscovery({ resetList: true })
replaceDevtoolsFont(mainWindow)
@ -248,7 +248,14 @@ if (!app.requestSingleInstanceLock()) {
app.on('will-quit', async () => {
// 简单的资源清理,不阻塞退出流程
await ovmsManager.stopOvms()
if (isOvmsSupported) {
const { ovmsManager } = await import('./services/OvmsManager')
if (ovmsManager) {
await ovmsManager.stopOvms()
} else {
logger.warn('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.')
}
}
try {
await mcpService.cleanup()

View File

@ -59,7 +59,7 @@ import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ocrService } from './services/ocr/OcrService'
import { ovmsManager } from './services/OvmsManager'
import { isOvmsSupported } from './services/OvmsManager'
import powerMonitorService from './services/PowerMonitorService'
import { proxyManager } from './services/ProxyManager'
import { pythonService } from './services/PythonService'
@ -97,6 +97,7 @@ import {
untildify
} from './utils/file'
import { updateAppDataConfig } from './utils/init'
import { getCpuName, getDeviceType, getHostname } from './utils/system'
import { compress, decompress } from './utils/zip'
const logger = loggerService.withContext('IPC')
@ -120,7 +121,7 @@ function extractPluginError(error: unknown): PluginError | null {
return null
}
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater()
const notificationService = new NotificationService()
@ -498,9 +499,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
// system
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
ipcMain.handle(IpcChannel.System_GetDeviceType, getDeviceType)
ipcMain.handle(IpcChannel.System_GetHostname, getHostname)
ipcMain.handle(IpcChannel.System_GetCpuName, getCpuName)
ipcMain.handle(IpcChannel.System_CheckGitBash, () => {
if (!isWin) {
return true // Non-Windows systems don't need Git Bash
@ -974,15 +975,36 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
// OVMS
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task)
)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
ipcMain.handle(IpcChannel.Ovms_IsSupported, () => isOvmsSupported)
if (isOvmsSupported) {
const { ovmsManager } = await import('./services/OvmsManager')
if (ovmsManager) {
ipcMain.handle(
IpcChannel.Ovms_AddModel,
(_, modelName: string, modelId: string, modelSource: string, task: string) =>
ovmsManager.addModel(modelName, modelId, modelSource, task)
)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
} else {
logger.error('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.')
}
} else {
const fallback = () => {
throw new Error('OVMS is only supported on Windows with intel CPU.')
}
ipcMain.handle(IpcChannel.Ovms_AddModel, fallback)
ipcMain.handle(IpcChannel.Ovms_StopAddModel, fallback)
ipcMain.handle(IpcChannel.Ovms_GetModels, fallback)
ipcMain.handle(IpcChannel.Ovms_IsRunning, fallback)
ipcMain.handle(IpcChannel.Ovms_GetStatus, fallback)
ipcMain.handle(IpcChannel.Ovms_RunOVMS, fallback)
ipcMain.handle(IpcChannel.Ovms_StopOVMS, fallback)
}
// CherryAI
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
@ -1039,12 +1061,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
} catch (error) {
const pluginError = extractPluginError(error)
if (pluginError) {
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
logger.error('Failed to list installed plugins', {
agentId,
error: pluginError
})
return { success: false, error: pluginError }
}
const err = normalizeError(error)
logger.error('Failed to list installed plugins', { agentId, error: err })
logger.error('Failed to list installed plugins', {
agentId,
error: err
})
return {
success: false,
error: {

View File

@ -1,5 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('node:fs', () => ({
default: {
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
},
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
}))
vi.mock('electron', () => {
const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => {
if (command === 'Runtime.evaluate') {
@ -21,24 +30,31 @@ vi.mock('electron', () => {
sendCommand
}
const webContents = {
const createWebContents = () => ({
debugger: debuggerObj,
setUserAgent: vi.fn(),
getURL: vi.fn(() => 'https://example.com/'),
getTitle: vi.fn(async () => 'Example Title'),
loadURL: vi.fn(async () => {}),
once: vi.fn(),
removeListener: vi.fn(),
on: vi.fn()
}
const loadURL = vi.fn(async () => {})
on: vi.fn(),
isDestroyed: vi.fn(() => false),
canGoBack: vi.fn(() => false),
canGoForward: vi.fn(() => false),
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
executeJavaScript: vi.fn(async () => null),
setWindowOpenHandler: vi.fn()
})
const windows: any[] = []
const views: any[] = []
class MockBrowserWindow {
private destroyed = false
public webContents = webContents
public loadURL = loadURL
public webContents = createWebContents()
public isDestroyed = vi.fn(() => this.destroyed)
public close = vi.fn(() => {
this.destroyed = true
@ -47,31 +63,58 @@ vi.mock('electron', () => {
this.destroyed = true
})
public on = vi.fn()
public setBrowserView = vi.fn()
public addBrowserView = vi.fn()
public removeBrowserView = vi.fn()
public getContentSize = vi.fn(() => [1200, 800])
public show = vi.fn()
constructor() {
windows.push(this)
}
}
class MockBrowserView {
public webContents = createWebContents()
public setBounds = vi.fn()
public setAutoResize = vi.fn()
public destroy = vi.fn()
constructor() {
views.push(this)
}
}
const app = {
isReady: vi.fn(() => true),
whenReady: vi.fn(async () => {}),
on: vi.fn()
on: vi.fn(),
getPath: vi.fn((key: string) => {
if (key === 'userData') return '/mock/userData'
if (key === 'temp') return '/tmp'
return '/mock/unknown'
}),
getAppPath: vi.fn(() => '/mock/app'),
setPath: vi.fn()
}
const nativeTheme = {
on: vi.fn(),
shouldUseDarkColors: false
}
return {
BrowserWindow: MockBrowserWindow as any,
BrowserView: MockBrowserView as any,
app,
nativeTheme,
__mockDebugger: debuggerObj,
__mockSendCommand: sendCommand,
__mockLoadURL: loadURL,
__mockWindows: windows
__mockWindows: windows,
__mockViews: views
}
})
import * as electron from 'electron'
const { __mockWindows } = electron as typeof electron & { __mockWindows: any[] }
import { CdpBrowserController } from '../browser'
describe('CdpBrowserController', () => {
@ -81,54 +124,249 @@ describe('CdpBrowserController', () => {
expect(result).toBe('ok')
})
it('opens a URL (hidden) and returns current page info', async () => {
it('opens a URL in normal mode and returns current page info', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://foo.bar/', 5000, false)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.title).toBe('Example Title')
})
it('opens a URL (visible) when show=true', async () => {
it('opens a URL in private mode', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://foo.bar/', 5000, true, 'session-a')
const result = await controller.open('https://foo.bar/', 5000, true)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.title).toBe('Example Title')
})
it('reuses session for execute and supports multiline', async () => {
const controller = new CdpBrowserController()
await controller.open('https://foo.bar/', 5000, false, 'session-b')
const result = await controller.execute('const a=1; const b=2; a+b;', 5000, 'session-b')
await controller.open('https://foo.bar/', 5000, false)
const result = await controller.execute('const a=1; const b=2; a+b;', 5000, false)
expect(result).toBe('ok')
})
it('evicts least recently used session when exceeding maxSessions', async () => {
const controller = new CdpBrowserController({ maxSessions: 2, idleTimeoutMs: 1000 * 60 })
await controller.open('https://foo.bar/', 5000, false, 's1')
await controller.open('https://foo.bar/', 5000, false, 's2')
await controller.open('https://foo.bar/', 5000, false, 's3')
const destroyedCount = __mockWindows.filter(
(w: any) => w.destroy.mock.calls.length > 0 || w.close.mock.calls.length > 0
).length
expect(destroyedCount).toBeGreaterThanOrEqual(1)
it('normal and private modes are isolated', async () => {
const controller = new CdpBrowserController()
await controller.open('https://foo.bar/', 5000, false)
await controller.open('https://foo.bar/', 5000, true)
const normalResult = await controller.execute('1+1', 5000, false)
const privateResult = await controller.execute('1+1', 5000, true)
expect(normalResult).toBe('ok')
expect(privateResult).toBe('ok')
})
it('fetches URL and returns html format', async () => {
it('fetches URL and returns html format with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html')
expect(result).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
it('fetches URL and returns txt format', async () => {
it('fetches URL and returns txt format with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'txt')
expect(result).toBe('Test\nContent')
expect(result.tabId).toBeDefined()
expect(result.content).toBe('Test\nContent')
})
it('fetches URL and returns markdown format (default)', async () => {
it('fetches URL and returns markdown format (default) with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/')
expect(typeof result).toBe('string')
expect(result).toContain('Test')
expect(result.tabId).toBeDefined()
expect(typeof result.content).toBe('string')
expect(result.content).toContain('Test')
})
it('fetches URL in private mode with tabId', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html', 10000, true)
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
describe('Multi-tab support', () => {
it('creates new tab with newTab parameter', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
const result2 = await controller.open('https://site2.com/', 5000, false, true)
expect(result1.tabId).toBeDefined()
expect(result2.tabId).toBeDefined()
expect(result1.tabId).not.toBe(result2.tabId)
})
it('reuses same tab without newTab parameter', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false)
const result2 = await controller.open('https://site2.com/', 5000, false)
expect(result1.tabId).toBe(result2.tabId)
})
it('fetches in new tab with newTab parameter', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
const tabs = await controller.listTabs(false)
const initialTabCount = tabs.length
await controller.fetch('https://other.com/', 'html', 10000, false, true)
const tabsAfter = await controller.listTabs(false)
expect(tabsAfter.length).toBe(initialTabCount + 1)
})
})
describe('Tab management', () => {
it('lists tabs in a window', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
const tabs = await controller.listTabs(false)
expect(tabs.length).toBeGreaterThan(0)
expect(tabs[0].tabId).toBeDefined()
})
it('lists tabs separately for normal and private modes', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(1)
expect(privateTabs.length).toBe(1)
expect(normalTabs[0].tabId).not.toBe(privateTabs[0].tabId)
})
it('closes specific tab', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
await controller.open('https://site2.com/', 5000, false, true)
const tabsBefore = await controller.listTabs(false)
expect(tabsBefore.length).toBe(2)
await controller.closeTab(false, result1.tabId)
const tabsAfter = await controller.listTabs(false)
expect(tabsAfter.length).toBe(1)
expect(tabsAfter.find((t) => t.tabId === result1.tabId)).toBeUndefined()
})
it('switches active tab', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
const result2 = await controller.open('https://site2.com/', 5000, false, true)
await controller.switchTab(false, result1.tabId)
await controller.switchTab(false, result2.tabId)
})
it('throws error when switching to non-existent tab', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await expect(controller.switchTab(false, 'non-existent-tab')).rejects.toThrow('Tab non-existent-tab not found')
})
})
describe('Reset behavior', () => {
it('resets specific tab only', async () => {
const controller = new CdpBrowserController()
const result1 = await controller.open('https://site1.com/', 5000, false, true)
await controller.open('https://site2.com/', 5000, false, true)
await controller.reset(false, result1.tabId)
const tabs = await controller.listTabs(false)
expect(tabs.length).toBe(1)
})
it('resets specific window only', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
await controller.reset(false)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(0)
expect(privateTabs.length).toBe(1)
})
it('resets all windows', async () => {
const controller = new CdpBrowserController()
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
await controller.reset()
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(normalTabs.length).toBe(0)
expect(privateTabs.length).toBe(0)
})
})
describe('showWindow parameter', () => {
it('passes showWindow parameter through open', async () => {
const controller = new CdpBrowserController()
const result = await controller.open('https://example.com/', 5000, false, false, true)
expect(result.currentUrl).toBe('https://example.com/')
expect(result.tabId).toBeDefined()
})
it('passes showWindow parameter through fetch', async () => {
const controller = new CdpBrowserController()
const result = await controller.fetch('https://example.com/', 'html', 10000, false, false, true)
expect(result.tabId).toBeDefined()
expect(result.content).toBe('<html><body><h1>Test</h1><p>Content</p></body></html>')
})
it('passes showWindow parameter through createTab', async () => {
const controller = new CdpBrowserController()
const { tabId, view } = await controller.createTab(false, true)
expect(tabId).toBeDefined()
expect(view).toBeDefined()
})
it('shows existing window when showWindow=true on subsequent calls', async () => {
const controller = new CdpBrowserController()
// First call creates window
await controller.open('https://example.com/', 5000, false, false, false)
// Second call with showWindow=true should show existing window
const result = await controller.open('https://example.com/', 5000, false, false, true)
expect(result.currentUrl).toBe('https://example.com/')
})
})
describe('Window limits and eviction', () => {
it('respects maxWindows limit', async () => {
const controller = new CdpBrowserController({ maxWindows: 1 })
await controller.open('https://example.com/', 5000, false)
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
const privateTabs = await controller.listTabs(true)
expect(privateTabs.length).toBe(1)
expect(normalTabs.length).toBe(0)
})
it('cleans up idle windows on next access', async () => {
const controller = new CdpBrowserController({ idleTimeoutMs: 1 })
await controller.open('https://example.com/', 5000, false)
await new Promise((r) => setTimeout(r, 10))
await controller.open('https://example.com/', 5000, true)
const normalTabs = await controller.listTabs(false)
expect(normalTabs.length).toBe(0)
})
})
})

View File

@ -0,0 +1,177 @@
# Browser MCP Server
A Model Context Protocol (MCP) server for controlling browser windows via Chrome DevTools Protocol (CDP).
## Features
### ✨ User Data Persistence
- **Normal mode (default)**: Cookies, localStorage, and sessionStorage persist across browser restarts
- **Private mode**: Ephemeral browsing - no data persists (like incognito mode)
### 🔄 Window Management
- Two browsing modes: normal (persistent) and private (ephemeral)
- Lazy idle timeout cleanup (cleaned on next window access)
- Maximum window limits to prevent resource exhaustion
> **Note**: Normal mode uses a global `persist:default` partition shared by all clients. This means login sessions and stored data are accessible to any code using the MCP server.
## Architecture
### How It Works
```
Normal Mode (BrowserWindow)
├─ Persistent Storage (partition: persist:default) ← Global, shared across all clients
└─ Tabs (BrowserView) ← created via newTab or automatically
Private Mode (BrowserWindow)
├─ Ephemeral Storage (partition: private) ← No disk persistence
└─ Tabs (BrowserView) ← created via newTab or automatically
```
- **One Window Per Mode**: Normal and private modes each have their own window
- **Multi-Tab Support**: Use `newTab: true` for parallel URL requests
- **Storage Isolation**: Normal and private modes have completely separate storage
## Available Tools
### `open`
Open a URL in a browser window. Optionally return page content.
```json
{
"url": "https://example.com",
"format": "markdown",
"timeout": 10000,
"privateMode": false,
"newTab": false,
"showWindow": false
}
```
- `format`: If set (`html`, `txt`, `markdown`, `json`), returns page content in that format along with tabId. If not set, just opens the page and returns navigation info.
- `newTab`: Set to `true` to open in a new tab (required for parallel requests)
- `showWindow`: Set to `true` to display the browser window (useful for debugging)
- Returns (without format): `{ currentUrl, title, tabId }`
- Returns (with format): `{ tabId, content }` where content is in the specified format
### `execute`
Execute JavaScript code in the page context.
```json
{
"code": "document.title",
"timeout": 5000,
"privateMode": false,
"tabId": "optional-tab-id"
}
```
- `tabId`: Target a specific tab (from `open` response)
### `reset`
Reset browser windows and tabs.
```json
{
"privateMode": false,
"tabId": "optional-tab-id"
}
```
- Omit all parameters to close all windows
- Set `privateMode` to close a specific window
- Set both `privateMode` and `tabId` to close a specific tab only
## Usage Examples
### Basic Navigation
```typescript
// Open a URL in normal mode (data persists)
await controller.open('https://example.com')
```
### Fetch Page Content
```typescript
// Open URL and get content as markdown
await open({ url: 'https://example.com', format: 'markdown' })
// Open URL and get raw HTML
await open({ url: 'https://example.com', format: 'html' })
```
### Multi-Tab / Parallel Requests
```typescript
// Open multiple URLs in parallel using newTab
const [page1, page2] = await Promise.all([
controller.open('https://site1.com', 10000, false, true), // newTab: true
controller.open('https://site2.com', 10000, false, true) // newTab: true
])
// Execute on specific tab
await controller.execute('document.title', 5000, false, page1.tabId)
// Close specific tab when done
await controller.reset(false, page1.tabId)
```
### Private Browsing
```typescript
// Open a URL in private mode (no data persistence)
await controller.open('https://example.com', 10000, true)
// Cookies and localStorage won't persist after reset
```
### Data Persistence (Normal Mode)
```typescript
// Set data
await controller.open('https://example.com', 10000, false)
await controller.execute('localStorage.setItem("key", "value")', 5000, false)
// Close window
await controller.reset(false)
// Reopen - data persists!
await controller.open('https://example.com', 10000, false)
const value = await controller.execute('localStorage.getItem("key")', 5000, false)
// Returns: "value"
```
### No Persistence (Private Mode)
```typescript
// Set data in private mode
await controller.open('https://example.com', 10000, true)
await controller.execute('localStorage.setItem("key", "value")', 5000, true)
// Close private window
await controller.reset(true)
// Reopen - data is gone!
await controller.open('https://example.com', 10000, true)
const value = await controller.execute('localStorage.getItem("key")', 5000, true)
// Returns: null
```
## Configuration
```typescript
const controller = new CdpBrowserController({
maxWindows: 5, // Maximum concurrent windows
idleTimeoutMs: 5 * 60 * 1000 // 5 minutes idle timeout (lazy cleanup)
})
```
> **Note on Idle Timeout**: Idle windows are cleaned up lazily when the next window is created or accessed, not on a background timer.
## Best Practices
1. **Use Normal Mode for Authentication**: When you need to stay logged in across sessions
2. **Use Private Mode for Sensitive Operations**: When you don't want data to persist
3. **Use `newTab: true` for Parallel Requests**: Avoid race conditions when fetching multiple URLs
4. **Resource Cleanup**: Call `reset()` when done, or `reset(privateMode, tabId)` to close specific tabs
5. **Error Handling**: All tool handlers return error responses on failure
6. **Timeout Configuration**: Adjust timeouts based on page complexity
## Technical Details
- **CDP Version**: 1.3
- **User Agent**: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0
- **Storage**:
- Normal mode: `persist:default` (disk-persisted, global)
- Private mode: `private` (memory only)
- **Window Size**: 1200x800 (default)
- **Visibility**: Windows hidden by default (use `showWindow: true` to display)

View File

@ -0,0 +1,3 @@
export const TAB_BAR_HEIGHT = 92 // Height for Chrome-style tab bar (42px) + address bar (50px)
export const SESSION_KEY_DEFAULT = 'default'
export const SESSION_KEY_PRIVATE = 'private'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,567 @@
export const TAB_BAR_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
user-select: none;
}
/* Light theme (default) */
:root {
--bg-tabrow: #dee1e6;
--bg-toolbar: #fff;
--bg-tab-hover: rgba(0,0,0,0.04);
--bg-tab-active: #fff;
--bg-url: #f1f3f4;
--bg-url-focus: #fff;
--bg-btn-hover: rgba(0,0,0,0.08);
--bg-favicon: #9aa0a6;
--color-text: #5f6368;
--color-text-active: #202124;
--color-separator: #c4c7cc;
--shadow-url-focus: 0 1px 6px rgba(32,33,36,0.28);
--window-close-hover: #e81123;
}
/* Dark theme */
body.theme-dark {
--bg-tabrow: #202124;
--bg-toolbar: #292a2d;
--bg-tab-hover: rgba(255,255,255,0.06);
--bg-tab-active: #292a2d;
--bg-url: #35363a;
--bg-url-focus: #202124;
--bg-btn-hover: rgba(255,255,255,0.1);
--bg-favicon: #5f6368;
--color-text: #9aa0a6;
--color-text-active: #e8eaed;
--color-separator: #3c3d41;
--shadow-url-focus: 0 1px 6px rgba(0,0,0,0.5);
--window-close-hover: #e81123;
}
body {
background: var(--bg-tabrow);
display: flex;
flex-direction: column;
position: relative;
}
body.platform-mac { --traffic-light-width: 70px; --window-controls-width: 0px; }
body.platform-win, body.platform-linux { --traffic-light-width: 0px; --window-controls-width: 138px; }
/* Chrome-style tab row */
#tab-row {
display: flex;
align-items: flex-end;
padding: 8px 8px 0 8px;
padding-left: calc(8px + var(--traffic-light-width, 0px));
padding-right: calc(8px + var(--window-controls-width, 0px));
height: 42px;
flex-shrink: 0;
-webkit-app-region: drag;
background: var(--bg-tabrow);
position: relative;
z-index: 1;
}
#tabs-container {
display: flex;
align-items: flex-end;
height: 34px;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* New tab button - inside tabs container, right after last tab */
#new-tab-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
margin-left: 4px;
margin-bottom: 3px;
-webkit-app-region: no-drag;
flex-shrink: 0;
}
#new-tab-btn:hover { background: var(--bg-btn-hover); }
#new-tab-btn svg { width: 18px; height: 18px; fill: var(--color-text); }
/* Chrome-style tabs - shrink instead of scroll */
.tab {
position: relative;
display: flex;
align-items: center;
height: 34px;
min-width: 36px;
max-width: 240px;
flex: 1 1 240px;
padding: 0 6px;
background: transparent;
cursor: pointer;
-webkit-app-region: no-drag;
border-radius: 8px 8px 0 0;
transition: background 0.1s;
}
/* When tab is narrow, hide title, show favicon by default, show close on hover */
.tab.narrow .tab-title { display: none; }
.tab.narrow { justify-content: center; padding: 0; }
.tab.narrow .tab-favicon { margin-right: 0; }
.tab.narrow .tab-close { position: absolute; margin-left: 0; }
/* On narrow tab hover, hide favicon and show close button */
.tab.narrow:hover .tab-favicon { display: none; }
.tab.narrow:hover .tab-close { opacity: 1; }
/* Separator line using pseudo-element */
.tab::after {
content: '';
position: absolute;
right: 0;
top: 8px;
bottom: 8px;
width: 1px;
background: var(--color-separator);
pointer-events: none;
}
/* Hide separator for last tab */
.tab:last-of-type::after { display: none; }
/* Hide separator when tab is hovered (right side) */
.tab:hover::after { display: none; }
/* Hide separator on tab before hovered tab (left side of hovered) - managed by JS .before-hover class */
.tab.before-hover::after { display: none; }
/* Hide separator for active tab and its neighbors */
.tab.active::after { display: none; }
/* Hide separator on tab before active (left side of active) - managed by JS .before-active class */
.tab.before-active::after { display: none; }
.tab:hover { background: var(--bg-tab-hover); }
.tab.active {
background: var(--bg-tab-active);
z-index: 1;
}
/* Tab favicon placeholder */
.tab-favicon {
width: 16px;
height: 16px;
margin-right: 8px;
border-radius: 2px;
background: var(--bg-favicon);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tab-favicon svg { width: 12px; height: 12px; fill: #fff; }
body.theme-dark .tab-favicon svg { fill: #9aa0a6; }
.tab-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-size: 12px;
font-weight: 400;
}
.tab.active .tab-title { color: var(--color-text-active); }
.tab-close {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4px;
opacity: 0;
transition: opacity 0.1s, background 0.1s;
flex-shrink: 0;
}
.tab:hover .tab-close { opacity: 1; }
.tab-close:hover { background: var(--bg-btn-hover); }
.tab-close svg { width: 16px; height: 16px; fill: var(--color-text); }
.tab-close:hover svg { fill: var(--color-text-active); }
/* Chrome-style address bar */
#address-bar {
display: flex;
align-items: center;
padding: 6px 16px 8px 8px;
gap: 4px;
background: var(--bg-toolbar);
-webkit-app-region: drag;
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
background: transparent;
border: none;
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.nav-btn:hover { background: var(--bg-btn-hover); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: transparent; }
.nav-btn svg { width: 20px; height: 20px; fill: var(--color-text); }
#url-container {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-url);
border-radius: 24px;
padding: 0 16px;
height: 36px;
-webkit-app-region: no-drag;
transition: background 0.2s, box-shadow 0.2s;
}
#url-container:focus-within {
background: var(--bg-url-focus);
box-shadow: var(--shadow-url-focus);
}
#url-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--color-text-active);
font-size: 14px;
font-family: inherit;
}
#url-input::placeholder { color: var(--color-text); }
#url-input::-webkit-input-placeholder { color: var(--color-text); }
/* Window controls for Windows/Linux - use inline-flex inside tab-row instead of fixed position */
#window-controls {
display: none;
height: 42px;
margin-left: auto;
margin-right: calc(-8px - var(--window-controls-width, 0px));
margin-top: -8px;
-webkit-app-region: no-drag;
}
body.platform-win #window-controls,
body.platform-linux #window-controls { display: flex; }
.window-control-btn {
width: 46px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.1s;
-webkit-app-region: no-drag;
}
.window-control-btn:hover { background: var(--bg-btn-hover); }
.window-control-btn.close:hover { background: var(--window-close-hover); }
.window-control-btn svg { width: 10px; height: 10px; color: var(--color-text); fill: var(--color-text); stroke: var(--color-text); }
.window-control-btn:hover svg { color: var(--color-text-active); fill: var(--color-text-active); stroke: var(--color-text-active); }
.window-control-btn.close:hover svg { color: #fff; fill: #fff; stroke: #fff; }
</style>
</head>
<body>
<div id="tab-row">
<div id="tabs-container">
<div id="new-tab-btn" title="New tab">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</div>
</div>
<!-- Window controls for Windows/Linux - inside tab-row to avoid drag region issues -->
<div id="window-controls">
<button class="window-control-btn" id="minimize-btn" title="Minimize">
<svg viewBox="0 0 10 1"><rect width="10" height="1"/></svg>
</button>
<button class="window-control-btn" id="maximize-btn" title="Maximize">
<svg viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="window-control-btn close" id="close-btn" title="Close">
<svg viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
</div>
<div id="address-bar">
<button class="nav-btn" id="back-btn" title="Back" disabled>
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<button class="nav-btn" id="forward-btn" title="Forward" disabled>
<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg>
</button>
<button class="nav-btn" id="refresh-btn" title="Refresh">
<svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button>
<div id="url-container">
<input type="text" id="url-input" placeholder="Search or enter URL" spellcheck="false" />
</div>
</div>
<script>
const tabsContainer = document.getElementById('tabs-container');
const urlInput = document.getElementById('url-input');
const backBtn = document.getElementById('back-btn');
const forwardBtn = document.getElementById('forward-btn');
const refreshBtn = document.getElementById('refresh-btn');
window.currentUrl = '';
window.canGoBack = false;
window.canGoForward = false;
// Helper function to update before-active class for separator hiding
function updateBeforeActiveClass() {
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab, index) {
tab.classList.remove('before-active');
if (index < tabs.length - 1 && tabs[index + 1].classList.contains('active')) {
tab.classList.add('before-active');
}
});
}
// Helper function to update narrow class based on tab width
function updateNarrowClass() {
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab) {
if (tab.offsetWidth < 72) {
tab.classList.add('narrow');
} else {
tab.classList.remove('narrow');
}
});
}
var newTabBtnHtml = '<div id="new-tab-btn" title="New tab"><svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></div>';
// Track if we're in "closing mode" where tab widths should be fixed
var closingModeTimeout = null;
var isInClosingMode = false;
function enterClosingMode() {
isInClosingMode = true;
// Clear any existing timeout
if (closingModeTimeout) {
clearTimeout(closingModeTimeout);
}
// Set timeout to exit closing mode after 1 second of no activity
closingModeTimeout = setTimeout(function() {
exitClosingMode();
}, 1000);
}
function exitClosingMode() {
isInClosingMode = false;
if (closingModeTimeout) {
clearTimeout(closingModeTimeout);
closingModeTimeout = null;
}
// Remove fixed widths from tabs
var tabs = tabsContainer.querySelectorAll('.tab');
tabs.forEach(function(tab) {
tab.style.flex = '';
tab.style.width = '';
});
}
// Exit closing mode when mouse leaves the tab row
document.getElementById('tab-row').addEventListener('mouseleave', function() {
if (isInClosingMode) {
exitClosingMode();
}
});
window.updateTabs = function(tabs, activeUrl, canGoBack, canGoForward) {
// Capture current tab widths before update if in closing mode
var previousWidths = {};
if (isInClosingMode) {
var existingTabs = tabsContainer.querySelectorAll('.tab');
existingTabs.forEach(function(tab) {
previousWidths[tab.dataset.id] = tab.offsetWidth;
});
}
if (!tabs || tabs.length === 0) {
// Window will be closed by main process when last tab is closed
// Just clear the UI in case this is called before window closes
tabsContainer.innerHTML = newTabBtnHtml;
urlInput.value = '';
document.getElementById('new-tab-btn').addEventListener('click', function() {
sendAction({ type: 'new' });
});
return;
}
tabsContainer.innerHTML = tabs.map(function(tab) {
var cls = 'tab' + (tab.isActive ? ' active' : '');
var title = (tab.title || 'New Tab').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
var url = (tab.url || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
return '<div class="' + cls + '" data-id="' + tab.id + '" title="' + url + '">' +
'<div class="tab-favicon"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg></div>' +
'<span class="tab-title">' + title + '</span>' +
'<div class="tab-close" data-id="' + tab.id + '">' +
'<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' +
'</div>' +
'</div>';
}).join('') + newTabBtnHtml;
// Re-attach event listener for new tab button
document.getElementById('new-tab-btn').addEventListener('click', function() {
sendAction({ type: 'new' });
});
// If in closing mode, fix the widths of remaining tabs
if (isInClosingMode) {
var newTabs = tabsContainer.querySelectorAll('.tab');
newTabs.forEach(function(tab) {
var prevWidth = previousWidths[tab.dataset.id];
if (prevWidth) {
tab.style.flex = '0 0 ' + prevWidth + 'px';
tab.style.width = prevWidth + 'px';
}
});
}
// Update before-active class for proper separator hiding
updateBeforeActiveClass();
// Update narrow class based on tab width
updateNarrowClass();
if (activeUrl !== undefined) {
window.currentUrl = activeUrl || '';
if (document.activeElement !== urlInput) {
urlInput.value = window.currentUrl;
}
}
if (canGoBack !== undefined) {
window.canGoBack = canGoBack;
backBtn.disabled = !canGoBack;
}
if (canGoForward !== undefined) {
window.canGoForward = canGoForward;
forwardBtn.disabled = !canGoForward;
}
};
function sendAction(action) {
window.postMessage({ channel: 'tabbar-action', payload: action }, '*');
}
tabsContainer.addEventListener('click', function(e) {
var closeBtn = e.target.closest('.tab-close');
if (closeBtn) {
e.stopPropagation();
enterClosingMode();
sendAction({ type: 'close', tabId: closeBtn.dataset.id });
return;
}
var tab = e.target.closest('.tab');
if (tab) {
sendAction({ type: 'switch', tabId: tab.dataset.id });
}
});
tabsContainer.addEventListener('auxclick', function(e) {
if (e.button === 1) {
var tab = e.target.closest('.tab');
if (tab) {
enterClosingMode();
sendAction({ type: 'close', tabId: tab.dataset.id });
}
}
});
// Handle hover state for separator hiding (left side of hovered tab)
tabsContainer.addEventListener('mouseover', function(e) {
var tab = e.target.closest('.tab');
// Clear all before-hover classes first
tabsContainer.querySelectorAll('.before-hover').forEach(function(t) {
t.classList.remove('before-hover');
});
if (tab) {
var prev = tab.previousElementSibling;
if (prev && prev.classList.contains('tab')) {
prev.classList.add('before-hover');
}
}
});
tabsContainer.addEventListener('mouseleave', function() {
tabsContainer.querySelectorAll('.before-hover').forEach(function(t) {
t.classList.remove('before-hover');
});
});
urlInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
var url = urlInput.value.trim();
if (url) {
sendAction({ type: 'navigate', url: url });
}
}
});
urlInput.addEventListener('focus', function() {
urlInput.select();
});
backBtn.addEventListener('click', function() {
if (window.canGoBack) {
sendAction({ type: 'back' });
}
});
forwardBtn.addEventListener('click', function() {
if (window.canGoForward) {
sendAction({ type: 'forward' });
}
});
refreshBtn.addEventListener('click', function() {
sendAction({ type: 'refresh' });
});
// Window controls for Windows/Linux
document.getElementById('minimize-btn').addEventListener('click', function() {
sendAction({ type: 'window-minimize' });
});
document.getElementById('maximize-btn').addEventListener('click', function() {
sendAction({ type: 'window-maximize' });
});
document.getElementById('close-btn').addEventListener('click', function() {
sendAction({ type: 'window-close' });
});
// Platform initialization - called from main process
window.initPlatform = function(platform) {
document.body.classList.add('platform-' + platform);
};
// Theme initialization - called from main process
window.setTheme = function(isDark) {
if (isDark) {
document.body.classList.add('theme-dark');
} else {
document.body.classList.remove('theme-dark');
}
};
// Update narrow class on window resize
window.addEventListener('resize', function() {
updateNarrowClass();
});
</script>
</body>
</html>`

View File

@ -1,36 +1,39 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const ExecuteSchema = z.object({
code: z
.string()
.describe(
'JavaScript evaluated via Chrome DevTools Runtime.evaluate. Keep it short; prefer one-line with semicolons for multiple statements.'
),
timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'),
sessionId: z.string().optional().describe('Session identifier to target a specific page (default: default)')
code: z.string().describe('JavaScript code to run in page context'),
timeout: z.number().default(5000).describe('Execution timeout in ms (default: 5000)'),
privateMode: z.boolean().optional().describe('Target private session (default: false)'),
tabId: z.string().optional().describe('Target specific tab by ID')
})
export const executeToolDefinition = {
name: 'execute',
description:
'Run JavaScript in the current page via Runtime.evaluate. Prefer short, single-line snippets; use semicolons for multiple statements.',
'Run JavaScript in the currently open page. Use after open to: click elements, fill forms, extract content (document.body.innerText), or interact with the page. The page must be opened first with open or fetch.',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'One-line JS to evaluate in page context'
description:
'JavaScript to evaluate. Examples: document.body.innerText (get text), document.querySelector("button").click() (click), document.title (get title)'
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default 5000)'
description: 'Execution timeout in ms (default: 5000)'
},
sessionId: {
privateMode: {
type: 'boolean',
description: 'Target private session (default: false)'
},
tabId: {
type: 'string',
description: 'Session identifier; targets a specific page (default: default)'
description: 'Target specific tab by ID (from open response)'
}
},
required: ['code']
@ -38,11 +41,12 @@ export const executeToolDefinition = {
}
export async function handleExecute(controller: CdpBrowserController, args: unknown) {
const { code, timeout, sessionId } = ExecuteSchema.parse(args)
const { code, timeout, privateMode, tabId } = ExecuteSchema.parse(args)
try {
const value = await controller.execute(code, timeout, sessionId ?? 'default')
const value = await controller.execute(code, timeout, privateMode ?? false, tabId)
return successResponse(typeof value === 'string' ? value : JSON.stringify(value))
} catch (error) {
logger.error('Execute failed', { error, code: code.slice(0, 100), privateMode, tabId })
return errorResponse(error as Error)
}
}

View File

@ -1,49 +0,0 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { errorResponse, successResponse } from './utils'
export const FetchSchema = z.object({
url: z.url().describe('URL to fetch'),
format: z.enum(['html', 'txt', 'markdown', 'json']).default('markdown').describe('Output format (default: markdown)'),
timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'),
sessionId: z.string().optional().describe('Session identifier (default: default)')
})
export const fetchToolDefinition = {
name: 'fetch',
description: 'Fetch a URL using the browser and return content in specified format (html, txt, markdown, json)',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to fetch'
},
format: {
type: 'string',
enum: ['html', 'txt', 'markdown', 'json'],
description: 'Output format (default: markdown)'
},
timeout: {
type: 'number',
description: 'Navigation timeout in milliseconds (default: 10000)'
},
sessionId: {
type: 'string',
description: 'Session identifier (default: default)'
}
},
required: ['url']
}
}
export async function handleFetch(controller: CdpBrowserController, args: unknown) {
const { url, format, timeout, sessionId } = FetchSchema.parse(args)
try {
const content = await controller.fetch(url, format, timeout ?? 10000, sessionId ?? 'default')
return successResponse(typeof content === 'string' ? content : JSON.stringify(content))
} catch (error) {
return errorResponse(error as Error)
}
}

View File

@ -1,15 +1,13 @@
export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute'
export { FetchSchema, fetchToolDefinition, handleFetch } from './fetch'
export { handleOpen, OpenSchema, openToolDefinition } from './open'
export { handleReset, resetToolDefinition } from './reset'
import type { CdpBrowserController } from '../controller'
import { executeToolDefinition, handleExecute } from './execute'
import { fetchToolDefinition, handleFetch } from './fetch'
import { handleOpen, openToolDefinition } from './open'
import { handleReset, resetToolDefinition } from './reset'
export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition, fetchToolDefinition]
export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition]
export const toolHandlers: Record<
string,
@ -20,6 +18,5 @@ export const toolHandlers: Record<
> = {
open: handleOpen,
execute: handleExecute,
reset: handleReset,
fetch: handleFetch
reset: handleReset
}

View File

@ -1,39 +1,52 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { successResponse } from './utils'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
export const OpenSchema = z.object({
url: z.url().describe('URL to open in the controlled Electron window'),
timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'),
show: z.boolean().optional().describe('Whether to show the browser window (default: false)'),
sessionId: z
.string()
url: z.url().describe('URL to navigate to'),
format: z
.enum(['html', 'txt', 'markdown', 'json'])
.optional()
.describe('Session identifier; separate sessions keep separate pages (default: default)')
.describe('If set, return page content in this format. If not set, just open the page and return tabId.'),
timeout: z.number().optional().describe('Navigation timeout in ms (default: 10000)'),
privateMode: z.boolean().optional().describe('Use incognito mode, no data persisted (default: false)'),
newTab: z.boolean().optional().describe('Open in new tab, required for parallel requests (default: false)'),
showWindow: z.boolean().optional().default(true).describe('Show browser window (default: true)')
})
export const openToolDefinition = {
name: 'open',
description: 'Open a URL in a hidden Electron window controlled via Chrome DevTools Protocol',
description:
'Navigate to a URL in a browser window. If format is specified, returns { tabId, content } with page content in that format. Otherwise, returns { currentUrl, title, tabId } for subsequent operations with execute tool. Set newTab=true when opening multiple URLs in parallel.',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL to load'
description: 'URL to navigate to'
},
format: {
type: 'string',
enum: ['html', 'txt', 'markdown', 'json'],
description: 'If set, return page content in this format. If not set, just open the page and return tabId.'
},
timeout: {
type: 'number',
description: 'Navigation timeout in milliseconds (default 10000)'
description: 'Navigation timeout in ms (default: 10000)'
},
show: {
privateMode: {
type: 'boolean',
description: 'Whether to show the browser window (default false)'
description: 'Use incognito mode, no data persisted (default: false)'
},
sessionId: {
type: 'string',
description: 'Session identifier; separate sessions keep separate pages (default: default)'
newTab: {
type: 'boolean',
description: 'Open in new tab, required for parallel requests (default: false)'
},
showWindow: {
type: 'boolean',
description: 'Show browser window (default: true)'
}
},
required: ['url']
@ -41,7 +54,28 @@ export const openToolDefinition = {
}
export async function handleOpen(controller: CdpBrowserController, args: unknown) {
const { url, timeout, show, sessionId } = OpenSchema.parse(args)
const res = await controller.open(url, timeout ?? 10000, show ?? false, sessionId ?? 'default')
return successResponse(JSON.stringify(res))
try {
const { url, format, timeout, privateMode, newTab, showWindow } = OpenSchema.parse(args)
if (format) {
const { tabId, content } = await controller.fetch(
url,
format,
timeout ?? 10000,
privateMode ?? false,
newTab ?? false,
showWindow
)
return successResponse(JSON.stringify({ tabId, content }))
} else {
const res = await controller.open(url, timeout ?? 10000, privateMode ?? false, newTab ?? false, showWindow)
return successResponse(JSON.stringify(res))
}
} catch (error) {
logger.error('Open failed', {
error,
url: args && typeof args === 'object' && 'url' in args ? args.url : undefined
})
return errorResponse(error instanceof Error ? error : String(error))
}
}

View File

@ -1,34 +1,43 @@
import * as z from 'zod'
import type { CdpBrowserController } from '../controller'
import { successResponse } from './utils'
import { logger } from '../types'
import { errorResponse, successResponse } from './utils'
/** Zod schema for validating reset tool arguments */
export const ResetSchema = z.object({
sessionId: z.string().optional().describe('Session identifier to reset; omit to reset all sessions')
privateMode: z.boolean().optional().describe('true=private window, false=normal window, omit=all windows'),
tabId: z.string().optional().describe('Close specific tab only (requires privateMode)')
})
/** MCP tool definition for the reset tool */
export const resetToolDefinition = {
name: 'reset',
description: 'Reset the controlled window and detach debugger',
description:
'Close browser windows and clear state. Call when done browsing to free resources. Omit all parameters to close everything.',
inputSchema: {
type: 'object',
properties: {
sessionId: {
privateMode: {
type: 'boolean',
description: 'true=reset private window only, false=reset normal window only, omit=reset all'
},
tabId: {
type: 'string',
description: 'Session identifier to reset; omit to reset all sessions'
description: 'Close specific tab only (requires privateMode to be set)'
}
}
}
}
/**
* Handler for the reset MCP tool.
* Closes browser window(s) and detaches debugger for the specified session or all sessions.
*/
export async function handleReset(controller: CdpBrowserController, args: unknown) {
const { sessionId } = ResetSchema.parse(args)
await controller.reset(sessionId)
return successResponse('reset')
try {
const { privateMode, tabId } = ResetSchema.parse(args)
await controller.reset(privateMode, tabId)
return successResponse('reset')
} catch (error) {
logger.error('Reset failed', {
error,
privateMode: args && typeof args === 'object' && 'privateMode' in args ? args.privateMode : undefined
})
return errorResponse(error instanceof Error ? error : String(error))
}
}

View File

@ -5,9 +5,10 @@ export function successResponse(text: string) {
}
}
export function errorResponse(error: Error) {
export function errorResponse(error: Error | string) {
const message = error instanceof Error ? error.message : error
return {
content: [{ type: 'text', text: error.message }],
content: [{ type: 'text', text: message }],
isError: true
}
}

View File

@ -1,4 +1,24 @@
import { loggerService } from '@logger'
import type { BrowserView, BrowserWindow } from 'electron'
export const logger = loggerService.withContext('MCPBrowserCDP')
export const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0'
export const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
export interface TabInfo {
id: string
view: BrowserView
url: string
title: string
lastActive: number
}
export interface WindowInfo {
windowKey: string
privateMode: boolean
window: BrowserWindow
tabs: Map<string, TabInfo>
activeTabId: string | null
lastActive: number
tabBarView?: BrowserView
}

View File

@ -10,6 +10,7 @@ import {
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { parseDataUrl } from '@shared/utils'
import type { FileMetadata, NotesTreeNode } from '@types'
import { FileTypes } from '@types'
import chardet from 'chardet'
@ -130,16 +131,18 @@ interface DirectoryListOptions {
includeDirectories?: boolean
maxEntries?: number
searchPattern?: string
fuzzy?: boolean
}
const DEFAULT_DIRECTORY_LIST_OPTIONS: Required<DirectoryListOptions> = {
recursive: true,
maxDepth: 3,
maxDepth: 10,
includeHidden: false,
includeFiles: true,
includeDirectories: true,
maxEntries: 10,
searchPattern: '.'
maxEntries: 20,
searchPattern: '.',
fuzzy: true
}
class FileStorage {
@ -670,8 +673,8 @@ class FileStorage {
throw new Error('Base64 data is required')
}
// 移除 base64 头部信息(如果存在)
const base64String = base64Data.replace(/^data:.*;base64,/, '')
const parseResult = parseDataUrl(base64Data)
const base64String = parseResult?.data ?? base64Data
const buffer = Buffer.from(base64String, 'base64')
const uuid = uuidv4()
const ext = '.png'
@ -1046,10 +1049,226 @@ class FileStorage {
}
/**
* Search files by content pattern
* Fuzzy match: checks if all characters in query appear in text in order (case-insensitive)
* Example: "updater" matches "packages/update/src/node/updateController.ts"
*/
private async searchByContent(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
const args: string[] = ['-l']
private isFuzzyMatch(text: string, query: string): boolean {
let i = 0 // text index
let j = 0 // query index
const textLower = text.toLowerCase()
const queryLower = query.toLowerCase()
while (i < textLower.length && j < queryLower.length) {
if (textLower[i] === queryLower[j]) {
j++
}
i++
}
return j === queryLower.length
}
/**
* Scoring constants for fuzzy match relevance ranking
* Higher values = higher priority in search results
*/
private static readonly SCORE_SEGMENT_MATCH = 60 // Per path segment that matches query
private static readonly SCORE_FILENAME_CONTAINS = 80 // Filename contains exact query substring
private static readonly SCORE_FILENAME_STARTS = 100 // Filename starts with query (highest priority)
private static readonly SCORE_CONSECUTIVE_CHAR = 15 // Per consecutive character match
private static readonly SCORE_WORD_BOUNDARY = 20 // Query matches start of a word
private static readonly PATH_LENGTH_PENALTY_FACTOR = 4 // Logarithmic penalty multiplier for longer paths
/**
* Calculate fuzzy match score (higher is better)
* Scoring factors:
* - Consecutive character matches (bonus)
* - Match at word boundaries (bonus)
* - Shorter path length (bonus)
* - Match in filename vs directory (bonus)
*/
private getFuzzyMatchScore(filePath: string, query: string): number {
const pathLower = filePath.toLowerCase()
const queryLower = query.toLowerCase()
const fileName = filePath.split('/').pop() || ''
const fileNameLower = fileName.toLowerCase()
let score = 0
// Count how many times query-related words appear in path segments
const pathSegments = pathLower.split(/[/\\]/)
let segmentMatchCount = 0
for (const segment of pathSegments) {
if (this.isFuzzyMatch(segment, queryLower)) {
segmentMatchCount++
}
}
score += segmentMatchCount * FileStorage.SCORE_SEGMENT_MATCH
// Bonus for filename starting with query (stronger than generic "contains")
if (fileNameLower.startsWith(queryLower)) {
score += FileStorage.SCORE_FILENAME_STARTS
} else if (fileNameLower.includes(queryLower)) {
// Bonus for exact substring match in filename (e.g., "updater" in "RCUpdater.js")
score += FileStorage.SCORE_FILENAME_CONTAINS
}
// Calculate consecutive match bonus
let i = 0
let j = 0
let consecutiveCount = 0
let maxConsecutive = 0
while (i < pathLower.length && j < queryLower.length) {
if (pathLower[i] === queryLower[j]) {
consecutiveCount++
maxConsecutive = Math.max(maxConsecutive, consecutiveCount)
j++
} else {
consecutiveCount = 0
}
i++
}
score += maxConsecutive * FileStorage.SCORE_CONSECUTIVE_CHAR
// Bonus for word boundary matches (e.g., "upd" matches start of "update")
// Only count once to avoid inflating scores for paths with repeated patterns
const boundaryPrefix = queryLower.slice(0, Math.min(3, queryLower.length))
const words = pathLower.split(/[/\\._-]/)
for (const word of words) {
if (word.startsWith(boundaryPrefix)) {
score += FileStorage.SCORE_WORD_BOUNDARY
break
}
}
// Penalty for longer paths (prefer shorter, more specific matches)
// Use logarithmic scaling to prevent long paths from dominating the score
// A 50-char path gets ~-16 penalty, 100-char gets ~-18, 200-char gets ~-21
score -= Math.log(filePath.length + 1) * FileStorage.PATH_LENGTH_PENALTY_FACTOR
return score
}
/**
* Convert query to glob pattern for ripgrep pre-filtering
* e.g., "updater" -> "*u*p*d*a*t*e*r*"
*/
private queryToGlobPattern(query: string): string {
// Escape special glob characters (including ! for negation)
const escaped = query.replace(/[[\]{}()*+?.,\\^$|#!]/g, '\\$&')
// Convert to fuzzy glob: each char separated by *
return '*' + escaped.split('').join('*') + '*'
}
/**
* Greedy substring match: check if all characters in query can be matched
* by finding consecutive substrings in text (not necessarily single chars)
* e.g., "updatercontroller" matches "updateController" by:
* "update" + "r" (from Controller) + "controller"
*/
private isGreedySubstringMatch(text: string, query: string): boolean {
const textLower = text.toLowerCase()
const queryLower = query.toLowerCase()
let queryIndex = 0
let searchStart = 0
while (queryIndex < queryLower.length) {
// Try to find the longest matching substring starting at queryIndex
let bestMatchLen = 0
let bestMatchPos = -1
for (let len = queryLower.length - queryIndex; len >= 1; len--) {
const substr = queryLower.slice(queryIndex, queryIndex + len)
const foundAt = textLower.indexOf(substr, searchStart)
if (foundAt !== -1) {
bestMatchLen = len
bestMatchPos = foundAt
break // Found longest possible match
}
}
if (bestMatchLen === 0) {
// No substring match found, query cannot be matched
return false
}
queryIndex += bestMatchLen
searchStart = bestMatchPos + bestMatchLen
}
return true
}
/**
* Calculate greedy substring match score (higher is better)
* Rewards: fewer match fragments, shorter match span, matches in filename
*/
private getGreedyMatchScore(filePath: string, query: string): number {
const textLower = filePath.toLowerCase()
const queryLower = query.toLowerCase()
const fileName = filePath.split('/').pop() || ''
const fileNameLower = fileName.toLowerCase()
let queryIndex = 0
let searchStart = 0
let fragmentCount = 0
let firstMatchPos = -1
let lastMatchEnd = 0
while (queryIndex < queryLower.length) {
let bestMatchLen = 0
let bestMatchPos = -1
for (let len = queryLower.length - queryIndex; len >= 1; len--) {
const substr = queryLower.slice(queryIndex, queryIndex + len)
const foundAt = textLower.indexOf(substr, searchStart)
if (foundAt !== -1) {
bestMatchLen = len
bestMatchPos = foundAt
break
}
}
if (bestMatchLen === 0) {
return -Infinity // No match
}
fragmentCount++
if (firstMatchPos === -1) firstMatchPos = bestMatchPos
lastMatchEnd = bestMatchPos + bestMatchLen
queryIndex += bestMatchLen
searchStart = lastMatchEnd
}
const matchSpan = lastMatchEnd - firstMatchPos
let score = 0
// Fewer fragments = better (single continuous match is best)
// Max bonus when fragmentCount=1, decreases as fragments increase
score += Math.max(0, 100 - (fragmentCount - 1) * 30)
// Shorter span relative to query length = better (tighter match)
// Perfect match: span equals query length
const spanRatio = queryLower.length / matchSpan
score += spanRatio * 50
// Bonus for match in filename
if (this.isGreedySubstringMatch(fileNameLower, queryLower)) {
score += 80
}
// Penalty for longer paths
score -= Math.log(filePath.length + 1) * 4
return score
}
/**
* Build common ripgrep arguments for file listing
*/
private buildRipgrepBaseArgs(options: Required<DirectoryListOptions>, resolvedPath: string): string[] {
const args: string[] = ['--files']
// Handle hidden files
if (!options.includeHidden) {
@ -1076,82 +1295,74 @@ class FileStorage {
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
return args
}
private async listDirectoryWithRipgrep(
resolvedPath: string,
options: Required<DirectoryListOptions>
): Promise<string[]> {
const maxEntries = options.maxEntries
// Fuzzy search mode: use ripgrep glob for pre-filtering, then score in JS
if (options.fuzzy && options.searchPattern && options.searchPattern !== '.') {
const args = this.buildRipgrepBaseArgs(options, resolvedPath)
// Step 1: Search by filename first
// Insert glob pattern before the path (last element)
const globPattern = this.queryToGlobPattern(options.searchPattern)
args.splice(args.length - 1, 0, '--iglob', globPattern)
const { exitCode, output } = await executeRipgrep(args)
if (exitCode >= 2) {
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
}
const filteredFiles = output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
// If fuzzy glob found results, validate fuzzy match, sort and return
if (filteredFiles.length > 0) {
return filteredFiles
.filter((file) => this.isFuzzyMatch(file, options.searchPattern))
.map((file) => ({ file, score: this.getFuzzyMatchScore(file, options.searchPattern) }))
.sort((a, b) => b.score - a.score)
.slice(0, options.maxEntries)
.map((item) => item.file)
}
// Fallback: if no results, try greedy substring match on all files
logger.debug('Fuzzy glob returned no results, falling back to greedy substring match')
const fallbackArgs = this.buildRipgrepBaseArgs(options, resolvedPath)
const fallbackResult = await executeRipgrep(fallbackArgs)
if (fallbackResult.exitCode >= 2) {
return []
}
const allFiles = fallbackResult.output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
const greedyMatched = allFiles.filter((file) => this.isGreedySubstringMatch(file, options.searchPattern))
return greedyMatched
.map((file) => ({ file, score: this.getGreedyMatchScore(file, options.searchPattern) }))
.sort((a, b) => b.score - a.score)
.slice(0, options.maxEntries)
.map((item) => item.file)
}
// Fallback: search by filename only (non-fuzzy mode)
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)
return filenameResults.slice(0, options.maxEntries)
}
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
@ -1254,8 +1465,8 @@ class FileStorage {
})
if (filePath) {
const base64Data = data.replace(/^data:image\/png;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const parseResult = parseDataUrl(data)
fs.writeFileSync(filePath, parseResult?.data ?? data, 'base64')
}
} catch (error) {
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)

View File

@ -3,6 +3,8 @@ import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getCpuName } from '@main/utils/system'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import * as fs from 'fs-extra'
import * as path from 'path'
@ -11,6 +13,8 @@ const logger = loggerService.withContext('OvmsManager')
const execAsync = promisify(exec)
export const isOvmsSupported = isWin && getCpuName().toLowerCase().includes('intel')
interface OvmsProcess {
pid: number
path: string
@ -29,6 +33,12 @@ interface OvmsConfig {
class OvmsManager {
private ovms: OvmsProcess | null = null
constructor() {
if (!isOvmsSupported) {
throw new Error('OVMS Manager is only supported on Windows platform with Intel CPU.')
}
}
/**
* Recursively terminate a process and all its child processes
* @param pid Process ID to terminate
@ -563,4 +573,4 @@ class OvmsManager {
}
// Export singleton instance
export const ovmsManager = new OvmsManager()
export const ovmsManager = isOvmsSupported ? new OvmsManager() : undefined

View File

@ -255,6 +255,12 @@ export class WindowService {
}
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
// Fix for Electron bug where zoom resets during in-page navigation (route changes)
// This complements the resize-based workaround by catching navigation events
mainWindow.webContents.on('did-navigate-in-page', () => {
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.includes('localhost:517')) {
return
@ -516,7 +522,9 @@ export class WindowService {
miniWindowState.manage(this.miniWindow)
//miniWindow should show in current desktop
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
this.miniWindow?.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true
})
//make miniWindow always on top of fullscreen apps with level set
//[mac] level higher than 'floating' will cover the pinyin input method
this.miniWindow.setAlwaysOnTop(true, 'floating')
@ -635,6 +643,11 @@ export class WindowService {
return
} else if (isMac) {
this.miniWindow.hide()
const majorVersion = parseInt(process.getSystemVersion().split('.')[0], 10)
if (majorVersion >= 26) {
// on macOS 26+, the popup of the mimiWindow would not change the focus to previous application.
return
}
if (!this.wasMainWindowFocused) {
app.hide()
}

View File

@ -39,22 +39,22 @@ const agent = await agentService.createAgent({
```bash
# Apply schema changes
yarn agents:generate
pnpm agents:generate
# Quick development sync
yarn agents:push
pnpm agents:push
# Database tools
yarn agents:studio # Open Drizzle Studio
yarn agents:health # Health check
yarn agents:drop # Reset database
pnpm agents:studio # Open Drizzle Studio
pnpm agents:health # Health check
pnpm agents:drop # Reset database
```
## Workflow
1. **Edit schema** in `/database/schema/`
2. **Generate migration** with `yarn agents:generate`
3. **Test changes** with `yarn agents:health`
2. **Generate migration** with `pnpm agents:generate`
3. **Test changes** with `pnpm agents:health`
4. **Deploy** - migrations apply automatically
## Services
@ -69,13 +69,13 @@ yarn agents:drop # Reset database
```bash
# Check status
yarn agents:health
pnpm agents:health
# Apply migrations
yarn agents:migrate
pnpm agents:migrate
# Reset completely
yarn agents:reset --yes
pnpm agents:reset --yes
```
The simplified migration system reduced complexity from 463 to ~30 lines while maintaining all functionality through Drizzle's native migration system.

View File

@ -8,6 +8,7 @@ import esES from '../../renderer/src/i18n/translate/es-es.json'
import frFR from '../../renderer/src/i18n/translate/fr-fr.json'
import JaJP from '../../renderer/src/i18n/translate/ja-jp.json'
import ptPT from '../../renderer/src/i18n/translate/pt-pt.json'
import roRO from '../../renderer/src/i18n/translate/ro-ro.json'
import RuRu from '../../renderer/src/i18n/translate/ru-ru.json'
const locales = Object.fromEntries(
@ -21,7 +22,8 @@ const locales = Object.fromEntries(
['el-GR', elGR],
['es-ES', esES],
['fr-FR', frFR],
['pt-PT', ptPT]
['pt-PT', ptPT],
['ro-RO', roRO]
].map(([locale, translation]) => [locale, { translation }])
)

19
src/main/utils/system.ts Normal file
View File

@ -0,0 +1,19 @@
import os from 'node:os'
import { isMac, isWin } from '@main/constant'
export const getDeviceType = () => (isMac ? 'mac' : isWin ? 'windows' : 'linux')
export const getHostname = () => os.hostname()
export const getCpuName = () => {
try {
const cpus = os.cpus()
if (!cpus || cpus.length === 0 || !cpus[0].model) {
return 'Unknown CPU'
}
return cpus[0].model
} catch {
return 'Unknown CPU'
}
}

View File

@ -340,6 +340,7 @@ const api = {
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
},
ovms: {
isSupported: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.Ovms_IsSupported),
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),

View File

@ -120,6 +120,21 @@ export class AiSdkToChunkAdapter {
}
}
/**
* THINKING_COMPLETE chunk
* @param final reasoningContent
* @returns THINKING_COMPLETE chunk
*/
private emitThinkingCompleteIfNeeded(final: { reasoningContent: string; [key: string]: any }) {
if (final.reasoningContent) {
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoningContent
})
final.reasoningContent = ''
}
}
/**
* AI SDK chunk Cherry Studio chunk
* @param chunk AI SDK chunk
@ -145,6 +160,9 @@ export class AiSdkToChunkAdapter {
}
// === 文本相关事件 ===
case 'text-start':
// 如果有未完成的思考内容,先生成 THINKING_COMPLETE
// 这处理了某些提供商不发送 reasoning-end 事件的情况
this.emitThinkingCompleteIfNeeded(final)
this.onChunk({
type: ChunkType.TEXT_START
})
@ -215,11 +233,7 @@ export class AiSdkToChunkAdapter {
})
break
case 'reasoning-end':
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: final.reasoningContent || ''
})
final.reasoningContent = ''
this.emitThinkingCompleteIfNeeded(final)
break
// === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) ===

View File

@ -8,13 +8,13 @@ import { loggerService } from '@logger'
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
import type { Message, Model } from '@renderer/types'
import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
import { parseDataUrlMediaType } from '@renderer/utils/image'
import {
findFileBlocks,
findImageBlocks,
findThinkingBlocks,
getMainTextContent
} from '@renderer/utils/messageUtils/find'
import { parseDataUrl } from '@shared/utils'
import type {
AssistantModelMessage,
FilePart,
@ -69,18 +69,16 @@ async function convertImageBlockToImagePart(imageBlocks: ImageMessageBlock[]): P
}
} else if (imageBlock.url) {
const url = imageBlock.url
const isDataUrl = url.startsWith('data:')
if (isDataUrl) {
const { mediaType } = parseDataUrlMediaType(url)
const commaIndex = url.indexOf(',')
if (commaIndex === -1) {
logger.error('Malformed data URL detected (missing comma separator), image will be excluded:', {
urlPrefix: url.slice(0, 50) + '...'
})
continue
}
const base64Data = url.slice(commaIndex + 1)
parts.push({ type: 'image', image: base64Data, ...(mediaType ? { mediaType } : {}) })
const parseResult = parseDataUrl(url)
if (parseResult?.isBase64) {
const { mediaType, data } = parseResult
parts.push({ type: 'image', image: data, ...(mediaType ? { mediaType } : {}) })
} else if (url.startsWith('data:')) {
// Malformed data URL or non-base64 data URL
logger.error('Malformed or non-base64 data URL detected, image will be excluded:', {
urlPrefix: url.slice(0, 50) + '...'
})
continue
} else {
// For remote URLs we keep payload minimal to match existing expectations.
parts.push({ type: 'image', image: url })

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -1,27 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" rx="32" fill="#0057CE"/>
<mask id="path-2-inside-1_4113_89308" fill="white">
<path d="M169.6 131.626C173.075 129.641 176.32 128.241 180.1 126.943C183.74 125.695 187.444 124.664 191.186 123.735C194.915 122.806 198.682 122.017 202.449 121.228C206.216 120.439 209.958 119.675 213.598 118.314C231.429 111.619 242.221 93.6357 239.612 74.9396C237.003 56.2435 221.692 41.8237 202.691 40.1564C194.062 39.4055 185.726 41.4164 178.013 44.9418C170.326 48.4545 163.288 53.4435 157.166 59.158C144.795 70.676 135.657 85.4649 130.083 101.208C124.47 117.054 122.37 134.095 123.694 150.806C124.356 159.129 125.883 167.504 128.326 175.509C130.719 183.362 134.181 191.469 138.839 198.342C136.828 185.475 138.559 172.175 143.917 160.262C149.262 148.375 158.121 138.193 169.6 131.626Z"/>
</mask>
<path d="M169.6 131.626C173.075 129.641 176.32 128.241 180.1 126.943C183.74 125.695 187.444 124.664 191.186 123.735C194.915 122.806 198.682 122.017 202.449 121.228C206.216 120.439 209.958 119.675 213.598 118.314C231.429 111.619 242.221 93.6357 239.612 74.9396C237.003 56.2435 221.692 41.8237 202.691 40.1564C194.062 39.4055 185.726 41.4164 178.013 44.9418C170.326 48.4545 163.288 53.4435 157.166 59.158C144.795 70.676 135.657 85.4649 130.083 101.208C124.47 117.054 122.37 134.095 123.694 150.806C124.356 159.129 125.883 167.504 128.326 175.509C130.719 183.362 134.181 191.469 138.839 198.342C136.828 185.475 138.559 172.175 143.917 160.262C149.262 148.375 158.121 138.193 169.6 131.626Z" fill="white" stroke="white" stroke-width="32" mask="url(#path-2-inside-1_4113_89308)"/>
<path d="M162.246 150.4C161.915 153.913 163.073 157.464 165.542 160.06C168.011 162.657 171.499 164.031 174.668 165.253C178.13 166.577 181.12 167.658 184.353 169.529C187.433 171.311 190.157 173.526 192.435 176.262C201.802 187.449 200.937 203.867 190.462 214.049C179.988 224.23 163.379 224.778 152.243 215.321C149.404 212.903 146.884 209.798 144.81 206.756C141.654 186.52 147.775 165.317 162.246 150.4Z" fill="white"/>
<mask id="path-4-outside-2_4113_89308" maskUnits="userSpaceOnUse" x="136" y="138.4" width="71" height="92" fill="black">
<rect fill="white" x="136" y="138.4" width="71" height="92"/>
<path d="M162.246 150.4C165.542 153.666 163.073 157.464 165.542 160.06C168.011 162.657 171.499 164.031 174.668 165.253C178.13 166.577 181.12 167.658 184.353 169.529C187.433 171.311 190.157 173.526 192.435 176.262C201.802 187.449 200.937 203.867 190.462 214.049C179.988 224.23 163.379 224.778 152.243 215.321C149.404 212.903 146.884 209.798 144.81 206.756C141.654 186.52 147.775 165.317 162.246 150.4Z"/>
</mask>
<path d="M162.246 150.4C165.542 153.666 163.073 157.464 165.542 160.06C168.011 162.657 171.499 164.031 174.668 165.253C178.13 166.577 181.12 167.658 184.353 169.529C187.433 171.311 190.157 173.526 192.435 176.262C201.802 187.449 200.937 203.867 190.462 214.049C179.988 224.23 163.379 224.778 152.243 215.321C149.404 212.903 146.884 209.798 144.81 206.756C141.654 186.52 147.775 165.317 162.246 150.4Z" stroke="#0057CE" stroke-width="16" mask="url(#path-4-outside-2_4113_89308)"/>
<mask id="path-5-inside-3_4113_89308" fill="white">
<path d="M50.4113 61.9063C63.3547 61.8935 75.9164 69.008 85.0163 76.9879C94.6761 85.4641 102.16 96.2567 107.085 107.991C112.036 119.789 114.416 132.542 114.327 145.282C114.238 157.665 111.769 171.079 106.296 182.394C105.774 167.821 100.123 153.885 90.3107 143.003C88.5926 141.107 86.7981 139.389 84.6599 137.938C82.5218 136.487 80.2691 135.418 77.8382 134.565C73.1164 132.911 67.7838 132.134 62.8711 131.6C57.8057 131.04 52.7149 130.709 47.6622 129.971C42.4695 129.207 37.8114 128.087 33.1787 125.427C19.688 117.715 13.1463 102.009 17.1808 87.1441C21.2153 72.2661 34.846 61.919 50.4113 61.9063Z"/>
</mask>
<path d="M50.4113 61.9063C63.3547 61.8935 75.9164 69.008 85.0163 76.9879C94.6761 85.4641 102.16 96.2567 107.085 107.991C112.036 119.789 114.416 132.542 114.327 145.282C114.238 157.665 111.769 171.079 106.296 182.394C105.774 167.821 100.123 153.885 90.3107 143.003C88.5926 141.107 86.7981 139.389 84.6599 137.938C82.5218 136.487 80.2691 135.418 77.8382 134.565C73.1164 132.911 67.7838 132.134 62.8711 131.6C57.8057 131.04 52.7149 130.709 47.6622 129.971C42.4695 129.207 37.8114 128.087 33.1787 125.427C19.688 117.715 13.1463 102.009 17.1808 87.1441C21.2153 72.2661 34.846 61.919 50.4113 61.9063Z" fill="white" stroke="white" stroke-width="32" mask="url(#path-5-inside-3_4113_89308)"/>
<mask id="path-6-inside-4_4113_89308" fill="white">
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z"/>
</mask>
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z" stroke="white" stroke-width="24" mask="url(#path-6-inside-4_4113_89308)"/>
<mask id="path-7-outside-5_4113_89308" maskUnits="userSpaceOnUse" x="45.3994" y="138.6" width="62" height="79" fill="black">
<rect fill="white" x="45.3994" y="138.6" width="62" height="79"/>
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z"/>
</mask>
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z" fill="white"/>
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z" stroke="#0057CE" stroke-width="16" mask="url(#path-7-outside-5_4113_89308)"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -222,6 +222,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
afterClose={onClose}
centered={!isFullscreen}
destroyOnHidden
forceRender={isFullscreen}
mask={!isFullscreen}
maskClosable={false}
width={isFullscreen ? '100vw' : '90vw'}

View File

@ -45,6 +45,7 @@ const i18nMap: Record<LanguageVarious, typeof en> = {
'fr-FR': fr,
'ja-JP': ja,
'pt-PT': pt_PT,
'ro-RO': en, // No Romanian available, fallback to English
'ru-RU': ru_RU
}
@ -60,6 +61,7 @@ const dataSourceMap: Record<LanguageVarious, string> = {
'fr-FR': dataFR,
'ja-JP': dataJA,
'pt-PT': dataPT,
'ro-RO': dataEN, // No Romanian CLDR available, fallback to English
'ru-RU': dataRU
}
@ -75,6 +77,7 @@ const localeMap: Record<LanguageVarious, string> = {
'fr-FR': 'fr',
'ja-JP': 'ja',
'pt-PT': 'pt',
'ro-RO': 'en',
'ru-RU': 'ru'
}

View File

@ -11,6 +11,7 @@ import {
import { loggerService } from '@logger'
import { download } from '@renderer/utils/download'
import { convertImageToPng } from '@renderer/utils/image'
import { parseDataUrl } from '@shared/utils'
import type { ImageProps as AntImageProps } from 'antd'
import { Dropdown, Image as AntImage, Space } from 'antd'
import { Base64 } from 'js-base64'
@ -37,12 +38,13 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
let blob: Blob
if (src.startsWith('data:')) {
// 处理 base64 格式的图片
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
if (!match) throw new Error('Invalid base64 image format')
const mimeType = match[1]
const byteArray = Base64.toUint8Array(match[2])
blob = new Blob([byteArray], { type: mimeType })
// 处理 base64 格式的图片 - 使用 parseDataUrl 避免正则匹配大字符串导致OOM
const parseResult = parseDataUrl(src)
if (!parseResult || !parseResult.mediaType || !parseResult.isBase64) {
throw new Error('Invalid base64 image format')
}
const byteArray = Base64.toUint8Array(parseResult.data)
blob = new Blob([byteArray], { type: parseResult.mediaType })
} else if (src.startsWith('file://')) {
// 处理本地文件路径
const bytes = await window.api.fs.read(src)

View File

@ -8,7 +8,7 @@ exports[`InputEmbeddingDimension > basic rendering > should match snapshot with
<input
data-testid="input-number"
placeholder="请输入维度大小"
style="flex: 1;"
style="flex: 1 1 0%;"
type="number"
value="1536"
/>
@ -43,7 +43,7 @@ exports[`InputEmbeddingDimension > basic rendering > should match snapshot with
<input
data-testid="input-number"
placeholder="请输入维度大小"
style="flex: 1;"
style="flex: 1 1 0%;"
type="number"
value=""
/>

View File

@ -24,6 +24,7 @@ exports[`Spinner > should match snapshot 1`] = `
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
style="color: unset;"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"

View File

@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.png?url'
import ApplicationLogo from '@renderer/assets/images/apps/application.png?url'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'

View File

@ -745,7 +745,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
})
it('should return doubao_after_251015 for Doubao-Seed-1.8 models', () => {
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251215' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251228' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1.8' }))).toBe('doubao_after_251015')
})
@ -879,7 +879,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => {
// auto > after_251015 > no_auto
expect(getThinkModelType(createModel({ id: 'doubao-seed-1.6' }))).toBe('doubao')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6-251015' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251215' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251228' }))).toBe('doubao_after_251015')
expect(getThinkModelType(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toBe('doubao_no_auto')
})

View File

@ -771,7 +771,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
],
doubao: [
{
id: 'doubao-seed-1-8-251215',
id: 'doubao-seed-1-8-251228',
provider: 'doubao',
name: 'Doubao-Seed-1.8',
group: 'Doubao-Seed-1.8'

View File

@ -75,12 +75,37 @@ const VISION_REGEX = new RegExp(
'i'
)
// For middleware to identify models that must use the dedicated Image API
// All dedicated image generation models (only generate images, no text chat capability)
// These models need:
// 1. Route to dedicated image generation API
// 2. Exclude from reasoning/websearch/tooluse selection
const DEDICATED_IMAGE_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
// OpenAI series
'dall-e(?:-[\\w-]+)?',
'gpt-image-1(?:-[\\w-]+)?',
'imagen(?:-[\\w-]+)?'
'gpt-image(?:-[\\w-]+)?',
// xAI
'grok-2-image(?:-[\\w-]+)?',
// Google
'imagen(?:-[\\w-]+)?',
// Stable Diffusion series
'flux(?:-[\\w-]+)?',
'stable-?diffusion(?:-[\\w-]+)?',
'stabilityai(?:-[\\w-]+)?',
'sd-[\\w-]+',
'sdxl(?:-[\\w-]+)?',
// zhipu
'cogview(?:-[\\w-]+)?',
// Alibaba
'qwen-image(?:-[\\w-]+)?',
// Others
'janus(?:-[\\w-]+)?',
'midjourney(?:-[\\w-]+)?',
'mj-[\\w-]+',
'z-image(?:-[\\w-]+)?',
'longcat-image(?:-[\\w-]+)?',
'hunyuanimage(?:-[\\w-]+)?',
'seedream(?:-[\\w-]+)?',
'kandinsky(?:-[\\w-]+)?'
]
const IMAGE_ENHANCEMENT_MODELS = [
@ -133,13 +158,23 @@ const GENERATE_IMAGE_MODELS_REGEX = new RegExp(GENERATE_IMAGE_MODELS.join('|'),
const MODERN_GENERATE_IMAGE_MODELS_REGEX = new RegExp(MODERN_IMAGE_MODELS.join('|'), 'i')
export const isDedicatedImageGenerationModel = (model: Model): boolean => {
/**
* Check if the model is a dedicated image generation model
* Dedicated image generation models can only generate images, no text chat capability
*
* These models need:
* 1. Route to dedicated image generation API
* 2. Exclude from reasoning/websearch/tooluse selection
*/
export function isDedicatedImageModel(model: Model): boolean {
if (!model) return false
const modelId = getLowerBaseModelName(model.id)
return DEDICATED_IMAGE_MODELS_REGEX.test(modelId)
}
// Backward compatible aliases
export const isDedicatedImageGenerationModel = isDedicatedImageModel
export const isAutoEnableImageGenerationModel = (model: Model): boolean => {
if (!model) return false
@ -195,14 +230,8 @@ export function isPureGenerateImageModel(model: Model): boolean {
return !OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS.some((m) => modelId.includes(m))
}
// TODO: refine the regex
// Text to image models
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|imagen|gpt-image/i
export function isTextToImageModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id)
return TEXT_TO_IMAGE_REGEX.test(modelId)
}
// Backward compatible alias - now uses unified dedicated image model detection
export const isTextToImageModel = isDedicatedImageModel
/**
*

View File

@ -212,6 +212,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.302.ai',
anthropicApiHost: 'https://api.302.ai',
models: SYSTEM_MODELS['302ai'],
isSystem: true,
enabled: false
@ -1089,7 +1090,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
websites: {
official: 'https://platform.minimaxi.com/',
apiKey: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
docs: 'https://platform.minimaxi.com/document/Announcement',
docs: 'https://platform.minimaxi.com/docs/api-reference/text-openai-api',
models: 'https://platform.minimaxi.com/document/Models'
}
},

View File

@ -8,6 +8,7 @@ import esES from 'antd/locale/es_ES'
import frFR from 'antd/locale/fr_FR'
import jaJP from 'antd/locale/ja_JP'
import ptPT from 'antd/locale/pt_PT'
import roRO from 'antd/locale/ro_RO'
import ruRU from 'antd/locale/ru_RU'
import zhCN from 'antd/locale/zh_CN'
import zhTW from 'antd/locale/zh_TW'
@ -141,6 +142,8 @@ function getAntdLocale(language: LanguageVarious) {
return frFR
case 'pt-PT':
return ptPT
case 'ro-RO':
return roRO
default:
return zhCN
}

View File

@ -7,7 +7,7 @@ interface UseSmoothStreamOptions {
initialText?: string
}
const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT']
const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT', 'ro-RO']
const segmenter = new Intl.Segmenter(languages)
export const useSmoothStream = ({ onUpdate, streamDone, minDelay = 10, initialText = '' }: UseSmoothStreamOptions) => {

View File

@ -36,18 +36,16 @@ export default function useTranslate() {
const getLanguageByLangcode = useCallback(
(langCode: string) => {
if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
return UNKNOWN
}
const result = translateLanguages.find((item) => item.langCode === langCode)
if (result) {
return result
} else if (!isLoaded) {
logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.')
} else {
logger.warn(`Unknown language ${langCode}`)
return UNKNOWN
}
return UNKNOWN
},
[isLoaded, translateLanguages]
)
@ -63,6 +61,7 @@ export default function useTranslate() {
prompt,
settings,
translateLanguages,
isLoaded,
getLanguageByLangcode,
updateSettings: handleUpdateSettings
}

View File

@ -14,6 +14,7 @@ import esES from './translate/es-es.json'
import frFR from './translate/fr-fr.json'
import jaJP from './translate/ja-jp.json'
import ptPT from './translate/pt-pt.json'
import roRO from './translate/ro-ro.json'
import ruRU from './translate/ru-ru.json'
const logger = loggerService.withContext('I18N')
@ -29,7 +30,8 @@ const resources = Object.fromEntries(
['el-GR', elGR],
['es-ES', esES],
['fr-FR', frFR],
['pt-PT', ptPT]
['pt-PT', ptPT],
['ro-RO', roRO]
].map(([locale, translation]) => [locale, { translation }])
)

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