mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-12 00:49:14 +08:00
Merge branch 'main' into feat/mcp-hub
This commit is contained in:
commit
bbeb052390
31
.github/workflows/auto-i18n.yml
vendored
31
.github/workflows/auto-i18n.yml
vendored
@ -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"
|
||||
|
||||
29
.github/workflows/nightly-build.yml
vendored
29
.github/workflows/nightly-build.yml
vendored
@ -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
|
||||
|
||||
33
.github/workflows/pr-ci.yml
vendored
33
.github/workflows/pr-ci.yml
vendored
@ -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
|
||||
|
||||
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
@ -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
|
||||
|
||||
9
.github/workflows/sync-to-gitcode.yml
vendored
9
.github/workflows/sync-to-gitcode.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1 +1 @@
|
||||
yarn lint-staged
|
||||
pnpm lint-staged
|
||||
|
||||
2
.npmrc
2
.npmrc
@ -1 +1 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
|
||||
BIN
.yarn/releases/yarn-4.9.1.cjs
vendored
BIN
.yarn/releases/yarn-4.9.1.cjs
vendored
Binary file not shown.
@ -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
|
||||
26
CLAUDE.md
26
CLAUDE.md
@ -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
|
||||
|
||||
14
README.md
14
README.md
@ -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
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
# ⭐️ Star 记录
|
||||
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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` 的 PR,Diff 仅包含该文件。
|
||||
|
||||
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
|
||||
如需本地调试,可执行 `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 分支)下的副本,方便文档/审查。
|
||||
|
||||
## 版本匹配逻辑
|
||||
|
||||
|
||||
129
docs/zh/references/fuzzy-search.md
Normal file
129
docs/zh/references/fuzzy-search.md
Normal file
@ -0,0 +1,129 @@
|
||||
# 文件列表模糊搜索
|
||||
|
||||
本文档描述了 Cherry Studio 中文件列表的模糊搜索实现。
|
||||
|
||||
## 概述
|
||||
|
||||
模糊搜索功能允许用户通过输入部分或近似的文件名/路径来查找文件。它使用两层文件过滤策略(ripgrep glob 预过滤 + 贪婪子串匹配回退),结合基于子序列的评分,以获得最佳性能和灵活性。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **Ripgrep Glob 预过滤**:使用 glob 模式进行快速原生级过滤的主要过滤策略
|
||||
- **贪婪子串匹配**:当 ripgrep glob 预过滤无结果时的回退文件过滤策略
|
||||
- **基于子序列的段评分**:评分时,当查询字符按顺序出现时,路径段获得额外权重
|
||||
- **相关性评分**:结果按多因素相关性分数排序
|
||||
|
||||
## 匹配策略
|
||||
|
||||
### 1. Ripgrep Glob 预过滤(主要)
|
||||
|
||||
查询被转换为 glob 模式供 ripgrep 进行初始过滤:
|
||||
|
||||
```
|
||||
查询: "updater"
|
||||
Glob: "*u*p*d*a*t*e*r*"
|
||||
```
|
||||
|
||||
这利用了 ripgrep 的原生性能进行初始文件过滤。
|
||||
|
||||
### 2. 贪婪子串匹配(回退)
|
||||
|
||||
当 glob 预过滤无结果时,系统回退到贪婪子串匹配。这允许更灵活的匹配:
|
||||
|
||||
```
|
||||
查询: "updatercontroller"
|
||||
文件: "packages/update/src/node/updateController.ts"
|
||||
|
||||
匹配过程:
|
||||
1. 找到 "update"(从开头的最长匹配)
|
||||
2. 剩余 "rcontroller" → 找到 "r" 然后 "controller"
|
||||
3. 所有部分都匹配 → 成功
|
||||
```
|
||||
|
||||
## 评分算法
|
||||
|
||||
结果根据 `FileStorage.ts` 中定义的命名常量进行相关性分数排名:
|
||||
|
||||
| 常量 | 值 | 描述 |
|
||||
|------|-----|------|
|
||||
| `SCORE_FILENAME_STARTS` | 100 | 文件名以查询开头(最高优先级)|
|
||||
| `SCORE_FILENAME_CONTAINS` | 80 | 文件名包含精确查询子串 |
|
||||
| `SCORE_SEGMENT_MATCH` | 60 | 每个匹配查询的路径段 |
|
||||
| `SCORE_WORD_BOUNDARY` | 20 | 查询匹配单词开头 |
|
||||
| `SCORE_CONSECUTIVE_CHAR` | 15 | 每个连续字符匹配 |
|
||||
| `PATH_LENGTH_PENALTY_FACTOR` | 4 | 较长路径的对数惩罚 |
|
||||
|
||||
### 评分策略
|
||||
|
||||
评分优先级:
|
||||
1. **文件名匹配**(最高):查询出现在文件名中的文件最相关
|
||||
2. **路径段匹配**:多个匹配段表示更强的相关性
|
||||
3. **词边界**:在单词开头匹配(如 "upd" 匹配 "update")更优先
|
||||
4. **连续匹配**:更长的连续字符序列得分更高
|
||||
5. **路径长度**:较短路径更优先(对数惩罚防止长路径主导评分)
|
||||
|
||||
### 评分示例
|
||||
|
||||
对于查询 `updater`:
|
||||
|
||||
| 文件 | 评分因素 |
|
||||
|------|----------|
|
||||
| `RCUpdater.js` | 短路径 + 文件名包含 "updater" |
|
||||
| `updateController.ts` | 多个路径段匹配 |
|
||||
| `UpdaterHelper.plist` | 长路径惩罚 |
|
||||
|
||||
## 配置
|
||||
|
||||
### DirectoryListOptions
|
||||
|
||||
```typescript
|
||||
interface DirectoryListOptions {
|
||||
recursive?: boolean // 默认: true
|
||||
maxDepth?: number // 默认: 10
|
||||
includeHidden?: boolean // 默认: false
|
||||
includeFiles?: boolean // 默认: true
|
||||
includeDirectories?: boolean // 默认: true
|
||||
maxEntries?: number // 默认: 20
|
||||
searchPattern?: string // 默认: '.'
|
||||
fuzzy?: boolean // 默认: true
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
```typescript
|
||||
// 基本模糊搜索
|
||||
const files = await window.api.file.listDirectory(dirPath, {
|
||||
searchPattern: 'updater',
|
||||
fuzzy: true,
|
||||
maxEntries: 20
|
||||
})
|
||||
|
||||
// 禁用模糊搜索(精确 glob 匹配)
|
||||
const files = await window.api.file.listDirectory(dirPath, {
|
||||
searchPattern: 'update',
|
||||
fuzzy: false
|
||||
})
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
1. **Ripgrep 预过滤**:大多数查询由 ripgrep 的原生 glob 匹配处理,速度极快
|
||||
2. **仅在需要时回退**:贪婪子串匹配(加载所有文件)仅在 glob 匹配返回空结果时运行
|
||||
3. **结果限制**:默认只返回前 20 个结果
|
||||
4. **排除目录**:自动排除常见的大型目录:
|
||||
- `node_modules`
|
||||
- `.git`
|
||||
- `dist`、`build`
|
||||
- `.next`、`.nuxt`
|
||||
- `coverage`、`.cache`
|
||||
|
||||
## 实现细节
|
||||
|
||||
实现位于 `src/main/services/FileStorage.ts`:
|
||||
|
||||
- `queryToGlobPattern()`:将查询转换为 ripgrep glob 模式
|
||||
- `isFuzzyMatch()`:子序列匹配算法
|
||||
- `isGreedySubstringMatch()`:贪婪子串匹配回退
|
||||
- `getFuzzyMatchScore()`:计算相关性分数
|
||||
- `listDirectoryWithRipgrep()`:主搜索协调
|
||||
@ -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-->
|
||||
|
||||
@ -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')
|
||||
|
||||
307
package.json
307
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.7.8",
|
||||
"version": "1.7.9",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -9,28 +9,13 @@
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"local",
|
||||
"packages/*"
|
||||
],
|
||||
"installConfig": {
|
||||
"hoistingLimits": [
|
||||
"packages/database",
|
||||
"packages/mcp-trace/trace-core",
|
||||
"packages/mcp-trace/trace-node",
|
||||
"packages/mcp-trace/trace-web",
|
||||
"packages/extension-table-plus"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron-vite preview",
|
||||
"dev": "dotenv electron-vite dev",
|
||||
"dev:watch": "dotenv electron-vite dev -- -w",
|
||||
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"build:check": "yarn lint && yarn test",
|
||||
"build:check": "pnpm lint && pnpm test",
|
||||
"build:unpack": "dotenv npm run build && electron-builder --dir",
|
||||
"build:win": "dotenv npm run build && electron-builder --win --x64 --arm64",
|
||||
"build:win:x64": "dotenv npm run build && electron-builder --win --x64",
|
||||
@ -42,107 +27,116 @@
|
||||
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"publish": "pnpm build:check && pnpm release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
"agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
|
||||
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
|
||||
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
|
||||
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
|
||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true pnpm build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true pnpm build",
|
||||
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
||||
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||
"i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
||||
"i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
||||
"i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||
"i18n:all": "yarn i18n:check && yarn i18n:sync && yarn i18n:translate",
|
||||
"i18n:all": "pnpm i18n:check && pnpm i18n:sync && pnpm i18n:translate",
|
||||
"update:languages": "tsx scripts/update-languages.ts",
|
||||
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
"test:aicore": "vitest run --project aiCore",
|
||||
"test:update": "yarn test:renderer --update",
|
||||
"test:update": "pnpm test:renderer --update",
|
||||
"test:coverage": "vitest run --coverage --silent",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "yarn playwright test",
|
||||
"test:e2e": "pnpm playwright test",
|
||||
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
|
||||
"test:scripts": "vitest scripts",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn i18n:check && yarn format:check",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && pnpm typecheck && pnpm i18n:check && pnpm format:check",
|
||||
"format": "biome format --write && biome lint --write",
|
||||
"format:check": "biome format && biome lint",
|
||||
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
||||
"claude": "dotenv -e .env -- claude",
|
||||
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
|
||||
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public",
|
||||
"release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public"
|
||||
"release:aicore:alpha": "pnpm --filter @cherrystudio/ai-core version prerelease --preid alpha && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag alpha --access public",
|
||||
"release:aicore:beta": "pnpm --filter @cherrystudio/ai-core version prerelease --preid beta && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --tag beta --access public",
|
||||
"release:aicore": "pnpm --filter @cherrystudio/ai-core version patch && pnpm --filter @cherrystudio/ai-core build && pnpm --filter @cherrystudio/ai-core publish --access public",
|
||||
"release:ai-sdk-provider": "pnpm --filter @cherrystudio/ai-sdk-provider version patch && pnpm --filter @cherrystudio/ai-sdk-provider build && pnpm --filter @cherrystudio/ai-sdk-provider publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.62",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"@paymoapp/electron-shutdown-handler": "^1.1.2",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"bonjour-service": "^1.3.0",
|
||||
"emoji-picker-element-data": "^1",
|
||||
"express": "^5.1.0",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"@napi-rs/system-ocr": "1.0.2",
|
||||
"@paymoapp/electron-shutdown-handler": "1.1.2",
|
||||
"express": "5.1.0",
|
||||
"font-list": "2.0.0",
|
||||
"graceful-fs": "4.2.11",
|
||||
"gray-matter": "4.0.3",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^1.0.12",
|
||||
"sharp": "^0.34.3",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"officeparser": "4.2.0",
|
||||
"os-proxy-config": "1.1.2",
|
||||
"selection-hook": "1.0.12",
|
||||
"sharp": "0.34.3",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tesseract.js": "6.0.1",
|
||||
"turndown": "7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"js-yaml": "4.1.0",
|
||||
"bonjour-service": "1.3.0",
|
||||
"emoji-picker-element-data": "1",
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.61",
|
||||
"@ai-sdk/anthropic": "^2.0.49",
|
||||
"@ai-sdk/azure": "2.0.87",
|
||||
"@ai-sdk/cerebras": "^1.0.31",
|
||||
"@ai-sdk/gateway": "^2.0.15",
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
|
||||
"@ai-sdk/google": "2.0.49",
|
||||
"@ai-sdk/google-vertex": "^3.0.94",
|
||||
"@ai-sdk/huggingface": "^0.0.10",
|
||||
"@ai-sdk/mistral": "^2.0.24",
|
||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
|
||||
"@ai-sdk/openai": "2.0.85",
|
||||
"@ai-sdk/perplexity": "^2.0.20",
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.17",
|
||||
"@ai-sdk/test-server": "^0.0.1",
|
||||
"@ai-sdk/xai": "2.0.36",
|
||||
"@ant-design/cssinjs": "1.23.0",
|
||||
"@ant-design/icons": "5.6.1",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.41.0",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
"@anthropic-ai/vertex-sdk": "0.11.4",
|
||||
"@aws-sdk/client-bedrock": "^3.910.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.910.0",
|
||||
"@aws-sdk/client-s3": "^3.910.0",
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@cherrystudio/ai-core": "workspace:^1.0.9",
|
||||
"@cherrystudio/embedjs": "^0.1.31",
|
||||
"@cherrystudio/embedjs-libsql": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-image": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-markdown": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-msoffice": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-pdf": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-sitemap": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-web": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@cherrystudio/embedjs": "0.1.31",
|
||||
"@cherrystudio/embedjs-interfaces": "0.1.31",
|
||||
"@cherrystudio/embedjs-libsql": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-csv": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-image": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-markdown": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-msoffice": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-pdf": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-sitemap": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-web": "0.1.31",
|
||||
"@cherrystudio/embedjs-loader-xml": "0.1.31",
|
||||
"@cherrystudio/embedjs-ollama": "0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "0.1.31",
|
||||
"@cherrystudio/embedjs-utils": "0.1.31",
|
||||
"@cherrystudio/extension-table-plus": "workspace:^",
|
||||
"@cherrystudio/openai": "^6.12.0",
|
||||
"@cherrystudio/openai": "6.15.0",
|
||||
"@codemirror/lang-json": "6.0.1",
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@ -155,18 +149,21 @@
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@eslint-react/eslint-plugin": "^1.36.1",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
|
||||
"@floating-ui/dom": "1.7.3",
|
||||
"@google/genai": "1.0.1",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@kangfenmao/keyv-storage": "^0.1.0",
|
||||
"@kangfenmao/keyv-storage": "^0.1.3",
|
||||
"@langchain/community": "^1.0.0",
|
||||
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
|
||||
"@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/core": "1.0.2",
|
||||
"@langchain/openai": "1.0.0",
|
||||
"@langchain/textsplitters": "0.1.0",
|
||||
"@mistralai/mistralai": "^1.7.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.8",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.0.1",
|
||||
"@opentelemetry/core": "2.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
@ -177,6 +174,7 @@
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.12.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@swc/plugin-styled-components": "^8.0.4",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
@ -185,21 +183,25 @@
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tiptap/extension-collaboration": "^3.2.0",
|
||||
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
|
||||
"@tiptap/extension-drag-handle-react": "^3.2.0",
|
||||
"@tiptap/extension-image": "^3.2.0",
|
||||
"@tiptap/extension-list": "^3.2.0",
|
||||
"@tiptap/extension-mathematics": "^3.2.0",
|
||||
"@tiptap/extension-mention": "^3.2.0",
|
||||
"@tiptap/extension-node-range": "^3.2.0",
|
||||
"@tiptap/extension-table-of-contents": "^3.2.0",
|
||||
"@tiptap/extension-typography": "^3.2.0",
|
||||
"@tiptap/extension-underline": "^3.2.0",
|
||||
"@tiptap/pm": "^3.2.0",
|
||||
"@tiptap/react": "^3.2.0",
|
||||
"@tiptap/starter-kit": "^3.2.0",
|
||||
"@tiptap/suggestion": "^3.2.0",
|
||||
"@tiptap/core": "3.2.0",
|
||||
"@tiptap/extension-code-block": "3.2.0",
|
||||
"@tiptap/extension-collaboration": "3.2.0",
|
||||
"@tiptap/extension-drag-handle": "3.2.0",
|
||||
"@tiptap/extension-drag-handle-react": "3.2.0",
|
||||
"@tiptap/extension-heading": "3.2.0",
|
||||
"@tiptap/extension-image": "3.2.0",
|
||||
"@tiptap/extension-link": "3.2.0",
|
||||
"@tiptap/extension-list": "3.2.0",
|
||||
"@tiptap/extension-mathematics": "3.2.0",
|
||||
"@tiptap/extension-mention": "3.2.0",
|
||||
"@tiptap/extension-node-range": "3.2.0",
|
||||
"@tiptap/extension-table-of-contents": "3.2.0",
|
||||
"@tiptap/extension-typography": "3.2.0",
|
||||
"@tiptap/extension-underline": "3.2.0",
|
||||
"@tiptap/pm": "3.2.0",
|
||||
"@tiptap/react": "3.2.0",
|
||||
"@tiptap/starter-kit": "3.2.0",
|
||||
"@tiptap/suggestion": "3.2.0",
|
||||
"@tiptap/y-tiptap": "^3.0.0",
|
||||
"@truto/turndown-plugin-gfm": "^1.0.2",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
@ -210,14 +212,17 @@
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/express": "^5",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/he": "^1",
|
||||
"@types/html-to-text": "^9",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/json-schema": "7.0.15",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/markdown-it": "^14",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/mdast": "4.0.4",
|
||||
"@types/mime-types": "^3",
|
||||
"@types/node": "^22.17.1",
|
||||
"@types/node": "22.17.2",
|
||||
"@types/pako": "^1.0.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@ -228,9 +233,10 @@
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/unist": "3.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/word-extractor": "^1",
|
||||
"@typescript/native-preview": "latest",
|
||||
"@typescript/native-preview": "7.0.0-dev.20250915.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
||||
"@uiw/codemirror-themes-all": "^4.25.1",
|
||||
"@uiw/react-codemirror": "^4.25.1",
|
||||
@ -242,12 +248,15 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"adm-zip": "0.4.16",
|
||||
"ai": "^5.0.98",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"antd": "5.27.0",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.7.3",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"builder-util-runtime": "9.5.0",
|
||||
"chalk": "4.1.2",
|
||||
"chardet": "^2.1.0",
|
||||
"check-disk-space": "3.4.0",
|
||||
"cheerio": "^1.1.2",
|
||||
@ -256,8 +265,10 @@
|
||||
"cli-progress": "^3.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
"codemirror-lang-mermaid": "0.5.0",
|
||||
"color": "^5.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "2.8.5",
|
||||
"country-flag-emoji-polyfill": "0.1.8",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
@ -265,6 +276,7 @@
|
||||
"diff": "^8.0.2",
|
||||
"docx": "^9.0.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv": "16.6.1",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
@ -273,12 +285,12 @@
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
|
||||
"electron-updater": "6.7.0",
|
||||
"electron-vite": "5.0.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"epub": "1.3.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-import-zod": "^1.2.0",
|
||||
"eslint-plugin-oxlint": "^1.15.0",
|
||||
@ -289,6 +301,7 @@
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "1.3.2",
|
||||
"form-data": "4.0.4",
|
||||
"framer-motion": "^12.23.12",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
@ -305,6 +318,10 @@
|
||||
"isbinaryfile": "5.0.4",
|
||||
"jaison": "^2.0.2",
|
||||
"jest-styled-components": "^7.2.0",
|
||||
"js-base64": "3.7.7",
|
||||
"json-schema": "0.4.0",
|
||||
"katex": "0.16.22",
|
||||
"ky": "1.8.1",
|
||||
"linguist-languages": "^8.1.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
@ -312,19 +329,27 @@
|
||||
"lucide-react": "^0.525.0",
|
||||
"macos-release": "^3.4.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"md5": "2.3.0",
|
||||
"mermaid": "^11.10.1",
|
||||
"mime": "^4.0.4",
|
||||
"mime-types": "^3.0.1",
|
||||
"motion": "^12.10.5",
|
||||
"nanoid": "3.3.11",
|
||||
"notion-helper": "^1.3.22",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"ollama-ai-provider-v2": "patch:ollama-ai-provider-v2@npm%3A1.5.5#~/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch",
|
||||
"ollama-ai-provider-v2": "1.5.5",
|
||||
"open": "^8.4.2",
|
||||
"oxlint": "^1.22.0",
|
||||
"oxlint-tsgolint": "^0.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"pako": "1.0.11",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"prosemirror-model": "1.25.2",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"rc-input": "1.8.0",
|
||||
"rc-select": "14.16.6",
|
||||
"rc-virtual-list": "3.18.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
@ -351,8 +376,11 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-github-blockquote-alert": "^2.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-stringify": "11.0.0",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"semver": "7.7.1",
|
||||
"shiki": "^3.12.0",
|
||||
"strict-url-sanitise": "^0.0.1",
|
||||
"string-width": "^7.2.0",
|
||||
@ -367,9 +395,10 @@
|
||||
"tsx": "^4.20.3",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "~5.8.2",
|
||||
"typescript": "~5.8.3",
|
||||
"undici": "6.21.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "5.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"vitest": "^3.2.4",
|
||||
@ -384,44 +413,66 @@
|
||||
"zipread": "^1.3.3",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"@smithy/types": "4.7.1",
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
|
||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"esbuild": "^0.25.0",
|
||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"node-abi": "4.24.0",
|
||||
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0",
|
||||
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0",
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"tar-fs": "^2.1.4",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
"@img/sharp-linux-arm": "0.34.3",
|
||||
"@img/sharp-linux-arm64": "0.34.3",
|
||||
"@img/sharp-linux-x64": "0.34.3",
|
||||
"@img/sharp-win32-x64": "0.34.3",
|
||||
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
|
||||
"@ai-sdk/google@npm:^2.0.40": "patch:@ai-sdk/google@npm%3A2.0.40#~/.yarn/patches/@ai-sdk-google-npm-2.0.40-47e0eeee83.patch",
|
||||
"@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch",
|
||||
"@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
|
||||
"@ai-sdk/openai-compatible@npm:1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
|
||||
"@ai-sdk/openai-compatible@npm:^1.0.19": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@smithy/types": "4.7.1",
|
||||
"@codemirror/language": "6.11.3",
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"node-abi": "4.24.0",
|
||||
"openai": "npm:@cherrystudio/openai@6.15.0",
|
||||
"tar-fs": "^2.1.4",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@7.3.0",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
"@img/sharp-linux-arm": "0.34.3",
|
||||
"@img/sharp-linux-arm64": "0.34.3",
|
||||
"@img/sharp-linux-x64": "0.34.3",
|
||||
"@img/sharp-win32-x64": "0.34.3",
|
||||
"@langchain/core": "1.0.2",
|
||||
"@ai-sdk/openai-compatible@1.0.27": "1.0.28"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk@0.1.62": "patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch",
|
||||
"@napi-rs/system-ocr@1.0.2": "patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"tesseract.js@6.0.1": "patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@2.0.49": "patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch",
|
||||
"@ai-sdk/openai@2.0.85": "patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch",
|
||||
"@anthropic-ai/vertex-sdk@0.11.4": "patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
"@google/genai@1.0.1": "patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
|
||||
"@langchain/core@1.0.2": "patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
|
||||
"@langchain/openai@1.0.0": "patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@tiptap/extension-drag-handle@3.2.0": "patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
|
||||
"antd@5.27.0": "patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"electron-updater@6.7.0": "patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
|
||||
"epub@1.3.0": "patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"ollama-ai-provider-v2@1.5.5": "patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch",
|
||||
"atomically@1.7.0": "patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"file-stream-rotator@0.6.1": "patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"libsql@0.4.7": "patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"pdf-parse@1.1.1": "patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"@ai-sdk/openai-compatible@1.0.28": "patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@kangfenmao/keyv-storage",
|
||||
"@paymoapp/electron-shutdown-handler",
|
||||
"@scarf/scarf",
|
||||
"@swc/core",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"msw",
|
||||
"protobufjs",
|
||||
"registry-js",
|
||||
"selection-hook",
|
||||
"sharp",
|
||||
"tesseract.js",
|
||||
"zipfile"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [
|
||||
"biome format --write --no-errors-on-unmatched",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
|
||||
"@ai-sdk/openai-compatible": "1.0.28",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.17"
|
||||
},
|
||||
|
||||
@ -42,7 +42,7 @@
|
||||
"@ai-sdk/anthropic": "^2.0.49",
|
||||
"@ai-sdk/azure": "^2.0.87",
|
||||
"@ai-sdk/deepseek": "^1.0.31",
|
||||
"@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch",
|
||||
"@ai-sdk/openai-compatible": "1.0.28",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.17",
|
||||
"@ai-sdk/xai": "^2.0.36",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -364,6 +364,7 @@ export enum IpcChannel {
|
||||
OCR_ListProviders = 'ocr:list-providers',
|
||||
|
||||
// OVMS
|
||||
Ovms_IsSupported = 'ovms:is-supported',
|
||||
Ovms_AddModel = 'ovms:add-model',
|
||||
Ovms_StopAddModel = 'ovms:stop-addmodel',
|
||||
Ovms_GetModels = 'ovms:get-models',
|
||||
|
||||
138
packages/shared/__tests__/utils.test.ts
Normal file
138
packages/shared/__tests__/utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
* ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
|
||||
*
|
||||
*/
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b2
|
||||
+ sendReasoning: z.ZodOptional<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>;
|
||||
|
||||
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644
|
||||
--- a/dist/index.js
|
||||
@ -48,7 +48,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: text,
|
||||
+ reasoning_content: reasoning_text ?? undefined,
|
||||
+ reasoning_content: reasoning_text || undefined,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
|
||||
...metadata
|
||||
});
|
||||
@ -60,7 +60,7 @@ index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e
|
||||
+ textVerbosity: import_v4.z.string().optional(),
|
||||
+ sendReasoning: import_v4.z.boolean().optional()
|
||||
});
|
||||
|
||||
|
||||
// src/openai-compatible-error.ts
|
||||
@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class {
|
||||
reasoning_effort: compatibleOptions.reasoningEffort,
|
||||
@ -175,7 +175,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: text,
|
||||
+ reasoning_content: reasoning_text ?? undefined,
|
||||
+ reasoning_content: reasoning_text || undefined,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : void 0,
|
||||
...metadata
|
||||
});
|
||||
@ -187,7 +187,7 @@ index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5
|
||||
+ textVerbosity: z.string().optional(),
|
||||
+ sendReasoning: z.boolean().optional()
|
||||
});
|
||||
|
||||
|
||||
// src/openai-compatible-error.ts
|
||||
@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class {
|
||||
reasoning_effort: compatibleOptions.reasoningEffort,
|
||||
25417
pnpm-lock.yaml
Normal file
25417
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
@ -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 = `
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 以解决问题。`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -37,7 +37,7 @@ import { versionService } from './services/VersionService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { initWebviewHotkeys } from './services/WebviewService'
|
||||
import { runAsyncFunction } from './utils'
|
||||
import { ovmsManager } from './services/OvmsManager'
|
||||
import { isOvmsSupported } from './services/OvmsManager'
|
||||
|
||||
const logger = loggerService.withContext('MainEntry')
|
||||
|
||||
@ -158,7 +158,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
registerShortcuts(mainWindow)
|
||||
|
||||
registerIpc(mainWindow, app)
|
||||
await registerIpc(mainWindow, app)
|
||||
localTransferService.startDiscovery({ resetList: true })
|
||||
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
@ -248,7 +248,14 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
// 简单的资源清理,不阻塞退出流程
|
||||
await ovmsManager.stopOvms()
|
||||
if (isOvmsSupported) {
|
||||
const { ovmsManager } = await import('./services/OvmsManager')
|
||||
if (ovmsManager) {
|
||||
await ovmsManager.stopOvms()
|
||||
} else {
|
||||
logger.warn('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mcpService.cleanup()
|
||||
|
||||
@ -59,7 +59,7 @@ import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import { ovmsManager } from './services/OvmsManager'
|
||||
import { isOvmsSupported } from './services/OvmsManager'
|
||||
import powerMonitorService from './services/PowerMonitorService'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
@ -97,6 +97,7 @@ import {
|
||||
untildify
|
||||
} from './utils/file'
|
||||
import { updateAppDataConfig } from './utils/init'
|
||||
import { getCpuName, getDeviceType, getHostname } from './utils/system'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const logger = loggerService.withContext('IPC')
|
||||
@ -120,7 +121,7 @@ function extractPluginError(error: unknown): PluginError | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
const notificationService = new NotificationService()
|
||||
|
||||
@ -498,9 +499,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))
|
||||
|
||||
// system
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, getDeviceType)
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, getHostname)
|
||||
ipcMain.handle(IpcChannel.System_GetCpuName, getCpuName)
|
||||
ipcMain.handle(IpcChannel.System_CheckGitBash, () => {
|
||||
if (!isWin) {
|
||||
return true // Non-Windows systems don't need Git Bash
|
||||
@ -974,15 +975,36 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
|
||||
|
||||
// OVMS
|
||||
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
ovmsManager.addModel(modelName, modelId, modelSource, task)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
|
||||
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
|
||||
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_IsSupported, () => isOvmsSupported)
|
||||
if (isOvmsSupported) {
|
||||
const { ovmsManager } = await import('./services/OvmsManager')
|
||||
if (ovmsManager) {
|
||||
ipcMain.handle(
|
||||
IpcChannel.Ovms_AddModel,
|
||||
(_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
ovmsManager.addModel(modelName, modelId, modelSource, task)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
|
||||
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
|
||||
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
|
||||
} else {
|
||||
logger.error('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.')
|
||||
}
|
||||
} else {
|
||||
const fallback = () => {
|
||||
throw new Error('OVMS is only supported on Windows with intel CPU.')
|
||||
}
|
||||
ipcMain.handle(IpcChannel.Ovms_AddModel, fallback)
|
||||
ipcMain.handle(IpcChannel.Ovms_StopAddModel, fallback)
|
||||
ipcMain.handle(IpcChannel.Ovms_GetModels, fallback)
|
||||
ipcMain.handle(IpcChannel.Ovms_IsRunning, fallback)
|
||||
ipcMain.handle(IpcChannel.Ovms_GetStatus, fallback)
|
||||
ipcMain.handle(IpcChannel.Ovms_RunOVMS, fallback)
|
||||
ipcMain.handle(IpcChannel.Ovms_StopOVMS, fallback)
|
||||
}
|
||||
|
||||
// CherryAI
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
@ -1039,12 +1061,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
} catch (error) {
|
||||
const pluginError = extractPluginError(error)
|
||||
if (pluginError) {
|
||||
logger.error('Failed to list installed plugins', { agentId, error: pluginError })
|
||||
logger.error('Failed to list installed plugins', {
|
||||
agentId,
|
||||
error: pluginError
|
||||
})
|
||||
return { success: false, error: pluginError }
|
||||
}
|
||||
|
||||
const err = normalizeError(error)
|
||||
logger.error('Failed to list installed plugins', { agentId, error: err })
|
||||
logger.error('Failed to list installed plugins', {
|
||||
agentId,
|
||||
error: err
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
177
src/main/mcpServers/browser/README.md
Normal file
177
src/main/mcpServers/browser/README.md
Normal 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)
|
||||
3
src/main/mcpServers/browser/constants.ts
Normal file
3
src/main/mcpServers/browser/constants.ts
Normal 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
567
src/main/mcpServers/browser/tabbar-html.ts
Normal file
567
src/main/mcpServers/browser/tabbar-html.ts
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
var url = (tab.url || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
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>`
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
scanDir
|
||||
} from '@main/utils/file'
|
||||
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
|
||||
import { parseDataUrl } from '@shared/utils'
|
||||
import type { FileMetadata, NotesTreeNode } from '@types'
|
||||
import { FileTypes } from '@types'
|
||||
import chardet from 'chardet'
|
||||
@ -130,16 +131,18 @@ interface DirectoryListOptions {
|
||||
includeDirectories?: boolean
|
||||
maxEntries?: number
|
||||
searchPattern?: string
|
||||
fuzzy?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_DIRECTORY_LIST_OPTIONS: Required<DirectoryListOptions> = {
|
||||
recursive: true,
|
||||
maxDepth: 3,
|
||||
maxDepth: 10,
|
||||
includeHidden: false,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntries: 10,
|
||||
searchPattern: '.'
|
||||
maxEntries: 20,
|
||||
searchPattern: '.',
|
||||
fuzzy: true
|
||||
}
|
||||
|
||||
class FileStorage {
|
||||
@ -670,8 +673,8 @@ class FileStorage {
|
||||
throw new Error('Base64 data is required')
|
||||
}
|
||||
|
||||
// 移除 base64 头部信息(如果存在)
|
||||
const base64String = base64Data.replace(/^data:.*;base64,/, '')
|
||||
const parseResult = parseDataUrl(base64Data)
|
||||
const base64String = parseResult?.data ?? base64Data
|
||||
const buffer = Buffer.from(base64String, 'base64')
|
||||
const uuid = uuidv4()
|
||||
const ext = '.png'
|
||||
@ -1046,10 +1049,226 @@ class FileStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files by content pattern
|
||||
* Fuzzy match: checks if all characters in query appear in text in order (case-insensitive)
|
||||
* Example: "updater" matches "packages/update/src/node/updateController.ts"
|
||||
*/
|
||||
private async searchByContent(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||
const args: string[] = ['-l']
|
||||
private isFuzzyMatch(text: string, query: string): boolean {
|
||||
let i = 0 // text index
|
||||
let j = 0 // query index
|
||||
const textLower = text.toLowerCase()
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
while (i < textLower.length && j < queryLower.length) {
|
||||
if (textLower[i] === queryLower[j]) {
|
||||
j++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return j === queryLower.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoring constants for fuzzy match relevance ranking
|
||||
* Higher values = higher priority in search results
|
||||
*/
|
||||
private static readonly SCORE_SEGMENT_MATCH = 60 // Per path segment that matches query
|
||||
private static readonly SCORE_FILENAME_CONTAINS = 80 // Filename contains exact query substring
|
||||
private static readonly SCORE_FILENAME_STARTS = 100 // Filename starts with query (highest priority)
|
||||
private static readonly SCORE_CONSECUTIVE_CHAR = 15 // Per consecutive character match
|
||||
private static readonly SCORE_WORD_BOUNDARY = 20 // Query matches start of a word
|
||||
private static readonly PATH_LENGTH_PENALTY_FACTOR = 4 // Logarithmic penalty multiplier for longer paths
|
||||
|
||||
/**
|
||||
* Calculate fuzzy match score (higher is better)
|
||||
* Scoring factors:
|
||||
* - Consecutive character matches (bonus)
|
||||
* - Match at word boundaries (bonus)
|
||||
* - Shorter path length (bonus)
|
||||
* - Match in filename vs directory (bonus)
|
||||
*/
|
||||
private getFuzzyMatchScore(filePath: string, query: string): number {
|
||||
const pathLower = filePath.toLowerCase()
|
||||
const queryLower = query.toLowerCase()
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const fileNameLower = fileName.toLowerCase()
|
||||
|
||||
let score = 0
|
||||
|
||||
// Count how many times query-related words appear in path segments
|
||||
const pathSegments = pathLower.split(/[/\\]/)
|
||||
let segmentMatchCount = 0
|
||||
for (const segment of pathSegments) {
|
||||
if (this.isFuzzyMatch(segment, queryLower)) {
|
||||
segmentMatchCount++
|
||||
}
|
||||
}
|
||||
score += segmentMatchCount * FileStorage.SCORE_SEGMENT_MATCH
|
||||
|
||||
// Bonus for filename starting with query (stronger than generic "contains")
|
||||
if (fileNameLower.startsWith(queryLower)) {
|
||||
score += FileStorage.SCORE_FILENAME_STARTS
|
||||
} else if (fileNameLower.includes(queryLower)) {
|
||||
// Bonus for exact substring match in filename (e.g., "updater" in "RCUpdater.js")
|
||||
score += FileStorage.SCORE_FILENAME_CONTAINS
|
||||
}
|
||||
|
||||
// Calculate consecutive match bonus
|
||||
let i = 0
|
||||
let j = 0
|
||||
let consecutiveCount = 0
|
||||
let maxConsecutive = 0
|
||||
|
||||
while (i < pathLower.length && j < queryLower.length) {
|
||||
if (pathLower[i] === queryLower[j]) {
|
||||
consecutiveCount++
|
||||
maxConsecutive = Math.max(maxConsecutive, consecutiveCount)
|
||||
j++
|
||||
} else {
|
||||
consecutiveCount = 0
|
||||
}
|
||||
i++
|
||||
}
|
||||
score += maxConsecutive * FileStorage.SCORE_CONSECUTIVE_CHAR
|
||||
|
||||
// Bonus for word boundary matches (e.g., "upd" matches start of "update")
|
||||
// Only count once to avoid inflating scores for paths with repeated patterns
|
||||
const boundaryPrefix = queryLower.slice(0, Math.min(3, queryLower.length))
|
||||
const words = pathLower.split(/[/\\._-]/)
|
||||
for (const word of words) {
|
||||
if (word.startsWith(boundaryPrefix)) {
|
||||
score += FileStorage.SCORE_WORD_BOUNDARY
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Penalty for longer paths (prefer shorter, more specific matches)
|
||||
// Use logarithmic scaling to prevent long paths from dominating the score
|
||||
// A 50-char path gets ~-16 penalty, 100-char gets ~-18, 200-char gets ~-21
|
||||
score -= Math.log(filePath.length + 1) * FileStorage.PATH_LENGTH_PENALTY_FACTOR
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert query to glob pattern for ripgrep pre-filtering
|
||||
* e.g., "updater" -> "*u*p*d*a*t*e*r*"
|
||||
*/
|
||||
private queryToGlobPattern(query: string): string {
|
||||
// Escape special glob characters (including ! for negation)
|
||||
const escaped = query.replace(/[[\]{}()*+?.,\\^$|#!]/g, '\\$&')
|
||||
// Convert to fuzzy glob: each char separated by *
|
||||
return '*' + escaped.split('').join('*') + '*'
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy substring match: check if all characters in query can be matched
|
||||
* by finding consecutive substrings in text (not necessarily single chars)
|
||||
* e.g., "updatercontroller" matches "updateController" by:
|
||||
* "update" + "r" (from Controller) + "controller"
|
||||
*/
|
||||
private isGreedySubstringMatch(text: string, query: string): boolean {
|
||||
const textLower = text.toLowerCase()
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
let queryIndex = 0
|
||||
let searchStart = 0
|
||||
|
||||
while (queryIndex < queryLower.length) {
|
||||
// Try to find the longest matching substring starting at queryIndex
|
||||
let bestMatchLen = 0
|
||||
let bestMatchPos = -1
|
||||
|
||||
for (let len = queryLower.length - queryIndex; len >= 1; len--) {
|
||||
const substr = queryLower.slice(queryIndex, queryIndex + len)
|
||||
const foundAt = textLower.indexOf(substr, searchStart)
|
||||
if (foundAt !== -1) {
|
||||
bestMatchLen = len
|
||||
bestMatchPos = foundAt
|
||||
break // Found longest possible match
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatchLen === 0) {
|
||||
// No substring match found, query cannot be matched
|
||||
return false
|
||||
}
|
||||
|
||||
queryIndex += bestMatchLen
|
||||
searchStart = bestMatchPos + bestMatchLen
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate greedy substring match score (higher is better)
|
||||
* Rewards: fewer match fragments, shorter match span, matches in filename
|
||||
*/
|
||||
private getGreedyMatchScore(filePath: string, query: string): number {
|
||||
const textLower = filePath.toLowerCase()
|
||||
const queryLower = query.toLowerCase()
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const fileNameLower = fileName.toLowerCase()
|
||||
|
||||
let queryIndex = 0
|
||||
let searchStart = 0
|
||||
let fragmentCount = 0
|
||||
let firstMatchPos = -1
|
||||
let lastMatchEnd = 0
|
||||
|
||||
while (queryIndex < queryLower.length) {
|
||||
let bestMatchLen = 0
|
||||
let bestMatchPos = -1
|
||||
|
||||
for (let len = queryLower.length - queryIndex; len >= 1; len--) {
|
||||
const substr = queryLower.slice(queryIndex, queryIndex + len)
|
||||
const foundAt = textLower.indexOf(substr, searchStart)
|
||||
if (foundAt !== -1) {
|
||||
bestMatchLen = len
|
||||
bestMatchPos = foundAt
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatchLen === 0) {
|
||||
return -Infinity // No match
|
||||
}
|
||||
|
||||
fragmentCount++
|
||||
if (firstMatchPos === -1) firstMatchPos = bestMatchPos
|
||||
lastMatchEnd = bestMatchPos + bestMatchLen
|
||||
queryIndex += bestMatchLen
|
||||
searchStart = lastMatchEnd
|
||||
}
|
||||
|
||||
const matchSpan = lastMatchEnd - firstMatchPos
|
||||
let score = 0
|
||||
|
||||
// Fewer fragments = better (single continuous match is best)
|
||||
// Max bonus when fragmentCount=1, decreases as fragments increase
|
||||
score += Math.max(0, 100 - (fragmentCount - 1) * 30)
|
||||
|
||||
// Shorter span relative to query length = better (tighter match)
|
||||
// Perfect match: span equals query length
|
||||
const spanRatio = queryLower.length / matchSpan
|
||||
score += spanRatio * 50
|
||||
|
||||
// Bonus for match in filename
|
||||
if (this.isGreedySubstringMatch(fileNameLower, queryLower)) {
|
||||
score += 80
|
||||
}
|
||||
|
||||
// Penalty for longer paths
|
||||
score -= Math.log(filePath.length + 1) * 4
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Build common ripgrep arguments for file listing
|
||||
*/
|
||||
private buildRipgrepBaseArgs(options: Required<DirectoryListOptions>, resolvedPath: string): string[] {
|
||||
const args: string[] = ['--files']
|
||||
|
||||
// Handle hidden files
|
||||
if (!options.includeHidden) {
|
||||
@ -1076,82 +1295,74 @@ class FileStorage {
|
||||
args.push('--max-depth', options.maxDepth.toString())
|
||||
}
|
||||
|
||||
// Handle max count
|
||||
if (options.maxEntries > 0) {
|
||||
args.push('--max-count', options.maxEntries.toString())
|
||||
}
|
||||
|
||||
// Add search pattern (search in content)
|
||||
args.push(options.searchPattern)
|
||||
|
||||
// Add the directory path
|
||||
args.push(resolvedPath)
|
||||
|
||||
const { exitCode, output } = await executeRipgrep(args)
|
||||
|
||||
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||
if (exitCode >= 2) {
|
||||
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||
}
|
||||
|
||||
// Parse ripgrep output (already sorted by relevance)
|
||||
const results = output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
.slice(0, options.maxEntries)
|
||||
|
||||
return results
|
||||
return args
|
||||
}
|
||||
|
||||
private async listDirectoryWithRipgrep(
|
||||
resolvedPath: string,
|
||||
options: Required<DirectoryListOptions>
|
||||
): Promise<string[]> {
|
||||
const maxEntries = options.maxEntries
|
||||
// Fuzzy search mode: use ripgrep glob for pre-filtering, then score in JS
|
||||
if (options.fuzzy && options.searchPattern && options.searchPattern !== '.') {
|
||||
const args = this.buildRipgrepBaseArgs(options, resolvedPath)
|
||||
|
||||
// Step 1: Search by filename first
|
||||
// Insert glob pattern before the path (last element)
|
||||
const globPattern = this.queryToGlobPattern(options.searchPattern)
|
||||
args.splice(args.length - 1, 0, '--iglob', globPattern)
|
||||
|
||||
const { exitCode, output } = await executeRipgrep(args)
|
||||
|
||||
if (exitCode >= 2) {
|
||||
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||
}
|
||||
|
||||
const filteredFiles = output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
|
||||
// If fuzzy glob found results, validate fuzzy match, sort and return
|
||||
if (filteredFiles.length > 0) {
|
||||
return filteredFiles
|
||||
.filter((file) => this.isFuzzyMatch(file, options.searchPattern))
|
||||
.map((file) => ({ file, score: this.getFuzzyMatchScore(file, options.searchPattern) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, options.maxEntries)
|
||||
.map((item) => item.file)
|
||||
}
|
||||
|
||||
// Fallback: if no results, try greedy substring match on all files
|
||||
logger.debug('Fuzzy glob returned no results, falling back to greedy substring match')
|
||||
const fallbackArgs = this.buildRipgrepBaseArgs(options, resolvedPath)
|
||||
|
||||
const fallbackResult = await executeRipgrep(fallbackArgs)
|
||||
|
||||
if (fallbackResult.exitCode >= 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allFiles = fallbackResult.output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
|
||||
const greedyMatched = allFiles.filter((file) => this.isGreedySubstringMatch(file, options.searchPattern))
|
||||
|
||||
return greedyMatched
|
||||
.map((file) => ({ file, score: this.getGreedyMatchScore(file, options.searchPattern) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, options.maxEntries)
|
||||
.map((item) => item.file)
|
||||
}
|
||||
|
||||
// Fallback: search by filename only (non-fuzzy mode)
|
||||
logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath })
|
||||
const filenameResults = await this.searchByFilename(resolvedPath, options)
|
||||
|
||||
logger.debug('Found matches by filename', { count: filenameResults.length })
|
||||
|
||||
// If we have enough filename matches, return them
|
||||
if (filenameResults.length >= maxEntries) {
|
||||
return filenameResults.slice(0, maxEntries)
|
||||
}
|
||||
|
||||
// Step 2: If filename matches are less than maxEntries, search by content to fill up
|
||||
logger.debug('Filename matches insufficient, searching by content to fill up', {
|
||||
filenameCount: filenameResults.length,
|
||||
needed: maxEntries - filenameResults.length
|
||||
})
|
||||
|
||||
// Adjust maxEntries for content search to get enough results
|
||||
const contentOptions = {
|
||||
...options,
|
||||
maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates
|
||||
}
|
||||
|
||||
const contentResults = await this.searchByContent(resolvedPath, contentOptions)
|
||||
|
||||
logger.debug('Found matches by content', { count: contentResults.length })
|
||||
|
||||
// Combine results: filename matches first, then content matches (deduplicated)
|
||||
const combined = [...filenameResults]
|
||||
const filenameSet = new Set(filenameResults)
|
||||
|
||||
for (const filePath of contentResults) {
|
||||
if (!filenameSet.has(filePath)) {
|
||||
combined.push(filePath)
|
||||
if (combined.length >= maxEntries) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length })
|
||||
return combined.slice(0, maxEntries)
|
||||
return filenameResults.slice(0, options.maxEntries)
|
||||
}
|
||||
|
||||
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
|
||||
@ -1254,8 +1465,8 @@ class FileStorage {
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
const parseResult = parseDataUrl(data)
|
||||
fs.writeFileSync(filePath, parseResult?.data ?? data, 'base64')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)
|
||||
|
||||
@ -3,6 +3,8 @@ import { homedir } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { getCpuName } from '@main/utils/system'
|
||||
import { HOME_CHERRY_DIR } from '@shared/config/constant'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
@ -11,6 +13,8 @@ const logger = loggerService.withContext('OvmsManager')
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export const isOvmsSupported = isWin && getCpuName().toLowerCase().includes('intel')
|
||||
|
||||
interface OvmsProcess {
|
||||
pid: number
|
||||
path: string
|
||||
@ -29,6 +33,12 @@ interface OvmsConfig {
|
||||
class OvmsManager {
|
||||
private ovms: OvmsProcess | null = null
|
||||
|
||||
constructor() {
|
||||
if (!isOvmsSupported) {
|
||||
throw new Error('OVMS Manager is only supported on Windows platform with Intel CPU.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively terminate a process and all its child processes
|
||||
* @param pid Process ID to terminate
|
||||
@ -563,4 +573,4 @@ class OvmsManager {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ovmsManager = new OvmsManager()
|
||||
export const ovmsManager = isOvmsSupported ? new OvmsManager() : undefined
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -8,6 +8,7 @@ import esES from '../../renderer/src/i18n/translate/es-es.json'
|
||||
import frFR from '../../renderer/src/i18n/translate/fr-fr.json'
|
||||
import JaJP from '../../renderer/src/i18n/translate/ja-jp.json'
|
||||
import ptPT from '../../renderer/src/i18n/translate/pt-pt.json'
|
||||
import roRO from '../../renderer/src/i18n/translate/ro-ro.json'
|
||||
import RuRu from '../../renderer/src/i18n/translate/ru-ru.json'
|
||||
|
||||
const locales = Object.fromEntries(
|
||||
@ -21,7 +22,8 @@ const locales = Object.fromEntries(
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
['pt-PT', ptPT]
|
||||
['pt-PT', ptPT],
|
||||
['ro-RO', roRO]
|
||||
].map(([locale, translation]) => [locale, { translation }])
|
||||
)
|
||||
|
||||
|
||||
19
src/main/utils/system.ts
Normal file
19
src/main/utils/system.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import os from 'node:os'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
|
||||
export const getDeviceType = () => (isMac ? 'mac' : isWin ? 'windows' : 'linux')
|
||||
|
||||
export const getHostname = () => os.hostname()
|
||||
|
||||
export const getCpuName = () => {
|
||||
try {
|
||||
const cpus = os.cpus()
|
||||
if (!cpus || cpus.length === 0 || !cpus[0].model) {
|
||||
return 'Unknown CPU'
|
||||
}
|
||||
return cpus[0].model
|
||||
} catch {
|
||||
return 'Unknown CPU'
|
||||
}
|
||||
}
|
||||
@ -340,6 +340,7 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail)
|
||||
},
|
||||
ovms: {
|
||||
isSupported: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.Ovms_IsSupported),
|
||||
addModel: (modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task),
|
||||
stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel),
|
||||
|
||||
@ -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 事件,如果没有被中间件处理) ===
|
||||
|
||||
@ -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 })
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/aistudio.png
Normal file
BIN
src/renderer/src/assets/images/apps/aistudio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@ -1,27 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" rx="32" fill="#0057CE"/>
|
||||
<mask id="path-2-inside-1_4113_89308" fill="white">
|
||||
<path d="M169.6 131.626C173.075 129.641 176.32 128.241 180.1 126.943C183.74 125.695 187.444 124.664 191.186 123.735C194.915 122.806 198.682 122.017 202.449 121.228C206.216 120.439 209.958 119.675 213.598 118.314C231.429 111.619 242.221 93.6357 239.612 74.9396C237.003 56.2435 221.692 41.8237 202.691 40.1564C194.062 39.4055 185.726 41.4164 178.013 44.9418C170.326 48.4545 163.288 53.4435 157.166 59.158C144.795 70.676 135.657 85.4649 130.083 101.208C124.47 117.054 122.37 134.095 123.694 150.806C124.356 159.129 125.883 167.504 128.326 175.509C130.719 183.362 134.181 191.469 138.839 198.342C136.828 185.475 138.559 172.175 143.917 160.262C149.262 148.375 158.121 138.193 169.6 131.626Z"/>
|
||||
</mask>
|
||||
<path d="M169.6 131.626C173.075 129.641 176.32 128.241 180.1 126.943C183.74 125.695 187.444 124.664 191.186 123.735C194.915 122.806 198.682 122.017 202.449 121.228C206.216 120.439 209.958 119.675 213.598 118.314C231.429 111.619 242.221 93.6357 239.612 74.9396C237.003 56.2435 221.692 41.8237 202.691 40.1564C194.062 39.4055 185.726 41.4164 178.013 44.9418C170.326 48.4545 163.288 53.4435 157.166 59.158C144.795 70.676 135.657 85.4649 130.083 101.208C124.47 117.054 122.37 134.095 123.694 150.806C124.356 159.129 125.883 167.504 128.326 175.509C130.719 183.362 134.181 191.469 138.839 198.342C136.828 185.475 138.559 172.175 143.917 160.262C149.262 148.375 158.121 138.193 169.6 131.626Z" fill="white" stroke="white" stroke-width="32" mask="url(#path-2-inside-1_4113_89308)"/>
|
||||
<path d="M162.246 150.4C161.915 153.913 163.073 157.464 165.542 160.06C168.011 162.657 171.499 164.031 174.668 165.253C178.13 166.577 181.12 167.658 184.353 169.529C187.433 171.311 190.157 173.526 192.435 176.262C201.802 187.449 200.937 203.867 190.462 214.049C179.988 224.23 163.379 224.778 152.243 215.321C149.404 212.903 146.884 209.798 144.81 206.756C141.654 186.52 147.775 165.317 162.246 150.4Z" fill="white"/>
|
||||
<mask id="path-4-outside-2_4113_89308" maskUnits="userSpaceOnUse" x="136" y="138.4" width="71" height="92" fill="black">
|
||||
<rect fill="white" x="136" y="138.4" width="71" height="92"/>
|
||||
<path d="M162.246 150.4C165.542 153.666 163.073 157.464 165.542 160.06C168.011 162.657 171.499 164.031 174.668 165.253C178.13 166.577 181.12 167.658 184.353 169.529C187.433 171.311 190.157 173.526 192.435 176.262C201.802 187.449 200.937 203.867 190.462 214.049C179.988 224.23 163.379 224.778 152.243 215.321C149.404 212.903 146.884 209.798 144.81 206.756C141.654 186.52 147.775 165.317 162.246 150.4Z"/>
|
||||
</mask>
|
||||
<path d="M162.246 150.4C165.542 153.666 163.073 157.464 165.542 160.06C168.011 162.657 171.499 164.031 174.668 165.253C178.13 166.577 181.12 167.658 184.353 169.529C187.433 171.311 190.157 173.526 192.435 176.262C201.802 187.449 200.937 203.867 190.462 214.049C179.988 224.23 163.379 224.778 152.243 215.321C149.404 212.903 146.884 209.798 144.81 206.756C141.654 186.52 147.775 165.317 162.246 150.4Z" stroke="#0057CE" stroke-width="16" mask="url(#path-4-outside-2_4113_89308)"/>
|
||||
<mask id="path-5-inside-3_4113_89308" fill="white">
|
||||
<path d="M50.4113 61.9063C63.3547 61.8935 75.9164 69.008 85.0163 76.9879C94.6761 85.4641 102.16 96.2567 107.085 107.991C112.036 119.789 114.416 132.542 114.327 145.282C114.238 157.665 111.769 171.079 106.296 182.394C105.774 167.821 100.123 153.885 90.3107 143.003C88.5926 141.107 86.7981 139.389 84.6599 137.938C82.5218 136.487 80.2691 135.418 77.8382 134.565C73.1164 132.911 67.7838 132.134 62.8711 131.6C57.8057 131.04 52.7149 130.709 47.6622 129.971C42.4695 129.207 37.8114 128.087 33.1787 125.427C19.688 117.715 13.1463 102.009 17.1808 87.1441C21.2153 72.2661 34.846 61.919 50.4113 61.9063Z"/>
|
||||
</mask>
|
||||
<path d="M50.4113 61.9063C63.3547 61.8935 75.9164 69.008 85.0163 76.9879C94.6761 85.4641 102.16 96.2567 107.085 107.991C112.036 119.789 114.416 132.542 114.327 145.282C114.238 157.665 111.769 171.079 106.296 182.394C105.774 167.821 100.123 153.885 90.3107 143.003C88.5926 141.107 86.7981 139.389 84.6599 137.938C82.5218 136.487 80.2691 135.418 77.8382 134.565C73.1164 132.911 67.7838 132.134 62.8711 131.6C57.8057 131.04 52.7149 130.709 47.6622 129.971C42.4695 129.207 37.8114 128.087 33.1787 125.427C19.688 117.715 13.1463 102.009 17.1808 87.1441C21.2153 72.2661 34.846 61.919 50.4113 61.9063Z" fill="white" stroke="white" stroke-width="32" mask="url(#path-5-inside-3_4113_89308)"/>
|
||||
<mask id="path-6-inside-4_4113_89308" fill="white">
|
||||
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z"/>
|
||||
</mask>
|
||||
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z" stroke="white" stroke-width="24" mask="url(#path-6-inside-4_4113_89308)"/>
|
||||
<mask id="path-7-outside-5_4113_89308" maskUnits="userSpaceOnUse" x="45.3994" y="138.6" width="62" height="79" fill="black">
|
||||
<rect fill="white" x="45.3994" y="138.6" width="62" height="79"/>
|
||||
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z"/>
|
||||
</mask>
|
||||
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z" fill="white"/>
|
||||
<path d="M82.5802 149.38C81.3584 148.03 80.0857 146.745 78.673 145.6C80.4294 148.578 80.6075 151.95 79.8694 155.196C79.1312 158.429 77.5021 161.419 75.4403 163.99C73.3149 166.625 70.8204 168.725 68.1095 170.71C65.7423 172.441 62.2932 174.656 60.1551 176.73C53.8679 182.839 52.5824 192.384 57.0369 199.893C61.4914 207.415 70.5277 210.979 78.9912 208.535C83.662 207.186 87.6202 204.144 90.7638 200.67C93.9455 197.157 96.5291 192.983 98.5655 188.757C98.0437 174.185 92.3928 160.261 82.5802 149.38Z" stroke="#0057CE" stroke-width="16" mask="url(#path-7-outside-5_4113_89308)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.3 KiB |
@ -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'}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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=""
|
||||
/>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { loggerService } from '@logger'
|
||||
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
|
||||
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
|
||||
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
|
||||
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.png?url'
|
||||
import ApplicationLogo from '@renderer/assets/images/apps/application.png?url'
|
||||
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
|
||||
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -771,7 +771,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
],
|
||||
doubao: [
|
||||
{
|
||||
id: 'doubao-seed-1-8-251215',
|
||||
id: 'doubao-seed-1-8-251228',
|
||||
provider: 'doubao',
|
||||
name: 'Doubao-Seed-1.8',
|
||||
group: 'Doubao-Seed-1.8'
|
||||
|
||||
@ -75,12 +75,37 @@ const VISION_REGEX = new RegExp(
|
||||
'i'
|
||||
)
|
||||
|
||||
// For middleware to identify models that must use the dedicated Image API
|
||||
// All dedicated image generation models (only generate images, no text chat capability)
|
||||
// These models need:
|
||||
// 1. Route to dedicated image generation API
|
||||
// 2. Exclude from reasoning/websearch/tooluse selection
|
||||
const DEDICATED_IMAGE_MODELS = [
|
||||
'grok-2-image(?:-[\\w-]+)?',
|
||||
// OpenAI series
|
||||
'dall-e(?:-[\\w-]+)?',
|
||||
'gpt-image-1(?:-[\\w-]+)?',
|
||||
'imagen(?:-[\\w-]+)?'
|
||||
'gpt-image(?:-[\\w-]+)?',
|
||||
// xAI
|
||||
'grok-2-image(?:-[\\w-]+)?',
|
||||
// Google
|
||||
'imagen(?:-[\\w-]+)?',
|
||||
// Stable Diffusion series
|
||||
'flux(?:-[\\w-]+)?',
|
||||
'stable-?diffusion(?:-[\\w-]+)?',
|
||||
'stabilityai(?:-[\\w-]+)?',
|
||||
'sd-[\\w-]+',
|
||||
'sdxl(?:-[\\w-]+)?',
|
||||
// zhipu
|
||||
'cogview(?:-[\\w-]+)?',
|
||||
// Alibaba
|
||||
'qwen-image(?:-[\\w-]+)?',
|
||||
// Others
|
||||
'janus(?:-[\\w-]+)?',
|
||||
'midjourney(?:-[\\w-]+)?',
|
||||
'mj-[\\w-]+',
|
||||
'z-image(?:-[\\w-]+)?',
|
||||
'longcat-image(?:-[\\w-]+)?',
|
||||
'hunyuanimage(?:-[\\w-]+)?',
|
||||
'seedream(?:-[\\w-]+)?',
|
||||
'kandinsky(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const IMAGE_ENHANCEMENT_MODELS = [
|
||||
@ -133,13 +158,23 @@ const GENERATE_IMAGE_MODELS_REGEX = new RegExp(GENERATE_IMAGE_MODELS.join('|'),
|
||||
|
||||
const MODERN_GENERATE_IMAGE_MODELS_REGEX = new RegExp(MODERN_IMAGE_MODELS.join('|'), 'i')
|
||||
|
||||
export const isDedicatedImageGenerationModel = (model: Model): boolean => {
|
||||
/**
|
||||
* Check if the model is a dedicated image generation model
|
||||
* Dedicated image generation models can only generate images, no text chat capability
|
||||
*
|
||||
* These models need:
|
||||
* 1. Route to dedicated image generation API
|
||||
* 2. Exclude from reasoning/websearch/tooluse selection
|
||||
*/
|
||||
export function isDedicatedImageModel(model: Model): boolean {
|
||||
if (!model) return false
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return DEDICATED_IMAGE_MODELS_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
// Backward compatible aliases
|
||||
export const isDedicatedImageGenerationModel = isDedicatedImageModel
|
||||
|
||||
export const isAutoEnableImageGenerationModel = (model: Model): boolean => {
|
||||
if (!model) return false
|
||||
|
||||
@ -195,14 +230,8 @@ export function isPureGenerateImageModel(model: Model): boolean {
|
||||
return !OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS.some((m) => modelId.includes(m))
|
||||
}
|
||||
|
||||
// TODO: refine the regex
|
||||
// Text to image models
|
||||
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|imagen|gpt-image/i
|
||||
|
||||
export function isTextToImageModel(model: Model): boolean {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return TEXT_TO_IMAGE_REGEX.test(modelId)
|
||||
}
|
||||
// Backward compatible alias - now uses unified dedicated image model detection
|
||||
export const isTextToImageModel = isDedicatedImageModel
|
||||
|
||||
/**
|
||||
* 判断模型是否支持图片增强(包括编辑、增强、修复等)
|
||||
|
||||
@ -212,6 +212,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.302.ai',
|
||||
anthropicApiHost: 'https://api.302.ai',
|
||||
models: SYSTEM_MODELS['302ai'],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@ -1089,7 +1090,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
websites: {
|
||||
official: 'https://platform.minimaxi.com/',
|
||||
apiKey: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
||||
docs: 'https://platform.minimaxi.com/document/Announcement',
|
||||
docs: 'https://platform.minimaxi.com/docs/api-reference/text-openai-api',
|
||||
models: 'https://platform.minimaxi.com/document/Models'
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import esES from './translate/es-es.json'
|
||||
import frFR from './translate/fr-fr.json'
|
||||
import jaJP from './translate/ja-jp.json'
|
||||
import ptPT from './translate/pt-pt.json'
|
||||
import roRO from './translate/ro-ro.json'
|
||||
import ruRU from './translate/ru-ru.json'
|
||||
|
||||
const logger = loggerService.withContext('I18N')
|
||||
@ -29,7 +30,8 @@ const resources = Object.fromEntries(
|
||||
['el-GR', elGR],
|
||||
['es-ES', esES],
|
||||
['fr-FR', frFR],
|
||||
['pt-PT', ptPT]
|
||||
['pt-PT', ptPT],
|
||||
['ro-RO', roRO]
|
||||
].map(([locale, translation]) => [locale, { translation }])
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user