Merge remote-tracking branch 'origin/migrate/v6-2' into migrate/v6-3

# Conflicts:
#	package.json
#	patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch
#	patches/ai-npm-6.0.1-b73221ad63.patch
#	src/main/services/FileStorage.ts
#	yarn.lock
This commit is contained in:
suyao 2026-01-06 20:03:45 +08:00
commit 58c4b2f020
No known key found for this signature in database
133 changed files with 33739 additions and 28635 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

@ -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

@ -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,71 +27,69 @@
"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",
"typecheck": "yarn workspace @cherrystudio/ai-sdk-provider build && concurrently -n \"node,web,aicore\" -c \"cyan,magenta,yellow\" \"npm run typecheck:node\" \"npm run typecheck:web\" \"yarn workspace @cherrystudio/ai-core typecheck\"",
"analyze:renderer": "VISUALIZER_RENDERER=true pnpm build",
"analyze:main": "VISUALIZER_MAIN=true pnpm build",
"typecheck": "pnpm --filter @cherrystudio/ai-sdk-provider build && concurrently -n \"node,web,aicore\" -c \"cyan,magenta,yellow\" \"npm run typecheck:node\" \"npm run typecheck:web\" \"pnpm --filter @cherrystudio/ai-core typecheck\"",
"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",
@ -114,35 +97,40 @@
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/cerebras": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A3.0.0#~/.yarn/patches/@ai-sdk-google-npm-3.0.0-ef668576ff.patch",
"@ai-sdk/google": "3.0.0",
"@ai-sdk/google-vertex": "^4.0.0",
"@ai-sdk/huggingface": "^1.0.0",
"@ai-sdk/mistral": "^3.0.0",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A3.0.0#~/.yarn/patches/@ai-sdk-openai-npm-3.0.0-0b1bba0aab.patch",
"@ai-sdk/openai": "3.0.0",
"@ai-sdk/perplexity": "^3.0.0",
"@ai-sdk/test-server": "^1.0.0",
"@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 +143,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 +168,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 +177,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 +206,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 +227,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 +242,14 @@
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"ai": "patch:ai@npm%3A6.0.1#~/.yarn/patches/ai-npm-6.0.1-b73221ad63.patch",
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
"ai": "6.0.1",
"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 +258,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 +269,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 +278,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 +294,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 +311,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 +322,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 +369,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 +388,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,46 +406,67 @@
"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%3A3.0.0#~/.yarn/patches/@ai-sdk-openai-npm-3.0.0-0b1bba0aab.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/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%3A2.0.0#~/.yarn/patches/@ai-sdk-openai-compatible-npm-2.0.0-d8d5f27c45.patch",
"@ai-sdk/openai-compatible@npm:2.0.0": "patch:@ai-sdk/openai-compatible@npm%3A2.0.0#~/.yarn/patches/@ai-sdk-openai-compatible-npm-2.0.0-d8d5f27c45.patch",
"@ai-sdk/openai@npm:3.0.0": "patch:@ai-sdk/openai@npm%3A3.0.0#~/.yarn/patches/@ai-sdk-openai-npm-3.0.0-0b1bba0aab.patch",
"@ai-sdk/google@npm:3.0.0": "patch:@ai-sdk/google@npm%3A3.0.0#~/.yarn/patches/@ai-sdk-google-npm-3.0.0-ef668576ff.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@3.0.0": "patches/@ai-sdk-google-npm-3.0.0-ef668576ff.patch",
"@ai-sdk/openai@3.0.0": "patches/@ai-sdk-openai-npm-3.0.0-0b1bba0aab.patch",
"@ai-sdk/openai-compatible@2.0.0": "patches/@ai-sdk-openai-compatible-npm-2.0.0-d8d5f27c45.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@6.0.1": "patches/ai-npm-6.0.1-b73221ad63.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%3A2.0.0#~/.yarn/patches/@ai-sdk-openai-compatible-npm-2.0.0-d8d5f27c45.patch",
"@ai-sdk/openai-compatible": "2.0.0",
"@ai-sdk/provider": "^3.0.0",
"@ai-sdk/provider-utils": "^4.0.0"
},

View File

@ -47,7 +47,7 @@
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/azure": "^3.0.0",
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A2.0.0#~/.yarn/patches/@ai-sdk-openai-compatible-npm-2.0.0-d8d5f27c45.patch",
"@ai-sdk/openai-compatible": "2.0.0",
"@ai-sdk/provider": "^3.0.0",
"@ai-sdk/provider-utils": "^4.0.0",
"@ai-sdk/xai": "^3.0.0",

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

@ -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 generated 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

@ -1061,12 +1061,18 @@ export async 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'
@ -672,13 +673,10 @@ class FileStorage {
throw new Error('Base64 data is required')
}
const header = base64Data.slice(0, 100)
const mimeMatch = header.match(/^data:([^;]+);base64,/)
const mimeType = mimeMatch ? mimeMatch[1] : null
const ext = mimeType ? this.getExtensionFromMimeType(mimeType) : '.png'
const parseResult = parseDataUrl(base64Data)
const base64String = parseResult?.data ?? base64Data
const ext = parseResult?.mediaType ? this.getExtensionFromMimeType(parseResult.mediaType) : '.png'
// 移除 base64 头部信息(如果存在)
const base64String = base64Data.replace(/^data:.*;base64,/, '')
const buffer = Buffer.from(base64String, 'base64')
const uuid = uuidv4()
const destPath = path.join(this.storageDir, uuid + ext)
@ -1468,8 +1466,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

@ -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

@ -13,18 +13,13 @@ export async function getIpCountry(): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const ipinfo = await net.fetch('https://ipinfo.io/json', {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
const ipinfo = await net.fetch(`https://api.ipinfo.io/lite/me?token=2a42580355dae4`, {
signal: controller.signal
})
clearTimeout(timeoutId)
const data = await ipinfo.json()
const country = data.country || 'CN'
const country = data.country_code || 'CN'
logger.info(`Detected user IP address country: ${country}`)
return country
} catch (error) {

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 }])
)

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 })

View File

@ -118,6 +118,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return { thinking: { type: 'disabled' } }
}
// Deepseek, default behavior is non-thinking
if (isDeepSeekHybridInferenceModel(model)) {
return {}
}
// GPT 5.1, GPT 5.2, or newer
if (isSupportNoneReasoningEffortModel(model)) {
return {

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

@ -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

@ -777,7 +777,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

@ -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

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 }])
)

View File

@ -1297,6 +1297,7 @@
"backup": {
"file_format": "Backup file format error"
},
"base64DataTruncated": "Base64 image data truncated, size",
"boundary": {
"default": {
"devtools": "Open debug panel",
@ -1377,6 +1378,8 @@
"text": "Text",
"toolInput": "Tool Input",
"toolName": "Tool Name",
"truncated": "Data truncated, original size",
"truncatedBadge": "Truncated",
"unknown": "Unknown error",
"usage": "Usage",
"user_message_not_found": "Cannot find original user message to resend",
@ -3165,6 +3168,7 @@
"label": "App Data",
"migration_title": "Data Migration",
"new_path": "New Path",
"open": "Open Directory",
"original_path": "Original Path",
"path_change_failed": "Failed to change data directory",
"path_changed_without_copy": "Path changed successfully",

View File

@ -1297,6 +1297,7 @@
"backup": {
"file_format": "备份文件格式错误"
},
"base64DataTruncated": "Base64 图片数据已截断,大小",
"boundary": {
"default": {
"devtools": "打开调试面板",
@ -1377,6 +1378,8 @@
"text": "文本",
"toolInput": "工具输入",
"toolName": "工具名",
"truncated": "数据已截断,原始大小",
"truncatedBadge": "已截断",
"unknown": "未知错误",
"usage": "用量",
"user_message_not_found": "无法找到原始用户消息",
@ -3165,6 +3168,7 @@
"label": "应用数据",
"migration_title": "数据迁移",
"new_path": "新路径",
"open": "打开目录",
"original_path": "原始路径",
"path_change_failed": "数据目录更改失败",
"path_changed_without_copy": "路径已更改成功",

View File

@ -1297,6 +1297,7 @@
"backup": {
"file_format": "備份檔案格式錯誤"
},
"base64DataTruncated": "Base64 圖片資料已截斷,大小",
"boundary": {
"default": {
"devtools": "開啟除錯面板",
@ -1377,6 +1378,8 @@
"text": "文字",
"toolInput": "工具輸入",
"toolName": "工具名稱",
"truncated": "資料已截斷,原始大小",
"truncatedBadge": "已截斷",
"unknown": "未知錯誤",
"usage": "用量",
"user_message_not_found": "無法找到原始使用者訊息",
@ -3165,6 +3168,7 @@
"label": "應用程式資料",
"migration_title": "資料移轉",
"new_path": "新路徑",
"open": "開啟目錄",
"original_path": "原始路徑",
"path_change_failed": "資料目錄變更失敗",
"path_changed_without_copy": "路徑已變更成功",

View File

@ -3165,6 +3165,7 @@
"label": "Anwendungsdaten",
"migration_title": "Datenmigration",
"new_path": "Neuer Pfad",
"open": "Offenes Verzeichnis",
"original_path": "Ursprünglicher Pfad",
"path_change_failed": "Datenverzeichnisänderung fehlgeschlagen",
"path_changed_without_copy": "Pfad erfolgreich geändert",

View File

@ -3165,6 +3165,7 @@
"label": "Δεδομένα εφαρμογής",
"migration_title": "Μεταφορά δεδομένων",
"new_path": "Νέα διαδρομή",
"open": "Ανοιχτός Κατάλογος",
"original_path": "Αρχική διαδρομή",
"path_change_failed": "Η αλλαγή του καταλόγου δεδομένων απέτυχε",
"path_changed_without_copy": "Η διαδρομή άλλαξε επιτυχώς",

View File

@ -3165,6 +3165,7 @@
"label": "Datos de la aplicación",
"migration_title": "Migración de datos",
"new_path": "Nueva ruta",
"open": "Directorio abierto",
"original_path": "Ruta original",
"path_change_failed": "Error al cambiar el directorio de datos",
"path_changed_without_copy": "La ruta se ha cambiado correctamente",

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