Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
4269a32cfb fix: Include notes directory in backup when configured outside Data folder
- Parse notes path from backup data during backup process
- If notes are outside {userData}/Data, backup them separately to Notes folder
- On restore, restore Notes folder to configured location
- Handles both default and custom notes paths correctly

This ensures users don't lose their notes when using custom paths.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 10:54:27 +00:00
copilot-swe-agent[bot]
6493f1853d refactor: Store default notes path as relative for portability
- Changed getNotesDir() to return './Data/Notes' instead of absolute path
- Added getNotesDirAbsolute() for cases requiring absolute paths
- Updated FileStorage to use getNotesDirAbsolute()
- Added tests for both functions

This allows the default notes path to be portable across devices
while externally selected paths remain absolute.

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 09:20:09 +00:00
copilot-swe-agent[bot]
cbd4f418f6 test: Add tests for expandNotesPath function
- Added comprehensive test cases for tilde expansion
- Added tests for relative path expansion
- Added tests for absolute path handling
- Added tests for edge cases and custom base paths

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 09:03:09 +00:00
copilot-swe-agent[bot]
7d6ffe472c feat: Add support for relative paths in notes working directory
- Added expandNotesPath utility function to handle ~, ., and .. paths
- Updated validateNotesDirectory to expand relative paths
- Updated getDirectoryStructure to expand paths before scanning
- Updated startFileWatcher to expand paths before watching
- Made notes path input field editable in settings UI
- Updated locale files to explain relative path support

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
2025-11-12 09:01:28 +00:00
copilot-swe-agent[bot]
f16b63bd69 Initial plan 2025-11-12 08:53:30 +00:00
197 changed files with 4178 additions and 12431 deletions

1
.github/CODEOWNERS vendored
View File

@@ -3,4 +3,3 @@
/src/main/services/ConfigManager.ts @0xfullex
/packages/shared/IpcChannel.ts @0xfullex
/src/main/ipc.ts @0xfullex
/app-upgrade-config.json @kangfenmao

View File

@@ -1,212 +0,0 @@
name: Update App Upgrade Config
on:
release:
types:
- released
- prereleased
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v1.2.3)"
required: true
type: string
is_prerelease:
description: "Mark the tag as a prerelease when running manually"
required: false
default: false
type: boolean
permissions:
contents: write
pull-requests: write
jobs:
propose-update:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false)
steps:
- name: Check if should proceed
id: check
run: |
EVENT="${{ github.event_name }}"
if [ "$EVENT" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${{ github.event.release.tag_name }}"
fi
latest_tag=$(
curl -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ github.token }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/releases/latest \
| jq -r '.tag_name'
)
if [ "$EVENT" = "workflow_dispatch" ]; then
MANUAL_IS_PRERELEASE="${{ github.event.inputs.is_prerelease }}"
if [ -z "$MANUAL_IS_PRERELEASE" ]; then
MANUAL_IS_PRERELEASE="false"
fi
if [ "$MANUAL_IS_PRERELEASE" = "true" ]; then
if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then
echo "Manual prerelease flag set but tag $TAG lacks beta/rc suffix. Skipping." >&2
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$MANUAL_IS_PRERELEASE" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_PRERELEASE="${{ github.event.release.prerelease }}"
if [ "$IS_PRERELEASE" = "true" ]; then
if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then
echo "Release marked as prerelease but tag $TAG lacks beta/rc suffix. Skipping." >&2
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release is prerelease, proceeding"
exit 0
fi
if [[ "${latest_tag}" == "$TAG" ]]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release is latest, proceeding"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release is neither prerelease nor latest, skipping"
fi
- name: Prepare metadata
id: meta
if: steps.check.outputs.should_run == 'true'
run: |
EVENT="${{ github.event_name }}"
LATEST_TAG="${{ steps.check.outputs.latest_tag }}"
if [ "$EVENT" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
PRE="${{ github.event.release.prerelease }}"
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ]; then
LATEST="true"
else
LATEST="false"
fi
TRIGGER="release"
else
TAG="${{ github.event.inputs.tag }}"
PRE="${{ github.event.inputs.is_prerelease }}"
if [ -z "$PRE" ]; then
PRE="false"
fi
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ] && [ "$PRE" != "true" ]; then
LATEST="true"
else
LATEST="false"
fi
TRIGGER="manual"
fi
SAFE_TAG=$(echo "$TAG" | sed 's/[^A-Za-z0-9._-]/-/g')
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "safe_tag=$SAFE_TAG" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
echo "trigger=$TRIGGER" >> "$GITHUB_OUTPUT"
- name: Checkout default branch
if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@v5
with:
ref: ${{ github.event.repository.default_branch }}
path: main
fetch-depth: 0
- name: Checkout x-files/app-upgrade-config branch
if: steps.check.outputs.should_run == 'true'
uses: actions/checkout@v5
with:
ref: x-files/app-upgrade-config
path: cs
fetch-depth: 0
- name: Setup Node.js
if: steps.check.outputs.should_run == 'true'
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable Corepack
if: steps.check.outputs.should_run == 'true'
run: corepack enable && corepack prepare yarn@4.9.1 --activate
- name: Install dependencies
if: steps.check.outputs.should_run == 'true'
working-directory: main
run: yarn install --immutable
- name: Update upgrade config
if: steps.check.outputs.should_run == 'true'
working-directory: main
env:
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }}
run: |
yarn tsx scripts/update-app-upgrade-config.ts \
--tag "$RELEASE_TAG" \
--config ../cs/app-upgrade-config.json \
--is-prerelease "$IS_PRERELEASE"
- name: Detect changes
if: steps.check.outputs.should_run == 'true'
id: diff
working-directory: cs
run: |
if git diff --quiet -- app-upgrade-config.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
path: cs
base: x-files/app-upgrade-config
branch: chore/update-app-upgrade-config/${{ steps.meta.outputs.safe_tag }}
commit-message: "🤖 chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}"
title: "chore: update app-upgrade-config for ${{ steps.meta.outputs.tag }}"
body: |
Automated update triggered by `${{ steps.meta.outputs.trigger }}`.
- Source tag: `${{ steps.meta.outputs.tag }}`
- Pre-release: `${{ steps.meta.outputs.prerelease }}`
- Latest: `${{ steps.meta.outputs.latest }}`
- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
labels: |
automation
app-upgrade
- name: No changes detected
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true'
run: echo "No updates required for x-files/app-upgrade-config/app-upgrade-config.json"

View File

@@ -51,12 +51,6 @@
"node": true
},
"files": ["src/preload/**"]
},
{
"files": ["packages/ai-sdk-provider/**"],
"globals": {
"fetch": "readonly"
}
}
],
"plugins": ["unicorn", "typescript", "oxc", "import"],

View File

@@ -0,0 +1,26 @@
diff --git a/dist/index.js b/dist/index.js
index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e8e4d353f 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index 0793085005d7968638d355f2f1e127939d965165..1c8bf852baf025d56dc35a0691eb95967de7e5c8 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts

View File

@@ -1,152 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index c2ef089c42e13a8ee4a833899a415564130e5d79..75efa7baafb0f019fb44dd50dec1641eee8879e7 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts
diff --git a/dist/index.mjs b/dist/index.mjs
index d75c0cc13c41192408c1f3f2d29d76a7bffa6268..ada730b8cb97d9b7d4cb32883a1d1ff416404d9b 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
// src/get-model-path.ts
function getModelPath(modelId) {
- return modelId.includes("/") ? modelId : `models/${modelId}`;
+ return modelId.includes("models/") ? modelId : `models/${modelId}`;
}
// src/google-generative-ai-options.ts
diff --git a/dist/internal/index.js b/dist/internal/index.js
index 277cac8dc734bea2fb4f3e9a225986b402b24f48..bb704cd79e602eb8b0cee1889e42497d59ccdb7a 100644
--- a/dist/internal/index.js
+++ b/dist/internal/index.js
@@ -432,7 +432,15 @@ function prepareTools({
var _a;
tools = (tools == null ? void 0 : tools.length) ? tools : void 0;
const toolWarnings = [];
- const isGemini2 = modelId.includes("gemini-2");
+ // These changes could be safely removed when @ai-sdk/google v3 released.
+ const isLatest = (
+ [
+ 'gemini-flash-latest',
+ 'gemini-flash-lite-latest',
+ 'gemini-pro-latest',
+ ]
+ ).some(id => id === modelId);
+ const isGemini2OrNewer = modelId.includes("gemini-2") || modelId.includes("gemini-3") || isLatest;
const supportsDynamicRetrieval = modelId.includes("gemini-1.5-flash") && !modelId.includes("-8b");
const supportsFileSearch = modelId.includes("gemini-2.5");
if (tools == null) {
@@ -458,7 +466,7 @@ function prepareTools({
providerDefinedTools.forEach((tool) => {
switch (tool.id) {
case "google.google_search":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({ googleSearch: {} });
} else if (supportsDynamicRetrieval) {
googleTools2.push({
@@ -474,7 +482,7 @@ function prepareTools({
}
break;
case "google.url_context":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({ urlContext: {} });
} else {
toolWarnings.push({
@@ -485,7 +493,7 @@ function prepareTools({
}
break;
case "google.code_execution":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({ codeExecution: {} });
} else {
toolWarnings.push({
@@ -507,7 +515,7 @@ function prepareTools({
}
break;
case "google.vertex_rag_store":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({
retrieval: {
vertex_rag_store: {
diff --git a/dist/internal/index.mjs b/dist/internal/index.mjs
index 03b7cc591be9b58bcc2e775a96740d9f98862a10..347d2c12e1cee79f0f8bb258f3844fb0522a6485 100644
--- a/dist/internal/index.mjs
+++ b/dist/internal/index.mjs
@@ -424,7 +424,15 @@ function prepareTools({
var _a;
tools = (tools == null ? void 0 : tools.length) ? tools : void 0;
const toolWarnings = [];
- const isGemini2 = modelId.includes("gemini-2");
+ // These changes could be safely removed when @ai-sdk/google v3 released.
+ const isLatest = (
+ [
+ 'gemini-flash-latest',
+ 'gemini-flash-lite-latest',
+ 'gemini-pro-latest',
+ ]
+ ).some(id => id === modelId);
+ const isGemini2OrNewer = modelId.includes("gemini-2") || modelId.includes("gemini-3") || isLatest;
const supportsDynamicRetrieval = modelId.includes("gemini-1.5-flash") && !modelId.includes("-8b");
const supportsFileSearch = modelId.includes("gemini-2.5");
if (tools == null) {
@@ -450,7 +458,7 @@ function prepareTools({
providerDefinedTools.forEach((tool) => {
switch (tool.id) {
case "google.google_search":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({ googleSearch: {} });
} else if (supportsDynamicRetrieval) {
googleTools2.push({
@@ -466,7 +474,7 @@ function prepareTools({
}
break;
case "google.url_context":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({ urlContext: {} });
} else {
toolWarnings.push({
@@ -477,7 +485,7 @@ function prepareTools({
}
break;
case "google.code_execution":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({ codeExecution: {} });
} else {
toolWarnings.push({
@@ -499,7 +507,7 @@ function prepareTools({
}
break;
case "google.vertex_rag_store":
- if (isGemini2) {
+ if (isGemini2OrNewer) {
googleTools2.push({
retrieval: {
vertex_rag_store: {
@@ -1434,9 +1442,7 @@ var googleTools = {
vertexRagStore
};
export {
- GoogleGenerativeAILanguageModel,
getGroundingMetadataSchema,
- getUrlContextMetadataSchema,
- googleTools
+ getUrlContextMetadataSchema, GoogleGenerativeAILanguageModel, googleTools
};
//# sourceMappingURL=index.mjs.map
\ No newline at end of file

View File

@@ -1,5 +1,5 @@
diff --git a/sdk.mjs b/sdk.mjs
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
--- a/sdk.mjs
+++ b/sdk.mjs
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
@@ -11,7 +11,7 @@ index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f79205830
import { createInterface } from "readline";
// ../src/utils/fsOperations.ts
@@ -6505,14 +6505,11 @@ class ProcessTransport {
@@ -6487,14 +6487,11 @@ class ProcessTransport {
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
throw new ReferenceError(errorMessage);
}

View File

@@ -0,0 +1,276 @@
diff --git a/out/macPackager.js b/out/macPackager.js
index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644
--- a/out/macPackager.js
+++ b/out/macPackager.js
@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager {
}
appPlist.CFBundleName = appInfo.productName;
appPlist.CFBundleDisplayName = appInfo.productName;
- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion;
+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion;
if (minimumSystemVersion != null) {
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
}
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
--- a/out/publish/updateInfoBuilder.js
+++ b/out/publish/updateInfoBuilder.js
@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) {
const customUpdateInfo = event.updateInfo;
const url = path.basename(event.file);
const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file));
+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion;
const files = [{ url, sha512 }];
const result = {
// @ts-ignore
@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) {
path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
// @ts-ignore
sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
+ minimumSystemVersion,
...releaseInfo,
};
if (customUpdateInfo != null) {
+ if (customUpdateInfo.minimumSystemVersion) {
+ delete customUpdateInfo.minimumSystemVersion;
+ }
// file info or nsis web installer packages info
Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo);
}
diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js
index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644
--- a/out/targets/ArchiveTarget.js
+++ b/out/targets/ArchiveTarget.js
@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target {
}
}
}
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
+ }
await packager.info.emitArtifactBuildCompleted({
updateInfo,
file: artifactPath,
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644
--- a/out/targets/nsis/NsisTarget.js
+++ b/out/targets/nsis/NsisTarget.js
@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target {
if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) {
updateInfo.isAdminRightsRequired = true;
}
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
+ }
await packager.info.emitArtifactBuildCompleted({
file: installerPath,
updateInfo,
diff --git a/out/util/yarn.js b/out/util/yarn.js
index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644
--- a/out/util/yarn.js
+++ b/out/util/yarn.js
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
arch,
platform,
buildFromSource,
+ ignoreModules: config.excludeReBuildModules || undefined,
projectRootPath: projectDir,
mode: config.nativeRebuilder || "sequential",
disablePreGypCopy: true,
diff --git a/scheme.json b/scheme.json
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a74dda74c9 100644
--- a/scheme.json
+++ b/scheme.json
@@ -1825,6 +1825,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableArgs": {
"anyOf": [
{
@@ -1975,6 +1989,13 @@
],
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"packageCategory": {
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
"type": [
@@ -2327,6 +2348,13 @@
"MacConfiguration": {
"additionalProperties": false,
"properties": {
+ "LSMinimumSystemVersion": {
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"additionalArguments": {
"anyOf": [
{
@@ -2527,6 +2555,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -2737,7 +2779,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "description": "The minimum os kernel version required to install the application.",
"type": [
"null",
"string"
@@ -2959,6 +3001,13 @@
"MasConfiguration": {
"additionalProperties": false,
"properties": {
+ "LSMinimumSystemVersion": {
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"additionalArguments": {
"anyOf": [
{
@@ -3159,6 +3208,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -3369,7 +3432,7 @@
"type": "boolean"
},
"minimumSystemVersion": {
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
+ "description": "The minimum os kernel version required to install the application.",
"type": [
"null",
"string"
@@ -6381,6 +6444,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -6507,6 +6584,13 @@
"string"
]
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"protocols": {
"anyOf": [
{
@@ -7153,6 +7237,20 @@
"string"
]
},
+ "excludeReBuildModules": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "The modules to exclude from the rebuild."
+ },
"executableName": {
"description": "The executable name. Defaults to `productName`.",
"type": [
@@ -7376,6 +7474,13 @@
],
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
},
+ "minimumSystemVersion": {
+ "description": "The minimum os kernel version required to install the application.",
+ "type": [
+ "null",
+ "string"
+ ]
+ },
"msi": {
"anyOf": [
{

View File

@@ -1,14 +0,0 @@
diff --git a/out/util.js b/out/util.js
index 9294ffd6ca8f02c2e0f90c663e7e9cdc02c1ac37..f52107493e2995320ee4efd0eb2a8c9bf03291a2 100644
--- a/out/util.js
+++ b/out/util.js
@@ -23,7 +23,8 @@ function newUrlFromBase(pathname, baseUrl, addRandomQueryToAvoidCaching = false)
result.search = search;
}
else if (addRandomQueryToAvoidCaching) {
- result.search = `noCache=${Date.now().toString(32)}`;
+ // use no cache header instead
+ // result.search = `noCache=${Date.now().toString(32)}`;
}
return result;
}

View File

@@ -1,49 +0,0 @@
{
"lastUpdated": "2025-11-10T08:14:28Z",
"versions": {
"1.6.7": {
"metadata": {
"segmentId": "legacy-v1",
"segmentType": "legacy"
},
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channels": {
"latest": {
"version": "1.6.7",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
"gitcode": "https://releases.cherry-ai.com"
}
},
"rc": {
"version": "1.6.0-rc.5",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
}
},
"beta": {
"version": "1.7.0-beta.3",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
}
}
}
},
"2.0.0": {
"metadata": {
"segmentId": "gateway-v2",
"segmentType": "breaking"
},
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channels": {
"latest": null,
"rc": null,
"beta": null
}
}
}
}

View File

@@ -1,81 +0,0 @@
{
"segments": [
{
"id": "legacy-v1",
"type": "legacy",
"match": {
"range": ">=1.0.0 <2.0.0"
},
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://releases.cherry-ai.com"
}
},
"rc": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"beta": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
},
{
"id": "gateway-v2",
"type": "breaking",
"match": {
"exact": ["2.0.0"]
},
"lockedVersion": "2.0.0",
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
},
{
"id": "current-v2",
"type": "latest",
"match": {
"range": ">=2.0.0 <3.0.0",
"excludeExact": ["2.0.0"]
},
"minCompatibleVersion": "2.0.0",
"description": "Current latest v2.x release",
"channelTemplates": {
"latest": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"rc": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
},
"beta": {
"feedTemplates": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
}
}
}
}
]
}

View File

@@ -1,430 +0,0 @@
# Update Configuration System Design Document
## Background
Currently, AppUpdater directly queries the GitHub API to retrieve beta and rc update information. To support users in China, we need to fetch a static JSON configuration file from GitHub/GitCode based on IP geolocation, which contains update URLs for all channels.
## Design Goals
1. Support different configuration sources based on IP geolocation (GitHub/GitCode)
2. Support version compatibility control (e.g., users below v1.x must upgrade to v1.7.0 before upgrading to v2.0)
3. Easy to extend, supporting future multi-major-version upgrade paths (v1.6 → v1.7 → v2.0 → v2.8 → v3.0)
4. Maintain compatibility with existing electron-updater mechanism
## Current Version Strategy
- **v1.7.x** is the last version of the 1.x series
- Users **below v1.7.0** must first upgrade to v1.7.0 (or higher 1.7.x version)
- Users **v1.7.0 and above** can directly upgrade to v2.x.x
## Automation Workflow
The `x-files/app-upgrade-config/app-upgrade-config.json` file is synchronized by the [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow. The workflow runs the [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) helper so that every release tag automatically updates the JSON in `x-files/app-upgrade-config`.
### Trigger Conditions
- **Release events (`release: released/prereleased`)**
- Draft releases are ignored.
- When GitHub marks the release as _prerelease_, the tag must include `-beta`/`-rc` (with optional numeric suffix). Otherwise the workflow exits early.
- When GitHub marks the release as stable, the tag must match the latest release returned by the GitHub API. This prevents out-of-order updates when publishing historical tags.
- If the guard clauses pass, the version is tagged as `latest` or `beta/rc` based on its semantic suffix and propagated to the script through the `IS_PRERELEASE` flag.
- **Manual dispatch (`workflow_dispatch`)**
- Required input: `tag` (e.g., `v2.0.1`). Optional input: `is_prerelease` (defaults to `false`).
- When `is_prerelease=true`, the tag must carry a beta/rc suffix, mirroring the automatic validation.
- Manual runs still download the latest release metadata so that the workflow knows whether the tag represents the newest stable version (for documentation inside the PR body).
### Workflow Steps
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.
- 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.
5. **Detect changes + create PR** if `cs/app-upgrade-config.json` changed, the workflow opens a PR `chore/update-app-upgrade-config/<safe_tag>` against `x-files/app-upgrade-config` with a commit message `🤖 chore: sync app-upgrade-config for <tag>`. Otherwise it logs that no update is required.
### Manual Trigger Guide
1. Open the Cherry Studio repository on GitHub → **Actions** tab → select **Update App Upgrade Config**.
2. Click **Run workflow**, choose the default branch (usually `main`), and fill in the `tag` input (e.g., `v2.1.0`).
3. Toggle `is_prerelease` only when the tag carries a prerelease suffix (`-beta`, `-rc`). Leave it unchecked for stable releases.
4. Start the run and wait for it to finish. Check the generated PR in the `x-files/app-upgrade-config` branch, verify the diff in `app-upgrade-config.json`, and merge once validated.
## JSON Configuration File Format
### File Location
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json`
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json`
**Note**: Both mirrors provide the same configuration file hosted on the `x-files/app-upgrade-config` branch. The client automatically selects the optimal mirror based on IP geolocation.
### Configuration Structure (Current Implementation)
```json
{
"lastUpdated": "2025-01-05T00:00:00Z",
"versions": {
"1.6.7": {
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channels": {
"latest": {
"version": "1.6.7",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7"
}
},
"rc": {
"version": "1.6.0-rc.5",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
}
},
"beta": {
"version": "1.6.7-beta.3",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
}
}
}
},
"2.0.0": {
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channels": {
"latest": null,
"rc": null,
"beta": null
}
}
}
}
```
### Future Extension Example
When releasing v3.0, if users need to first upgrade to v2.8, you can add:
```json
{
"2.8.0": {
"minCompatibleVersion": "2.0.0",
"description": "Stable v2.8 - required for v3 upgrade",
"channels": {
"latest": {
"version": "2.8.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0"
}
},
"rc": null,
"beta": null
}
},
"3.0.0": {
"minCompatibleVersion": "2.8.0",
"description": "Major release v3.0",
"channels": {
"latest": {
"version": "3.0.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/latest",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest"
}
},
"rc": {
"version": "3.0.0-rc.1",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1"
}
},
"beta": null
}
}
}
```
### Field Descriptions
- `lastUpdated`: Last update time of the configuration file (ISO 8601 format)
- `versions`: Version configuration object, key is the version number, sorted by semantic versioning
- `minCompatibleVersion`: Minimum compatible version that can upgrade to this version
- `description`: Version description
- `channels`: Update channel configuration
- `latest`: Stable release channel
- `rc`: Release Candidate channel
- `beta`: Beta testing channel
- Each channel contains:
- `version`: Version number for this channel
- `feedUrls`: Multi-mirror URL configuration
- `github`: electron-updater feed URL for GitHub mirror
- `gitcode`: electron-updater feed URL for GitCode mirror
- `metadata`: Stable mapping info for automation
- `segmentId`: ID from `config/app-upgrade-segments.json`
- `segmentType`: Optional flag (`legacy` | `breaking` | `latest`) for documentation/debugging
## TypeScript Type Definitions
```typescript
// Mirror enum
enum UpdateMirror {
GITHUB = 'github',
GITCODE = 'gitcode'
}
interface UpdateConfig {
lastUpdated: string
versions: {
[versionKey: string]: VersionConfig
}
}
interface VersionConfig {
minCompatibleVersion: string
description: string
channels: {
latest: ChannelConfig | null
rc: ChannelConfig | null
beta: ChannelConfig | null
}
metadata?: {
segmentId: string
segmentType?: 'legacy' | 'breaking' | 'latest'
}
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
// Equivalent to:
// feedUrls: {
// github: string
// gitcode: string
// }
}
```
## Segment Metadata & Breaking Markers
- **Segment definitions** now live in `config/app-upgrade-segments.json`. Each segment describes a semantic-version range (or exact matches) plus metadata such as `segmentId`, `segmentType`, `minCompatibleVersion`, and per-channel feed URL templates.
- Each entry under `versions` carries a `metadata.segmentId`. This acts as the stable key that scripts use to decide which slot to update, even if the actual semantic version string changes.
- Mark major upgrade gateways (e.g., `2.0.0`) by giving the related segment a `segmentType: "breaking"` and (optionally) `lockedVersion`. This prevents automation from accidentally moving that entry when other 2.x builds ship.
- Adding another breaking hop (e.g., `3.0.0`) only requires defining a new segment in the JSON file; the automation will pick it up on the next run.
## Automation Workflow
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.
3. If the file changed, it opens a PR against `x-files/app-upgrade-config` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`.
You can run the same script locally via `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages arent published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
## Version Matching Logic
### Algorithm Flow
1. Get user's current version (`currentVersion`) and requested channel (`requestedChannel`)
2. Get all version numbers from configuration file, sort in descending order by semantic versioning
3. Iterate through the sorted version list:
- Check if `currentVersion >= minCompatibleVersion`
- Check if the requested `channel` exists and is not `null`
- If conditions are met, return the channel configuration
4. If no matching version is found, return `null`
### Pseudocode Implementation
```typescript
function findCompatibleVersion(
currentVersion: string,
requestedChannel: UpgradeChannel,
config: UpdateConfig
): ChannelConfig | null {
// Get all version numbers and sort in descending order
const versions = Object.keys(config.versions).sort(semver.rcompare)
for (const versionKey of versions) {
const versionConfig = config.versions[versionKey]
const channelConfig = versionConfig.channels[requestedChannel]
// Check version compatibility and channel availability
if (
semver.gte(currentVersion, versionConfig.minCompatibleVersion) &&
channelConfig !== null
) {
return channelConfig
}
}
return null // No compatible version found
}
```
## Upgrade Path Examples
### Scenario 1: v1.6.5 User Upgrade (Below 1.7)
- **Current Version**: 1.6.5
- **Requested Channel**: latest
- **Match Result**: 1.7.0
- **Reason**: 1.6.5 >= 0.0.0 (satisfies 1.7.0's minCompatibleVersion), but doesn't satisfy 2.0.0's minCompatibleVersion (1.7.0)
- **Action**: Prompt user to upgrade to 1.7.0, which is the required intermediate version for v2.x upgrade
### Scenario 2: v1.6.5 User Requests rc/beta
- **Current Version**: 1.6.5
- **Requested Channel**: rc or beta
- **Match Result**: 1.7.0 (latest)
- **Reason**: 1.7.0 version doesn't provide rc/beta channels (values are null)
- **Action**: Upgrade to 1.7.0 stable version
### Scenario 3: v1.7.0 User Upgrades to Latest
- **Current Version**: 1.7.0
- **Requested Channel**: latest
- **Match Result**: 2.0.0
- **Reason**: 1.7.0 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion)
- **Action**: Directly upgrade to 2.0.0 (current latest stable version)
### Scenario 4: v1.7.2 User Upgrades to RC Version
- **Current Version**: 1.7.2
- **Requested Channel**: rc
- **Match Result**: 2.0.0-rc.1
- **Reason**: 1.7.2 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion), and rc channel exists
- **Action**: Upgrade to 2.0.0-rc.1
### Scenario 5: v1.7.0 User Upgrades to Beta Version
- **Current Version**: 1.7.0
- **Requested Channel**: beta
- **Match Result**: 2.0.0-beta.1
- **Reason**: 1.7.0 >= 1.7.0, and beta channel exists
- **Action**: Upgrade to 2.0.0-beta.1
### Scenario 6: v2.5.0 User Upgrade (Future)
Assuming v2.8.0 and v3.0.0 configurations have been added:
- **Current Version**: 2.5.0
- **Requested Channel**: latest
- **Match Result**: 2.8.0
- **Reason**: 2.5.0 >= 2.0.0 (satisfies 2.8.0's minCompatibleVersion), but doesn't satisfy 3.0.0's requirement
- **Action**: Prompt user to upgrade to 2.8.0, which is the required intermediate version for v3.x upgrade
## Code Changes
### Main Modifications
1. **New Methods**
- `_fetchUpdateConfig(ipCountry: string): Promise<UpdateConfig | null>` - Fetch configuration file based on IP
- `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - Find compatible channel configuration
2. **Modified Methods**
- `_getReleaseVersionFromGithub()` → Remove or refactor to `_getChannelFeedUrl()`
- `_setFeedUrl()` - Use new configuration system to replace existing logic
3. **New Type Definitions**
- `UpdateConfig`
- `VersionConfig`
- `ChannelConfig`
### Mirror Selection Logic
The client automatically selects the optimal mirror based on IP geolocation:
```typescript
private async _setFeedUrl() {
const currentVersion = app.getVersion()
const testPlan = configManager.getTestPlan()
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
// Determine mirror based on IP country
const ipCountry = await getIpCountry()
const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github'
// Fetch update config
const config = await this._fetchUpdateConfig(mirror)
if (config) {
const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config)
if (channelConfig) {
// Select feed URL from the corresponding mirror
const feedUrl = channelConfig.feedUrls[mirror]
this._setChannel(requestedChannel, feedUrl)
return
}
}
// Fallback logic
const defaultFeedUrl = mirror === 'gitcode'
? FeedUrl.PRODUCTION
: FeedUrl.GITHUB_LATEST
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
}
private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise<UpdateConfig | null> {
const configUrl = mirror === 'gitcode'
? UpdateConfigUrl.GITCODE
: UpdateConfigUrl.GITHUB
try {
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
'Accept': 'application/json',
'X-Client-Id': configManager.getClientId()
}
})
return await response.json() as UpdateConfig
} catch (error) {
logger.error('Failed to fetch update config:', error)
return null
}
}
```
## Fallback and Error Handling Strategy
1. **Configuration file fetch failure**: Log error, return current version, don't offer updates
2. **No matching version**: Notify user that current version doesn't support automatic upgrade
3. **Network exception**: Cache last successfully fetched configuration (optional)
## GitHub Release Requirements
To support intermediate version upgrades, the following files need to be retained:
- **v1.7.0 release** and its latest*.yml files (as upgrade target for users below v1.7)
- Future intermediate versions (e.g., v2.8.0) need to retain corresponding release and latest*.yml files
- Complete installation packages for each version
### Currently Required Releases
| Version | Purpose | Must Retain |
|---------|---------|-------------|
| v1.7.0 | Upgrade target for users below 1.7 | ✅ Yes |
| v2.0.0-rc.1 | RC testing channel | ❌ Optional |
| v2.0.0-beta.1 | Beta testing channel | ❌ Optional |
| latest | Latest stable version (automatic) | ✅ Yes |
## Advantages
1. **Flexibility**: Supports arbitrarily complex upgrade paths
2. **Extensibility**: Adding new versions only requires adding new entries to the configuration file
3. **Maintainability**: Configuration is separated from code, allowing upgrade strategy adjustments without releasing new versions
4. **Multi-source support**: Automatically selects optimal configuration source based on geolocation
5. **Version control**: Enforces intermediate version upgrades, ensuring data migration and compatibility
## Future Extensions
- Support more granular version range control (e.g., `>=1.5.0 <1.8.0`)
- Support multi-step upgrade path hints (e.g., notify user needs 1.5 → 1.8 → 2.0)
- Support A/B testing and gradual rollout
- Support local caching and expiration strategy for configuration files

View File

@@ -1,430 +0,0 @@
# 更新配置系统设计文档
## 背景
当前 AppUpdater 直接请求 GitHub API 获取 beta 和 rc 的更新信息。为了支持国内用户,需要根据 IP 地理位置,分别从 GitHub/GitCode 获取一个固定的 JSON 配置文件,该文件包含所有渠道的更新地址。
## 设计目标
1. 支持根据 IP 地理位置选择不同的配置源GitHub/GitCode
2. 支持版本兼容性控制(如 v1.x 以下必须先升级到 v1.7.0 才能升级到 v2.0
3. 易于扩展支持未来多个主版本的升级路径v1.6 → v1.7 → v2.0 → v2.8 → v3.0
4. 保持与现有 electron-updater 机制的兼容性
## 当前版本策略
- **v1.7.x** 是 1.x 系列的最后版本
- **v1.7.0 以下**的用户必须先升级到 v1.7.0(或更高的 1.7.x 版本)
- **v1.7.0 及以上**的用户可以直接升级到 v2.x.x
## 自动化工作流
`x-files/app-upgrade-config/app-upgrade-config.json` 由 [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow 自动同步。工作流会调用 [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) 脚本,根据指定 tag 更新 `x-files/app-upgrade-config` 分支上的配置文件。
### 触发条件
- **Release 事件(`release: released/prereleased`**
- Draft release 会被忽略。
- 当 GitHub 将 release 标记为 *prerelease*tag 必须包含 `-beta`/`-rc`(可带序号),否则直接跳过。
- 当 release 标记为稳定版时tag 必须与 GitHub API 返回的最新稳定版本一致,防止发布历史 tag 时意外挂起工作流。
- 满足上述条件后,工作流会根据语义化版本判断渠道(`latest`/`beta`/`rc`),并通过 `IS_PRERELEASE` 传递给脚本。
- **手动触发(`workflow_dispatch`**
- 必填:`tag`(例:`v2.0.1`);选填:`is_prerelease`(默认 `false`)。
-`is_prerelease=true` 时,同样要求 tag 带有 beta/rc 后缀。
- 手动运行仍会请求 GitHub 最新 release 信息,用于在 PR 说明中标注该 tag 是否是最新稳定版。
### 工作流步骤
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>`
- 脚本会标准化 tag去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。
- 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。
- 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON并刷新 `lastUpdated`
5. **检测变更并创建 PR**:若 `cs/app-upgrade-config.json` 有变更,则创建 `chore/update-app-upgrade-config/<safe_tag>` 分支,提交信息为 `🤖 chore: sync app-upgrade-config for <tag>`,并向 `x-files/app-upgrade-config` 提 PR无变更则输出提示。
### 手动触发指南
1. 进入 Cherry Studio 仓库的 GitHub **Actions** 页面,选择 **Update App Upgrade Config** 工作流。
2. 点击 **Run workflow**,保持默认分支(通常为 `main`),填写 `tag`(如 `v2.1.0`)。
3. 只有在 tag 带 `-beta`/`-rc` 后缀时才勾选 `is_prerelease`,稳定版保持默认。
4. 启动运行并等待完成,随后到 `x-files/app-upgrade-config` 分支的 PR 查看 `app-upgrade-config.json` 的变更并在验证后合并。
## JSON 配置文件格式
### 文件位置
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json`
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json`
**说明**:两个镜像源提供相同的配置文件,统一托管在 `x-files/app-upgrade-config` 分支上。客户端根据 IP 地理位置自动选择最优镜像源。
### 配置结构(当前实际配置)
```json
{
"lastUpdated": "2025-01-05T00:00:00Z",
"versions": {
"1.6.7": {
"minCompatibleVersion": "1.0.0",
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
"channels": {
"latest": {
"version": "1.6.7",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7"
}
},
"rc": {
"version": "1.6.0-rc.5",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
}
},
"beta": {
"version": "1.6.7-beta.3",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
}
}
}
},
"2.0.0": {
"minCompatibleVersion": "1.7.0",
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
"channels": {
"latest": null,
"rc": null,
"beta": null
}
}
}
}
```
### 未来扩展示例
当需要发布 v3.0 时,如果需要强制用户先升级到 v2.8,可以添加:
```json
{
"2.8.0": {
"minCompatibleVersion": "2.0.0",
"description": "Stable v2.8 - required for v3 upgrade",
"channels": {
"latest": {
"version": "2.8.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0"
}
},
"rc": null,
"beta": null
}
},
"3.0.0": {
"minCompatibleVersion": "2.8.0",
"description": "Major release v3.0",
"channels": {
"latest": {
"version": "3.0.0",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/latest",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest"
}
},
"rc": {
"version": "3.0.0-rc.1",
"feedUrls": {
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1",
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1"
}
},
"beta": null
}
}
}
```
### 字段说明
- `lastUpdated`: 配置文件最后更新时间ISO 8601 格式)
- `versions`: 版本配置对象key 为版本号,按语义化版本排序
- `minCompatibleVersion`: 可以升级到此版本的最低兼容版本
- `description`: 版本描述
- `channels`: 更新渠道配置
- `latest`: 稳定版渠道
- `rc`: Release Candidate 渠道
- `beta`: Beta 测试渠道
- 每个渠道包含:
- `version`: 该渠道的版本号
- `feedUrls`: 多镜像源 URL 配置
- `github`: GitHub 镜像源的 electron-updater feed URL
- `gitcode`: GitCode 镜像源的 electron-updater feed URL
- `metadata`: 自动化匹配所需的稳定标识
- `segmentId`: 来自 `config/app-upgrade-segments.json` 的段位 ID
- `segmentType`: 可选字段(`legacy` | `breaking` | `latest`),便于文档/调试
## TypeScript 类型定义
```typescript
// 镜像源枚举
enum UpdateMirror {
GITHUB = 'github',
GITCODE = 'gitcode'
}
interface UpdateConfig {
lastUpdated: string
versions: {
[versionKey: string]: VersionConfig
}
}
interface VersionConfig {
minCompatibleVersion: string
description: string
channels: {
latest: ChannelConfig | null
rc: ChannelConfig | null
beta: ChannelConfig | null
}
metadata?: {
segmentId: string
segmentType?: 'legacy' | 'breaking' | 'latest'
}
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
// 等同于:
// feedUrls: {
// github: string
// gitcode: string
// }
}
```
## 段位元数据Break Change 标记)
- 所有段位定义(如 `legacy-v1``gateway-v2` 等)集中在 `config/app-upgrade-segments.json`,用于描述匹配范围、`segmentId``segmentType`、默认 `minCompatibleVersion/description` 以及各渠道的 URL 模板。
- `versions` 下的每个节点都会带上 `metadata.segmentId`。自动脚本始终依据该 ID 来定位并更新条目,即便 key 从 `2.1.5` 切换到 `2.1.6` 也不会错位。
- 如果某段需要锁死在特定版本(例如 `2.0.0` 的 break change可在段定义中设置 `segmentType: "breaking"` 并提供 `lockedVersion`,脚本在遇到不匹配的 tag 时会短路报错,保证升级路径安全。
- 面对未来新的断层(例如 `3.0.0`),只需要在段定义里新增一段,自动化即可识别并更新。
## 自动化工作流
`.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` 分支里的配置文件。
3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PRDiff 仅包含该文件。
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
## 版本匹配逻辑
### 算法流程
1. 获取用户当前版本(`currentVersion`)和请求的渠道(`requestedChannel`
2. 获取配置文件中所有版本号,按语义化版本从大到小排序
3. 遍历排序后的版本列表:
- 检查 `currentVersion >= minCompatibleVersion`
- 检查请求的 `channel` 是否存在且不为 `null`
- 如果满足条件,返回该渠道配置
4. 如果没有找到匹配版本,返回 `null`
### 伪代码实现
```typescript
function findCompatibleVersion(
currentVersion: string,
requestedChannel: UpgradeChannel,
config: UpdateConfig
): ChannelConfig | null {
// 获取所有版本号并从大到小排序
const versions = Object.keys(config.versions).sort(semver.rcompare)
for (const versionKey of versions) {
const versionConfig = config.versions[versionKey]
const channelConfig = versionConfig.channels[requestedChannel]
// 检查版本兼容性和渠道可用性
if (
semver.gte(currentVersion, versionConfig.minCompatibleVersion) &&
channelConfig !== null
) {
return channelConfig
}
}
return null // 没有找到兼容版本
}
```
## 升级路径示例
### 场景 1: v1.6.5 用户升级(低于 1.7
- **当前版本**: 1.6.5
- **请求渠道**: latest
- **匹配结果**: 1.7.0
- **原因**: 1.6.5 >= 0.0.0(满足 1.7.0 的 minCompatibleVersion但不满足 2.0.0 的 minCompatibleVersion (1.7.0)
- **操作**: 提示用户升级到 1.7.0,这是升级到 v2.x 的必要中间版本
### 场景 2: v1.6.5 用户请求 rc/beta
- **当前版本**: 1.6.5
- **请求渠道**: rc 或 beta
- **匹配结果**: 1.7.0 (latest)
- **原因**: 1.7.0 版本不提供 rc/beta 渠道(值为 null
- **操作**: 升级到 1.7.0 稳定版
### 场景 3: v1.7.0 用户升级到最新版
- **当前版本**: 1.7.0
- **请求渠道**: latest
- **匹配结果**: 2.0.0
- **原因**: 1.7.0 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion
- **操作**: 直接升级到 2.0.0(当前最新稳定版)
### 场景 4: v1.7.2 用户升级到 RC 版本
- **当前版本**: 1.7.2
- **请求渠道**: rc
- **匹配结果**: 2.0.0-rc.1
- **原因**: 1.7.2 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion且 rc 渠道存在
- **操作**: 升级到 2.0.0-rc.1
### 场景 5: v1.7.0 用户升级到 Beta 版本
- **当前版本**: 1.7.0
- **请求渠道**: beta
- **匹配结果**: 2.0.0-beta.1
- **原因**: 1.7.0 >= 1.7.0,且 beta 渠道存在
- **操作**: 升级到 2.0.0-beta.1
### 场景 6: v2.5.0 用户升级(未来)
假设已添加 v2.8.0 和 v3.0.0 配置:
- **当前版本**: 2.5.0
- **请求渠道**: latest
- **匹配结果**: 2.8.0
- **原因**: 2.5.0 >= 2.0.0(满足 2.8.0 的 minCompatibleVersion但不满足 3.0.0 的要求
- **操作**: 提示用户升级到 2.8.0,这是升级到 v3.x 的必要中间版本
## 代码改动计划
### 主要修改
1. **新增方法**
- `_fetchUpdateConfig(ipCountry: string): Promise<UpdateConfig | null>` - 根据 IP 获取配置文件
- `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - 查找兼容的渠道配置
2. **修改方法**
- `_getReleaseVersionFromGithub()` → 移除或重构为 `_getChannelFeedUrl()`
- `_setFeedUrl()` - 使用新的配置系统替代现有逻辑
3. **新增类型定义**
- `UpdateConfig`
- `VersionConfig`
- `ChannelConfig`
### 镜像源选择逻辑
客户端根据 IP 地理位置自动选择最优镜像源:
```typescript
private async _setFeedUrl() {
const currentVersion = app.getVersion()
const testPlan = configManager.getTestPlan()
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
// 根据 IP 国家确定镜像源
const ipCountry = await getIpCountry()
const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github'
// 获取更新配置
const config = await this._fetchUpdateConfig(mirror)
if (config) {
const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config)
if (channelConfig) {
// 从配置中选择对应镜像源的 URL
const feedUrl = channelConfig.feedUrls[mirror]
this._setChannel(requestedChannel, feedUrl)
return
}
}
// Fallback 逻辑
const defaultFeedUrl = mirror === 'gitcode'
? FeedUrl.PRODUCTION
: FeedUrl.GITHUB_LATEST
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
}
private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise<UpdateConfig | null> {
const configUrl = mirror === 'gitcode'
? UpdateConfigUrl.GITCODE
: UpdateConfigUrl.GITHUB
try {
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
'Accept': 'application/json',
'X-Client-Id': configManager.getClientId()
}
})
return await response.json() as UpdateConfig
} catch (error) {
logger.error('Failed to fetch update config:', error)
return null
}
}
```
## 降级和容错策略
1. **配置文件获取失败**: 记录错误日志,返回当前版本,不提供更新
2. **没有匹配的版本**: 提示用户当前版本不支持自动升级
3. **网络异常**: 缓存上次成功获取的配置(可选)
## GitHub Release 要求
为支持中间版本升级,需要保留以下文件:
- **v1.7.0 release** 及其 latest*.yml 文件(作为 v1.7 以下用户的升级目标)
- 未来如需强制中间版本(如 v2.8.0),需要保留对应的 release 和 latest*.yml 文件
- 各版本的完整安装包
### 当前需要的 Release
| 版本 | 用途 | 必须保留 |
|------|------|---------|
| v1.7.0 | 1.7 以下用户的升级目标 | ✅ 是 |
| v2.0.0-rc.1 | RC 测试渠道 | ❌ 可选 |
| v2.0.0-beta.1 | Beta 测试渠道 | ❌ 可选 |
| latest | 最新稳定版(自动) | ✅ 是 |
## 优势
1. **灵活性**: 支持任意复杂的升级路径
2. **可扩展性**: 新增版本只需在配置文件中添加新条目
3. **可维护性**: 配置与代码分离,无需发版即可调整升级策略
4. **多源支持**: 自动根据地理位置选择最优配置源
5. **版本控制**: 强制中间版本升级,确保数据迁移和兼容性
## 未来扩展
- 支持更细粒度的版本范围控制(如 `>=1.5.0 <1.8.0`
- 支持多步升级路径提示(如提示用户需要 1.5 → 1.8 → 2.0
- 支持 A/B 测试和灰度发布
- 支持配置文件的本地缓存和过期策略

View File

@@ -97,6 +97,7 @@ mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
@@ -134,58 +135,50 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
What's New in v1.7.0-rc.1
What's New in v1.7.0-beta.5
🎉 MAJOR NEW FEATURE: AI Agents
- Create and manage custom AI agents with specialized tools and permissions
- Dedicated agent sessions with persistent SQLite storage, separate from regular chats
- Real-time tool approval system - review and approve agent actions dynamically
- MCP (Model Context Protocol) integration for connecting external tools
- Slash commands support for quick agent interactions
- OpenAI-compatible REST API for agent access
New Features:
- MCPRouter Provider: Added MCPRouter provider integration with token management and server synchronization
- MCP Marketplace: Enhanced MCP server discovery and management with multi-provider marketplace support
- Agent Permission Mode Display: Visual permission mode cards in empty session states
- Assistant Subscription Settings: Added subscription URL management in assistant presets
✨ New Features:
- AI Providers: Added support for Hugging Face, Mistral, Perplexity, and SophNet
- Knowledge Base: OpenMinerU document preprocessor, full-text search in notes, enhanced tool selection
- Image & OCR: Intel OVMS painting provider and Intel OpenVINO (NPU) OCR support
- MCP Management: Redesigned interface with dual-column layout for easier management
- Languages: Added German language support
Improvements:
- UI Optimization: Sidebar tooltip placement improved on macOS to avoid overlapping window controls
- MCP Server Logos: Display server logos in Agent settings tooling section
- Long Command Handling: Bash command tags now auto-truncate (hover to view full command for commands over 100 chars)
- MCP OAuth Callback: Fixed callback page hanging and added multilingual support (10 languages)
- Error Display: Improved error block display order for better readability
- Plugin Browser: Centered tab alignment for better visual consistency
⚡ Improvements:
- Upgraded to Electron 38.7.0
- Enhanced system shutdown handling and automatic update checks
- Improved proxy bypass rules
🐛 Important Bug Fixes:
- Fixed streaming response issues across multiple AI providers
- Fixed session list scrolling problems
- Fixed knowledge base deletion errors
Bug Fixes:
- Fixed Agent sessions not inheriting allowed_tools configuration
- Fixed Gemini endpoint thinking budget spelling error
- Fixed MCP card description text overflow
- Fixed unnecessary message timestamp updates on UI-only state changes
- Updated dependencies: Bun to 1.3.1, uv to 0.9.5
<!--LANG:zh-CN-->
v1.7.0-rc.1 新特性
v1.7.0-beta.5 新特性
🎉 重大更新AI Agent 智能体系统
- 创建和管理专属 AI Agent配置专用工具和权限
- 独立的 Agent 会话,使用 SQLite 持久化存储,与普通聊天分离
- 实时工具审批系统 - 动态审查和批准 Agent 操作
- MCP模型上下文协议集成连接外部工具
- 支持斜杠命令快速交互
- 兼容 OpenAI 的 REST API 访问
新功能:
- MCPRouter 提供商:新增 MCPRouter 提供商集成,支持 token 管理和服务器同步
- MCP 市场:增强 MCP 服务器发现和管理功能,支持多提供商市场
- Agent 权限模式展示:空会话状态显示可视化权限模式卡片
- 助手订阅设置:在助手预设中添加订阅 URL 管理功能
✨ 新功能:
- AI 提供商:新增 Hugging Face、Mistral、Perplexity 和 SophNet 支持
- 知识库OpenMinerU 文档预处理器、笔记全文搜索、增强的工具选择
- 图像与 OCRIntel OVMS 绘图提供商和 Intel OpenVINO (NPU) OCR 支持
- MCP 管理:重构管理界面,采用双列布局,更加方便管理
- 语言:新增德语支持
改进:
- UI 优化macOS 上侧边栏工具提示位置优化,避免与窗口控制按钮重叠
- MCP 服务器标志:在 Agent 设置工具部分显示服务器 logo
- 长命令处理Bash 命令标签自动截断(超过 100 字符时悬停查看完整内容)
- MCP OAuth 回调修复回调页面挂起问题并添加多语言支持10 种语言)
- 错误信息展示:改进错误块显示顺序,提高可读性
- 插件浏览器:标签页居中对齐,视觉效果更统一
⚡ 改进:
- 升级到 Electron 38.7.0
- 增强的系统关机处理和自动更新检查
- 改进的代理绕过规则
🐛 重要修复:
- 修复多个 AI 提供商的流式响应问题
- 修复会话列表滚动问题
- 修复知识库删除错误
问题修复:
- 修复 Agent 会话未继承 allowed_tools 配置
- 修复 Gemini 端点 thinking budget 拼写错误
- 修复 MCP 卡片描述文本溢出问题
- 修复仅 UI 状态变化时消息时间戳不必要的更新
- 依赖更新Bun 升级到 1.3.1uv 升级到 0.9.5
<!--LANG:END-->

View File

@@ -95,8 +95,7 @@ export default defineConfig({
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
'@cherrystudio/ai-core': resolve('packages/aiCore/src'),
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src')
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
}
},
optimizeDeps: {

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-rc.1",
"version": "1.7.0-beta.3",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -58,7 +58,6 @@
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
"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",
@@ -74,15 +73,14 @@
"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": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && 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 npm publish --access public"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
"@libsql/client": "0.15.15",
"@libsql/win32-x64-msvc": "^0.5.22",
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch",
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@paymoapp/electron-shutdown-handler": "^1.1.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
@@ -109,14 +107,9 @@
"@agentic/searxng": "^7.3.3",
"@agentic/tavily": "^7.3.3",
"@ai-sdk/amazon-bedrock": "^3.0.53",
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/cerebras": "^1.0.31",
"@ai-sdk/gateway": "^2.0.9",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.36#~/.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch",
"@ai-sdk/google-vertex": "^3.0.68",
"@ai-sdk/google-vertex": "^3.0.61",
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch",
"@ai-sdk/mistral": "^2.0.23",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/perplexity": "^2.0.17",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@anthropic-ai/sdk": "^0.41.0",
@@ -125,7 +118,7 @@
"@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/ai-core": "workspace:^1.0.0-alpha.18",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
@@ -139,7 +132,7 @@
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "^6.9.0",
"@cherrystudio/openai": "^6.5.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -169,7 +162,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "0.1.21",
"@opeoginni/github-copilot-openai-compatible": "0.1.19",
"@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5",
@@ -264,12 +257,12 @@
"dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.5",
"electron": "38.7.0",
"electron-builder": "26.1.0",
"electron": "38.4.0",
"electron-builder": "26.0.15",
"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.6.4",
"electron-vite": "4.0.1",
"electron-window-state": "^5.0.3",
"emittery": "^1.0.3",
@@ -386,11 +379,13 @@
"@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",
"@libsql/client": "0.15.15",
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.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",
"node-abi": "4.24.0",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.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",
@@ -399,6 +394,7 @@
"undici": "6.21.2",
"vite": "npm:rolldown-vite@7.1.5",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch",
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
@@ -410,9 +406,9 @@
"@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/google@npm:2.0.30": "patch:@ai-sdk/google@npm%3A2.0.30#~/.yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch",
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/google@npm:2.0.36": "patch:@ai-sdk/google@npm%3A2.0.36#~/.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch"
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -1,39 +0,0 @@
# @cherrystudio/ai-sdk-provider
CherryIN provider bundle for the [Vercel AI SDK](https://ai-sdk.dev/).
It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Anthropic and Gemini model ids to their CherryIN upstream equivalents.
## Installation
```bash
npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
# or
yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
```
> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed.
## Usage
```ts
import { createCherryIn, cherryIn } from '@cherrystudio/ai-sdk-provider'
const cherryInProvider = createCherryIn({
apiKey: process.env.CHERRYIN_API_KEY,
// optional overrides:
// baseURL: 'https://open.cherryin.net/v1',
// anthropicBaseURL: 'https://open.cherryin.net/anthropic',
// geminiBaseURL: 'https://open.cherryin.net/gemini/v1beta',
})
// Chat models will auto-route based on the model id prefix:
const openaiModel = cherryInProvider.chat('gpt-4o-mini')
const anthropicModel = cherryInProvider.chat('claude-3-5-sonnet-latest')
const geminiModel = cherryInProvider.chat('gemini-2.0-pro-exp')
const { text } = await openaiModel.invoke('Hello CherryIN!')
```
The provider also exposes `completion`, `responses`, `embedding`, `image`, `transcription`, and `speech` helpers aligned with the upstream APIs.
See [AI SDK docs](https://ai-sdk.dev/providers/community-providers/custom-providers) for configuring custom providers.

View File

@@ -1,64 +0,0 @@
{
"name": "@cherrystudio/ai-sdk-provider",
"version": "0.1.2",
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
"keywords": [
"ai-sdk",
"provider",
"cherryin",
"vercel-ai-sdk",
"cherry-studio"
],
"author": "Cherry Studio",
"license": "MIT",
"homepage": "https://github.com/CherryHQ/cherry-studio",
"repository": {
"type": "git",
"url": "git+https://github.com/CherryHQ/cherry-studio.git",
"directory": "packages/ai-sdk-provider"
},
"bugs": {
"url": "https://github.com/CherryHQ/cherry-studio/issues"
},
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"dev": "tsc -w",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@ai-sdk/anthropic": "^2.0.29",
"@ai-sdk/google": "^2.0.23",
"@ai-sdk/openai": "^2.0.64",
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.12"
},
"devDependencies": {
"tsdown": "^0.13.3",
"typescript": "^5.8.2",
"vitest": "^3.2.4"
},
"sideEffects": false,
"engines": {
"node": ">=18.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}

View File

@@ -1,319 +0,0 @@
import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal'
import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal'
import type { OpenAIProviderSettings } from '@ai-sdk/openai'
import {
OpenAIChatLanguageModel,
OpenAICompletionLanguageModel,
OpenAIEmbeddingModel,
OpenAIImageModel,
OpenAIResponsesLanguageModel,
OpenAISpeechModel,
OpenAITranscriptionModel
} from '@ai-sdk/openai/internal'
import {
type EmbeddingModelV2,
type ImageModelV2,
type LanguageModelV2,
type ProviderV2,
type SpeechModelV2,
type TranscriptionModelV2
} from '@ai-sdk/provider'
import type { FetchFunction } from '@ai-sdk/provider-utils'
import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils'
export const CHERRYIN_PROVIDER_NAME = 'cherryin' as const
export const DEFAULT_CHERRYIN_BASE_URL = 'https://open.cherryin.net/v1'
export const DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL = 'https://open.cherryin.net/v1'
export const DEFAULT_CHERRYIN_GEMINI_BASE_URL = 'https://open.cherryin.net/v1beta/models'
const ANTHROPIC_PREFIX = /^anthropic\//i
const GEMINI_PREFIX = /^google\//i
// const GEMINI_EXCLUDED_SUFFIXES = ['-nothink', '-search']
type HeaderValue = string | undefined
type HeadersInput = Record<string, HeaderValue> | (() => Record<string, HeaderValue>)
export interface CherryInProviderSettings {
/**
* CherryIN API key.
*
* If omitted, the provider will read the `CHERRYIN_API_KEY` environment variable.
*/
apiKey?: string
/**
* Optional custom fetch implementation.
*/
fetch?: FetchFunction
/**
* Base URL for OpenAI-compatible CherryIN endpoints.
*
* Defaults to `https://open.cherryin.net/v1`.
*/
baseURL?: string
/**
* Base URL for Anthropic-compatible endpoints.
*
* Defaults to `https://open.cherryin.net/anthropic`.
*/
anthropicBaseURL?: string
/**
* Base URL for Gemini-compatible endpoints.
*
* Defaults to `https://open.cherryin.net/gemini/v1beta`.
*/
geminiBaseURL?: string
/**
* Optional static headers applied to every request.
*/
headers?: HeadersInput
}
export interface CherryInProvider extends ProviderV2 {
(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
languageModel(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
chat(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
responses(modelId: string): LanguageModelV2
completion(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
embedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
image(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
imageModel(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
transcription(modelId: string): TranscriptionModelV2
transcriptionModel(modelId: string): TranscriptionModelV2
speech(modelId: string): SpeechModelV2
speechModel(modelId: string): SpeechModelV2
}
const resolveApiKey = (options: CherryInProviderSettings): string =>
loadApiKey({
apiKey: options.apiKey,
environmentVariableName: 'CHERRYIN_API_KEY',
description: 'CherryIN'
})
const isAnthropicModel = (modelId: string) => ANTHROPIC_PREFIX.test(modelId)
const isGeminiModel = (modelId: string) => GEMINI_PREFIX.test(modelId)
const createCustomFetch = (originalFetch?: any) => {
return async (url: string, options: any) => {
if (options?.body) {
try {
const body = JSON.parse(options.body)
if (body.tools && Array.isArray(body.tools) && body.tools.length === 0 && body.tool_choice) {
delete body.tool_choice
options.body = JSON.stringify(body)
}
} catch (error) {
// ignore error
}
}
return originalFetch ? originalFetch(url, options) : fetch(url, options)
}
}
class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel {
constructor(modelId: string, settings: any) {
super(modelId, {
...settings,
fetch: createCustomFetch(settings.fetch)
})
}
}
const resolveConfiguredHeaders = (headers?: HeadersInput): Record<string, HeaderValue> => {
if (typeof headers === 'function') {
return { ...headers() }
}
return headers ? { ...headers } : {}
}
const toBearerToken = (authorization?: string) => (authorization ? authorization.replace(/^Bearer\s+/i, '') : undefined)
const createJsonHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
return () => ({
Authorization: `Bearer ${resolveApiKey(options)}`,
'Content-Type': 'application/json',
...resolveConfiguredHeaders(options.headers)
})
}
const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
return () => ({
Authorization: `Bearer ${resolveApiKey(options)}`,
...resolveConfiguredHeaders(options.headers)
})
}
export const createCherryIn = (options: CherryInProviderSettings = {}): CherryInProvider => {
const {
baseURL = DEFAULT_CHERRYIN_BASE_URL,
anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL,
geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL,
fetch
} = options
const getJsonHeaders = createJsonHeadersGetter(options)
const getAuthHeaders = createAuthHeadersGetter(options)
const url = ({ path }: { path: string; modelId: string }) => `${withoutTrailingSlash(baseURL)}${path}`
const createAnthropicModel = (modelId: string) =>
new AnthropicMessagesLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.anthropic`,
baseURL: anthropicBaseURL,
headers: () => {
const headers = getJsonHeaders()
const apiKey = toBearerToken(headers.Authorization)
return {
...headers,
'x-api-key': apiKey
}
},
fetch,
supportedUrls: () => ({
'image/*': [/^https?:\/\/.*$/]
})
})
const createGeminiModel = (modelId: string) =>
new GoogleGenerativeAILanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.google`,
baseURL: geminiBaseURL,
headers: () => {
const headers = getJsonHeaders()
const apiKey = toBearerToken(headers.Authorization)
return {
...headers,
'x-goog-api-key': apiKey
}
},
fetch,
generateId: () => `${CHERRYIN_PROVIDER_NAME}-${Date.now()}`,
supportedUrls: () => ({})
})
const createOpenAIChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
new CherryInOpenAIChatLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.openai-chat`,
url,
headers: () => ({
...getJsonHeaders(),
...settings.headers
}),
fetch
})
const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => {
if (isAnthropicModel(modelId)) {
return createAnthropicModel(modelId)
}
if (isGeminiModel(modelId)) {
return createGeminiModel(modelId)
}
return new OpenAIResponsesLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.openai`,
url,
headers: () => ({
...getJsonHeaders(),
...settings.headers
}),
fetch
})
}
const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
new OpenAICompletionLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.completion`,
url,
headers: () => ({
...getJsonHeaders(),
...settings.headers
}),
fetch
})
const createEmbeddingModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
new OpenAIEmbeddingModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.embeddings`,
url,
headers: () => ({
...getJsonHeaders(),
...settings.headers
}),
fetch
})
const createResponsesModel = (modelId: string) =>
new OpenAIResponsesLanguageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.responses`,
url,
headers: () => ({
...getJsonHeaders()
}),
fetch
})
const createImageModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
new OpenAIImageModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.image`,
url,
headers: () => ({
...getJsonHeaders(),
...settings.headers
}),
fetch
})
const createTranscriptionModel = (modelId: string) =>
new OpenAITranscriptionModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.transcription`,
url,
headers: () => ({
...getAuthHeaders()
}),
fetch
})
const createSpeechModel = (modelId: string) =>
new OpenAISpeechModel(modelId, {
provider: `${CHERRYIN_PROVIDER_NAME}.speech`,
url,
headers: () => ({
...getJsonHeaders()
}),
fetch
})
const provider: CherryInProvider = function (modelId: string, settings?: OpenAIProviderSettings) {
if (new.target) {
throw new Error('CherryIN provider function cannot be called with the new keyword.')
}
return createChatModel(modelId, settings)
}
provider.languageModel = createChatModel
provider.chat = createOpenAIChatModel
provider.responses = createResponsesModel
provider.completion = createCompletionModel
provider.embedding = createEmbeddingModel
provider.textEmbedding = createEmbeddingModel
provider.textEmbeddingModel = createEmbeddingModel
provider.image = createImageModel
provider.imageModel = createImageModel
provider.transcription = createTranscriptionModel
provider.transcriptionModel = createTranscriptionModel
provider.speech = createSpeechModel
provider.speechModel = createSpeechModel
return provider
}
export const cherryIn = createCherryIn()

View File

@@ -1 +0,0 @@
export * from './cherryin-provider'

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"noEmitOnError": false,
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"strict": true,
"target": "ES2020"
},
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}

View File

@@ -1,12 +0,0 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: 'src/index.ts'
},
outDir: 'dist',
format: ['esm', 'cjs'],
clean: true,
dts: true,
tsconfig: 'tsconfig.json'
})

View File

@@ -71,7 +71,7 @@ Cherry Studio AI Core 是一个基于 Vercel AI SDK 的统一 AI Provider 接口
## 安装
```bash
npm install @cherrystudio/ai-core ai @ai-sdk/google @ai-sdk/openai
npm install @cherrystudio/ai-core ai
```
### React Native

View File

@@ -1,6 +1,6 @@
{
"name": "@cherrystudio/ai-core",
"version": "1.0.9",
"version": "1.0.1",
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
"main": "dist/index.js",
"module": "dist/index.mjs",
@@ -33,15 +33,13 @@
},
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
"peerDependencies": {
"@ai-sdk/google": "^2.0.36",
"@ai-sdk/openai": "^2.0.64",
"@cherrystudio/ai-sdk-provider": "^0.1.2",
"ai": "^5.0.26"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.43",
"@ai-sdk/azure": "^2.0.66",
"@ai-sdk/deepseek": "^1.0.27",
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
"@ai-sdk/openai-compatible": "^1.0.26",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.16",

View File

@@ -4,7 +4,12 @@
*/
export const BUILT_IN_PLUGIN_PREFIX = 'built-in:'
export * from './googleToolsPlugin'
export * from './toolUsePlugin/promptToolUsePlugin'
export * from './toolUsePlugin/type'
export * from './webSearchPlugin'
export { googleToolsPlugin } from './googleToolsPlugin'
export { createLoggingPlugin } from './logging'
export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin'
export type {
PromptToolUseConfig,
ToolUseRequestContext,
ToolUseResult
} from './toolUsePlugin/type'
export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin'

View File

@@ -1,10 +1,9 @@
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import type { anthropic } from '@ai-sdk/anthropic'
import type { google } from '@ai-sdk/google'
import type { openai } from '@ai-sdk/openai'
import type { InferToolInput, InferToolOutput } from 'ai'
import { type Tool } from 'ai'
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
import type { ProviderOptionsMap } from '../../../options/types'
import type { OpenRouterSearchConfig } from './openrouter'
@@ -96,56 +95,3 @@ export type WebSearchToolInputSchema = {
google: InferToolInput<GoogleWebSearchTool>
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
}
export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => {
switch (providerId) {
case 'openai': {
if (config.openai) {
if (!params.tools) params.tools = {}
params.tools.web_search = openai.tools.webSearch(config.openai)
}
break
}
case 'openai-chat': {
if (config['openai-chat']) {
if (!params.tools) params.tools = {}
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
}
break
}
case 'anthropic': {
if (config.anthropic) {
if (!params.tools) params.tools = {}
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
}
break
}
case 'google': {
// case 'google-vertex':
if (!params.tools) params.tools = {}
params.tools.web_search = google.tools.googleSearch(config.google || {})
break
}
case 'xai': {
if (config.xai) {
const searchOptions = createXaiOptions({
searchParameters: { ...config.xai, mode: 'on' }
})
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
}
break
}
case 'openrouter': {
if (config.openrouter) {
const searchOptions = createOpenRouterOptions(config.openrouter)
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
}
break
}
}
return params
}

View File

@@ -2,11 +2,15 @@
* Web Search Plugin
* 提供统一的网络搜索能力,支持多个 AI Provider
*/
import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { openai } from '@ai-sdk/openai'
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
import { definePlugin } from '../../'
import type { AiRequestContext } from '../../types'
import type { WebSearchPluginConfig } from './helper'
import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper'
import { DEFAULT_WEB_SEARCH_CONFIG } from './helper'
/**
* 网络搜索插件
@@ -20,19 +24,62 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
transformParams: async (params: any, context: AiRequestContext) => {
const { providerId } = context
switchWebSearchTool(providerId, config, params)
switch (providerId) {
case 'openai': {
if (config.openai) {
if (!params.tools) params.tools = {}
params.tools.web_search = openai.tools.webSearch(config.openai)
}
break
}
case 'openai-chat': {
if (config['openai-chat']) {
if (!params.tools) params.tools = {}
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
}
break
}
if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
// cherryin.gemini
const _providerId = params.model.provider.split('.')[1]
switchWebSearchTool(_providerId, config, params)
case 'anthropic': {
if (config.anthropic) {
if (!params.tools) params.tools = {}
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
}
break
}
case 'google': {
// case 'google-vertex':
if (!params.tools) params.tools = {}
params.tools.web_search = google.tools.googleSearch(config.google || {})
break
}
case 'xai': {
if (config.xai) {
const searchOptions = createXaiOptions({
searchParameters: { ...config.xai, mode: 'on' }
})
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
}
break
}
case 'openrouter': {
if (config.openrouter) {
const searchOptions = createOpenRouterOptions(config.openrouter)
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
}
break
}
}
return params
}
})
// 导出类型定义供开发者使用
export * from './helper'
export type { WebSearchPluginConfig, WebSearchToolOutputSchema } from './helper'
// 默认导出
export default webSearchPlugin

View File

@@ -44,7 +44,7 @@ export {
// ==================== 基础数据和类型 ====================
// 基础Provider数据源
export { baseProviderIds, baseProviders, isBaseProvider } from './schemas'
export { baseProviderIds, baseProviders } from './schemas'
// 类型定义和Schema
export type {

View File

@@ -7,11 +7,11 @@ import { createAzure } from '@ai-sdk/azure'
import { type AzureOpenAIProviderSettings } from '@ai-sdk/azure'
import { createDeepSeek } from '@ai-sdk/deepseek'
import { createGoogleGenerativeAI } from '@ai-sdk/google'
import { createHuggingFace } from '@ai-sdk/huggingface'
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import type { LanguageModelV2 } from '@ai-sdk/provider'
import { createXai } from '@ai-sdk/xai'
import { type CherryInProviderSettings, createCherryIn } from '@cherrystudio/ai-sdk-provider'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import type { Provider } from 'ai'
import { customProvider } from 'ai'
@@ -31,8 +31,7 @@ export const baseProviderIds = [
'azure-responses',
'deepseek',
'openrouter',
'cherryin',
'cherryin-chat'
'huggingface'
] as const
/**
@@ -138,23 +137,9 @@ export const baseProviders = [
supportsImageGeneration: true
},
{
id: 'cherryin',
name: 'CherryIN',
creator: createCherryIn,
supportsImageGeneration: true
},
{
id: 'cherryin-chat',
name: 'CherryIN Chat',
creator: (options: CherryInProviderSettings) => {
const provider = createCherryIn(options)
return customProvider({
fallbackProvider: {
...provider,
languageModel: (modelId: string) => provider.chat(modelId)
}
})
},
id: 'huggingface',
name: 'HuggingFace',
creator: createHuggingFace,
supportsImageGeneration: true
}
] as const satisfies BaseProvider[]

View File

@@ -41,7 +41,6 @@ export enum IpcChannel {
App_SetFullScreen = 'app:set-full-screen',
App_IsFullScreen = 'app:is-full-screen',
App_GetSystemFonts = 'app:get-system-fonts',
APP_CrashRenderProcess = 'app:crash-render-process',
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
@@ -190,7 +189,6 @@ export enum IpcChannel {
Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath',
File_IsTextFile = 'file:isTextFile',
File_ListDirectory = 'file:listDirectory',
File_GetDirectoryStructure = 'file:getDirectoryStructure',
File_CheckFileName = 'file:checkFileName',
File_ValidateNotesDirectory = 'file:validateNotesDirectory',

View File

@@ -197,22 +197,12 @@ export enum FeedUrl {
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
}
export enum UpdateConfigUrl {
GITHUB = 'https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json',
GITCODE = 'https://raw.gitcode.com/CherryHQ/cherry-studio/raw/x-files%2Fapp-upgrade-config/app-upgrade-config.json'
}
export enum UpgradeChannel {
LATEST = 'latest', // 最新稳定版本
RC = 'rc', // 公测版本
BETA = 'beta' // 预览版本
}
export enum UpdateMirror {
GITHUB = 'github',
GITCODE = 'gitcode'
}
export const defaultTimeout = 10 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']

View File

@@ -1 +0,0 @@
ALTER TABLE `sessions` ADD `slash_commands` text;

View File

@@ -1,346 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
"prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session_messages": {
"name": "session_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_session_id": {
"name": "agent_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"migrations": {
"name": "migrations",
"columns": {
"version": {
"name": "version",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"executed_at": {
"name": "executed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"slash_commands": {
"name": "slash_commands",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,13 +15,6 @@
"when": 1758187378775,
"tag": "0001_woozy_captain_flint",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1762526423527,
"tag": "0002_wealthy_naoko",
"breakpoints": true
}
]
}

View File

@@ -1,532 +0,0 @@
import fs from 'fs/promises'
import path from 'path'
import semver from 'semver'
type UpgradeChannel = 'latest' | 'rc' | 'beta'
type UpdateMirror = 'github' | 'gitcode'
const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta']
const MIRRORS: UpdateMirror[] = ['github', 'gitcode']
const GITHUB_REPO = 'CherryHQ/cherry-studio'
const GITCODE_REPO = 'CherryHQ/cherry-studio'
const DEFAULT_FEED_TEMPLATES: Record<UpdateMirror, string> = {
github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`,
gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}`
}
const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com'
interface CliOptions {
tag?: string
configPath?: string
segmentsPath?: string
dryRun?: boolean
skipReleaseChecks?: boolean
isPrerelease?: boolean
}
interface ChannelTemplateConfig {
feedTemplates?: Partial<Record<UpdateMirror, string>>
}
interface SegmentMatchRule {
range?: string
exact?: string[]
excludeExact?: string[]
}
interface SegmentDefinition {
id: string
type: 'legacy' | 'breaking' | 'latest'
match: SegmentMatchRule
lockedVersion?: string
minCompatibleVersion: string
description: string
channelTemplates?: Partial<Record<UpgradeChannel, ChannelTemplateConfig>>
}
interface SegmentMetadataFile {
segments: SegmentDefinition[]
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
}
interface VersionMetadata {
segmentId: string
segmentType?: string
}
interface VersionEntry {
metadata?: VersionMetadata
minCompatibleVersion: string
description: string
channels: Record<UpgradeChannel, ChannelConfig | null>
}
interface UpgradeConfigFile {
lastUpdated: string
versions: Record<string, VersionEntry>
}
interface ReleaseInfo {
tag: string
version: string
channel: UpgradeChannel
}
interface UpdateVersionsResult {
versions: Record<string, VersionEntry>
updated: boolean
}
const ROOT_DIR = path.resolve(__dirname, '..')
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json')
const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json')
async function main() {
const options = parseArgs()
const releaseTag = resolveTag(options)
const normalizedVersion = normalizeVersion(releaseTag)
const releaseChannel = detectChannel(normalizedVersion)
if (!releaseChannel) {
console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`)
return
}
// Validate version format matches prerelease status
if (options.isPrerelease !== undefined) {
const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc'
if (options.isPrerelease && !hasPrereleaseSuffix) {
console.warn(
`[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.`
)
return
}
if (!options.isPrerelease && hasPrereleaseSuffix) {
console.warn(
`[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.`
)
return
}
}
const [config, segmentFile] = await Promise.all([
readJson<UpgradeConfigFile>(options.configPath ?? DEFAULT_CONFIG_PATH),
readJson<SegmentMetadataFile>(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH)
])
const segment = pickSegment(segmentFile.segments, normalizedVersion)
if (!segment) {
throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`)
}
if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) {
throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`)
}
const releaseInfo: ReleaseInfo = {
tag: formatTag(releaseTag),
version: normalizedVersion,
channel: releaseChannel
}
const { versions: updatedVersions, updated } = await updateVersions(
config.versions,
segment,
releaseInfo,
Boolean(options.skipReleaseChecks)
)
if (!updated) {
throw new Error(
`[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.`
)
}
const updatedConfig: UpgradeConfigFile = {
...config,
lastUpdated: new Date().toISOString(),
versions: updatedVersions
}
const output = JSON.stringify(updatedConfig, null, 2) + '\n'
if (options.dryRun) {
console.log('Dry run enabled. Generated configuration:\n')
console.log(output)
return
}
await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8')
console.log(
`✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}`
)
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2)
const options: CliOptions = {}
for (let i = 0; i < args.length; i += 1) {
const arg = args[i]
if (arg === '--tag') {
options.tag = args[i + 1]
i += 1
} else if (arg === '--config') {
options.configPath = args[i + 1]
i += 1
} else if (arg === '--segments') {
options.segmentsPath = args[i + 1]
i += 1
} else if (arg === '--dry-run') {
options.dryRun = true
} else if (arg === '--skip-release-checks') {
options.skipReleaseChecks = true
} else if (arg === '--is-prerelease') {
options.isPrerelease = args[i + 1] === 'true'
i += 1
} else if (arg === '--help') {
printHelp()
process.exit(0)
} else {
console.warn(`Ignoring unknown argument "${arg}"`)
}
}
if (options.skipReleaseChecks && !options.dryRun) {
throw new Error('--skip-release-checks can only be used together with --dry-run')
}
return options
}
function printHelp() {
console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options]
Options:
--tag <tag> Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG.
--config <path> Path to app-upgrade-config.json.
--segments <path> Path to app-upgrade-segments.json.
--is-prerelease <true|false> Whether this is a prerelease (validates version format).
--dry-run Print the result without writing to disk.
--skip-release-checks Skip release page availability checks (only valid with --dry-run).
--help Show this help message.`)
}
function resolveTag(options: CliOptions): string {
const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME
const tag = options.tag ?? envTag
if (!tag) {
throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.')
}
return tag
}
function normalizeVersion(tag: string): string {
const cleaned = semver.clean(tag, { loose: true })
if (!cleaned) {
throw new Error(`Tag "${tag}" is not a valid semantic version`)
}
const valid = semver.valid(cleaned, { loose: true })
if (!valid) {
throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`)
}
return valid
}
function detectChannel(version: string): UpgradeChannel | null {
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
if (!parsed) {
return null
}
if (parsed.prerelease.length === 0) {
return 'latest'
}
const label = String(parsed.prerelease[0]).toLowerCase()
if (label === 'beta') {
return 'beta'
}
if (label === 'rc') {
return 'rc'
}
return null
}
async function readJson<T>(filePath: string): Promise<T> {
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
const data = await fs.readFile(absolute, 'utf-8')
return JSON.parse(data) as T
}
function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null {
for (const segment of segments) {
if (matchesSegment(segment.match, version)) {
return segment
}
}
return null
}
function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean {
if (matchRule.exact && matchRule.exact.includes(version)) {
return true
}
if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) {
return false
}
if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) {
return false
}
if (matchRule.exact) {
return matchRule.exact.includes(version)
}
return Boolean(matchRule.range)
}
function formatTag(tag: string): string {
if (tag.startsWith('refs/tags/')) {
return tag.replace('refs/tags/', '')
}
return tag
}
async function updateVersions(
versions: Record<string, VersionEntry>,
segment: SegmentDefinition,
releaseInfo: ReleaseInfo,
skipReleaseValidation: boolean
): Promise<UpdateVersionsResult> {
const versionsCopy: Record<string, VersionEntry> = { ...versions }
const existingKey = findVersionKeyBySegment(versionsCopy, segment.id)
const targetKey = resolveVersionKey(existingKey, segment, releaseInfo)
const shouldRename = existingKey && existingKey !== targetKey
let entry: VersionEntry
if (existingKey) {
entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } }
} else {
entry = createEmptyVersionEntry()
}
entry.channels = ensureChannelSlots(entry.channels)
const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation)
if (!channelUpdated) {
return { versions, updated: false }
}
if (shouldRename && existingKey) {
delete versionsCopy[existingKey]
}
entry.metadata = {
segmentId: segment.id,
segmentType: segment.type
}
entry.minCompatibleVersion = segment.minCompatibleVersion
entry.description = segment.description
versionsCopy[targetKey] = entry
return {
versions: sortVersionMap(versionsCopy),
updated: true
}
}
function findVersionKeyBySegment(versions: Record<string, VersionEntry>, segmentId: string): string | null {
for (const [key, value] of Object.entries(versions)) {
if (value.metadata?.segmentId === segmentId) {
return key
}
}
return null
}
function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string {
if (segment.lockedVersion) {
return segment.lockedVersion
}
if (releaseInfo.channel === 'latest') {
return releaseInfo.version
}
if (existingKey) {
return existingKey
}
const baseVersion = getBaseVersion(releaseInfo.version)
return baseVersion ?? releaseInfo.version
}
function getBaseVersion(version: string): string | null {
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
if (!parsed) {
return null
}
return `${parsed.major}.${parsed.minor}.${parsed.patch}`
}
function createEmptyVersionEntry(): VersionEntry {
return {
minCompatibleVersion: '',
description: '',
channels: {
latest: null,
rc: null,
beta: null
}
}
}
function ensureChannelSlots(
channels: Record<UpgradeChannel, ChannelConfig | null>
): Record<UpgradeChannel, ChannelConfig | null> {
return CHANNELS.reduce(
(acc, channel) => {
acc[channel] = channels[channel] ?? null
return acc
},
{} as Record<UpgradeChannel, ChannelConfig | null>
)
}
async function applyChannelUpdate(
entry: VersionEntry,
segment: SegmentDefinition,
releaseInfo: ReleaseInfo,
skipReleaseValidation: boolean
): Promise<boolean> {
if (!CHANNELS.includes(releaseInfo.channel)) {
throw new Error(`Unsupported channel "${releaseInfo.channel}"`)
}
const feedUrls = buildFeedUrls(segment, releaseInfo)
if (skipReleaseValidation) {
console.warn(
`[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).`
)
} else {
const availability = await ensureReleaseAvailability(releaseInfo)
if (!availability.github) {
return false
}
if (releaseInfo.channel === 'latest' && !availability.gitcode) {
console.warn(
`[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.`
)
feedUrls.gitcode = GITCODE_LATEST_FALLBACK
}
}
entry.channels[releaseInfo.channel] = {
version: releaseInfo.version,
feedUrls
}
return true
}
function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record<UpdateMirror, string> {
return MIRRORS.reduce(
(acc, mirror) => {
const template = resolveFeedTemplate(segment, releaseInfo, mirror)
acc[mirror] = applyTemplate(template, releaseInfo)
return acc
},
{} as Record<UpdateMirror, string>
)
}
function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string {
if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') {
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github
}
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror]
}
function applyTemplate(template: string, releaseInfo: ReleaseInfo): string {
return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version)
}
function sortVersionMap(versions: Record<string, VersionEntry>): Record<string, VersionEntry> {
const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b))
return sorted.reduce(
(acc, [version, entry]) => {
acc[version] = entry
return acc
},
{} as Record<string, VersionEntry>
)
}
interface ReleaseAvailability {
github: boolean
gitcode: boolean
}
async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise<ReleaseAvailability> {
const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github']
const availability: ReleaseAvailability = {
github: false,
gitcode: releaseInfo.channel === 'latest' ? false : true
}
for (const mirror of mirrorsToCheck) {
const url = getReleasePageUrl(mirror, releaseInfo.tag)
try {
const response = await fetch(url, {
method: mirror === 'github' ? 'HEAD' : 'GET',
redirect: 'follow'
})
if (response.ok) {
availability[mirror] = true
} else {
console.warn(
`[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).`
)
availability[mirror] = false
}
} catch (error) {
console.warn(
`[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`,
error
)
availability[mirror] = false
}
}
return availability
}
function getReleasePageUrl(mirror: UpdateMirror, tag: string): string {
if (mirror === 'github') {
return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`
}
// Use latest.yml download URL for GitCode to check if release exists
// Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability
return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml`
}
main().catch((error) => {
console.error('❌ Failed to update app-upgrade-config:', error)
process.exit(1)
})

View File

@@ -8,7 +8,7 @@ import '@main/config'
import { loggerService } from '@logger'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app, crashReporter } from 'electron'
import { app } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { isDev, isLinux, isWin } from './constant'
@@ -37,14 +37,6 @@ import { initWebviewHotkeys } from './services/WebviewService'
const logger = loggerService.withContext('MainEntry')
// enable local crash reports
crashReporter.start({
companyName: 'CherryHQ',
productName: 'CherryStudio',
submitURL: '',
uploadToServer: false
})
/**
* Disable hardware acceleration if setting is enabled
*/

View File

@@ -551,7 +551,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager))
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
@@ -1038,8 +1037,4 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => {
mainWindow.webContents.forcefullyCrashRenderer()
})
}

View File

@@ -21,7 +21,6 @@ type ApiResponse<T> = {
type BatchUploadResponse = {
batch_id: string
file_urls: string[]
headers?: Record<string, string>[]
}
type ExtractProgress = {
@@ -56,7 +55,7 @@ type QuotaResponse = {
export default class MineruPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider, userId?: string) {
super(provider, userId)
// TODO: remove after free period ends
// todo免费期结束后删除
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
}
@@ -69,21 +68,21 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
logger.info(`MinerU preprocess processing started: ${filePath}`)
await this.validateFile(filePath)
// 1. Get upload URL and upload file
// 1. 获取上传URL并上传文件
const batchId = await this.uploadFile(file)
logger.info(`MinerU file upload completed: batch_id=${batchId}`)
// 2. Wait for completion and fetch results
// 2. 等待处理完成并获取结果
const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name)
logger.info(`MinerU processing completed for batch: ${batchId}`)
// 3. Download and extract output
// 3. 下载并解压文件
const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file)
// 4. check quota
const quota = await this.checkQuota()
// 5. Create processed file metadata
// 5. 创建处理后的文件信息
return {
processedFile: this.createProcessedFileInfo(file, outputPath),
quota
@@ -116,48 +115,23 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
}
private async validateFile(filePath: string): Promise<void> {
// Phase 1: check file size (without loading into memory)
logger.info(`Validating PDF file: ${filePath}`)
const stats = await fs.promises.stat(filePath)
const fileSizeBytes = stats.size
// Ensure file size is under 200MB
if (fileSizeBytes >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(fileSizeBytes / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
// Phase 2: check page count (requires reading file with error handling)
const pdfBuffer = await fs.promises.readFile(filePath)
try {
const doc = await this.readPdf(pdfBuffer)
const doc = await this.readPdf(pdfBuffer)
// Ensure page count is under 600 pages
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
logger.info(`PDF validation passed: ${doc.numPages} pages, ${Math.round(fileSizeBytes / (1024 * 1024))}MB`)
} catch (error: any) {
// If the page limit is exceeded, rethrow immediately
if (error.message.includes('exceeds the limit')) {
throw error
}
// If PDF parsing fails, log a detailed warning but continue processing
logger.warn(
`Failed to parse PDF structure (file may be corrupted or use non-standard format). ` +
`Skipping page count validation. Will attempt to process with MinerU API. ` +
`Error details: ${error.message}. ` +
`Suggestion: If processing fails, try repairing the PDF using tools like Adobe Acrobat or online PDF repair services.`
)
// Do not throw; continue processing
// 文件页数小于600页
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
// 文件大小小于200MB
if (pdfBuffer.length >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
}
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
// Locate the main extracted file
// 查找解压后的主要文件
let finalPath = ''
let finalName = file.origin_name.replace('.pdf', '.md')
@@ -169,14 +143,14 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
const originalMdPath = path.join(outputPath, mdFile)
const newMdPath = path.join(outputPath, finalName)
// Rename the file to match the original name
// 重命名文件为原始文件名
try {
fs.renameSync(originalMdPath, newMdPath)
finalPath = newMdPath
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
} catch (renameError) {
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
// If renaming fails, fall back to the original file
// 如果重命名失败,使用原文件
finalPath = originalMdPath
finalName = mdFile
}
@@ -204,7 +178,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
logger.info(`Downloading MinerU result to: ${zipPath}`)
try {
// Download the ZIP file
// 下载ZIP文件
const response = await net.fetch(zipUrl, { method: 'GET' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
@@ -213,17 +187,17 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
logger.info(`Downloaded ZIP file: ${zipPath}`)
// Ensure the extraction directory exists
// 确保提取目录存在
if (!fs.existsSync(extractPath)) {
fs.mkdirSync(extractPath, { recursive: true })
}
// Extract the ZIP contents
// 解压文件
const zip = new AdmZip(zipPath)
zip.extractAllTo(extractPath, true)
logger.info(`Extracted files to: ${extractPath}`)
// Remove the temporary ZIP file
// 删除临时ZIP文件
fs.unlinkSync(zipPath)
return { path: extractPath }
@@ -235,11 +209,11 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
private async uploadFile(file: FileMetadata): Promise<string> {
try {
// Step 1: obtain the upload URL
const { batchId, fileUrls, uploadHeaders } = await this.getBatchUploadUrls(file)
// Step 2: upload the file to the obtained URL
// 步骤1: 获取上传URL
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
// 步骤2: 上传文件到获取的URL
const filePath = fileStorage.getFilePathById(file)
await this.putFileToUrl(filePath, fileUrls[0], file.origin_name, uploadHeaders?.[0])
await this.putFileToUrl(filePath, fileUrls[0])
logger.info(`File uploaded successfully: ${filePath}`, { batchId, fileUrls })
return batchId
@@ -249,9 +223,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
}
}
private async getBatchUploadUrls(
file: FileMetadata
): Promise<{ batchId: string; fileUrls: string[]; uploadHeaders?: Record<string, string>[] }> {
private async getBatchUploadUrls(file: FileMetadata): Promise<{ batchId: string; fileUrls: string[] }> {
const endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch`
const payload = {
@@ -282,11 +254,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
if (response.ok) {
const data: ApiResponse<BatchUploadResponse> = await response.json()
if (data.code === 0 && data.data) {
const { batch_id, file_urls, headers: uploadHeaders } = data.data
const { batch_id, file_urls } = data.data
return {
batchId: batch_id,
fileUrls: file_urls,
uploadHeaders
fileUrls: file_urls
}
} else {
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
@@ -300,28 +271,18 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
}
}
private async putFileToUrl(
filePath: string,
uploadUrl: string,
fileName?: string,
headers?: Record<string, string>
): Promise<void> {
private async putFileToUrl(filePath: string, uploadUrl: string): Promise<void> {
try {
const fileBuffer = await fs.promises.readFile(filePath)
const fileSize = fileBuffer.byteLength
const displayName = fileName ?? path.basename(filePath)
logger.info(`Uploading file to MinerU OSS: ${displayName} (${fileSize} bytes)`)
// https://mineru.net/apiManage/docs
const response = await net.fetch(uploadUrl, {
method: 'PUT',
headers,
body: new Uint8Array(fileBuffer)
body: fileBuffer
})
if (!response.ok) {
// Clone the response to avoid consuming the body stream
// 克隆 response 以避免消费 body stream
const responseClone = response.clone()
try {
@@ -392,20 +353,20 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
try {
const result = await this.getExtractResults(batchId)
// Find the corresponding file result
// 查找对应文件的处理结果
const fileResult = result.extract_result.find((item) => item.file_name === fileName)
if (!fileResult) {
throw new Error(`File ${fileName} not found in batch results`)
}
// Check the processing state
// 检查处理状态
if (fileResult.state === 'done' && fileResult.full_zip_url) {
logger.info(`Processing completed for file: ${fileName}`)
return fileResult
} else if (fileResult.state === 'failed') {
throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`)
} else if (fileResult.state === 'running') {
// Send progress updates
// 发送进度更新
if (fileResult.extract_progress) {
const progress = Math.round(
(fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100
@@ -413,7 +374,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
await this.sendPreprocessProgress(sourceId, progress)
logger.info(`File ${fileName} processing progress: ${progress}%`)
} else {
// If no detailed progress information is available, send a generic update
// 如果没有具体进度信息,发送一个通用进度
await this.sendPreprocessProgress(sourceId, 50)
logger.info(`File ${fileName} is still processing...`)
}

View File

@@ -53,43 +53,18 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
}
private async validateFile(filePath: string): Promise<void> {
// 第一阶段:检查文件大小(无需读取文件到内存)
logger.info(`Validating PDF file: ${filePath}`)
const stats = await fs.promises.stat(filePath)
const fileSizeBytes = stats.size
// File size must be less than 200MB
if (fileSizeBytes >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(fileSizeBytes / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
// 第二阶段:检查页数(需要读取文件,带错误处理)
const pdfBuffer = await fs.promises.readFile(filePath)
try {
const doc = await this.readPdf(pdfBuffer)
const doc = await this.readPdf(pdfBuffer)
// File page count must be less than 600 pages
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
logger.info(`PDF validation passed: ${doc.numPages} pages, ${Math.round(fileSizeBytes / (1024 * 1024))}MB`)
} catch (error: any) {
// 如果是页数超限错误,直接抛出
if (error.message.includes('exceeds the limit')) {
throw error
}
// PDF 解析失败,记录详细警告但允许继续处理
logger.warn(
`Failed to parse PDF structure (file may be corrupted or use non-standard format). ` +
`Skipping page count validation. Will attempt to process with MinerU API. ` +
`Error details: ${error.message}. ` +
`Suggestion: If processing fails, try repairing the PDF using tools like Adobe Acrobat or online PDF repair services.`
)
// 不抛出错误,允许继续处理
// File page count must be less than 600 pages
if (doc.numPages >= 600) {
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
}
// File size must be less than 200MB
if (pdfBuffer.length >= 200 * 1024 * 1024) {
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
}
}
@@ -97,8 +72,8 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
// Find the main file after extraction
let finalPath = ''
let finalName = file.origin_name.replace('.pdf', '.md')
// Find the corresponding folder by file id
outputPath = path.join(outputPath, file.id)
// Find the corresponding folder by file name
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
try {
const files = fs.readdirSync(outputPath)
@@ -150,7 +125,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
formData.append('return_md', 'true')
formData.append('response_format_zip', 'true')
formData.append('files', fileBuffer, {
filename: file.name
filename: file.origin_name
})
while (retries < maxRetries) {
@@ -164,7 +139,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
...formData.getHeaders()
},
body: new Uint8Array(formData.getBuffer())
body: formData.getBuffer()
})
if (!response.ok) {

View File

@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpdateConfigUrl, UpdateMirror, UpgradeChannel } from '@shared/config/constant'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import type { UpdateInfo } from 'builder-util-runtime'
import { CancellationToken } from 'builder-util-runtime'
@@ -22,29 +22,7 @@ const LANG_MARKERS = {
EN_START: '<!--LANG:en-->',
ZH_CN_START: '<!--LANG:zh-CN-->',
END: '<!--LANG:END-->'
}
interface UpdateConfig {
lastUpdated: string
versions: {
[versionKey: string]: VersionConfig
}
}
interface VersionConfig {
minCompatibleVersion: string
description: string
channels: {
latest: ChannelConfig | null
rc: ChannelConfig | null
beta: ChannelConfig | null
}
}
interface ChannelConfig {
version: string
feedUrls: Record<UpdateMirror, string>
}
} as const
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
@@ -59,9 +37,7 @@ export default class AppUpdater {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
'X-Client-Id': configManager.getClientId()
}
autoUpdater.on('error', (error) => {
@@ -99,6 +75,61 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
private async _getReleaseVersionFromGithub(channel: UpgradeChannel) {
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
try {
logger.info(`get release version from github: ${channel}`)
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers
})
const data = (await responses.json()) as GithubReleaseInfo[]
let mightHaveLatest = false
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
if (!item.draft && !item.prerelease) {
mightHaveLatest = true
}
return item.prerelease && item.tag_name.includes(`-${channel}.`)
})
if (!release) {
return null
}
// if the release version is the same as the current version, return null
if (release.tag_name === app.getVersion()) {
return null
}
if (mightHaveLatest) {
logger.info(`might have latest release, get latest release`)
const latestReleaseResponse = await net.fetch(
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
{
headers
}
)
const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo
if (semver.gt(latestRelease.tag_name, release.tag_name)) {
logger.info(
`latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null`
)
return null
}
}
logger.info(`release url is ${release.tag_name}, set channel to ${channel}`)
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
} catch (error) {
logger.error('Failed to get latest not draft version from github:', error as Error)
return null
}
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
@@ -130,88 +161,6 @@ export default class AppUpdater {
return UpgradeChannel.LATEST
}
/**
* Fetch update configuration from GitHub or GitCode based on mirror
* @param mirror - Mirror to fetch config from
* @returns UpdateConfig object or null if fetch fails
*/
private async _fetchUpdateConfig(mirror: UpdateMirror): Promise<UpdateConfig | null> {
const configUrl = mirror === UpdateMirror.GITCODE ? UpdateConfigUrl.GITCODE : UpdateConfigUrl.GITHUB
try {
logger.info(`Fetching update config from ${configUrl} (mirror: ${mirror})`)
const response = await net.fetch(configUrl, {
headers: {
'User-Agent': generateUserAgent(),
Accept: 'application/json',
'X-Client-Id': configManager.getClientId(),
// no-cache
'Cache-Control': 'no-cache'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const config = (await response.json()) as UpdateConfig
logger.info(`Update config fetched successfully, last updated: ${config.lastUpdated}`)
return config
} catch (error) {
logger.error('Failed to fetch update config:', error as Error)
return null
}
}
/**
* Find compatible channel configuration based on current version
* @param currentVersion - Current app version
* @param requestedChannel - Requested upgrade channel (latest/rc/beta)
* @param config - Update configuration object
* @returns Object containing ChannelConfig and actual channel if found, null otherwise
*/
private _findCompatibleChannel(
currentVersion: string,
requestedChannel: UpgradeChannel,
config: UpdateConfig
): { config: ChannelConfig; channel: UpgradeChannel } | null {
// Get all version keys and sort descending (newest first)
const versionKeys = Object.keys(config.versions).sort(semver.rcompare)
logger.info(
`Finding compatible channel for version ${currentVersion}, requested channel: ${requestedChannel}, available versions: ${versionKeys.join(', ')}`
)
for (const versionKey of versionKeys) {
const versionConfig = config.versions[versionKey]
const channelConfig = versionConfig.channels[requestedChannel]
const latestChannelConfig = versionConfig.channels[UpgradeChannel.LATEST]
// Check version compatibility and channel availability
if (semver.gte(currentVersion, versionConfig.minCompatibleVersion) && channelConfig !== null) {
logger.info(
`Found compatible version: ${versionKey} (minCompatibleVersion: ${versionConfig.minCompatibleVersion}), version: ${channelConfig.version}`
)
if (
requestedChannel !== UpgradeChannel.LATEST &&
latestChannelConfig &&
semver.gte(latestChannelConfig.version, channelConfig.version)
) {
logger.info(
`latest channel version is greater than the requested channel version: ${latestChannelConfig.version} > ${channelConfig.version}, using latest instead`
)
return { config: latestChannelConfig, channel: UpgradeChannel.LATEST }
}
return { config: channelConfig, channel: requestedChannel }
}
}
logger.warn(`No compatible channel found for version ${currentVersion} and channel ${requestedChannel}`)
return null
}
private _setChannel(channel: UpgradeChannel, feedUrl: string) {
this.autoUpdater.channel = channel
this.autoUpdater.setFeedURL(feedUrl)
@@ -223,42 +172,33 @@ export default class AppUpdater {
}
private async _setFeedUrl() {
const currentVersion = app.getVersion()
const testPlan = configManager.getTestPlan()
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
if (testPlan) {
const channel = this._getTestChannel()
// Determine mirror based on IP country
const ipCountry = await getIpCountry()
const mirror = ipCountry.toLowerCase() === 'cn' ? UpdateMirror.GITCODE : UpdateMirror.GITHUB
logger.info(
`Setting feed URL for version ${currentVersion}, testPlan: ${testPlan}, requested channel: ${requestedChannel}, mirror: ${mirror} (IP country: ${ipCountry})`
)
// Try to fetch update config from remote
const config = await this._fetchUpdateConfig(mirror)
if (config) {
// Use new config-based system
const result = this._findCompatibleChannel(currentVersion, requestedChannel, config)
if (result) {
const { config: channelConfig, channel: actualChannel } = result
const feedUrl = channelConfig.feedUrls[mirror]
logger.info(
`Using config-based feed URL: ${feedUrl} for channel ${actualChannel} (requested: ${requestedChannel}, mirror: ${mirror})`
)
this._setChannel(actualChannel, feedUrl)
if (channel === UpgradeChannel.LATEST) {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
const releaseUrl = await this._getReleaseVersionFromGithub(channel)
if (releaseUrl) {
logger.info(`release url is ${releaseUrl}, set channel to ${channel}`)
this._setChannel(channel, releaseUrl)
return
}
// if no prerelease url, use github latest to get release
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
logger.info('Failed to fetch update config, falling back to default feed URL')
// Fallback: use default feed URL based on mirror
const defaultFeedUrl = mirror === UpdateMirror.GITCODE ? FeedUrl.PRODUCTION : FeedUrl.GITHUB_LATEST
logger.info(`Using fallback feed URL: ${defaultFeedUrl}`)
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
const ipCountry = await getIpCountry()
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
if (ipCountry.toLowerCase() !== 'cn') {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
}
}
public cancelDownload() {
@@ -380,3 +320,8 @@ export default class AppUpdater {
return processedInfo
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}

View File

@@ -11,6 +11,7 @@ import * as path from 'path'
import type { CreateDirectoryOptions, FileStat } from 'webdav'
import { getDataPath } from '../utils'
import { expandNotesPath } from '../utils/file'
import S3Storage from './S3Storage'
import WebDav from './WebDav'
import { windowService } from './WindowService'
@@ -240,11 +241,49 @@ class BackupManager {
// 使用流式复制
await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => {
copiedSize += size
const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50))
const progress = Math.min(45, Math.floor((copiedSize / totalSize) * 45))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
await this.setWritableRecursive(tempDataDir)
// 检查并备份 notes 目录(如果配置在 Data 目录外)
try {
const backupData = JSON.parse(data)
const persistData = JSON.parse(backupData.localStorage?.['persist:cherry-studio'] || '{}')
const noteState = JSON.parse(persistData.note || '{}')
const notesPath = noteState.notesPath
if (notesPath) {
// 展开路径获取绝对路径
const expandedNotesPath = expandNotesPath(notesPath)
const dataPath = path.join(app.getPath('userData'), 'Data')
const normalizedDataPath = path.normalize(dataPath)
const normalizedNotesPath = path.normalize(expandedNotesPath)
// 检查 notes 是否在 Data 目录外
const isOutsideData =
!normalizedNotesPath.startsWith(normalizedDataPath + path.sep) &&
normalizedNotesPath !== normalizedDataPath
if (isOutsideData && fs.existsSync(expandedNotesPath)) {
logger.info(`Backing up notes from external location: ${expandedNotesPath}`)
const tempNotesDir = path.join(this.tempDir, 'Notes')
await this.copyDirWithProgress(expandedNotesPath, tempNotesDir, (size) => {
// Notes backup progress from 45% to 50%
copiedSize += size
const notesProgress = 45 + Math.min(5, Math.floor((size / totalSize) * 5))
onProgress({ stage: 'copying_notes', progress: notesProgress, total: 100 })
})
await this.setWritableRecursive(tempNotesDir)
logger.info('External notes directory backed up successfully')
}
}
} catch (error) {
// 如果解析失败或获取 notes 路径失败,继续备份其他内容
logger.warn('Failed to parse notes path from backup data, skipping external notes backup', error as Error)
}
onProgress({ stage: 'preparing_compression', progress: 50, total: 100 })
} else {
logger.debug('Skip the backup of the file')
@@ -399,13 +438,52 @@ class BackupManager {
// 使用流式复制
await this.copyDirWithProgress(sourcePath, destPath, (size) => {
copiedSize += size
const progress = Math.min(85, 35 + Math.floor((copiedSize / totalSize) * 50))
const progress = Math.min(75, 35 + Math.floor((copiedSize / totalSize) * 40))
onProgress({ stage: 'copying_files', progress, total: 100 })
})
} else {
logger.debug('skipBackupFile is true, skip restoring Data directory')
}
// 检查并恢复外部 Notes 目录
logger.debug('step 3.5: check and restore external Notes directory')
const notesBackupPath = path.join(this.tempDir, 'Notes')
const notesExists = await fs.pathExists(notesBackupPath)
if (notesExists) {
try {
// 从 data.json 中获取 notes 路径配置
const backupData = JSON.parse(data)
const persistData = JSON.parse(backupData.localStorage?.['persist:cherry-studio'] || '{}')
const noteState = JSON.parse(persistData.note || '{}')
const notesPath = noteState.notesPath
if (notesPath) {
const expandedNotesPath = expandNotesPath(notesPath)
logger.info(`Restoring notes to configured location: ${expandedNotesPath}`)
// 确保目标目录的父目录存在
await fs.ensureDir(path.dirname(expandedNotesPath))
// 如果目标已存在,先删除
if (await fs.pathExists(expandedNotesPath)) {
await this.setWritableRecursive(expandedNotesPath)
await fs.remove(expandedNotesPath)
}
// 复制 Notes 目录
await this.copyDirWithProgress(notesBackupPath, expandedNotesPath, (size) => {
const progress = Math.min(85, 75 + Math.floor(size / 1000000))
onProgress({ stage: 'copying_notes', progress, total: 100 })
})
logger.info('External notes directory restored successfully')
}
} catch (error) {
logger.warn('Failed to restore external notes directory', error as Error)
}
}
logger.debug('step 4: clean up temp directory')
// 清理临时目录
await this.setWritableRecursive(this.tempDir)

View File

@@ -1,10 +1,11 @@
import { loggerService } from '@logger'
import {
checkName,
expandNotesPath,
getFilesDir,
getFileType,
getName,
getNotesDir,
getNotesDirAbsolute,
getTempDir,
readTextFileWithAutoEncoding,
scanDir
@@ -16,7 +17,6 @@ import type { FSWatcher } from 'chokidar'
import chokidar from 'chokidar'
import * as crypto from 'crypto'
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { app } from 'electron'
import { dialog, net, shell } from 'electron'
import * as fs from 'fs'
import { writeFileSync } from 'fs'
@@ -31,73 +31,6 @@ import WordExtractor from 'word-extractor'
const logger = loggerService.withContext('FileStorage')
// Get ripgrep binary path
const getRipgrepBinaryPath = (): string | null => {
try {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux'
let ripgrepBinaryPath = path.join(
__dirname,
'../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep',
`${arch}-${platform}`,
process.platform === 'win32' ? 'rg.exe' : 'rg'
)
if (app.isPackaged) {
ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
if (fs.existsSync(ripgrepBinaryPath)) {
return ripgrepBinaryPath
}
return null
} catch (error) {
logger.error('Failed to locate ripgrep binary:', error as Error)
return null
}
}
/**
* Execute ripgrep with captured output
*/
function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> {
return new Promise((resolve, reject) => {
const ripgrepBinaryPath = getRipgrepBinaryPath()
if (!ripgrepBinaryPath) {
reject(new Error('Ripgrep binary not available'))
return
}
const { spawn } = require('child_process')
const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], {
stdio: ['pipe', 'pipe', 'pipe']
})
let output = ''
let errorOutput = ''
child.stdout.on('data', (data: Buffer) => {
output += data.toString()
})
child.stderr.on('data', (data: Buffer) => {
errorOutput += data.toString()
})
child.on('close', (code: number) => {
resolve({
exitCode: code || 0,
output: output || errorOutput
})
})
child.on('error', (error: Error) => {
reject(error)
})
})
}
interface FileWatcherConfig {
watchExtensions?: string[]
ignoredPatterns?: (string | RegExp)[]
@@ -122,29 +55,9 @@ const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
eventChannel: 'file-change'
}
interface DirectoryListOptions {
recursive?: boolean
maxDepth?: number
includeHidden?: boolean
includeFiles?: boolean
includeDirectories?: boolean
maxEntries?: number
searchPattern?: string
}
const DEFAULT_DIRECTORY_LIST_OPTIONS: Required<DirectoryListOptions> = {
recursive: true,
maxDepth: 3,
includeHidden: false,
includeFiles: true,
includeDirectories: true,
maxEntries: 10,
searchPattern: '.'
}
class FileStorage {
private storageDir = getFilesDir()
private notesDir = getNotesDir()
private notesDir = getNotesDirAbsolute()
private tempDir = getTempDir()
private watcher?: FSWatcher
private watcherSender?: Electron.WebContents
@@ -829,299 +742,23 @@ class FileStorage {
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
try {
return await scanDir(dirPath)
// Expand relative paths before scanning
const expandedPath = expandNotesPath(dirPath)
return await scanDir(expandedPath)
} catch (error) {
logger.error('Failed to get directory structure:', error as Error)
throw error
}
}
public listDirectory = async (
_: Electron.IpcMainInvokeEvent,
dirPath: string,
options?: DirectoryListOptions
): Promise<string[]> => {
const mergedOptions: Required<DirectoryListOptions> = {
...DEFAULT_DIRECTORY_LIST_OPTIONS,
...options
}
const resolvedPath = path.resolve(dirPath)
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
throw error
})
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${resolvedPath}`)
}
// Use ripgrep for file listing with relevance-based sorting
if (!getRipgrepBinaryPath()) {
throw new Error('Ripgrep binary not available')
}
return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions)
}
/**
* Search directories by name pattern
*/
private async searchDirectories(
resolvedPath: string,
options: Required<DirectoryListOptions>,
currentDepth: number = 0
): Promise<string[]> {
if (!options.includeDirectories) return []
if (!options.recursive && currentDepth > 0) return []
if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return []
const directories: string[] = []
const excludedDirs = new Set([
'node_modules',
'.git',
'.idea',
'.vscode',
'dist',
'build',
'.next',
'.nuxt',
'coverage',
'.cache'
])
try {
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true })
const searchPatternLower = options.searchPattern.toLowerCase()
for (const entry of entries) {
if (!entry.isDirectory()) continue
// Skip hidden directories unless explicitly included
if (!options.includeHidden && entry.name.startsWith('.')) continue
// Skip excluded directories
if (excludedDirs.has(entry.name)) continue
const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/')
// Check if directory name matches search pattern
if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) {
directories.push(fullPath)
}
// Recursively search subdirectories
if (options.recursive && currentDepth < options.maxDepth) {
const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1)
directories.push(...subDirs)
}
}
} catch (error) {
logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error)
}
return directories
}
/**
* Search files by filename pattern
*/
private async searchByFilename(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
const files: string[] = []
const directories: string[] = []
// Search for files using ripgrep
if (options.includeFiles) {
const args: string[] = ['--files']
// Handle hidden files
if (!options.includeHidden) {
args.push('--glob', '!.*')
}
// Use --iglob to let ripgrep filter filenames (case-insensitive)
if (options.searchPattern && options.searchPattern !== '.') {
args.push('--iglob', `*${options.searchPattern}*`)
}
// Exclude common hidden directories and large directories
args.push('-g', '!**/node_modules/**')
args.push('-g', '!**/.git/**')
args.push('-g', '!**/.idea/**')
args.push('-g', '!**/.vscode/**')
args.push('-g', '!**/.DS_Store')
args.push('-g', '!**/dist/**')
args.push('-g', '!**/build/**')
args.push('-g', '!**/.next/**')
args.push('-g', '!**/.nuxt/**')
args.push('-g', '!**/coverage/**')
args.push('-g', '!**/.cache/**')
// Handle max depth
if (!options.recursive) {
args.push('--max-depth', '1')
} else if (options.maxDepth > 0) {
args.push('--max-depth', options.maxDepth.toString())
}
// Add the directory path
args.push(resolvedPath)
const { exitCode, output } = await executeRipgrep(args)
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
if (exitCode >= 2) {
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
}
// Parse ripgrep output (no need to filter by filename - ripgrep already did it)
files.push(
...output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
)
}
// Search for directories
if (options.includeDirectories) {
directories.push(...(await this.searchDirectories(resolvedPath, options)))
}
// Combine and sort: directories first (alphabetically), then files (alphabetically)
const sortedDirectories = directories.sort((a, b) => {
const aName = path.basename(a)
const bName = path.basename(b)
return aName.localeCompare(bName)
})
const sortedFiles = files.sort((a, b) => {
const aName = path.basename(a)
const bName = path.basename(b)
return aName.localeCompare(bName)
})
return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries)
}
/**
* Search files by content pattern
*/
private async searchByContent(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
const args: string[] = ['-l']
// Handle hidden files
if (!options.includeHidden) {
args.push('--glob', '!.*')
}
// Exclude common hidden directories and large directories
args.push('-g', '!**/node_modules/**')
args.push('-g', '!**/.git/**')
args.push('-g', '!**/.idea/**')
args.push('-g', '!**/.vscode/**')
args.push('-g', '!**/.DS_Store')
args.push('-g', '!**/dist/**')
args.push('-g', '!**/build/**')
args.push('-g', '!**/.next/**')
args.push('-g', '!**/.nuxt/**')
args.push('-g', '!**/coverage/**')
args.push('-g', '!**/.cache/**')
// Handle max depth
if (!options.recursive) {
args.push('--max-depth', '1')
} else if (options.maxDepth > 0) {
args.push('--max-depth', options.maxDepth.toString())
}
// Handle max count
if (options.maxEntries > 0) {
args.push('--max-count', options.maxEntries.toString())
}
// Add search pattern (search in content)
args.push(options.searchPattern)
// Add the directory path
args.push(resolvedPath)
const { exitCode, output } = await executeRipgrep(args)
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
if (exitCode >= 2) {
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
}
// Parse ripgrep output (already sorted by relevance)
const results = output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.replace(/\\/g, '/'))
.slice(0, options.maxEntries)
return results
}
private async listDirectoryWithRipgrep(
resolvedPath: string,
options: Required<DirectoryListOptions>
): Promise<string[]> {
const maxEntries = options.maxEntries
// Step 1: Search by filename first
logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath })
const filenameResults = await this.searchByFilename(resolvedPath, options)
logger.debug('Found matches by filename', { count: filenameResults.length })
// If we have enough filename matches, return them
if (filenameResults.length >= maxEntries) {
return filenameResults.slice(0, maxEntries)
}
// Step 2: If filename matches are less than maxEntries, search by content to fill up
logger.debug('Filename matches insufficient, searching by content to fill up', {
filenameCount: filenameResults.length,
needed: maxEntries - filenameResults.length
})
// Adjust maxEntries for content search to get enough results
const contentOptions = {
...options,
maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates
}
const contentResults = await this.searchByContent(resolvedPath, contentOptions)
logger.debug('Found matches by content', { count: contentResults.length })
// Combine results: filename matches first, then content matches (deduplicated)
const combined = [...filenameResults]
const filenameSet = new Set(filenameResults)
for (const filePath of contentResults) {
if (!filenameSet.has(filePath)) {
combined.push(filePath)
if (combined.length >= maxEntries) {
break
}
}
}
logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length })
return combined.slice(0, maxEntries)
}
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
try {
if (!dirPath || typeof dirPath !== 'string') {
return false
}
// Normalize path
const normalizedPath = path.resolve(dirPath)
// Expand and normalize path (handles ~, ., and .. paths)
const normalizedPath = expandNotesPath(dirPath)
// Check if directory exists
if (!fs.existsSync(normalizedPath)) {
@@ -1137,7 +774,7 @@ class FileStorage {
// Get app paths to prevent selection of restricted directories
const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config'))
const filesDir = path.resolve(getFilesDir())
const currentNotesDir = path.resolve(getNotesDir())
const currentNotesDir = getNotesDirAbsolute()
// Prevent selecting app data directories
if (
@@ -1374,7 +1011,8 @@ class FileStorage {
throw new Error('Directory path is required')
}
const normalizedPath = path.resolve(dirPath.trim())
// Expand relative paths before watching
const normalizedPath = expandNotesPath(dirPath.trim())
if (!fs.existsSync(normalizedPath)) {
throw new Error(`Directory does not exist: ${normalizedPath}`)

View File

@@ -375,16 +375,13 @@ export class WindowService {
mainWindow.hide()
//for mac users, should hide dock icon if close to tray
if (isMac && isTrayOnClose) {
app.dock?.hide()
mainWindow.once('show', () => {
//restore the window can hide by cmd+h when the window is shown again
// https://github.com/electron/electron/pull/47970
app.dock?.show()
})
}
// TODO: don't hide dock icon when close to tray
// will cause the cmd+h behavior not working
// after the electron fix the bug, we can restore this code
// //for mac users, should hide dock icon if close to tray
// if (isMac && isTrayOnClose) {
// app.dock?.hide()
// }
})
mainWindow.on('closed', () => {

View File

@@ -85,9 +85,6 @@ vi.mock('electron-updater', () => ({
}))
// Import after mocks
import { UpdateMirror } from '@shared/config/constant'
import { app, net } from 'electron'
import AppUpdater from '../AppUpdater'
import { configManager } from '../ConfigManager'
@@ -277,711 +274,4 @@ describe('AppUpdater', () => {
expect(result.releaseNotes).toBeNull()
})
})
describe('_fetchUpdateConfig', () => {
const mockConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'Test version',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: null,
beta: null
}
}
}
}
it('should fetch config from GitHub mirror', async () => {
vi.mocked(net.fetch).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as any)
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
expect(result).toEqual(mockConfig)
expect(net.fetch).toHaveBeenCalledWith(expect.stringContaining('github'), expect.any(Object))
})
it('should fetch config from GitCode mirror', async () => {
vi.mocked(net.fetch).mockResolvedValue({
ok: true,
json: async () => mockConfig
} as any)
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITCODE)
expect(result).toEqual(mockConfig)
// GitCode URL may vary, just check that fetch was called
expect(net.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object))
})
it('should return null on HTTP error', async () => {
vi.mocked(net.fetch).mockResolvedValue({
ok: false,
status: 404
} as any)
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
expect(result).toBeNull()
})
it('should return null on network error', async () => {
vi.mocked(net.fetch).mockRejectedValue(new Error('Network error'))
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
expect(result).toBeNull()
})
})
describe('_findCompatibleChannel', () => {
const mockConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'v1.6.7',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: {
version: '1.7.0-beta.3',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.3',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
}
}
}
},
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'v2.0.0',
channels: {
latest: null,
rc: null,
beta: null
}
}
}
}
it('should find compatible latest channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'latest', mockConfig)
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
expect(result?.channel).toBe('latest')
})
it('should find compatible rc channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', mockConfig)
expect(result?.config).toEqual({
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
})
expect(result?.channel).toBe('rc')
})
it('should find compatible beta channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'beta', mockConfig)
expect(result?.config).toEqual({
version: '1.7.0-beta.3',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.3',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
}
})
expect(result?.channel).toBe('beta')
})
it('should return latest when latest version >= rc version', () => {
const configWithNewerLatest = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerLatest)
// Should return latest instead of rc because 1.7.0 >= 1.7.0-rc.1
expect(result?.config).toEqual({
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
})
expect(result?.channel).toBe('latest') // ✅ 返回 latest 频道
})
it('should return latest when latest version >= beta version', () => {
const configWithNewerLatest = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
},
rc: null,
beta: {
version: '1.6.8-beta.1',
feedUrls: {
github: 'https://github.com/test/v1.6.8-beta.1',
gitcode: 'https://gitcode.com/test/v1.6.8-beta.1'
}
}
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerLatest)
// Should return latest instead of beta because 1.7.0 >= 1.6.8-beta.1
expect(result?.config).toEqual({
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
})
})
it('should not compare latest with itself when requesting latest channel', () => {
const config = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'latest', config)
// Should return latest directly without comparing with itself
expect(result?.config).toEqual({
version: '1.7.0',
feedUrls: {
github: 'https://github.com/test/v1.7.0',
gitcode: 'https://gitcode.com/test/v1.7.0'
}
})
})
it('should return rc when rc version > latest version', () => {
const configWithNewerRc = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerRc)
// Should return rc because 1.7.0-rc.1 > 1.6.7
expect(result?.config).toEqual({
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
})
})
it('should return beta when beta version > latest version', () => {
const configWithNewerBeta = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.0': {
minCompatibleVersion: '1.0.0',
description: 'v1.7.0',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: null,
beta: {
version: '1.7.0-beta.5',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.5',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.5'
}
}
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerBeta)
// Should return beta because 1.7.0-beta.5 > 1.6.7
expect(result?.config).toEqual({
version: '1.7.0-beta.5',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.5',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.5'
}
})
})
it('should return lower version when higher version has no compatible channel', () => {
vi.mocked(app.getVersion).mockReturnValue('1.8.0')
const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'latest', mockConfig)
// 1.8.0 >= 1.7.0 but 2.0.0 has no latest channel, so return 1.6.7
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
})
it('should return null when current version does not meet minCompatibleVersion', () => {
vi.mocked(app.getVersion).mockReturnValue('0.9.0')
const result = (appUpdater as any)._findCompatibleChannel('0.9.0', 'latest', mockConfig)
// 0.9.0 < 1.0.0 (minCompatibleVersion)
expect(result).toBeNull()
})
it('should return lower version rc when higher version has no rc channel', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'rc', mockConfig)
// 1.8.0 >= 1.7.0 but 2.0.0 has no rc channel, so return 1.6.7 rc
expect(result?.config).toEqual({
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
})
})
it('should return null when no version has the requested channel', () => {
const configWithoutRc = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'v1.6.7',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: null,
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', configWithoutRc)
expect(result).toBeNull()
})
})
describe('Upgrade Path', () => {
const fullConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.6.7': {
minCompatibleVersion: '1.0.0',
description: 'Last v1.x',
channels: {
latest: {
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
},
rc: {
version: '1.7.0-rc.1',
feedUrls: {
github: 'https://github.com/test/v1.7.0-rc.1',
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
}
},
beta: {
version: '1.7.0-beta.3',
feedUrls: {
github: 'https://github.com/test/v1.7.0-beta.3',
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
}
}
}
},
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'First v2.x',
channels: {
latest: null,
rc: null,
beta: null
}
}
}
}
it('should upgrade from 1.6.3 to 1.6.7', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullConfig)
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
})
it('should block upgrade from 1.6.7 to 2.0.0 (minCompatibleVersion not met)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.7', 'latest', fullConfig)
// Should return 1.6.7, not 2.0.0, because 1.6.7 < 1.7.0 (minCompatibleVersion of 2.0.0)
expect(result?.config).toEqual({
version: '1.6.7',
feedUrls: {
github: 'https://github.com/test/v1.6.7',
gitcode: 'https://gitcode.com/test/v1.6.7'
}
})
})
it('should allow upgrade from 1.7.0 to 2.0.0', () => {
const configWith2x = {
...fullConfig,
versions: {
...fullConfig.versions,
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'First v2.x',
channels: {
latest: {
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
},
rc: null,
beta: null
}
}
}
}
const result = (appUpdater as any)._findCompatibleChannel('1.7.0', 'latest', configWith2x)
expect(result?.config).toEqual({
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
})
})
})
describe('Complete Multi-Step Upgrade Path', () => {
const fullUpgradeConfig = {
lastUpdated: '2025-01-05T00:00:00Z',
versions: {
'1.7.5': {
minCompatibleVersion: '1.0.0',
description: 'Last v1.x stable',
channels: {
latest: {
version: '1.7.5',
feedUrls: {
github: 'https://github.com/test/v1.7.5',
gitcode: 'https://gitcode.com/test/v1.7.5'
}
},
rc: null,
beta: null
}
},
'2.0.0': {
minCompatibleVersion: '1.7.0',
description: 'First v2.x - intermediate version',
channels: {
latest: {
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
},
rc: null,
beta: null
}
},
'2.1.6': {
minCompatibleVersion: '2.0.0',
description: 'Current v2.x stable',
channels: {
latest: {
version: '2.1.6',
feedUrls: {
github: 'https://github.com/test/latest',
gitcode: 'https://gitcode.com/test/latest'
}
},
rc: null,
beta: null
}
}
}
}
it('should upgrade from 1.6.3 to 1.7.5 (step 1)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig)
expect(result?.config).toEqual({
version: '1.7.5',
feedUrls: {
github: 'https://github.com/test/v1.7.5',
gitcode: 'https://gitcode.com/test/v1.7.5'
}
})
})
it('should upgrade from 1.7.5 to 2.0.0 (step 2)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig)
expect(result?.config).toEqual({
version: '2.0.0',
feedUrls: {
github: 'https://github.com/test/v2.0.0',
gitcode: 'https://gitcode.com/test/v2.0.0'
}
})
})
it('should upgrade from 2.0.0 to 2.1.6 (step 3)', () => {
const result = (appUpdater as any)._findCompatibleChannel('2.0.0', 'latest', fullUpgradeConfig)
expect(result?.config).toEqual({
version: '2.1.6',
feedUrls: {
github: 'https://github.com/test/latest',
gitcode: 'https://gitcode.com/test/latest'
}
})
})
it('should complete full upgrade path: 1.6.3 -> 1.7.5 -> 2.0.0 -> 2.1.6', () => {
// Step 1: 1.6.3 -> 1.7.5
let currentVersion = '1.6.3'
let result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('1.7.5')
// Step 2: 1.7.5 -> 2.0.0
currentVersion = result?.config.version!
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('2.0.0')
// Step 3: 2.0.0 -> 2.1.6
currentVersion = result?.config.version!
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('2.1.6')
// Final: 2.1.6 is the latest, no more upgrades
currentVersion = result?.config.version!
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
expect(result?.config.version).toBe('2.1.6')
})
it('should block direct upgrade from 1.6.3 to 2.0.0 (skip intermediate)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig)
// Should return 1.7.5, not 2.0.0, because 1.6.3 < 1.7.0 (minCompatibleVersion of 2.0.0)
expect(result?.config.version).toBe('1.7.5')
expect(result?.config.version).not.toBe('2.0.0')
})
it('should block direct upgrade from 1.7.5 to 2.1.6 (skip intermediate)', () => {
const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig)
// Should return 2.0.0, not 2.1.6, because 1.7.5 < 2.0.0 (minCompatibleVersion of 2.1.6)
expect(result?.config.version).toBe('2.0.0')
expect(result?.config.version).not.toBe('2.1.6')
})
})
})

View File

@@ -36,14 +36,7 @@ export abstract class BaseService {
protected static db: LibSQLDatabase<typeof schema> | null = null
protected static isInitialized = false
protected static initializationPromise: Promise<void> | null = null
protected jsonFields: string[] = [
'tools',
'mcps',
'configuration',
'accessible_paths',
'allowed_tools',
'slash_commands'
]
protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
/**
* Initialize database with retry logic and proper error handling

View File

@@ -22,7 +22,6 @@ export const sessionsTable = sqliteTable('sessions', {
mcps: text('mcps'), // JSON array of MCP tool IDs
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
configuration: text('configuration'), // JSON, extensible settings

View File

@@ -1,5 +1,4 @@
import { loggerService } from '@logger'
import type { SlashCommand, UpdateSessionResponse } from '@types'
import type { UpdateSessionResponse } from '@types'
import {
AgentBaseSchema,
type AgentEntity,
@@ -14,10 +13,6 @@ import { and, count, desc, eq, type SQL } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
import type { AgentModelField } from '../errors'
import { pluginService } from '../plugins/PluginService'
import { builtinSlashCommands } from './claudecode/commands'
const logger = loggerService.withContext('SessionService')
export class SessionService extends BaseService {
private static instance: SessionService | null = null
@@ -34,52 +29,6 @@ export class SessionService extends BaseService {
await BaseService.initialize()
}
/**
* Override BaseService.listSlashCommands to merge builtin and plugin commands
*/
async listSlashCommands(agentType: string, agentId?: string): Promise<SlashCommand[]> {
const commands: SlashCommand[] = []
// Add builtin slash commands
if (agentType === 'claude-code') {
commands.push(...builtinSlashCommands)
}
// Add local command plugins from .claude/commands/
if (agentId) {
try {
const installedPlugins = await pluginService.listInstalled(agentId)
// Filter for command type plugins
const commandPlugins = installedPlugins.filter((p) => p.type === 'command')
// Convert plugin metadata to SlashCommand format
for (const plugin of commandPlugins) {
const commandName = plugin.metadata.filename.replace(/\.md$/i, '')
commands.push({
command: `/${commandName}`,
description: plugin.metadata.description
})
}
logger.info('Listed slash commands', {
agentType,
agentId,
builtinCount: builtinSlashCommands.length,
localCount: commandPlugins.length,
totalCount: commands.length
})
} catch (error) {
logger.warn('Failed to list local command plugins', {
agentId,
error: error instanceof Error ? error.message : String(error)
})
}
}
return commands
}
async createSession(
agentId: string,
req: Partial<CreateSessionRequest> = {}
@@ -162,13 +111,7 @@ export class SessionService extends BaseService {
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
// If slash_commands is not in database yet (e.g., first invoke before init message),
// fall back to builtin + local commands. Otherwise, use the merged commands from database.
if (!session.slash_commands || session.slash_commands.length === 0) {
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
}
session.slash_commands = await this.listSlashCommands(session.agent_type)
return session
}

View File

@@ -1,7 +1,7 @@
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
import { describe, expect, it } from 'vitest'
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
const baseStreamMetadata = {
parent_tool_use_id: null,
@@ -10,22 +10,9 @@ const baseStreamMetadata = {
const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}`
describe('stripLocalCommandTags', () => {
it('removes stdout wrapper while preserving inner text', () => {
const input = 'before <local-command-stdout>echo "hi"</local-command-stdout> after'
expect(stripLocalCommandTags(input)).toBe('before echo "hi" after')
})
it('strips multiple stdout/stderr blocks and leaves other content intact', () => {
const input =
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
})
})
describe('Claude → AiSDK transform', () => {
it('handles tool call streaming lifecycle', () => {
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const state = new ClaudeStreamState()
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [
@@ -182,14 +169,14 @@ describe('Claude → AiSDK transform', () => {
(typeof parts)[number],
{ type: 'tool-result' }
>
expect(toolResult.toolCallId).toBe('session-123:tool-1')
expect(toolResult.toolCallId).toBe('tool-1')
expect(toolResult.toolName).toBe('Bash')
expect(toolResult.input).toEqual({ command: 'ls' })
expect(toolResult.output).toBe('ok')
})
it('handles streaming text completion', () => {
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
const state = new ClaudeStreamState()
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
const messages: SDKMessage[] = [

View File

@@ -10,21 +10,8 @@
* Every Claude turn gets its own instance. `resetStep` should be invoked once the finish event has
* been emitted to avoid leaking state into the next turn.
*/
import { loggerService } from '@logger'
import type { FinishReason, LanguageModelUsage, ProviderMetadata } from 'ai'
/**
* Builds a namespaced tool call ID by combining session ID with raw tool call ID.
* This ensures tool calls from different sessions don't conflict even if they have
* the same raw ID from the SDK.
*
* @param sessionId - The agent session ID
* @param rawToolCallId - The raw tool call ID from SDK (e.g., "WebFetch_0")
*/
export function buildNamespacedToolCallId(sessionId: string, rawToolCallId: string): string {
return `${sessionId}:${rawToolCallId}`
}
/**
* Shared fields for every block that Claude can stream (text, reasoning, tool).
*/
@@ -47,7 +34,6 @@ type ReasoningBlockState = BaseBlockState & {
type ToolBlockState = BaseBlockState & {
kind: 'tool'
toolCallId: string
rawToolCallId: string
toolName: string
inputBuffer: string
providerMetadata?: ProviderMetadata
@@ -62,17 +48,12 @@ type PendingUsageState = {
}
type PendingToolCall = {
rawToolCallId: string
toolCallId: string
toolName: string
input: unknown
providerMetadata?: ProviderMetadata
}
type ClaudeStreamStateOptions = {
agentSessionId: string
}
/**
* Tracks the lifecycle of Claude streaming blocks (text, thinking, tool calls)
* across individual websocket events. The transformer relies on this class to
@@ -80,20 +61,12 @@ type ClaudeStreamStateOptions = {
* usage/finish metadata once Anthropic closes a message.
*/
export class ClaudeStreamState {
private logger
private readonly agentSessionId: string
private blocksByIndex = new Map<number, BlockState>()
private toolIndexByNamespacedId = new Map<string, number>()
private toolIndexById = new Map<string, number>()
private pendingUsage: PendingUsageState = {}
private pendingToolCalls = new Map<string, PendingToolCall>()
private stepActive = false
constructor(options: ClaudeStreamStateOptions) {
this.logger = loggerService.withContext('ClaudeStreamState')
this.agentSessionId = options.agentSessionId
this.logger.silly('ClaudeStreamState', options)
}
/** Marks the beginning of a new AiSDK step. */
beginStep(): void {
this.stepActive = true
@@ -131,21 +104,19 @@ export class ClaudeStreamState {
/** Caches tool metadata so subsequent input deltas and results can find it. */
openToolBlock(
index: number,
params: { rawToolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
params: { toolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
): ToolBlockState {
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, params.rawToolCallId)
const block: ToolBlockState = {
kind: 'tool',
id: toolCallId,
id: params.toolCallId,
index,
toolCallId,
rawToolCallId: params.rawToolCallId,
toolCallId: params.toolCallId,
toolName: params.toolName,
inputBuffer: '',
providerMetadata: params.providerMetadata
}
this.blocksByIndex.set(index, block)
this.toolIndexByNamespacedId.set(toolCallId, index)
this.toolIndexById.set(params.toolCallId, index)
return block
}
@@ -154,17 +125,13 @@ export class ClaudeStreamState {
}
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
const index = this.toolIndexByNamespacedId.get(toolCallId)
const index = this.toolIndexById.get(toolCallId)
if (index === undefined) return undefined
const block = this.blocksByIndex.get(index)
if (!block || block.kind !== 'tool') return undefined
return block
}
getToolBlockByRawId(rawToolCallId: string): ToolBlockState | undefined {
return this.getToolBlockById(buildNamespacedToolCallId(this.agentSessionId, rawToolCallId))
}
/** Appends streamed text to a text block, returning the updated state when present. */
appendTextDelta(index: number, text: string): TextBlockState | undefined {
const block = this.blocksByIndex.get(index)
@@ -191,12 +158,10 @@ export class ClaudeStreamState {
/** Records a tool call to be consumed once its result arrives from the user. */
registerToolCall(
rawToolCallId: string,
toolCallId: string,
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
): void {
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
this.pendingToolCalls.set(rawToolCallId, {
rawToolCallId,
this.pendingToolCalls.set(toolCallId, {
toolCallId,
toolName: payload.toolName,
input: payload.input,
@@ -205,10 +170,10 @@ export class ClaudeStreamState {
}
/** Retrieves and clears the buffered tool call metadata for the given id. */
consumePendingToolCall(rawToolCallId: string): PendingToolCall | undefined {
const entry = this.pendingToolCalls.get(rawToolCallId)
consumePendingToolCall(toolCallId: string): PendingToolCall | undefined {
const entry = this.pendingToolCalls.get(toolCallId)
if (entry) {
this.pendingToolCalls.delete(rawToolCallId)
this.pendingToolCalls.delete(toolCallId)
}
return entry
}
@@ -218,12 +183,12 @@ export class ClaudeStreamState {
* completion so that downstream tool results can reference the original call.
*/
completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void {
const block = this.getToolBlockByRawId(toolCallId)
this.registerToolCall(toolCallId, {
toolName: block?.toolName ?? 'unknown',
toolName: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
input,
providerMetadata
})
const block = this.getToolBlockById(toolCallId)
if (block) {
block.resolvedInput = input
}
@@ -235,7 +200,7 @@ export class ClaudeStreamState {
if (!block) return undefined
this.blocksByIndex.delete(index)
if (block.kind === 'tool') {
this.toolIndexByNamespacedId.delete(block.toolCallId)
this.toolIndexById.delete(block.toolCallId)
}
return block
}
@@ -262,7 +227,7 @@ export class ClaudeStreamState {
/** Drops cached block metadata for the currently active message. */
resetBlocks(): void {
this.blocksByIndex.clear()
this.toolIndexByNamespacedId.clear()
this.toolIndexById.clear()
}
/** Resets the entire step lifecycle after emitting a terminal frame. */
@@ -271,10 +236,6 @@ export class ClaudeStreamState {
this.resetPendingUsage()
this.stepActive = false
}
getNamespacedToolCallId(rawToolCallId: string): string {
return buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
}
}
export type { PendingToolCall }

View File

@@ -1,12 +1,25 @@
import type { SlashCommand } from '@types'
export const builtinSlashCommands: SlashCommand[] = [
{ command: '/add-dir', description: 'Add additional working directories' },
{ command: '/agents', description: 'Manage custom AI subagents for specialized tasks' },
{ command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' },
{ command: '/clear', description: 'Clear conversation history' },
{ command: '/compact', description: 'Compact conversation with optional focus instructions' },
{ command: '/context', description: 'Visualize current context usage as a colored grid' },
{
command: '/cost',
description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)'
},
{ command: '/todos', description: 'List current todo items' }
{ command: '/config', description: 'View/modify configuration' },
{ command: '/cost', description: 'Show token usage statistics' },
{ command: '/doctor', description: 'Checks the health of your Claude Code installation' },
{ command: '/help', description: 'Get usage help' },
{ command: '/init', description: 'Initialize project with CLAUDE.md guide' },
{ command: '/login', description: 'Switch Anthropic accounts' },
{ command: '/logout', description: 'Sign out from your Anthropic account' },
{ command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' },
{ command: '/memory', description: 'Edit CLAUDE.md memory files' },
{ command: '/model', description: 'Select or change the AI model' },
{ command: '/permissions', description: 'View or update permissions' },
{ command: '/pr_comments', description: 'View pull request comments' },
{ command: '/review', description: 'Request code review' },
{ command: '/status', description: 'View account and system statuses' },
{ command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' },
{ command: '/vim', description: 'Enter vim mode for alternating insert and command modes' }
]

View File

@@ -12,8 +12,6 @@ import { app } from 'electron'
import type { GetAgentSessionResponse } from '../..'
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
import { sessionService } from '../SessionService'
import { buildNamespacedToolCallId } from './claude-stream-state'
import { promptForToolApproval } from './tool-permissions'
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
@@ -21,7 +19,6 @@ const require_ = createRequire(import.meta.url)
const logger = loggerService.withContext('ClaudeCodeService')
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
const NO_RESUME_COMMANDS = ['/clear']
type UserInputMessage = {
type: 'user'
@@ -151,10 +148,7 @@ class ClaudeCodeService implements AgentServiceInterface {
return { behavior: 'allow', updatedInput: input }
}
return promptForToolApproval(toolName, input, {
...options,
toolCallId: buildNamespacedToolCallId(session.id, options.toolUseID)
})
return promptForToolApproval(toolName, input, options)
}
// Build SDK options from parameters
@@ -203,7 +197,7 @@ class ClaudeCodeService implements AgentServiceInterface {
options.strictMcpConfig = true
}
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
if (lastAgentSessionId) {
options.resume = lastAgentSessionId
// TODO: use fork session when we support branching sessions
// options.forkSession = true
@@ -226,15 +220,7 @@ class ClaudeCodeService implements AgentServiceInterface {
// Start async processing on the next tick so listeners can subscribe first
setImmediate(() => {
this.processSDKQuery(
userInputStream,
closeUserStream,
options,
aiStream,
errorChunks,
session.agent_id,
session.id
).catch((error) => {
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
logger.error('Unhandled Claude Code stream error', {
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
})
@@ -343,14 +329,12 @@ class ClaudeCodeService implements AgentServiceInterface {
closePromptStream: () => void,
options: Options,
stream: ClaudeCodeStream,
errorChunks: string[],
agentId: string,
sessionId: string
errorChunks: string[]
): Promise<void> {
const jsonOutput: SDKMessage[] = []
let hasCompleted = false
const startTime = Date.now()
const streamState = new ClaudeStreamState({ agentSessionId: sessionId })
const streamState = new ClaudeStreamState()
try {
for await (const message of query({ prompt: promptStream, options })) {
@@ -358,62 +342,6 @@ class ClaudeCodeService implements AgentServiceInterface {
jsonOutput.push(message)
// Handle init message - merge builtin and SDK slash_commands
if (message.type === 'system' && message.subtype === 'init') {
const sdkSlashCommands = message.slash_commands || []
logger.info('Received init message with slash commands', {
sessionId,
commands: sdkSlashCommands
})
try {
// Get builtin + local slash commands from BaseService
const existingCommands = await sessionService.listSlashCommands('claude-code', agentId)
// Convert SDK slash_commands (string[]) to SlashCommand[] format
// Ensure all commands start with '/'
const sdkCommands = sdkSlashCommands.map((cmd) => {
const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}`
return {
command: normalizedCmd,
description: undefined
}
})
// Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name
const commandMap = new Map<string, { command: string; description?: string }>()
for (const cmd of existingCommands) {
commandMap.set(cmd.command, cmd)
}
for (const cmd of sdkCommands) {
if (!commandMap.has(cmd.command)) {
commandMap.set(cmd.command, cmd)
}
}
const mergedCommands = Array.from(commandMap.values())
// Update session in database
await sessionService.updateSession(agentId, sessionId, {
slash_commands: mergedCommands
})
logger.info('Updated session with merged slash commands', {
sessionId,
existingCount: existingCommands.length,
sdkCount: sdkCommands.length,
totalCount: mergedCommands.length
})
} catch (error) {
logger.error('Failed to update session slash_commands', {
sessionId,
error: error instanceof Error ? error.message : String(error)
})
}
}
if (message.type === 'assistant' || message.type === 'user') {
logger.silly('claude response', {
message,
@@ -450,6 +378,7 @@ class ClaudeCodeService implements AgentServiceInterface {
}
}
hasCompleted = true
const duration = Date.now() - startTime
logger.debug('SDK query completed successfully', {

View File

@@ -37,7 +37,6 @@ type RendererPermissionRequestPayload = {
requestId: string
toolName: string
toolId: string
toolCallId: string
description?: string
requiresPermissions: boolean
input: Record<string, unknown>
@@ -207,19 +206,10 @@ const ensureIpcHandlersRegistered = () => {
})
}
type PromptForToolApprovalOptions = {
signal: AbortSignal
suggestions?: PermissionUpdate[]
// NOTICE: This ID is namespaced with session ID, not the raw SDK tool call ID.
// Format: `${sessionId}:${rawToolCallId}`, e.g., `session_123:WebFetch_0`
toolCallId: string
}
export async function promptForToolApproval(
toolName: string,
input: Record<string, unknown>,
options: PromptForToolApprovalOptions
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
): Promise<PermissionResult> {
if (shouldAutoApproveTools) {
logger.debug('promptForToolApproval auto-approving tool for test', {
@@ -255,7 +245,6 @@ export async function promptForToolApproval(
logger.info('Requesting user approval for tool usage', {
requestId,
toolName,
toolCallId: options.toolCallId,
description: toolMetadata?.description
})
@@ -263,7 +252,6 @@ export async function promptForToolApproval(
requestId,
toolName,
toolId: toolMetadata?.id ?? toolName,
toolCallId: options.toolCallId,
description: toolMetadata?.description,
requiresPermissions: toolMetadata?.requirePermissions ?? false,
input: sanitizedInput,
@@ -278,7 +266,6 @@ export async function promptForToolApproval(
logger.debug('Registering tool permission request', {
requestId,
toolName,
toolCallId: options.toolCallId,
requiresPermissions: requestPayload.requiresPermissions,
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
suggestionCount: sanitizedSuggestions.length
@@ -286,11 +273,7 @@ export async function promptForToolApproval(
return new Promise<PermissionResult>((resolve) => {
const timeout = setTimeout(() => {
logger.info('User tool permission request timed out', {
requestId,
toolName,
toolCallId: options.toolCallId
})
logger.info('User tool permission request timed out', { requestId, toolName })
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
}, TOOL_APPROVAL_TIMEOUT_MS)
@@ -304,11 +287,7 @@ export async function promptForToolApproval(
if (options?.signal) {
const abortListener = () => {
logger.info('Tool permission request aborted before user responded', {
requestId,
toolName,
toolCallId: options.toolCallId
})
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
}

View File

@@ -73,21 +73,13 @@ const emptyUsage: LanguageModelUsage = {
*/
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
/**
* Removes any local command stdout/stderr XML wrappers that should never surface to the UI.
*/
export const stripLocalCommandTags = (text: string): string => {
return text.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
}
/**
* Filters out command-* tags from text content to prevent internal command
* messages from appearing in the user-facing UI.
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
*/
const filterCommandTags = (text: string): string => {
const withoutLocalCommandTags = stripLocalCommandTags(text)
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
}
/**
@@ -110,7 +102,6 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
* blocks across calls so that incremental deltas can be correlated correctly.
*/
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
logger.silly('Transforming SDKMessage', { message: sdkMessage })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@@ -144,8 +135,7 @@ function handleAssistantMessage(
const isStreamingActive = state.hasActiveStep()
if (typeof content === 'string') {
const sanitizedContent = stripLocalCommandTags(content)
if (!sanitizedContent) {
if (!content) {
return chunks
}
@@ -167,7 +157,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id: textId,
text: sanitizedContent,
text: content,
providerMetadata
})
chunks.push({
@@ -188,10 +178,7 @@ function handleAssistantMessage(
switch (block.type) {
case 'text':
if (!isStreamingActive) {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
textBlocks.push(block.text)
}
break
case 'tool_use':
@@ -243,10 +230,9 @@ function handleAssistantToolUse(
state: ClaudeStreamState,
chunks: AgentStreamPart[]
): void {
const toolCallId = state.getNamespacedToolCallId(block.id)
chunks.push({
type: 'tool-call',
toolCallId,
toolCallId: block.id,
toolName: block.name,
input: block.input,
providerExecuted: true,
@@ -332,11 +318,10 @@ function handleUserMessage(
if (block.type === 'tool_result') {
const toolResult = block as ToolResultContent
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
if (toolResult.is_error) {
chunks.push({
type: 'tool-error',
toolCallId,
toolCallId: toolResult.tool_use_id,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
error: toolResult.content,
@@ -345,7 +330,7 @@ function handleUserMessage(
} else {
chunks.push({
type: 'tool-result',
toolCallId,
toolCallId: toolResult.tool_use_id,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
output: toolResult.content,
@@ -516,7 +501,7 @@ function handleContentBlockStart(
}
case 'tool_use': {
const block = state.openToolBlock(index, {
rawToolCallId: contentBlock.id,
toolCallId: contentBlock.id,
toolName: contentBlock.name,
providerMetadata
})
@@ -552,10 +537,6 @@ function handleContentBlockDelta(
logger.warn('Received text_delta for unknown block', { index })
return
}
block.text = stripLocalCommandTags(block.text)
if (!block.text) {
break
}
chunks.push({
type: 'text-delta',
id: block.id,

View File

@@ -10,11 +10,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readTextFileWithAutoEncoding } from '../file'
import {
expandNotesPath,
getAllFiles,
getAppConfigDir,
getConfigDir,
getFilesDir,
getFileType,
getNotesDir,
getNotesDirAbsolute,
getTempDir,
isPathInside,
untildify
@@ -244,6 +247,20 @@ describe('file', () => {
})
})
describe('getNotesDir', () => {
it('should return relative path for portability', () => {
const notesDir = getNotesDir()
expect(notesDir).toBe('./Data/Notes')
})
})
describe('getNotesDirAbsolute', () => {
it('should return absolute notes directory path', () => {
const notesDirAbsolute = getNotesDirAbsolute()
expect(notesDirAbsolute).toBe('/mock/userData/Data/Notes')
})
})
describe('getAppConfigDir', () => {
it('should return correct app config directory path', () => {
const appConfigDir = getAppConfigDir('test-app')
@@ -331,6 +348,64 @@ describe('file', () => {
})
})
describe('expandNotesPath', () => {
beforeEach(() => {
// Mock path.isAbsolute
vi.mocked(path.isAbsolute).mockImplementation((p) => {
return p.startsWith('/') || /^[A-Za-z]:/.test(p)
})
// Mock path.resolve
vi.mocked(path.resolve).mockImplementation((...args) => {
const joined = args.join('/')
return joined.startsWith('/') ? joined : `/${joined}`
})
// Mock path.normalize
vi.mocked(path.normalize).mockImplementation((p) => p.replace(/\/+/g, '/'))
})
it('should expand tilde paths to home directory', () => {
const result = expandNotesPath('~/Notes')
expect(result).toBe('/mock/home/Notes')
})
it('should expand relative paths using userData as base', () => {
const result = expandNotesPath('./Notes')
expect(result).toContain('userData')
})
it('should return absolute paths unchanged', () => {
const result = expandNotesPath('/absolute/path/Notes')
expect(result).toBe('/absolute/path/Notes')
})
it('should handle Windows absolute paths', () => {
const result = expandNotesPath('C:\\Users\\Notes')
expect(result).toBe('C:\\Users\\Notes')
})
it('should handle empty string', () => {
const result = expandNotesPath('')
expect(result).toBe('')
})
it('should expand parent directory paths', () => {
const result = expandNotesPath('../Notes')
expect(result).toContain('userData')
})
it('should use custom base path when provided', () => {
const result = expandNotesPath('./Notes', '/custom/base')
expect(result).toContain('/custom/base')
})
it('should handle complex relative paths', () => {
const result = expandNotesPath('../../Notes')
expect(result).toContain('userData')
})
})
describe('isPathInside', () => {
beforeEach(() => {
// Mock path.resolve to simulate path resolution

View File

@@ -38,6 +38,33 @@ export function untildify(pathWithTilde: string) {
return pathWithTilde
}
/**
* Expand relative paths to absolute paths.
* Handles paths starting with ~, ., or ..
* @param pathString - The path to expand
* @param basePath - Optional base path for relative paths (defaults to userData directory)
* @returns Absolute path
*/
export function expandNotesPath(pathString: string, basePath?: string): string {
if (!pathString) {
return pathString
}
// First handle tilde expansion
let expandedPath = untildify(pathString)
// If it's already an absolute path, return it
if (path.isAbsolute(expandedPath)) {
return path.normalize(expandedPath)
}
// For relative paths, resolve against the base path (default to userData)
const base = basePath || app.getPath('userData')
expandedPath = path.resolve(base, expandedPath)
return path.normalize(expandedPath)
}
export async function hasWritePermission(dir: string) {
try {
logger.info(`Checking write permission for ${dir}`)
@@ -156,7 +183,12 @@ export function getNotesDir() {
fs.mkdirSync(notesDir, { recursive: true })
logger.info(`Notes directory created at: ${notesDir}`)
}
return notesDir
// Return relative path for better portability across devices
return './Data/Notes'
}
export function getNotesDirAbsolute() {
return path.join(app.getPath('userData'), 'Data', 'Notes')
}
export function getConfigDir() {

View File

@@ -48,16 +48,6 @@ import type {
} from '../renderer/src/types/plugin'
import type { ActionItem } from '../renderer/src/types/selectionTypes'
type DirectoryListOptions = {
recursive?: boolean
maxDepth?: number
includeHidden?: boolean
includeFiles?: boolean
includeDirectories?: boolean
maxEntries?: number
searchPattern?: string
}
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
if (spanContext) {
const data = { type: 'trace', context: spanContext }
@@ -111,7 +101,6 @@ const api = {
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
getSystemFonts: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts),
mockCrashRenderProcess: () => ipcRenderer.invoke(IpcChannel.APP_CrashRenderProcess),
mac: {
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
@@ -212,8 +201,6 @@ const api = {
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath),
listDirectory: (dirPath: string, options?: DirectoryListOptions) =>
ipcRenderer.invoke(IpcChannel.File_ListDirectory, dirPath, options),
checkFileName: (dirPath: string, fileName: string, isFile: boolean) =>
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),

View File

@@ -30,22 +30,18 @@ export class AiSdkToChunkAdapter {
private onSessionUpdate?: (sessionId: string) => void
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
private hasTextContent = false
private getSessionWasCleared?: () => boolean
constructor(
private onChunk: (chunk: Chunk) => void,
mcpTools: MCPTool[] = [],
accumulate?: boolean,
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void,
getSessionWasCleared?: () => boolean
onSessionUpdate?: (sessionId: string) => void
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
this.enableWebSearch = enableWebSearch || false
this.onSessionUpdate = onSessionUpdate
this.getSessionWasCleared = getSessionWasCleared
}
private markFirstTokenIfNeeded() {
@@ -88,9 +84,8 @@ export class AiSdkToChunkAdapter {
}
this.resetTimingState()
this.responseStartTimestamp = Date.now()
// Reset state at the start of stream
// Reset link converter state at the start of stream
this.isFirstChunk = true
this.hasTextContent = false
try {
while (true) {
@@ -134,8 +129,6 @@ export class AiSdkToChunkAdapter {
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
this.onSessionUpdate?.(agentRawMessage.session_id)
} else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) {
this.onSessionUpdate?.(agentRawMessage.session_id)
}
this.onChunk({
type: ChunkType.RAW,
@@ -150,7 +143,6 @@ export class AiSdkToChunkAdapter {
})
break
case 'text-delta': {
this.hasTextContent = true
const processedText = chunk.text || ''
let finalText: string
@@ -309,25 +301,6 @@ export class AiSdkToChunkAdapter {
}
case 'finish': {
// Check if session was cleared (e.g., /clear command) and no text was output
const sessionCleared = this.getSessionWasCleared?.() ?? false
if (sessionCleared && !this.hasTextContent) {
// Inject a "context cleared" message for the user
const clearMessage = '✨ Context cleared. Starting fresh conversation.'
this.onChunk({
type: ChunkType.TEXT_START
})
this.onChunk({
type: ChunkType.TEXT_DELTA,
text: clearMessage
})
this.onChunk({
type: ChunkType.TEXT_COMPLETE,
text: clearMessage
})
final.text = clearMessage
}
const usage = {
completion_tokens: chunk.totalUsage?.outputTokens || 0,
prompt_tokens: chunk.totalUsage?.inputTokens || 0,

View File

@@ -7,17 +7,16 @@
* 2. 暂时保持接口兼容性
*/
import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway'
import { createExecutor } from '@cherrystudio/ai-core'
import { loggerService } from '@logger'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { gateway, type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
import LegacyAiProvider from './legacy/index'
@@ -440,18 +439,6 @@ export default class ModernAiProvider {
// 代理其他方法到原有实现
public async models() {
if (this.actualProvider.id === SystemProviderIds['ai-gateway']) {
const formatModel = function (models: GatewayLanguageModelEntry[]): Model[] {
return models.map((m) => ({
id: m.id,
name: m.name,
provider: 'gateway',
group: m.id.split('/')[0],
description: m.description ?? undefined
}))
}
return formatModel((await gateway.getAvailableModels()).models)
}
return this.legacyProvider.models()
}

View File

@@ -1,6 +1,5 @@
import { loggerService } from '@logger'
import {
getModelSupportedVerbosity,
isFunctionCallingModel,
isNotSupportTemperatureAndTopP,
isOpenAIModel,
@@ -243,18 +242,12 @@ export abstract class BaseApiClient<
return serviceTierSetting
}
protected getVerbosity(model?: Model): OpenAIVerbosity {
protected getVerbosity(): OpenAIVerbosity {
try {
const state = window.store?.getState()
const verbosity = state?.settings?.openAI?.verbosity
if (verbosity && ['low', 'medium', 'high'].includes(verbosity)) {
// If model is provided, check if the verbosity is supported by the model
if (model) {
const supportedVerbosity = getModelSupportedVerbosity(model)
// Use user's verbosity if supported, otherwise use the first supported option
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
}
return verbosity
}
} catch (error) {

View File

@@ -35,7 +35,6 @@ import {
isSupportedThinkingTokenModel,
isSupportedThinkingTokenQwenModel,
isSupportedThinkingTokenZhipuModel,
isSupportVerbosityModel,
isVisionModel,
MODEL_SUPPORTED_REASONING_EFFORT,
ZHIPU_RESULT_TOKENS
@@ -734,13 +733,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
...modalities,
// groq 有不同的 service tier 配置,不符合 openai 接口类型
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
...(isSupportVerbosityModel(model)
? {
text: {
verbosity: this.getVerbosity(model)
}
}
: {}),
...this.getProviderSpecificParameters(assistant, model),
...reasoningEffort,
...getOpenAIWebSearchParams(model, enableWebSearch),

View File

@@ -48,8 +48,9 @@ export abstract class OpenAIBaseClient<
}
// 仅适用于openai
override getBaseURL(isSupportedAPIVerion: boolean = true): string {
return formatApiHost(this.provider.apiHost, isSupportedAPIVerion)
override getBaseURL(): string {
const host = this.provider.apiHost
return formatApiHost(host)
}
override async generateImage({
@@ -143,11 +144,6 @@ export abstract class OpenAIBaseClient<
}
let apiKeyForSdkInstance = this.apiKey
let baseURLForSdkInstance = this.getBaseURL()
let headersForSdkInstance = {
...this.defaultHeaders(),
...this.provider.extra_headers
}
if (this.provider.id === 'copilot') {
const defaultHeaders = store.getState().copilot.defaultHeaders
@@ -155,11 +151,6 @@ export abstract class OpenAIBaseClient<
// this.provider.apiKey不允许修改
// this.provider.apiKey = token
apiKeyForSdkInstance = token
baseURLForSdkInstance = this.getBaseURL(false)
headersForSdkInstance = {
...headersForSdkInstance,
...COPILOT_DEFAULT_HEADERS
}
}
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
@@ -173,8 +164,12 @@ export abstract class OpenAIBaseClient<
this.sdkInstance = new OpenAI({
dangerouslyAllowBrowser: true,
apiKey: apiKeyForSdkInstance,
baseURL: baseURLForSdkInstance,
defaultHeaders: headersForSdkInstance
baseURL: this.getBaseURL(),
defaultHeaders: {
...this.defaultHeaders(),
...this.provider.extra_headers,
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
}
}) as TSdkInstance
}
return this.sdkInstance

View File

@@ -90,7 +90,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
this.provider = { ...this.provider, apiHost: this.formatApiHost() }
if (this.provider.apiVersion === 'preview' || this.provider.apiVersion === 'v1') {
if (this.provider.apiVersion === 'preview') {
return this
} else {
return this.client
@@ -297,31 +297,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
const content: OpenAI.Responses.ResponseInput = []
response.output.forEach((item) => {
if (item.type !== 'apply_patch_call' && item.type !== 'apply_patch_call_output') {
content.push(item)
} else if (item.type === 'apply_patch_call') {
if (item.operation !== undefined) {
const applyPatchToolCall: OpenAI.Responses.ResponseInputItem.ApplyPatchCall = {
...item,
operation: item.operation
}
content.push(applyPatchToolCall)
} else {
logger.warn('Undefined tool call operation for ApplyPatchToolCall.')
}
} else if (item.type === 'apply_patch_call_output') {
if (item.output !== undefined) {
const applyPatchToolCallOutput: OpenAI.Responses.ResponseInputItem.ApplyPatchCallOutput = {
...item,
output: item.output === null ? undefined : item.output
}
content.push(applyPatchToolCallOutput)
} else {
logger.warn('Undefined tool call operation for ApplyPatchToolCall.')
}
}
})
content.push(...response.output)
return content
}
@@ -520,7 +496,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
...(isSupportVerbosityModel(model)
? {
text: {
verbosity: this.getVerbosity(model)
verbosity: this.getVerbosity()
}
}
: {}),

View File

@@ -1,13 +0,0 @@
import { isClaude45ReasoningModel } from '@renderer/config/models'
import type { Assistant, Model } from '@renderer/types'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
const INTERLEAVED_THINKING_HEADER = 'interleaved-thinking-2025-05-14'
export function addAnthropicHeaders(assistant: Assistant, model: Model): string[] {
const anthropicHeaders: string[] = []
if (isClaude45ReasoningModel(model) && isToolUseModeFunction(assistant)) {
anthropicHeaders.push(INTERLEAVED_THINKING_HEADER)
}
return anthropicHeaders
}

View File

@@ -7,12 +7,10 @@ import { anthropic } from '@ai-sdk/anthropic'
import { google } from '@ai-sdk/google'
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
import { vertex } from '@ai-sdk/google-vertex/edge'
import { combineHeaders } from '@ai-sdk/provider-utils'
import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas'
import { loggerService } from '@logger'
import {
isAnthropicModel,
isGenerateImageModel,
isOpenRouterBuiltInWebSearchModel,
isReasoningModel,
@@ -21,8 +19,6 @@ import {
isSupportedThinkingTokenModel,
isWebSearchModel
} from '@renderer/config/models'
import { isAwsBedrockProvider } from '@renderer/config/providers'
import { isVertexProvider } from '@renderer/hooks/useVertexAI'
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
import store from '@renderer/store'
import type { CherryWebSearchConfig } from '@renderer/store/websearch'
@@ -38,7 +34,6 @@ import { setupToolsConfig } from '../utils/mcp'
import { buildProviderOptions } from '../utils/options'
import { getAnthropicThinkingBudget } from '../utils/reasoning'
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
import { addAnthropicHeaders } from './header'
import { supportsTopP } from './modelCapabilities'
import { getTemperature, getTopP } from './modelParameters'
@@ -177,21 +172,13 @@ export async function buildStreamTextParams(
}
}
let headers: Record<string, string | undefined> = options.requestOptions?.headers ?? {}
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
if (!isVertexProvider(provider) && !isAwsBedrockProvider(provider) && isAnthropicModel(model)) {
const newBetaHeaders = { 'anthropic-beta': addAnthropicHeaders(assistant, model).join(',') }
headers = combineHeaders(headers, newBetaHeaders)
}
// 构建基础参数
const params: StreamTextParams = {
messages: sdkMessages,
maxOutputTokens: maxTokens,
temperature: getTemperature(assistant, model),
abortSignal: options.requestOptions?.signal,
headers,
headers: options.requestOptions?.headers,
providerOptions,
stopWhen: stepCountIs(20),
maxRetries: 0

View File

@@ -84,8 +84,6 @@ export async function createAiSdkProvider(config) {
config.providerId = `${config.providerId}-chat`
} else if (config.providerId === 'azure' && config.options?.mode === 'responses') {
config.providerId = `${config.providerId}-responses`
} else if (config.providerId === 'cherryin' && config.options?.mode === 'chat') {
config.providerId = 'cherryin-chat'
}
localProvider = await createProviderCore(config.providerId, config.options)

View File

@@ -171,7 +171,7 @@ export function providerToAiSdkConfig(
extraOptions.endpoint = endpoint
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
extraOptions.mode = 'responses'
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
} else if (aiSdkProviderId === 'openai') {
extraOptions.mode = 'chat'
}
@@ -189,11 +189,9 @@ export function providerToAiSdkConfig(
}
}
// azure
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
// extraOptions.apiVersion = actualProvider.apiVersion === 'preview' ? 'v1' : actualProvider.apiVersion 默认使用v1不使用azure endpoint
if (actualProvider.apiVersion === 'preview' || actualProvider.apiVersion === 'v1') {
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1不使用azure endpoint
if (actualProvider.apiVersion === 'preview') {
extraOptions.mode = 'responses'
} else {
extraOptions.mode = 'chat'

View File

@@ -71,21 +71,6 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
creatorFunctionName: 'createHuggingFace',
supportsImageGeneration: true,
aliases: ['hf', 'hugging-face']
},
{
id: 'ai-gateway',
name: 'AI Gateway',
import: () => import('@ai-sdk/gateway'),
creatorFunctionName: 'createGateway',
supportsImageGeneration: true,
aliases: ['gateway']
},
{
id: 'cerebras',
name: 'Cerebras',
import: () => import('@ai-sdk/cerebras'),
creatorFunctionName: 'createCerebras',
supportsImageGeneration: false
}
] as const

View File

@@ -1,12 +1,5 @@
import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-core/provider'
import { loggerService } from '@logger'
import {
getModelSupportedVerbosity,
isOpenAIModel,
isQwenMTModel,
isSupportFlexServiceTierModel,
isSupportVerbosityModel
} from '@renderer/config/models'
import { isOpenAIModel, isQwenMTModel, isSupportFlexServiceTierModel } from '@renderer/config/models'
import { isSupportServiceTierProvider } from '@renderer/config/providers'
import { mapLanguageToQwenMTModel } from '@renderer/config/translate'
import type { Assistant, Model, Provider } from '@renderer/types'
@@ -33,8 +26,6 @@ import {
} from './reasoning'
import { getWebSearchParams } from './websearch'
const logger = loggerService.withContext('aiCore.utils.options')
// copy from BaseApiClient.ts
const getServiceTier = (model: Model, provider: Provider) => {
const serviceTierSetting = provider.serviceTier
@@ -79,7 +70,6 @@ export function buildProviderOptions(
enableGenerateImage: boolean
}
): Record<string, any> {
logger.debug('buildProviderOptions', { assistant, model, actualProvider, capabilities })
const rawProviderId = getAiSdkProviderId(actualProvider)
// 构建 provider 特定的选项
let providerSpecificOptions: Record<string, any> = {}
@@ -99,6 +89,9 @@ export function buildProviderOptions(
serviceTier: serviceTierSetting
}
break
case 'huggingface':
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
break
case 'anthropic':
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
break
@@ -120,9 +113,6 @@ export function buildProviderOptions(
}
break
}
case 'cherryin':
providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider)
break
default:
throw new Error(`Unsupported base provider ${baseProviderId}`)
}
@@ -141,9 +131,6 @@ export function buildProviderOptions(
case 'bedrock':
providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities)
break
case 'huggingface':
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
break
default:
// 对于其他 provider使用通用的构建逻辑
providerSpecificOptions = {
@@ -161,18 +148,13 @@ export function buildProviderOptions(
...providerSpecificOptions,
...getCustomParameters(assistant)
}
let rawProviderKey =
// vertex需要映射到google或anthropic
const rawProviderKey =
{
'google-vertex': 'google',
'google-vertex-anthropic': 'anthropic',
'ai-gateway': 'gateway'
'google-vertex-anthropic': 'anthropic'
}[rawProviderId] || rawProviderId
if (rawProviderKey === 'cherryin') {
rawProviderKey = { gemini: 'google' }[actualProvider.type] || actualProvider.type
}
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
return {
[rawProviderKey]: providerSpecificOptions
@@ -201,23 +183,6 @@ function buildOpenAIProviderOptions(
...reasoningParams
}
}
if (isSupportVerbosityModel(model)) {
const state = window.store?.getState()
const userVerbosity = state?.settings?.openAI?.verbosity
if (userVerbosity && ['low', 'medium', 'high'].includes(userVerbosity)) {
const supportedVerbosity = getModelSupportedVerbosity(model)
// Use user's verbosity if supported, otherwise use the first supported option
const verbosity = supportedVerbosity.includes(userVerbosity) ? userVerbosity : supportedVerbosity[0]
providerOptions = {
...providerOptions,
textVerbosity: verbosity
}
}
}
return providerOptions
}
@@ -305,34 +270,6 @@ function buildXAIProviderOptions(
return providerOptions
}
function buildCherryInProviderOptions(
assistant: Assistant,
model: Model,
capabilities: {
enableReasoning: boolean
enableWebSearch: boolean
enableGenerateImage: boolean
},
actualProvider: Provider
): Record<string, any> {
const serviceTierSetting = getServiceTier(model, actualProvider)
switch (actualProvider.type) {
case 'openai':
return {
...buildOpenAIProviderOptions(assistant, model, capabilities),
serviceTier: serviceTierSetting
}
case 'anthropic':
return buildAnthropicProviderOptions(assistant, model, capabilities)
case 'gemini':
return buildGeminiProviderOptions(assistant, model, capabilities)
}
return {}
}
/**
* Build Bedrock providerOptions
*/

View File

@@ -1,7 +1,3 @@
import type { BedrockProviderOptions } from '@ai-sdk/amazon-bedrock'
import type { AnthropicProviderOptions } from '@ai-sdk/anthropic'
import type { GoogleGenerativeAIProviderOptions } from '@ai-sdk/google'
import type { XaiProviderOptions } from '@ai-sdk/xai'
import { loggerService } from '@logger'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
@@ -11,7 +7,6 @@ import {
isDeepSeekHybridInferenceModel,
isDoubaoSeedAfter251015,
isDoubaoThinkingAutoModel,
isGPT51SeriesModel,
isGrok4FastReasoningModel,
isGrokReasoningModel,
isOpenAIDeepResearchModel,
@@ -61,20 +56,13 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
}
const reasoningEffort = assistant?.settings?.reasoning_effort
// Handle undefined and 'none' reasoningEffort.
// TODO: They should be separated.
if (!reasoningEffort || reasoningEffort === 'none') {
if (!reasoningEffort) {
// openrouter: use reasoning
if (model.provider === SystemProviderIds.openrouter) {
// Don't disable reasoning for Gemini models that support thinking tokens
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
return {}
}
// 'none' is not an available value for effort for now.
// I think they should resolve this issue soon, so I'll just go ahead and use this value.
if (isGPT51SeriesModel(model) && reasoningEffort === 'none') {
return { reasoning: { effort: 'none' } }
}
// Don't disable reasoning for models that require it
if (
isGrokReasoningModel(model) ||
@@ -121,21 +109,9 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// use thinking, doubao, zhipu, etc.
if (isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenZhipuModel(model)) {
if (provider.id === SystemProviderIds.cerebras) {
return {
disable_reasoning: true
}
}
return { thinking: { type: 'disabled' } }
}
// Specially for GPT-5.1. Suppose this is a OpenAI Compatible provider
if (isGPT51SeriesModel(model) && reasoningEffort === 'none') {
return {
reasoningEffort: 'none'
}
}
return {}
}
@@ -330,9 +306,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return {}
}
if (isSupportedThinkingTokenZhipuModel(model)) {
if (provider.id === SystemProviderIds.cerebras) {
return {}
}
return { thinking: { type: 'enabled' } }
}
@@ -390,7 +363,7 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number {
const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (reasoningEffort === undefined || reasoningEffort === 'none') {
if (reasoningEffort === undefined) {
return 0
}
const effortRatio = EFFORT_RATIO[reasoningEffort]
@@ -412,17 +385,14 @@ export function getAnthropicThinkingBudget(assistant: Assistant, model: Model):
* 获取 Anthropic 推理参数
* 从 AnthropicAPIClient 中提取的逻辑
*/
export function getAnthropicReasoningParams(
assistant: Assistant,
model: Model
): Pick<AnthropicProviderOptions, 'thinking'> {
export function getAnthropicReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isReasoningModel(model)) {
return {}
}
const reasoningEffort = assistant?.settings?.reasoning_effort
if (reasoningEffort === undefined || reasoningEffort === 'none') {
if (reasoningEffort === undefined) {
return {
thinking: {
type: 'disabled'
@@ -451,10 +421,7 @@ export function getAnthropicReasoningParams(
* 注意Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递
* 而在 Google 官方提供的 OpenAI 兼容端点中则使用蛇形命名法 thinking_budget
*/
export function getGeminiReasoningParams(
assistant: Assistant,
model: Model
): Pick<GoogleGenerativeAIProviderOptions, 'thinkingConfig'> {
export function getGeminiReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isReasoningModel(model)) {
return {}
}
@@ -463,7 +430,7 @@ export function getGeminiReasoningParams(
// Gemini 推理参数
if (isSupportedThinkingTokenGeminiModel(model)) {
if (reasoningEffort === undefined || reasoningEffort === 'none') {
if (reasoningEffort === undefined) {
return {
thinkingConfig: {
includeThoughts: false,
@@ -503,35 +470,27 @@ export function getGeminiReasoningParams(
* @param model - The model being used
* @returns XAI-specific reasoning parameters
*/
export function getXAIReasoningParams(assistant: Assistant, model: Model): Pick<XaiProviderOptions, 'reasoningEffort'> {
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isSupportedReasoningEffortGrokModel(model)) {
return {}
}
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
if (!reasoningEffort || reasoningEffort === 'none') {
if (!reasoningEffort) {
return {}
}
switch (reasoningEffort) {
case 'auto':
case 'minimal':
case 'medium':
return { reasoningEffort: 'low' }
case 'low':
case 'high':
return { reasoningEffort }
// For XAI provider Grok models, use reasoningEffort parameter directly
return {
reasoningEffort
}
}
/**
* Get Bedrock reasoning parameters
*/
export function getBedrockReasoningParams(
assistant: Assistant,
model: Model
): Pick<BedrockProviderOptions, 'reasoningConfig'> {
export function getBedrockReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isReasoningModel(model)) {
return {}
}
@@ -542,14 +501,6 @@ export function getBedrockReasoningParams(
return {}
}
if (reasoningEffort === 'none') {
return {
reasoningConfig: {
type: 'disabled'
}
}
}
// Only apply thinking budget for Claude reasoning models
if (!isSupportedThinkingTokenClaudeModel(model)) {
return {}

View File

@@ -107,11 +107,6 @@ export function buildProviderBuiltinWebSearchConfig(
}
}
}
case 'cherryin': {
const _providerId =
{ 'openai-response': 'openai', openai: 'openai-chat' }[model?.endpoint_type ?? ''] ?? model?.endpoint_type
return buildProviderBuiltinWebSearchConfig(_providerId, webSearchConfig, model)
}
default: {
return {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Vercel</title><path d="M12 0l12 20.785H0L12 0z"></path></svg>

Before

Width:  |  Height:  |  Size: 225 B

View File

@@ -1,6 +1,5 @@
import { ActionIconButton } from '@renderer/components/Buttons'
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { scrollElementIntoView } from '@renderer/utils'
import { Tooltip } from 'antd'
import { debounce } from 'lodash'
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
@@ -182,14 +181,17 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
// 3. 将当前项滚动到视图中
// 获取第一个文本节点的父元素来进行滚动
const parentElement = currentMatchRange.startContainer.parentElement
if (shouldScroll && parentElement) {
// 优先在指定的滚动容器内滚动,避免滚动整个页面导致索引错乱/看起来"跳到第一条"
scrollElementIntoView(parentElement, target)
if (shouldScroll) {
parentElement?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
})
}
}
}
},
[allRanges, currentIndex, target]
[allRanges, currentIndex]
)
const search = useCallback(

View File

@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { TopView } from '@renderer/components/TopView'
import { permissionModeCards } from '@renderer/config/agent'
@@ -8,6 +9,7 @@ import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAg
import type {
AddAgentForm,
AgentEntity,
AgentType,
ApiModel,
BaseAgentForm,
PermissionMode,
@@ -15,22 +17,30 @@ import type {
UpdateAgentForm
} from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { Button, Input, Modal, Select } from 'antd'
import { Avatar, Button, Input, Modal, Select } from 'antd'
import { AlertTriangleIcon } from 'lucide-react'
import type { ChangeEvent, FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { BaseOption } from './shared'
const { TextArea } = Input
const logger = loggerService.withContext('AddAgentPopup')
interface AgentTypeOption extends BaseOption {
type: 'type'
key: AgentEntity['type']
name: AgentEntity['name']
}
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
type: existing?.type ?? 'claude-code',
name: existing?.name ?? 'Agent',
name: existing?.name ?? 'Claude Code',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? '',
@@ -90,6 +100,54 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
})
}, [])
// add supported agents type here.
const agentConfig = useMemo(
() =>
[
{
type: 'type',
key: 'claude-code',
label: 'Claude Code',
name: 'Claude Code',
avatar: ClaudeIcon
}
] as const satisfies AgentTypeOption[],
[]
)
const agentOptions = useMemo(
() =>
agentConfig.map((option) => ({
value: option.key,
label: (
<OptionWrapper>
<Avatar src={option.avatar} size={24} />
<span>{option.label}</span>
</OptionWrapper>
)
})),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(value: AgentType) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: value,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({
...prev,
@@ -97,12 +155,12 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
}))
}, [])
// const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
// setForm((prev) => ({
// ...prev,
// description: e.target.value
// }))
// }, [])
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
...prev,
description: e.target.value
}))
}, [])
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
@@ -276,6 +334,16 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<StyledForm onSubmit={onSubmit}>
<FormContent>
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>{t('agent.type.label')}</Label>
<Select
value={form.type}
onChange={onAgentTypeChange}
options={agentOptions}
disabled={isEditing(agent)}
style={{ width: '100%' }}
/>
</FormItem>
<FormItem style={{ flex: 1 }}>
<Label>
{t('common.name')} <RequiredMark>*</RequiredMark>
@@ -295,7 +363,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
avatarSize={24}
iconSize={16}
buttonStyle={{
padding: '3px 8px',
padding: '8px 12px',
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 6,
@@ -314,6 +382,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
onChange={onPermissionModeChange}
style={{ width: '100%' }}
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
dropdownStyle={{ minWidth: '500px' }}
optionLabelProp="label">
{permissionModeCards.map((item) => (
<Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
@@ -369,10 +438,10 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
<TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
</FormItem>
{/* <FormItem>
<FormItem>
<Label>{t('common.description')}</Label>
<TextArea rows={1} value={form.description ?? ''} onChange={onDescChange} />
</FormItem> */}
<TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
</FormItem>
</FormContent>
<FormFooter>
@@ -506,7 +575,14 @@ const FormFooter = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
`
const OptionWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const PermissionOptionWrapper = styled.div`

View File

@@ -1,104 +0,0 @@
import * as tinyPinyin from 'tiny-pinyin'
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
/**
* Default filter function
* Implements standard filtering logic with pinyin support
*/
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
if (!searchText) return true
let filterText = item.filterText || ''
if (typeof item.label === 'string') {
filterText += item.label
}
if (typeof item.description === 'string') {
filterText += item.description
}
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = searchText.toLowerCase()
// Direct substring match
if (lowerFilterText.includes(lowerSearchText)) {
return true
}
// Pinyin fuzzy match for Chinese characters
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try {
let pinyinText = pinyinCache.get(item)
if (!pinyinText) {
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
pinyinCache.set(item, pinyinText)
}
return fuzzyRegex.test(pinyinText)
} catch (error) {
return true
}
} else {
return fuzzyRegex.test(filterText.toLowerCase())
}
}
/**
* Calculate match score for sorting
* Higher score = better match
*/
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
let filterText = item.filterText || ''
if (typeof item.label === 'string') {
filterText += item.label
}
if (typeof item.description === 'string') {
filterText += item.description
}
const lowerFilterText = filterText.toLowerCase()
const lowerSearchText = searchText.toLowerCase()
// Exact match (highest priority)
if (lowerFilterText === lowerSearchText) {
return 1000
}
// Label exact match (very high priority)
if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) {
return 900
}
// Starts with search text (high priority)
if (lowerFilterText.startsWith(lowerSearchText)) {
return 800
}
// Label starts with search text
if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) {
return 700
}
// Contains search text (medium priority)
if (lowerFilterText.includes(lowerSearchText)) {
// Earlier position = higher score
const position = lowerFilterText.indexOf(lowerSearchText)
return 600 - position
}
// Pinyin fuzzy match (lower priority)
return 100
}
/**
* Default sort function
* Sorts items by match score in descending order
*/
export const defaultSortFn: QuickPanelSortFn = (items, searchText) => {
if (!searchText) return items
return [...items].sort((a, b) => {
const scoreA = calculateMatchScore(a, searchText)
const scoreB = calculateMatchScore(b, searchText)
return scoreB - scoreA
})
}

View File

@@ -1,4 +1,3 @@
export * from './defaultStrategies'
export * from './hook'
export * from './provider'
export * from './types'

View File

@@ -4,12 +4,11 @@ import type {
QuickPanelCallBackOptions,
QuickPanelCloseAction,
QuickPanelContextType,
QuickPanelFilterFn,
QuickPanelListItem,
QuickPanelOpenOptions,
QuickPanelSortFn,
QuickPanelTriggerInfo
} from './types'
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
@@ -18,39 +17,19 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const [list, setList] = useState<QuickPanelListItem[]>([])
const [title, setTitle] = useState<string | undefined>()
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
const [defaultIndex, setDefaultIndex] = useState<number>(0)
const [pageSize, setPageSize] = useState<number>(7)
const [multiple, setMultiple] = useState<boolean>(false)
const [manageListExternally, setManageListExternally] = useState<boolean>(false)
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
const [filterFn, setFilterFn] = useState<QuickPanelFilterFn | undefined>()
const [sortFn, setSortFn] = useState<QuickPanelSortFn | undefined>()
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
const [onSearchChange, setOnSearchChange] = useState<((searchText: string) => void) | undefined>()
const [lastCloseAction, setLastCloseAction] = useState<QuickPanelCloseAction | undefined>(undefined)
const clearTimer = useRef<NodeJS.Timeout | null>(null)
// 添加更新item选中状态的方法
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
setList((prevList) => {
// 先尝试引用匹配(快速路径)
const refIndex = prevList.findIndex((item) => item === targetItem)
if (refIndex !== -1) {
return prevList.map((item, idx) => (idx === refIndex ? { ...item, isSelected } : item))
}
// 如果引用匹配失败,使用内容匹配(兜底方案)
// 通过 label 和 filterText 来识别同一个item
return prevList.map((item) => {
const isSameItem =
(item.label === targetItem.label || item.filterText === targetItem.filterText) &&
(!targetItem.filterText || item.filterText === targetItem.filterText)
return isSameItem ? { ...item, isSelected } : item
})
})
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
}, [])
// 添加更新整个列表的方法
@@ -64,23 +43,17 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
clearTimer.current = null
}
setLastCloseAction(undefined)
setTitle(options.title)
setList(options.list)
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
setDefaultIndex(nextDefaultIndex)
setDefaultIndex(options.defaultIndex ?? 0)
setPageSize(options.pageSize ?? 7)
setMultiple(options.multiple ?? false)
setManageListExternally(options.manageListExternally ?? false)
setSymbol(options.symbol)
setTriggerInfo(options.triggerInfo)
setOnClose(() => options.onClose)
setBeforeAction(() => options.beforeAction)
setAfterAction(() => options.afterAction)
setOnSearchChange(() => options.onSearchChange)
setFilterFn(() => options.filterFn)
setSortFn(() => options.sortFn)
setIsVisible(true)
}, [])
@@ -88,8 +61,6 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
const close = useCallback(
(action?: QuickPanelCloseAction, searchText?: string) => {
setIsVisible(false)
setManageListExternally(false)
setLastCloseAction(action)
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
clearTimer.current = setTimeout(() => {
@@ -97,13 +68,9 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
setOnClose(undefined)
setBeforeAction(undefined)
setAfterAction(undefined)
setOnSearchChange(undefined)
setFilterFn(undefined)
setSortFn(undefined)
setTitle(undefined)
setSymbol('')
setTriggerInfo(undefined)
setManageListExternally(false)
}, 200)
},
[onClose]
@@ -133,15 +100,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
manageListExternally,
triggerInfo,
lastCloseAction,
filterFn,
sortFn,
onClose,
beforeAction,
afterAction,
onSearchChange
afterAction
}),
[
open,
@@ -155,15 +117,10 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
defaultIndex,
pageSize,
multiple,
manageListExternally,
triggerInfo,
lastCloseAction,
filterFn,
sortFn,
onClose,
beforeAction,
afterAction,
onSearchChange
afterAction
]
)

View File

@@ -10,8 +10,7 @@ export enum QuickPanelReservedSymbol {
WebSearch = '?',
Mcp = 'mcp',
McpPrompt = 'mcp-prompt',
McpResource = 'mcp-resource',
SlashCommands = 'slash-commands'
McpResource = 'mcp-resource'
}
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
@@ -28,29 +27,6 @@ export type QuickPanelCallBackOptions = {
searchText?: string
}
/**
* Filter function type
* @param item - The item to check
* @param searchText - The search text (without leading symbol)
* @param fuzzyRegex - Fuzzy matching regex
* @param pinyinCache - Cache for pinyin conversions
* @returns true if item matches the search
*/
export type QuickPanelFilterFn = (
item: QuickPanelListItem,
searchText: string,
fuzzyRegex: RegExp,
pinyinCache: WeakMap<QuickPanelListItem, string>
) => boolean
/**
* Sort function type
* @param items - The filtered items to sort
* @param searchText - The search text (without leading symbol)
* @returns sorted items
*/
export type QuickPanelSortFn = (items: QuickPanelListItem[], searchText: string) => QuickPanelListItem[]
export type QuickPanelOpenOptions = {
/** 显示在底部左边类似于Placeholder */
title?: string
@@ -72,14 +48,6 @@ export type QuickPanelOpenOptions = {
beforeAction?: (options: QuickPanelCallBackOptions) => void
afterAction?: (options: QuickPanelCallBackOptions) => void
onClose?: (options: QuickPanelCallBackOptions) => void
/** Callback when search text changes (called with debounced search text) */
onSearchChange?: (searchText: string) => void
/** Tool manages list + collapse behavior externally (skip filtering/auto-close) */
manageListExternally?: boolean
/** Custom filter function for items (follows open-closed principle) */
filterFn?: QuickPanelFilterFn
/** Custom sort function for filtered items (follows open-closed principle) */
sortFn?: QuickPanelSortFn
}
export type QuickPanelListItem = {
@@ -120,15 +88,10 @@ export interface QuickPanelContextType {
readonly pageSize: number
readonly multiple: boolean
readonly triggerInfo?: QuickPanelTriggerInfo
readonly manageListExternally?: boolean
readonly lastCloseAction?: QuickPanelCloseAction
readonly filterFn?: QuickPanelFilterFn
readonly sortFn?: QuickPanelSortFn
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
readonly onSearchChange?: (searchText: string) => void
}
export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none'

View File

@@ -10,8 +10,8 @@ import { debounce } from 'lodash'
import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import * as tinyPinyin from 'tiny-pinyin'
import { defaultFilterFn, defaultSortFn } from './defaultStrategies'
import { QuickPanelContext } from './provider'
import type {
QuickPanelCallBackOptions,
@@ -62,50 +62,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
const searchTextRef = useRef('')
// 缓存:按 item 缓存拼音文本,避免重复转换
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
// 轻量防抖:减少高频输入时的过滤调用
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
// 跟踪上一次的搜索文本和符号用于判断是否需要重置index
const prevSearchTextRef = useRef('')
const prevSymbolRef = useRef('')
const { setTimeoutTimer } = useTimer()
// Use injected filter and sort functions, or fall back to defaults
const filterFn = ctx.filterFn || defaultFilterFn
const sortFn = ctx.sortFn || defaultSortFn
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
const list = useMemo(() => {
if (!ctx.isVisible && !ctx.symbol) return []
const baseList = (ctx.list || []).filter((item) => !item.hidden)
if (ctx.manageListExternally) {
const combinedLength = baseList.length
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
if (isSymbolChanged) {
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
const desiredIndex =
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
setIndex(desiredIndex)
} else {
setIndex((prevIndex) => {
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
return prevIndex
})
}
prevSearchTextRef.current = ''
prevSymbolRef.current = ctx.symbol
return baseList
}
const _searchText = searchText.replace(/^[/@]/, '')
const lowerSearchText = _searchText.toLowerCase()
const fuzzyPattern = lowerSearchText
@@ -115,35 +86,52 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
// 拆分:固定显示项(不参与过滤)与普通项
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
const normalItems = baseList.filter((item) => !item.alwaysVisible)
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
// Filter normal items using injected filter function
const filteredNormalItems = normalItems.filter((item) => {
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
})
if (!_searchText) return true
// Sort filtered items using injected sort function
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
let filterText = item.filterText || ''
if (typeof item.label === 'string') {
filterText += item.label
}
if (typeof item.description === 'string') {
filterText += item.description
}
const lowerFilterText = filterText.toLowerCase()
if (lowerFilterText.includes(lowerSearchText)) {
return true
}
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try {
let pinyinText = pinyinCacheRef.current.get(item)
if (!pinyinText) {
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
pinyinCacheRef.current.set(item, pinyinText)
}
return fuzzyRegex.test(pinyinText)
} catch (error) {
return true
}
} else {
return fuzzyRegex.test(filterText.toLowerCase())
}
})
// 只有在搜索文本变化或面板符号变化时才重置index
const isSearchChanged = prevSearchTextRef.current !== searchText
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
if (isSearchChanged || isSymbolChanged) {
const combinedLength = pinnedItems.length + sortedNormalItems.length
if (isSymbolChanged) {
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
const desiredIndex =
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
setIndex(desiredIndex)
} else {
setIndex(-1) // 搜索文本变化时不默认高亮
}
setIndex(-1) // 不默认高亮任何项,让用户主动选择
} else {
// 如果当前index超出范围调整到有效范围内
setIndex((prevIndex) => {
const combinedLength = pinnedItems.length + sortedNormalItems.length
const combinedLength = pinnedItems.length + filteredNormalItems.length
if (prevIndex >= combinedLength) {
return combinedLength > 0 ? combinedLength - 1 : -1
}
@@ -154,9 +142,10 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
prevSearchTextRef.current = searchText
prevSymbolRef.current = ctx.symbol
// 固定项置顶 + 排序后的普通项
return [...pinnedItems, ...sortedNormalItems]
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
// 固定项置顶 + 过滤后的普通项
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
return pinnedFiltered.filter((item) => !item.hidden)
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
const canForwardAndBackward = useMemo(() => {
return list.some((item) => item.isMenu) || historyPanel.length > 0
@@ -190,64 +179,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (deleteStart >= deleteEnd) return
const activeSearchText = searchTextRef.current ?? ''
// 删除文本
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
setInputText(newText)
setInputText((currentText) => {
const safeText = currentText ?? ''
const expectedSegment = includeSymbol ? symbolSegment : symbolSegment.slice(1)
const typedSearch = activeSearchText
const normalizedTyped = includeSymbol
? typedSearch
: typedSearch.startsWith(symbolSegment[0] ?? '')
? typedSearch.slice(1)
: typedSearch
if (normalizedTyped && expectedSegment !== normalizedTyped) {
return safeText
}
const segmentStart = includeSymbol ? symbolStart : symbolStart + 1
const segmentEnd = segmentStart + expectedSegment.length
if (segmentStart < 0 || segmentStart > safeText.length) {
return safeText
}
if (segmentEnd > safeText.length) {
return safeText
}
const actualSegment = safeText.slice(segmentStart, segmentEnd)
if (actualSegment !== expectedSegment) {
return safeText
}
const clampedDeleteStart = Math.max(0, Math.min(deleteStart, safeText.length))
const clampedDeleteEnd = Math.max(clampedDeleteStart, Math.min(deleteEnd, safeText.length))
if (clampedDeleteStart >= clampedDeleteEnd) {
return safeText
}
const updatedText = safeText.slice(0, clampedDeleteStart) + safeText.slice(clampedDeleteEnd)
if (updatedText === safeText) {
return safeText
}
setTimeoutTimer(
'quickpanel_focus',
() => {
const textareaEl = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
if (!textareaEl) return
textareaEl.focus()
textareaEl.setSelectionRange(clampedDeleteStart, clampedDeleteStart)
},
0
)
return updatedText
})
// 设置光标位置
setTimeoutTimer(
'quickpanel_focus',
() => {
textArea.focus()
textArea.setSelectionRange(deleteStart, deleteStart)
},
0
)
setSearchText('')
},
@@ -267,21 +211,11 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (textArea) {
setInputText(textArea.value)
}
} else if (
action &&
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
ctx.triggerInfo?.type === 'input'
) {
setTimeoutTimer(
'quickpanel_deferred_clear',
() => {
clearSearchText(true)
},
0
)
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
clearSearchText(true)
}
},
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
[ctx, clearSearchText, setInputText, searchText]
)
const handleItemAction = useCallback(
@@ -351,86 +285,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
searchTextRef.current = searchText
}, [searchText])
// Track onSearchChange callback and search state for debouncing
const prevSearchCallbackTextRef = useRef('')
const isFirstSearchRef = useRef(true)
const searchCallbackTimerRef = useRef<NodeJS.Timeout | null>(null)
const onSearchChangeRef = useRef(ctx.onSearchChange)
// Keep onSearchChange ref up to date
useEffect(() => {
onSearchChangeRef.current = ctx.onSearchChange
}, [ctx.onSearchChange])
// Reset search history when panel closes
useEffect(() => {
if (!ctx.isVisible) {
prevSearchCallbackTextRef.current = ''
isFirstSearchRef.current = true
if (searchCallbackTimerRef.current) {
clearTimeout(searchCallbackTimerRef.current)
searchCallbackTimerRef.current = null
}
}
}, [ctx.isVisible])
// Trigger onSearchChange with debounce (called from handleInput)
const triggerSearchChange = useCallback((searchText: string) => {
if (!onSearchChangeRef.current) return
// Clean search text: remove leading symbol (/ or @) and trim
const cleanSearchText = searchText.replace(/^[/@]/, '').trim()
// Don't trigger if search text hasn't changed
if (cleanSearchText === prevSearchCallbackTextRef.current) {
return
}
// Don't trigger callback for empty search text
if (!cleanSearchText) {
prevSearchCallbackTextRef.current = ''
return
}
// Clear previous timer
if (searchCallbackTimerRef.current) {
clearTimeout(searchCallbackTimerRef.current)
}
// First search triggers immediately (0ms), subsequent searches have 300ms debounce
const delay = isFirstSearchRef.current ? 0 : 300
searchCallbackTimerRef.current = setTimeout(() => {
prevSearchCallbackTextRef.current = cleanSearchText
isFirstSearchRef.current = false
onSearchChangeRef.current?.(cleanSearchText)
searchCallbackTimerRef.current = null
}, delay)
}, [])
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (searchCallbackTimerRef.current) {
clearTimeout(searchCallbackTimerRef.current)
searchCallbackTimerRef.current = null
}
}
}, [])
// 获取当前输入的搜索词
const isComposing = useRef(false)
useEffect(() => {
return () => {
setSearchTextDebounced.cancel()
}
}, [setSearchTextDebounced])
useEffect(() => {
if (!ctx.isVisible) return
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
if (!textArea) return
const handleInput = (e: Event) => {
if (isComposing.current) return
@@ -445,8 +305,6 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
if (lastSymbolIndex !== -1) {
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
setSearchTextDebounced(newSearchText)
// Trigger server-side search callback immediately (with its own debounce)
triggerSearchChange(newSearchText)
} else {
// 使用本地 handleClose确保在删除触发符时同步受控输入值
handleClose('delete-symbol')
@@ -470,17 +328,16 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
textArea.removeEventListener('input', handleInput)
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
textArea.removeEventListener('compositionend', handleCompositionEnd)
setSearchTextDebounced.cancel()
setTimeoutTimer(
'quickpanel_clear_search',
() => {
setSearchText('')
},
200
) // 等待面板关闭动画结束后,再清空搜索词
}
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
useEffect(() => {
if (ctx.isVisible) return
const timer = setTimeout(() => {
setSearchText('')
}, 200)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx.isVisible])
useLayoutEffect(() => {
@@ -688,7 +545,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
const collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
const collapsed = hasSearchText && visibleNonPinnedCount === 0
useEffect(() => {
if (!ctx.isVisible) return
if (!collapsed) return
if (ctx.triggerInfo?.type !== 'input') return
if (ctx.multiple) return
const trimmedSearch = searchText.replace(/^[/@]/, '').trim()
if (!trimmedSearch) return
handleClose('no_result')
}, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText])
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
@@ -747,9 +616,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return prev ? prev : true
})
}>
{collapsed ? (
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
) : (
{!collapsed && (
<DynamicVirtualList
ref={listRef}
list={list}
@@ -859,13 +726,6 @@ const QuickPanelBody = styled.div`
}
`
const QuickPanelEmpty = styled.div`
padding: 16px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
`
const QuickPanelFooter = styled.div`
display: flex;
width: 100%;

View File

@@ -81,16 +81,6 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
* Hide the scrollbar automatically when scrolling is stopped
*/
autoHideScrollbar?: boolean
/**
* Header content to display above the list
*/
header?: React.ReactNode
/**
* Additional CSS class name for the container
*/
className?: string
}
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
@@ -105,8 +95,6 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
itemContainerStyle,
scrollerStyle,
autoHideScrollbar = false,
header,
className,
...restOptions
} = props
@@ -201,7 +189,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
return (
<ScrollContainer
ref={scrollerRef}
className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'}
className="dynamic-virtual-list"
role="region"
aria-label="Dynamic Virtual List"
aria-hidden={!showScrollbar}
@@ -212,7 +200,6 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
...scrollerStyle
}}>
{header}
<div
style={{
position: 'relative',

View File

@@ -1,12 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
isDoubaoSeedAfter251015,
isDoubaoThinkingAutoModel,
isGeminiReasoningModel,
isLingReasoningModel,
isSupportedThinkingTokenGeminiModel
} from '../models/reasoning'
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isLingReasoningModel } from '../models/reasoning'
vi.mock('@renderer/store', () => ({
default: {
@@ -237,284 +231,3 @@ describe('Ling Models', () => {
})
})
})
describe('Gemini Models', () => {
describe('isSupportedThinkingTokenGeminiModel', () => {
it('should return true for gemini 2.5 models', () => {
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-2.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-2.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-2.5-flash-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-2.5-pro-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini latest models', () => {
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-flash-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-pro-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-flash-lite-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini 3 models', () => {
// Preview versions
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-3-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'google/gemini-3-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
// Future stable versions
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-3-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'google/gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'google/gemini-3-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return false for image and tts models', () => {
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-2.5-flash-image',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-2.5-flash-preview-tts',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
it('should return false for older gemini models', () => {
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-1.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-1.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isSupportedThinkingTokenGeminiModel({
id: 'gemini-1.0-pro',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
})
describe('isGeminiReasoningModel', () => {
it('should return true for gemini thinking models', () => {
expect(
isGeminiReasoningModel({
id: 'gemini-2.0-flash-thinking',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGeminiReasoningModel({
id: 'gemini-thinking-exp',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for supported thinking token gemini models', () => {
expect(
isGeminiReasoningModel({
id: 'gemini-2.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGeminiReasoningModel({
id: 'gemini-2.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini-3 models', () => {
// Preview versions
expect(
isGeminiReasoningModel({
id: 'gemini-3-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGeminiReasoningModel({
id: 'google/gemini-3-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
// Future stable versions
expect(
isGeminiReasoningModel({
id: 'gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGeminiReasoningModel({
id: 'gemini-3-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGeminiReasoningModel({
id: 'google/gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isGeminiReasoningModel({
id: 'google/gemini-3-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return false for older gemini models without thinking', () => {
expect(
isGeminiReasoningModel({
id: 'gemini-1.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isGeminiReasoningModel({
id: 'gemini-1.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
it('should return false for undefined model', () => {
expect(isGeminiReasoningModel(undefined)).toBe(false)
})
})
})

View File

@@ -1,167 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { isVisionModel } from '../models/vision'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
vi.mock('@renderer/services/AssistantService.ts', () => ({
getDefaultAssistant: () => {
return {
id: 'default',
name: 'default',
emoji: '😀',
prompt: '',
topics: [],
messages: [],
type: 'assistant',
regularPhrases: [],
settings: {}
}
},
getProviderByModel: () => null
}))
describe('isVisionModel', () => {
describe('Gemini Models', () => {
it('should return true for gemini 1.5 models', () => {
expect(
isVisionModel({
id: 'gemini-1.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-1.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini 2.x models', () => {
expect(
isVisionModel({
id: 'gemini-2.0-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-2.0-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-2.5-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-2.5-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini latest models', () => {
expect(
isVisionModel({
id: 'gemini-flash-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-pro-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-flash-lite-latest',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini 3 models', () => {
// Preview versions
expect(
isVisionModel({
id: 'gemini-3-pro-preview',
name: '',
provider: '',
group: ''
})
).toBe(true)
// Future stable versions
expect(
isVisionModel({
id: 'gemini-3-flash',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isVisionModel({
id: 'gemini-3-pro',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return true for gemini exp models', () => {
expect(
isVisionModel({
id: 'gemini-exp-1206',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return false for gemini 1.0 models', () => {
expect(
isVisionModel({
id: 'gemini-1.0-pro',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
})
})

View File

@@ -1,64 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { GEMINI_SEARCH_REGEX } from '../models/websearch'
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
llm: {
settings: {}
}
})
}
}))
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
vi.mock('@renderer/services/AssistantService.ts', () => ({
getDefaultAssistant: () => {
return {
id: 'default',
name: 'default',
emoji: '😀',
prompt: '',
topics: [],
messages: [],
type: 'assistant',
regularPhrases: [],
settings: {}
}
},
getProviderByModel: () => null
}))
describe('Gemini Search Models', () => {
describe('GEMINI_SEARCH_REGEX', () => {
it('should match gemini 2.x models', () => {
expect(GEMINI_SEARCH_REGEX.test('gemini-2.0-flash')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-2.0-pro')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-flash')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-pro')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-flash-latest')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-pro-latest')).toBe(true)
})
it('should match gemini latest models', () => {
expect(GEMINI_SEARCH_REGEX.test('gemini-flash-latest')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-pro-latest')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-flash-lite-latest')).toBe(true)
})
it('should match gemini 3 models', () => {
// Preview versions
expect(GEMINI_SEARCH_REGEX.test('gemini-3-pro-preview')).toBe(true)
// Future stable versions
expect(GEMINI_SEARCH_REGEX.test('gemini-3-flash')).toBe(true)
expect(GEMINI_SEARCH_REGEX.test('gemini-3-pro')).toBe(true)
})
it('should not match older gemini models', () => {
expect(GEMINI_SEARCH_REGEX.test('gemini-1.5-flash')).toBe(false)
expect(GEMINI_SEARCH_REGEX.test('gemini-1.5-pro')).toBe(false)
expect(GEMINI_SEARCH_REGEX.test('gemini-1.0-pro')).toBe(false)
})
})
})

View File

@@ -1003,18 +1003,6 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
provider: 'minimax',
name: 'minimax-01',
group: 'minimax-01'
},
{
id: 'MiniMax-M2',
provider: 'minimax',
name: 'MiniMax M2',
group: 'minimax-m2'
},
{
id: 'MiniMax-M2-Stable',
provider: 'minimax',
name: 'MiniMax M2 Stable',
group: 'minimax-m2'
}
],
hyperbolic: [
@@ -1852,26 +1840,5 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
group: 'LongCat'
}
],
huggingface: [],
'ai-gateway': [],
cerebras: [
{
id: 'gpt-oss-120b',
name: 'GPT oss 120B',
provider: 'cerebras',
group: 'openai'
},
{
id: 'zai-glm-4.6',
name: 'GLM 4.6',
provider: 'cerebras',
group: 'zai'
},
{
id: 'qwen-3-235b-a22b-instruct-2507',
name: 'Qwen 3 235B A22B Instruct',
provider: 'cerebras',
group: 'qwen'
}
]
huggingface: []
}

View File

@@ -59,10 +59,6 @@ import {
} from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.png'
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
import GPT51ModelLogo from '@renderer/assets/images/models/gpt-5.1.png'
import GPT51ChatModelLogo from '@renderer/assets/images/models/gpt-5.1-chat.png'
import GPT51CodexModelLogo from '@renderer/assets/images/models/gpt-5.1-codex.png'
import GPT51CodexMiniModelLogo from '@renderer/assets/images/models/gpt-5.1-codex-mini.png'
import GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
import GPT5CodexModelLogo from '@renderer/assets/images/models/gpt-5-codex.png'
@@ -186,10 +182,6 @@ export function getModelLogoById(modelId: string): string | undefined {
'gpt-5-nano': GPT5NanoModelLogo,
'gpt-5-chat': GPT5ChatModelLogo,
'gpt-5-codex': GPT5CodexModelLogo,
'gpt-5.1-codex-mini': GPT51CodexMiniModelLogo,
'gpt-5.1-codex': GPT51CodexModelLogo,
'gpt-5.1-chat': GPT51ChatModelLogo,
'gpt-5.1': GPT51ModelLogo,
'gpt-5': GPT5ModelLogo,
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,

View File

@@ -8,7 +8,7 @@ import type {
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
import { isEmbeddingModel, isRerankModel } from './embedding'
import { isGPT5ProModel, isGPT5SeriesModel, isGPT51SeriesModel } from './utils'
import { isGPT5SeriesModel } from './utils'
import { isTextToImageModel } from './vision'
import { GEMINI_FLASH_MODEL_REGEX, isOpenAIDeepResearchModel } from './websearch'
@@ -24,9 +24,6 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
openai_deep_research: ['medium'] as const,
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
gpt5_codex: ['low', 'medium', 'high'] as const,
gpt5_1: ['none', 'low', 'medium', 'high'] as const,
gpt5_1_codex: ['none', 'medium', 'high'] as const,
gpt5pro: ['high'] as const,
grok: ['low', 'high'] as const,
grok4_fast: ['auto'] as const,
gemini: ['low', 'medium', 'high', 'auto'] as const,
@@ -44,27 +41,24 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
// 模型类型到支持选项的映射表
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
default: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
openai_deep_research: MODEL_SUPPORTED_REASONING_EFFORT.openai_deep_research,
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
gpt5pro: MODEL_SUPPORTED_REASONING_EFFORT.gpt5pro,
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
gpt5_1: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_1,
gpt5_1_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_1_codex,
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
grok4_fast: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
qwen: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
doubao: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
doubao_no_auto: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
doubao_after_251015: MODEL_SUPPORTED_REASONING_EFFORT.doubao_after_251015,
hunyuan: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
zhipu: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
deepseek_hybrid: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
} as const
const withModelIdAndNameAsId = <T>(model: Model, fn: (model: Model) => T): { idResult: T; nameResult: T } => {
@@ -81,20 +75,11 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
if (isOpenAIDeepResearchModel(model)) {
return 'openai_deep_research'
}
if (isGPT51SeriesModel(model)) {
if (modelId.includes('codex')) {
thinkingModelType = 'gpt5_1_codex'
} else {
thinkingModelType = 'gpt5_1'
}
} else if (isGPT5SeriesModel(model)) {
if (isGPT5SeriesModel(model)) {
if (modelId.includes('codex')) {
thinkingModelType = 'gpt5_codex'
} else {
thinkingModelType = 'gpt5'
if (isGPT5ProModel(model)) {
thinkingModelType = 'gpt5pro'
}
}
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
thinkingModelType = 'o'
@@ -254,7 +239,7 @@ export function isGeminiReasoningModel(model?: Model): boolean {
// Gemini 支持思考模式的模型正则
export const GEMINI_THINKING_MODEL_REGEX =
/gemini-(?:2\.5.*(?:-latest)?|3-(?:flash|pro)(?:-preview)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
const modelId = getLowerBaseModelName(model.id, '/')
@@ -541,7 +526,7 @@ export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean {
modelId.includes('o3') ||
modelId.includes('o4') ||
modelId.includes('gpt-oss') ||
((isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat'))
(isGPT5SeriesModel(model) && !modelId.includes('chat'))
)
}

View File

@@ -54,7 +54,7 @@ export function isSupportedFlexServiceTier(model: Model): boolean {
export function isSupportVerbosityModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id)
return (isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat')
return isGPT5SeriesModel(model) && !modelId.includes('chat')
}
export function isOpenAIChatCompletionOnlyModel(model: Model): boolean {
@@ -227,32 +227,12 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => {
export const isGPT5SeriesModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gpt-5') && !modelId.includes('gpt-5.1')
return modelId.includes('gpt-5')
}
export const isGPT5SeriesReasoningModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return isGPT5SeriesModel(model) && !modelId.includes('chat')
}
export const isGPT51SeriesModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gpt-5.1')
}
// GPT-5 verbosity configuration
// gpt-5-pro only supports 'high', other GPT-5 models support all levels
export const MODEL_SUPPORTED_VERBOSITY: Record<string, ('low' | 'medium' | 'high')[]> = {
'gpt-5-pro': ['high'],
default: ['low', 'medium', 'high']
}
export const getModelSupportedVerbosity = (model: Model): ('low' | 'medium' | 'high')[] => {
const modelId = getLowerBaseModelName(model.id)
if (modelId.includes('gpt-5-pro')) {
return MODEL_SUPPORTED_VERBOSITY['gpt-5-pro']
}
return MODEL_SUPPORTED_VERBOSITY.default
return modelId.includes('gpt-5') && !modelId.includes('chat')
}
export const isGeminiModel = (model: Model) => {
@@ -271,8 +251,3 @@ export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as con
export const agentModelFilter = (model: Model): boolean => {
return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model)
}
export const isGPT5ProModel = (model: Model) => {
const modelId = getLowerBaseModelName(model.id)
return modelId.includes('gpt-5-pro')
}

View File

@@ -12,7 +12,6 @@ const visionAllowedModels = [
'gemini-1\\.5',
'gemini-2\\.0',
'gemini-2\\.5',
'gemini-3-(?:flash|pro)(?:-preview)?',
'gemini-(flash|pro|flash-lite)-latest',
'gemini-exp',
'claude-3',
@@ -65,13 +64,13 @@ const visionExcludedModels = [
'o1-preview',
'AIDC-AI/Marco-o1'
]
const VISION_REGEX = new RegExp(
export const VISION_REGEX = new RegExp(
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
'i'
)
// For middleware to identify models that must use the dedicated Image API
const DEDICATED_IMAGE_MODELS = [
export const DEDICATED_IMAGE_MODELS = [
'grok-2-image',
'grok-2-image-1212',
'grok-2-image-latest',
@@ -80,7 +79,7 @@ const DEDICATED_IMAGE_MODELS = [
'gpt-image-1'
]
const IMAGE_ENHANCEMENT_MODELS = [
export const IMAGE_ENHANCEMENT_MODELS = [
'grok-2-image(?:-[\\w-]+)?',
'qwen-image-edit',
'gpt-image-1',
@@ -91,9 +90,9 @@ const IMAGE_ENHANCEMENT_MODELS = [
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
// Models that should auto-enable image generation button when selected
const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'o3',
'gpt-4o',
'gpt-4o-mini',
@@ -103,9 +102,9 @@ const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
'gpt-5'
]
const OPENAI_IMAGE_GENERATION_MODELS = [...OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS, 'gpt-image-1']
export const OPENAI_IMAGE_GENERATION_MODELS = [...OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS, 'gpt-image-1']
const GENERATE_IMAGE_MODELS = [
export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-preview-image-generation',
@@ -170,23 +169,22 @@ export function isPureGenerateImageModel(model: Model): boolean {
}
// Text to image models
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|image|gpt-image/i
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|image|gpt-image/i
export function isTextToImageModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id)
return TEXT_TO_IMAGE_REGEX.test(modelId)
}
// It's not used now
// export function isNotSupportedImageSizeModel(model?: Model): boolean {
// if (!model) {
// return false
// }
export function isNotSupportedImageSizeModel(model?: Model): boolean {
if (!model) {
return false
}
// const baseName = getLowerBaseModelName(model.id, '/')
const baseName = getLowerBaseModelName(model.id, '/')
// return baseName.includes('grok-2-image')
// }
return baseName.includes('grok-2-image')
}
/**
* 判断模型是否支持图片增强(包括编辑、增强、修复等)

View File

@@ -3,13 +3,7 @@ import type { Model } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
import {
isGeminiProvider,
isNewApiProvider,
isOpenAICompatibleProvider,
isOpenAIProvider,
isVertexAiProvider
} from '../providers'
import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider } from '../providers'
import { isEmbeddingModel, isRerankModel } from './embedding'
import { isAnthropicModel } from './utils'
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
@@ -22,7 +16,7 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
export const GEMINI_SEARCH_REGEX = new RegExp(
'gemini-(?:2.*(?:-latest)?|3-(?:flash|pro)(?:-preview)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
'gemini-(?:2.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
'i'
)
@@ -76,7 +70,7 @@ export function isWebSearchModel(model: Model): boolean {
// bedrock和vertex不支持
if (
isAnthropicModel(model) &&
!(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
(provider.id === SystemProviderIds['aws-bedrock'] || provider.id === SystemProviderIds.vertexai)
) {
return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId)
}
@@ -113,7 +107,7 @@ export function isWebSearchModel(model: Model): boolean {
}
}
if (isGeminiProvider(provider) || isVertexAiProvider(provider)) {
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
return GEMINI_SEARCH_REGEX.test(modelId)
}

View File

@@ -12,7 +12,6 @@ import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-clou
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png'
import CephalonProviderLogo from '@renderer/assets/images/providers/cephalon.jpeg'
import CerebrasProviderLogo from '@renderer/assets/images/providers/cerebras.webp'
import CherryInProviderLogo from '@renderer/assets/images/providers/cherryin.png'
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
@@ -52,7 +51,6 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
import AIGatewayProviderLogo from '@renderer/assets/images/providers/vercel.svg'
import VertexAIProviderLogo from '@renderer/assets/images/providers/vertexai.svg'
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
@@ -67,7 +65,7 @@ import type {
SystemProvider,
SystemProviderId
} from '@renderer/types'
import { isSystemProvider, OpenAIServiceTiers, SystemProviderIds } from '@renderer/types'
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
import { TOKENFLUX_HOST } from './constant'
import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models'
@@ -275,7 +273,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.qnaigc.com',
anthropicApiHost: 'https://api.qnaigc.com',
models: SYSTEM_MODELS.qiniu,
isSystem: true,
enabled: false
@@ -473,8 +470,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
name: 'MiniMax',
type: 'openai',
apiKey: '',
apiHost: 'https://api.minimaxi.com/v1',
anthropicApiHost: 'https://api.minimaxi.com/anthropic',
apiHost: 'https://api.minimax.chat/v1/',
models: SYSTEM_MODELS.minimax,
isSystem: true,
enabled: false
@@ -666,7 +662,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.longcat.chat/openai',
anthropicApiHost: 'https://api.longcat.chat/anthropic',
models: SYSTEM_MODELS.longcat,
isSystem: true,
enabled: false
@@ -680,26 +675,6 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
models: [],
isSystem: true,
enabled: false
},
'ai-gateway': {
id: 'ai-gateway',
name: 'AI Gateway',
type: 'ai-gateway',
apiKey: '',
apiHost: 'https://ai-gateway.vercel.sh/v1/ai',
models: [],
isSystem: true,
enabled: false
},
cerebras: {
id: 'cerebras',
name: 'Cerebras AI',
type: 'openai',
apiKey: '',
apiHost: 'https://api.cerebras.ai/v1',
models: SYSTEM_MODELS.cerebras,
isSystem: true,
enabled: false
}
} as const
@@ -766,9 +741,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
aionly: AiOnlyProviderLogo,
longcat: LongCatProviderLogo,
huggingface: HuggingfaceProviderLogo,
sophnet: SophnetProviderLogo,
'ai-gateway': AIGatewayProviderLogo,
cerebras: CerebrasProviderLogo
sophnet: SophnetProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@@ -1075,7 +1048,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
},
minimax: {
api: {
url: 'https://api.minimaxi.com/v1/'
url: 'https://api.minimax.chat/v1/'
},
websites: {
official: 'https://platform.minimaxi.com/',
@@ -1417,28 +1390,6 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
docs: 'https://huggingface.co/docs',
models: 'https://huggingface.co/models'
}
},
'ai-gateway': {
api: {
url: 'https://ai-gateway.vercel.sh/v1/ai'
},
websites: {
official: 'https://vercel.com/ai-gateway',
apiKey: 'https://vercel.com/',
docs: 'https://vercel.com/docs/ai-gateway',
models: 'https://vercel.com/ai-gateway/models'
}
},
cerebras: {
api: {
url: 'https://api.cerebras.ai/v1'
},
websites: {
official: 'https://www.cerebras.ai',
apiKey: 'https://cloud.cerebras.ai',
docs: 'https://inference-docs.cerebras.ai/introduction',
models: 'https://inference-docs.cerebras.ai/models/overview'
}
}
}
@@ -1501,7 +1452,7 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => {
)
}
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[]
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
/**
* 判断提供商是否支持 service_tier 设置。 Only for OpenAI API.
@@ -1521,10 +1472,7 @@ const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
] as const satisfies ProviderType[]
export const isSupportUrlContextProvider = (provider: Provider) => {
return (
SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type) ||
provider.id === SystemProviderIds.cherryin
)
return SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type)
}
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
@@ -1571,18 +1519,6 @@ export function isGeminiProvider(provider: Provider): boolean {
return provider.type === 'gemini'
}
export function isVertexAiProvider(provider: Provider): boolean {
return provider.type === 'vertexai'
}
export function isAIGatewayProvider(provider: Provider): boolean {
return provider.type === 'ai-gateway'
}
export function isAwsBedrockProvider(provider: Provider): boolean {
return provider.type === 'aws-bedrock'
}
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[]
export const isSupportAPIVersionProvider = (provider: Provider) => {

View File

@@ -1,4 +1,3 @@
import { loggerService } from '@logger'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useAppDispatch } from '@renderer/store'
@@ -7,8 +6,6 @@ import type { CreateSessionForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useCreateDefaultSession')
/**
* Returns a stable callback that creates a default agent session and updates UI state.
*/
@@ -40,9 +37,6 @@ export const useCreateDefaultSession = (agentId: string | null) => {
}
return created
} catch (error) {
logger.error('Error creating default session:', error as Error)
return null
} finally {
setCreatingSession(false)
}

View File

@@ -123,9 +123,9 @@ export function useAssistant(id: string) {
}
updateAssistantSettings({
reasoning_effort: fallbackOption === 'none' ? undefined : fallbackOption,
reasoning_effort_cache: fallbackOption === 'none' ? undefined : fallbackOption,
qwenThinkMode: fallbackOption === 'none' ? undefined : true
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
qwenThinkMode: fallbackOption === 'off' ? undefined : true
})
} else {
// 对于支持的选项, 不再更新 cache.

View File

@@ -1,63 +0,0 @@
import { useCallback, useRef, useState } from 'react'
export interface UseInputTextOptions {
initialValue?: string
onChange?: (text: string) => void
}
export interface UseInputTextReturn {
text: string
setText: (text: string | ((prev: string) => string)) => void
prevText: string
isEmpty: boolean
clear: () => void
}
/**
* 管理文本输入状态的通用 Hook
*
* 提供文本状态管理、历史追踪和便捷方法
*
* @param options - 配置选项
* @param options.initialValue - 初始文本值
* @param options.onChange - 文本变化回调
* @returns 文本状态和操作方法
*
* @example
* ```tsx
* const { text, setText, isEmpty, clear } = useInputText({
* initialValue: '',
* onChange: (text) => console.log('Text changed:', text)
* })
*
* <input value={text} onChange={(e) => setText(e.target.value)} />
* <button disabled={isEmpty}>Send</button>
* <button onClick={clear}>Clear</button>
* ```
*/
export function useInputText(options: UseInputTextOptions = {}): UseInputTextReturn {
const [text, setText] = useState(options.initialValue ?? '')
const prevTextRef = useRef(text)
const handleSetText = useCallback(
(value: string | ((prev: string) => string)) => {
const newText = typeof value === 'function' ? value(text) : value
prevTextRef.current = text
setText(newText)
options.onChange?.(newText)
},
[text, options]
)
const clear = useCallback(() => {
handleSetText('')
}, [handleSetText])
return {
text,
setText: handleSetText,
prevText: prevTextRef.current,
isEmpty: text.trim().length === 0,
clear
}
}

View File

@@ -1,94 +0,0 @@
import { useCallback, useRef } from 'react'
export interface KeyboardHandlerCallbacks {
onSend?: () => void
onEscape?: () => void
onTab?: () => void
onCustom?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
}
export interface KeyboardHandlerOptions {
sendShortcut?: 'Enter' | 'Ctrl+Enter' | 'Cmd+Enter' | 'Shift+Enter'
enableTabNavigation?: boolean
enableEscape?: boolean
}
/**
* 通用键盘事件处理 Hook
*
* 提供常见的键盘快捷键处理发送、取消、Tab 导航等)
*
* @param callbacks - 键盘事件回调函数
* @param callbacks.onSend - 发送消息回调(根据 sendShortcut 触发)
* @param callbacks.onEscape - Escape 键回调
* @param callbacks.onTab - Tab 键回调
* @param callbacks.onCustom - 自定义键盘处理回调
* @param options - 配置选项
* @param options.sendShortcut - 发送快捷键类型(默认 'Enter'
* @param options.enableTabNavigation - 是否启用 Tab 导航(默认 false
* @param options.enableEscape - 是否启用 Escape 键处理(默认 false
* @returns 键盘事件处理函数
*
* @example
* ```tsx
* const handleKeyDown = useKeyboardHandler(
* {
* onSend: () => sendMessage(),
* onEscape: () => closeModal(),
* onTab: () => navigateToNextField()
* },
* {
* sendShortcut: 'Ctrl+Enter',
* enableTabNavigation: true,
* enableEscape: true
* }
* )
*
* <textarea onKeyDown={handleKeyDown} />
* ```
*/
export function useKeyboardHandler(callbacks: KeyboardHandlerCallbacks, options: KeyboardHandlerOptions = {}) {
const callbacksRef = useRef(callbacks)
callbacksRef.current = callbacks
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { sendShortcut = 'Enter', enableTabNavigation = false, enableEscape = false } = options
// Tab 导航
if (enableTabNavigation && event.key === 'Tab') {
event.preventDefault()
callbacksRef.current.onTab?.()
return
}
// Escape 键
if (enableEscape && event.key === 'Escape') {
event.stopPropagation()
callbacksRef.current.onEscape?.()
return
}
// Enter 键处理
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
const isSendPressed =
(sendShortcut === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey) ||
(sendShortcut === 'Ctrl+Enter' && event.ctrlKey) ||
(sendShortcut === 'Cmd+Enter' && event.metaKey) ||
(sendShortcut === 'Shift+Enter' && event.shiftKey)
if (isSendPressed) {
event.preventDefault()
callbacksRef.current.onSend?.()
return
}
}
// 自定义处理器
callbacksRef.current.onCustom?.(event)
},
[options]
)
return handleKeyDown
}

View File

@@ -1,125 +0,0 @@
import type { TextAreaRef } from 'antd/es/input/TextArea'
import { useCallback, useRef, useState } from 'react'
export interface UseTextareaResizeOptions {
maxHeight?: number
minHeight?: number
autoResize?: boolean
}
export interface UseTextareaResizeReturn {
textareaRef: React.RefObject<TextAreaRef | null>
resize: (force?: boolean) => void
focus: () => void
customHeight: number | undefined
setCustomHeight: (height: number | undefined) => void
setExpanded: (expanded: boolean, expandedHeight?: number) => void
isExpanded: boolean
}
/**
* 管理 Textarea 自动调整大小的通用 Hook
*
* 支持自动调整高度、手动展开/收起、自定义高度限制
*
* @param options - 配置选项
* @param options.maxHeight - 最大高度限制(默认 400px
* @param options.minHeight - 最小高度限制(默认 30px
* @param options.autoResize - 是否自动调整大小(默认 true
* @returns Textarea ref 和调整方法
*
* @example
* ```tsx
* const { textareaRef, resize, setExpanded, isExpanded, customHeight } = useTextareaResize({
* maxHeight: 400,
* minHeight: 30
* })
*
* useEffect(() => {
* resize() // 在内容变化后调用
* }, [text])
*
* <TextArea
* ref={textareaRef}
* style={{ height: customHeight }}
* autoSize={customHeight ? false : { minRows: 2, maxRows: 20 }}
* />
* <button onClick={() => setExpanded(!isExpanded)}>Toggle Expand</button>
* ```
*/
export function useTextareaResize(options: UseTextareaResizeOptions = {}): UseTextareaResizeReturn {
const { maxHeight = 400, minHeight = 30, autoResize = true } = options
const textareaRef = useRef<TextAreaRef>(null)
const [customHeight, setCustomHeight] = useState<number>()
const [isExpanded, setIsExpanded] = useState(false)
const resize = useCallback(
(force = false) => {
if (!autoResize && !force) {
return
}
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (!textArea) {
return
}
// 如果设置了自定义高度且不是强制调整,则跳过
if (customHeight !== undefined && !force) {
return
}
textArea.style.height = 'auto'
if (textArea.scrollHeight) {
const newHeight = Math.max(minHeight, Math.min(textArea.scrollHeight, maxHeight))
textArea.style.height = `${newHeight}px`
}
},
[autoResize, customHeight, maxHeight, minHeight]
)
const focus = useCallback(() => {
textareaRef.current?.focus()
}, [])
const setExpanded = useCallback(
(expanded: boolean, expandedHeight = 0.7 * window.innerHeight) => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (!textArea) {
setIsExpanded(expanded)
setCustomHeight(expanded ? expandedHeight : undefined)
return
}
if (expanded) {
const viewportHeight = window.innerHeight || expandedHeight
const desiredHeight = Math.max(minHeight, Math.min(expandedHeight, viewportHeight * 0.9))
textArea.style.height = `${desiredHeight}px`
setCustomHeight(desiredHeight)
setIsExpanded(true)
} else {
textArea.style.height = 'auto'
setCustomHeight(undefined)
setIsExpanded(false)
// 收起后重新计算高度
requestAnimationFrame(() => {
const contentHeight = textArea.scrollHeight
const nextHeight = Math.max(minHeight, Math.min(contentHeight, maxHeight))
textArea.style.height = `${nextHeight}px`
})
}
},
[maxHeight, minHeight]
)
return {
textareaRef,
resize,
focus,
customHeight,
setCustomHeight,
setExpanded,
isExpanded
}
}

View File

@@ -86,9 +86,7 @@ const providerKeyMap = {
aionly: 'provider.aionly',
longcat: 'provider.longcat',
huggingface: 'provider.huggingface',
sophnet: 'provider.sophnet',
'ai-gateway': 'provider.ai-gateway',
cerebras: 'provider.cerebras'
sophnet: 'provider.sophnet'
} as const
/**
@@ -311,7 +309,7 @@ export const getHttpMessageLabel = (key: string): string => {
}
const reasoningEffortOptionsKeyMap: Record<ThinkingOption, string> = {
none: 'assistants.settings.reasoning_effort.off',
off: 'assistants.settings.reasoning_effort.off',
minimal: 'assistants.settings.reasoning_effort.minimal',
high: 'assistants.settings.reasoning_effort.high',
low: 'assistants.settings.reasoning_effort.low',

View File

@@ -27,9 +27,6 @@
"null_id": "Agent ID is null."
}
},
"input": {
"placeholder": "Enter your message here, send with {{key}} - @ select path, / select command"
},
"list": {
"error": {
"failed": "Failed to list agents."
@@ -634,15 +631,6 @@
"view_full_content": "View Full Content"
},
"input": {
"activity_directory": {
"description": "Select file from activity directory",
"loading": "Loading Files...",
"no_file_found": {
"description": "No files available in accessible directories",
"label": "No File Found"
},
"title": "Activity Directory"
},
"auto_resize": "Auto resize height",
"clear": {
"content": "Do you want to clear all messages of the current topic?",
@@ -666,7 +654,6 @@
"new": {
"context": "Clear Context {{Command}}"
},
"new_session": "New Session {{Command}}",
"new_topic": "New Topic {{Command}}",
"paste_text_file_confirm": "Paste into input bar?",
"pause": "Pause",
@@ -674,10 +661,6 @@
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
"send": "Send",
"settings": "Settings",
"slash_commands": {
"description": "Agent session slash commands",
"title": "Slash Commands"
},
"thinking": {
"budget_exceeds_max": "Thinking budget exceeds the maximum token number",
"label": "Thinking",
@@ -1788,9 +1771,6 @@
},
"message": {
"code_style": "Code style",
"compact": {
"title": "Conversation Compacted"
},
"delete": {
"content": "Are you sure you want to delete this message?",
"title": "Delete Message"
@@ -2122,8 +2102,8 @@
"select": "Select",
"select_directory_failed": "Failed to select directory",
"title": "Data Settings",
"work_directory_description": "Work directory is where all note files are stored. Changing the work directory won't move existing files, please migrate files manually.",
"work_directory_placeholder": "Select notes work directory"
"work_directory_description": "Work directory is where all note files are stored. Supports relative paths like ~/Notes or ./Notes for multi-device sync. Changing the work directory won't move existing files, please migrate files manually.",
"work_directory_placeholder": "Enter or select notes work directory (e.g., ~/Notes)"
},
"display": {
"compress_content": "Content Compression",
@@ -2487,7 +2467,6 @@
},
"provider": {
"302ai": "302.AI",
"ai-gateway": "AI Gateway",
"aihubmix": "AiHubMix",
"aionly": "AiOnly",
"alayanew": "Alaya NeW",
@@ -2498,7 +2477,6 @@
"baidu-cloud": "Baidu Cloud",
"burncloud": "BurnCloud",
"cephalon": "Cephalon",
"cerebras": "Cerebras AI",
"cherryin": "CherryIN",
"copilot": "GitHub Copilot",
"dashscope": "Alibaba Cloud",
@@ -4346,7 +4324,7 @@
},
"azure": {
"apiversion": {
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the v1 version"
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the preview version"
}
},
"basic_auth": {
@@ -4481,7 +4459,6 @@
"confirm": "Confirm",
"forward": "Forward",
"multiple": "Multiple Select",
"noResult": "No results found",
"page": "Page",
"select": "Select",
"title": "Quick Menu"

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