Compare commits
47 Commits
v1.7.0-bet
...
libsql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04cb96558b | ||
|
|
0f1a487bb0 | ||
|
|
2df8bb58df | ||
|
|
62976f6fe0 | ||
|
|
77529b3cd3 | ||
|
|
c8e9a10190 | ||
|
|
0e011ff35f | ||
|
|
40a64a7c92 | ||
|
|
dc9503ef8b | ||
|
|
f2c8484c48 | ||
|
|
a9c9224835 | ||
|
|
43223fd1f5 | ||
|
|
4bac843b37 | ||
|
|
34723934f4 | ||
|
|
096c36caf8 | ||
|
|
139950e193 | ||
|
|
31eec403f7 | ||
|
|
7fd4837a47 | ||
|
|
90b0c8b4a6 | ||
|
|
556353e910 | ||
|
|
11fb730b4d | ||
|
|
2511113b62 | ||
|
|
a29b2bb3d6 | ||
|
|
d2be450906 | ||
|
|
9c020f0d56 | ||
|
|
e033eb5b5c | ||
|
|
073d43c7cb | ||
|
|
fa7646e18f | ||
|
|
038d30831c | ||
|
|
68ee5164f0 | ||
|
|
a1a3b9bd96 | ||
|
|
4e699c48bc | ||
|
|
75fcf8fbb5 | ||
|
|
35aa9d7355 | ||
|
|
b08aecb22b | ||
|
|
45fc6c2afd | ||
|
|
d6e7ce330e | ||
|
|
4f7d8731ea | ||
|
|
2b5ac5ab51 | ||
|
|
060fcd2ce6 | ||
|
|
a6182eaf85 | ||
|
|
649f9420a4 | ||
|
|
2552d97ea7 | ||
|
|
803f4b5a64 | ||
|
|
31f8fff6e2 | ||
|
|
2663cb19ce | ||
|
|
ce5d46bfc7 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -3,3 +3,4 @@
|
||||
/src/main/services/ConfigManager.ts @0xfullex
|
||||
/packages/shared/IpcChannel.ts @0xfullex
|
||||
/src/main/ipc.ts @0xfullex
|
||||
/app-upgrade-config.json @kangfenmao
|
||||
|
||||
212
.github/workflows/update-app-upgrade-config.yml
vendored
Normal file
212
.github/workflows/update-app-upgrade-config.yml
vendored
Normal file
@@ -0,0 +1,212 @@
|
||||
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"
|
||||
@@ -51,6 +51,12 @@
|
||||
"node": true
|
||||
},
|
||||
"files": ["src/preload/**"]
|
||||
},
|
||||
{
|
||||
"files": ["packages/ai-sdk-provider/**"],
|
||||
"globals": {
|
||||
"fetch": "readonly"
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": ["unicorn", "typescript", "oxc", "import"],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 4cc66d83af1cef39f6447dc62e680251e05ddf9f..eb9819cb674c1808845ceb29936196c4bb355172 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 a032505ec54e132dc386dde001dc51f710f84c83..5efada51b9a8b56e3f01b35e734908ebe3c37043 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
|
||||
152
.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch
vendored
Normal file
152
.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f318026d9b2 100644
|
||||
index 992c85ac6656e51c3471af741583533c5a7bf79f..83c05952a07aebb95fc6c62f9ddb8aa96b52ac0d 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -274,6 +274,7 @@ var openaiChatResponseSchema = (0, import_provider_utils3.lazyValidator)(
|
||||
@@ -18,30 +18,29 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31
|
||||
tool_calls: import_v42.z.array(
|
||||
import_v42.z.object({
|
||||
index: import_v42.z.number(),
|
||||
@@ -785,6 +787,14 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -785,6 +787,13 @@ var OpenAIChatLanguageModel = class {
|
||||
if (text != null && text.length > 0) {
|
||||
content.push({ type: "text", text });
|
||||
}
|
||||
+ const reasoning =
|
||||
+ choice.message.reasoning_content;
|
||||
+ const reasoning = choice.message.reasoning_content;
|
||||
+ if (reasoning != null && reasoning.length > 0) {
|
||||
+ content.push({
|
||||
+ type: 'reasoning',
|
||||
+ text: reasoning,
|
||||
+ text: reasoning
|
||||
+ });
|
||||
+ }
|
||||
for (const toolCall of (_a = choice.message.tool_calls) != null ? _a : []) {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
@@ -866,6 +876,7 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -866,6 +875,7 @@ var OpenAIChatLanguageModel = class {
|
||||
};
|
||||
let isFirstChunk = true;
|
||||
let metadataExtracted = false;
|
||||
let isActiveText = false;
|
||||
+ let isActiveReasoning = false;
|
||||
const providerMetadata = { openai: {} };
|
||||
return {
|
||||
stream: response.pipeThrough(
|
||||
@@ -920,6 +931,22 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -923,6 +933,21 @@ var OpenAIChatLanguageModel = class {
|
||||
return;
|
||||
}
|
||||
const delta = choice.delta;
|
||||
@@ -54,7 +53,6 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31
|
||||
+ });
|
||||
+ isActiveReasoning = true;
|
||||
+ }
|
||||
+
|
||||
+ controller.enqueue({
|
||||
+ type: 'reasoning-delta',
|
||||
+ id: 'reasoning-0',
|
||||
@@ -64,7 +62,7 @@ index cc6652c4e7f32878a64a2614115bf7eeb3b7c890..76e989017549c89b45d633525efb1f31
|
||||
if (delta.content != null) {
|
||||
if (!isActiveText) {
|
||||
controller.enqueue({ type: "text-start", id: "0" });
|
||||
@@ -1032,6 +1059,9 @@ var OpenAIChatLanguageModel = class {
|
||||
@@ -1035,6 +1060,9 @@ var OpenAIChatLanguageModel = class {
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
||||
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
@@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8
|
||||
import { createInterface } from "readline";
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6487,14 +6487,11 @@ class ProcessTransport {
|
||||
@@ -6505,14 +6505,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);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
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": [
|
||||
{
|
||||
14
.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch
vendored
Normal file
14
.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
49
app-upgrade-config.json
Normal file
49
app-upgrade-config.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
config/app-upgrade-segments.json
Normal file
81
config/app-upgrade-segments.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
430
docs/technical/app-upgrade-config-en.md
Normal file
430
docs/technical/app-upgrade-config-en.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 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 aren’t published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
|
||||
|
||||
## Version Matching Logic
|
||||
|
||||
### 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
|
||||
430
docs/technical/app-upgrade-config-zh.md
Normal file
430
docs/technical/app-upgrade-config-zh.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 更新配置系统设计文档
|
||||
|
||||
## 背景
|
||||
|
||||
当前 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` 的 PR,Diff 仅包含该文件。
|
||||
|
||||
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
|
||||
|
||||
## 版本匹配逻辑
|
||||
|
||||
### 算法流程
|
||||
|
||||
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 测试和灰度发布
|
||||
- 支持配置文件的本地缓存和过期策略
|
||||
@@ -97,7 +97,6 @@ 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.
|
||||
@@ -135,50 +134,58 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
What's New in v1.7.0-beta.5
|
||||
What's New in v1.7.0-rc.1
|
||||
|
||||
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
|
||||
🎉 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
|
||||
|
||||
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
|
||||
✨ 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
|
||||
|
||||
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
|
||||
⚡ 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
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
v1.7.0-beta.5 新特性
|
||||
v1.7.0-rc.1 新特性
|
||||
|
||||
新功能:
|
||||
- MCPRouter 提供商:新增 MCPRouter 提供商集成,支持 token 管理和服务器同步
|
||||
- MCP 市场:增强 MCP 服务器发现和管理功能,支持多提供商市场
|
||||
- Agent 权限模式展示:空会话状态显示可视化权限模式卡片
|
||||
- 助手订阅设置:在助手预设中添加订阅 URL 管理功能
|
||||
🎉 重大更新:AI Agent 智能体系统
|
||||
- 创建和管理专属 AI Agent,配置专用工具和权限
|
||||
- 独立的 Agent 会话,使用 SQLite 持久化存储,与普通聊天分离
|
||||
- 实时工具审批系统 - 动态审查和批准 Agent 操作
|
||||
- MCP(模型上下文协议)集成,连接外部工具
|
||||
- 支持斜杠命令快速交互
|
||||
- 兼容 OpenAI 的 REST API 访问
|
||||
|
||||
改进:
|
||||
- UI 优化:macOS 上侧边栏工具提示位置优化,避免与窗口控制按钮重叠
|
||||
- MCP 服务器标志:在 Agent 设置工具部分显示服务器 logo
|
||||
- 长命令处理:Bash 命令标签自动截断(超过 100 字符时悬停查看完整内容)
|
||||
- MCP OAuth 回调:修复回调页面挂起问题并添加多语言支持(10 种语言)
|
||||
- 错误信息展示:改进错误块显示顺序,提高可读性
|
||||
- 插件浏览器:标签页居中对齐,视觉效果更统一
|
||||
✨ 新功能:
|
||||
- AI 提供商:新增 Hugging Face、Mistral、Perplexity 和 SophNet 支持
|
||||
- 知识库:OpenMinerU 文档预处理器、笔记全文搜索、增强的工具选择
|
||||
- 图像与 OCR:Intel OVMS 绘图提供商和 Intel OpenVINO (NPU) OCR 支持
|
||||
- MCP 管理:重构管理界面,采用双列布局,更加方便管理
|
||||
- 语言:新增德语支持
|
||||
|
||||
问题修复:
|
||||
- 修复 Agent 会话未继承 allowed_tools 配置
|
||||
- 修复 Gemini 端点 thinking budget 拼写错误
|
||||
- 修复 MCP 卡片描述文本溢出问题
|
||||
- 修复仅 UI 状态变化时消息时间戳不必要的更新
|
||||
- 依赖更新:Bun 升级到 1.3.1,uv 升级到 0.9.5
|
||||
⚡ 改进:
|
||||
- 升级到 Electron 38.7.0
|
||||
- 增强的系统关机处理和自动更新检查
|
||||
- 改进的代理绕过规则
|
||||
|
||||
🐛 重要修复:
|
||||
- 修复多个 AI 提供商的流式响应问题
|
||||
- 修复会话列表滚动问题
|
||||
- 修复知识库删除错误
|
||||
<!--LANG:END-->
|
||||
|
||||
@@ -95,7 +95,8 @@ 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/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
59
package.json
59
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.7.0-beta.3",
|
||||
"version": "1.7.0-rc.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -58,6 +58,7 @@
|
||||
"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",
|
||||
@@ -73,14 +74,15 @@
|
||||
"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 --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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -106,11 +108,16 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.42",
|
||||
"@ai-sdk/google-vertex": "^3.0.48",
|
||||
"@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.4#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.4-8080836bc1.patch",
|
||||
"@ai-sdk/mistral": "^2.0.19",
|
||||
"@ai-sdk/perplexity": "^2.0.13",
|
||||
"@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/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",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
@@ -118,7 +125,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.0-alpha.18",
|
||||
"@cherrystudio/ai-core": "workspace:^1.0.9",
|
||||
"@cherrystudio/embedjs": "^0.1.31",
|
||||
"@cherrystudio/embedjs-libsql": "^0.1.31",
|
||||
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
|
||||
@@ -132,7 +139,7 @@
|
||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||
"@cherrystudio/extension-table-plus": "workspace:^",
|
||||
"@cherrystudio/openai": "^6.5.0",
|
||||
"@cherrystudio/openai": "^6.9.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -162,7 +169,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.19",
|
||||
"@opeoginni/github-copilot-openai-compatible": "0.1.21",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
@@ -231,7 +238,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.76",
|
||||
"ai": "^5.0.90",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
@@ -241,7 +248,7 @@
|
||||
"check-disk-space": "3.4.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"claude-code-plugins": "1.0.1",
|
||||
"claude-code-plugins": "1.0.3",
|
||||
"cli-progress": "^3.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
@@ -257,12 +264,12 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"electron": "38.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron": "38.7.0",
|
||||
"electron-builder": "26.1.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
|
||||
"electron-vite": "4.0.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"emittery": "^1.0.3",
|
||||
@@ -379,13 +386,11 @@
|
||||
"@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",
|
||||
"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",
|
||||
"@libsql/client": "0.15.15",
|
||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"esbuild": "^0.25.0",
|
||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"node-abi": "4.12.0",
|
||||
"node-abi": "4.24.0",
|
||||
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0",
|
||||
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0",
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
@@ -394,7 +399,6 @@
|
||||
"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",
|
||||
@@ -405,7 +409,10 @@
|
||||
"openai@npm:5.12.2": "npm:@cherrystudio/openai@6.5.0",
|
||||
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch"
|
||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||
"@ai-sdk/google@npm:2.0.36": "patch:@ai-sdk/google@npm%3A2.0.36#~/.yarn/patches/@ai-sdk-google-npm-2.0.36-6f3cc06026.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
39
packages/ai-sdk-provider/README.md
Normal file
39
packages/ai-sdk-provider/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @cherrystudio/ai-sdk-provider
|
||||
|
||||
CherryIN provider bundle for the [Vercel AI SDK](https://ai-sdk.dev/).
|
||||
It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Anthropic and Gemini model ids to their CherryIN upstream equivalents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
|
||||
# or
|
||||
yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
|
||||
```
|
||||
|
||||
> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createCherryIn, cherryIn } from '@cherrystudio/ai-sdk-provider'
|
||||
|
||||
const cherryInProvider = createCherryIn({
|
||||
apiKey: process.env.CHERRYIN_API_KEY,
|
||||
// optional overrides:
|
||||
// baseURL: 'https://open.cherryin.net/v1',
|
||||
// anthropicBaseURL: 'https://open.cherryin.net/anthropic',
|
||||
// geminiBaseURL: 'https://open.cherryin.net/gemini/v1beta',
|
||||
})
|
||||
|
||||
// Chat models will auto-route based on the model id prefix:
|
||||
const openaiModel = cherryInProvider.chat('gpt-4o-mini')
|
||||
const anthropicModel = cherryInProvider.chat('claude-3-5-sonnet-latest')
|
||||
const geminiModel = cherryInProvider.chat('gemini-2.0-pro-exp')
|
||||
|
||||
const { text } = await openaiModel.invoke('Hello CherryIN!')
|
||||
```
|
||||
|
||||
The provider also exposes `completion`, `responses`, `embedding`, `image`, `transcription`, and `speech` helpers aligned with the upstream APIs.
|
||||
|
||||
See [AI SDK docs](https://ai-sdk.dev/providers/community-providers/custom-providers) for configuring custom providers.
|
||||
64
packages/ai-sdk-provider/package.json
Normal file
64
packages/ai-sdk-provider/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
319
packages/ai-sdk-provider/src/cherryin-provider.ts
Normal file
319
packages/ai-sdk-provider/src/cherryin-provider.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal'
|
||||
import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal'
|
||||
import type { OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import {
|
||||
OpenAIChatLanguageModel,
|
||||
OpenAICompletionLanguageModel,
|
||||
OpenAIEmbeddingModel,
|
||||
OpenAIImageModel,
|
||||
OpenAIResponsesLanguageModel,
|
||||
OpenAISpeechModel,
|
||||
OpenAITranscriptionModel
|
||||
} from '@ai-sdk/openai/internal'
|
||||
import {
|
||||
type EmbeddingModelV2,
|
||||
type ImageModelV2,
|
||||
type LanguageModelV2,
|
||||
type ProviderV2,
|
||||
type SpeechModelV2,
|
||||
type TranscriptionModelV2
|
||||
} from '@ai-sdk/provider'
|
||||
import type { FetchFunction } from '@ai-sdk/provider-utils'
|
||||
import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils'
|
||||
|
||||
export const CHERRYIN_PROVIDER_NAME = 'cherryin' as const
|
||||
export const DEFAULT_CHERRYIN_BASE_URL = 'https://open.cherryin.net/v1'
|
||||
export const DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL = 'https://open.cherryin.net/v1'
|
||||
export const DEFAULT_CHERRYIN_GEMINI_BASE_URL = 'https://open.cherryin.net/v1beta/models'
|
||||
|
||||
const ANTHROPIC_PREFIX = /^anthropic\//i
|
||||
const GEMINI_PREFIX = /^google\//i
|
||||
// const GEMINI_EXCLUDED_SUFFIXES = ['-nothink', '-search']
|
||||
|
||||
type HeaderValue = string | undefined
|
||||
|
||||
type HeadersInput = Record<string, HeaderValue> | (() => Record<string, HeaderValue>)
|
||||
|
||||
export interface CherryInProviderSettings {
|
||||
/**
|
||||
* CherryIN API key.
|
||||
*
|
||||
* If omitted, the provider will read the `CHERRYIN_API_KEY` environment variable.
|
||||
*/
|
||||
apiKey?: string
|
||||
/**
|
||||
* Optional custom fetch implementation.
|
||||
*/
|
||||
fetch?: FetchFunction
|
||||
/**
|
||||
* Base URL for OpenAI-compatible CherryIN endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/v1`.
|
||||
*/
|
||||
baseURL?: string
|
||||
/**
|
||||
* Base URL for Anthropic-compatible endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/anthropic`.
|
||||
*/
|
||||
anthropicBaseURL?: string
|
||||
/**
|
||||
* Base URL for Gemini-compatible endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/gemini/v1beta`.
|
||||
*/
|
||||
geminiBaseURL?: string
|
||||
/**
|
||||
* Optional static headers applied to every request.
|
||||
*/
|
||||
headers?: HeadersInput
|
||||
}
|
||||
|
||||
export interface CherryInProvider extends ProviderV2 {
|
||||
(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
languageModel(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
chat(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
responses(modelId: string): LanguageModelV2
|
||||
completion(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
embedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
image(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
|
||||
imageModel(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
|
||||
transcription(modelId: string): TranscriptionModelV2
|
||||
transcriptionModel(modelId: string): TranscriptionModelV2
|
||||
speech(modelId: string): SpeechModelV2
|
||||
speechModel(modelId: string): SpeechModelV2
|
||||
}
|
||||
|
||||
const resolveApiKey = (options: CherryInProviderSettings): string =>
|
||||
loadApiKey({
|
||||
apiKey: options.apiKey,
|
||||
environmentVariableName: 'CHERRYIN_API_KEY',
|
||||
description: 'CherryIN'
|
||||
})
|
||||
|
||||
const isAnthropicModel = (modelId: string) => ANTHROPIC_PREFIX.test(modelId)
|
||||
const isGeminiModel = (modelId: string) => GEMINI_PREFIX.test(modelId)
|
||||
|
||||
const createCustomFetch = (originalFetch?: any) => {
|
||||
return async (url: string, options: any) => {
|
||||
if (options?.body) {
|
||||
try {
|
||||
const body = JSON.parse(options.body)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length === 0 && body.tool_choice) {
|
||||
delete body.tool_choice
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch ? originalFetch(url, options) : fetch(url, options)
|
||||
}
|
||||
}
|
||||
class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel {
|
||||
constructor(modelId: string, settings: any) {
|
||||
super(modelId, {
|
||||
...settings,
|
||||
fetch: createCustomFetch(settings.fetch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resolveConfiguredHeaders = (headers?: HeadersInput): Record<string, HeaderValue> => {
|
||||
if (typeof headers === 'function') {
|
||||
return { ...headers() }
|
||||
}
|
||||
return headers ? { ...headers } : {}
|
||||
}
|
||||
|
||||
const toBearerToken = (authorization?: string) => (authorization ? authorization.replace(/^Bearer\s+/i, '') : undefined)
|
||||
|
||||
const createJsonHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
'Content-Type': 'application/json',
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
export const createCherryIn = (options: CherryInProviderSettings = {}): CherryInProvider => {
|
||||
const {
|
||||
baseURL = DEFAULT_CHERRYIN_BASE_URL,
|
||||
anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL,
|
||||
geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL,
|
||||
fetch
|
||||
} = options
|
||||
|
||||
const getJsonHeaders = createJsonHeadersGetter(options)
|
||||
const getAuthHeaders = createAuthHeadersGetter(options)
|
||||
|
||||
const url = ({ path }: { path: string; modelId: string }) => `${withoutTrailingSlash(baseURL)}${path}`
|
||||
|
||||
const createAnthropicModel = (modelId: string) =>
|
||||
new AnthropicMessagesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.anthropic`,
|
||||
baseURL: anthropicBaseURL,
|
||||
headers: () => {
|
||||
const headers = getJsonHeaders()
|
||||
const apiKey = toBearerToken(headers.Authorization)
|
||||
return {
|
||||
...headers,
|
||||
'x-api-key': apiKey
|
||||
}
|
||||
},
|
||||
fetch,
|
||||
supportedUrls: () => ({
|
||||
'image/*': [/^https?:\/\/.*$/]
|
||||
})
|
||||
})
|
||||
|
||||
const createGeminiModel = (modelId: string) =>
|
||||
new GoogleGenerativeAILanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.google`,
|
||||
baseURL: geminiBaseURL,
|
||||
headers: () => {
|
||||
const headers = getJsonHeaders()
|
||||
const apiKey = toBearerToken(headers.Authorization)
|
||||
return {
|
||||
...headers,
|
||||
'x-goog-api-key': apiKey
|
||||
}
|
||||
},
|
||||
fetch,
|
||||
generateId: () => `${CHERRYIN_PROVIDER_NAME}-${Date.now()}`,
|
||||
supportedUrls: () => ({})
|
||||
})
|
||||
|
||||
const createOpenAIChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new CherryInOpenAIChatLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.openai-chat`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => {
|
||||
if (isAnthropicModel(modelId)) {
|
||||
return createAnthropicModel(modelId)
|
||||
}
|
||||
if (isGeminiModel(modelId)) {
|
||||
return createGeminiModel(modelId)
|
||||
}
|
||||
return new OpenAIResponsesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.openai`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
}
|
||||
|
||||
const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAICompletionLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.completion`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createEmbeddingModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAIEmbeddingModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.embeddings`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createResponsesModel = (modelId: string) =>
|
||||
new OpenAIResponsesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.responses`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createImageModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAIImageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.image`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createTranscriptionModel = (modelId: string) =>
|
||||
new OpenAITranscriptionModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.transcription`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getAuthHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createSpeechModel = (modelId: string) =>
|
||||
new OpenAISpeechModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.speech`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const provider: CherryInProvider = function (modelId: string, settings?: OpenAIProviderSettings) {
|
||||
if (new.target) {
|
||||
throw new Error('CherryIN provider function cannot be called with the new keyword.')
|
||||
}
|
||||
|
||||
return createChatModel(modelId, settings)
|
||||
}
|
||||
|
||||
provider.languageModel = createChatModel
|
||||
provider.chat = createOpenAIChatModel
|
||||
|
||||
provider.responses = createResponsesModel
|
||||
provider.completion = createCompletionModel
|
||||
|
||||
provider.embedding = createEmbeddingModel
|
||||
provider.textEmbedding = createEmbeddingModel
|
||||
provider.textEmbeddingModel = createEmbeddingModel
|
||||
|
||||
provider.image = createImageModel
|
||||
provider.imageModel = createImageModel
|
||||
|
||||
provider.transcription = createTranscriptionModel
|
||||
provider.transcriptionModel = createTranscriptionModel
|
||||
|
||||
provider.speech = createSpeechModel
|
||||
provider.speechModel = createSpeechModel
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
export const cherryIn = createCherryIn()
|
||||
1
packages/ai-sdk-provider/src/index.ts
Normal file
1
packages/ai-sdk-provider/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './cherryin-provider'
|
||||
19
packages/ai-sdk-provider/tsconfig.json
Normal file
19
packages/ai-sdk-provider/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmitOnError": false,
|
||||
"outDir": "./dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2020"
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
12
packages/ai-sdk-provider/tsdown.config.ts
Normal file
12
packages/ai-sdk-provider/tsdown.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts'
|
||||
},
|
||||
outDir: 'dist',
|
||||
format: ['esm', 'cjs'],
|
||||
clean: true,
|
||||
dts: true,
|
||||
tsconfig: 'tsconfig.json'
|
||||
})
|
||||
@@ -71,7 +71,7 @@ Cherry Studio AI Core 是一个基于 Vercel AI SDK 的统一 AI Provider 接口
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @cherrystudio/ai-core ai
|
||||
npm install @cherrystudio/ai-core ai @ai-sdk/google @ai-sdk/openai
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cherrystudio/ai-core",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.9",
|
||||
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@@ -33,17 +33,19 @@
|
||||
},
|
||||
"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.32",
|
||||
"@ai-sdk/azure": "^2.0.53",
|
||||
"@ai-sdk/deepseek": "^1.0.23",
|
||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
||||
"@ai-sdk/openai-compatible": "^1.0.22",
|
||||
"@ai-sdk/anthropic": "^2.0.43",
|
||||
"@ai-sdk/azure": "^2.0.66",
|
||||
"@ai-sdk/deepseek": "^1.0.27",
|
||||
"@ai-sdk/openai-compatible": "^1.0.26",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.12",
|
||||
"@ai-sdk/xai": "^2.0.26",
|
||||
"@ai-sdk/provider-utils": "^3.0.16",
|
||||
"@ai-sdk/xai": "^2.0.31",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
*/
|
||||
export const BUILT_IN_PLUGIN_PREFIX = 'built-in:'
|
||||
|
||||
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'
|
||||
export * from './googleToolsPlugin'
|
||||
export * from './toolUsePlugin/promptToolUsePlugin'
|
||||
export * from './toolUsePlugin/type'
|
||||
export * from './webSearchPlugin'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { anthropic } from '@ai-sdk/anthropic'
|
||||
import type { google } from '@ai-sdk/google'
|
||||
import type { openai } from '@ai-sdk/openai'
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { 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'
|
||||
|
||||
@@ -95,3 +96,56 @@ export type WebSearchToolInputSchema = {
|
||||
google: InferToolInput<GoogleWebSearchTool>
|
||||
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
|
||||
}
|
||||
|
||||
export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => {
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
if (config.openai) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = openai.tools.webSearch(config.openai)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'openai-chat': {
|
||||
if (config['openai-chat']) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'anthropic': {
|
||||
if (config.anthropic) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
// case 'google-vertex':
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = google.tools.googleSearch(config.google || {})
|
||||
break
|
||||
}
|
||||
|
||||
case 'xai': {
|
||||
if (config.xai) {
|
||||
const searchOptions = createXaiOptions({
|
||||
searchParameters: { ...config.xai, mode: 'on' }
|
||||
})
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
if (config.openrouter) {
|
||||
const searchOptions = createOpenRouterOptions(config.openrouter)
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
* 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 } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper'
|
||||
|
||||
/**
|
||||
* 网络搜索插件
|
||||
@@ -24,62 +20,19 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
|
||||
|
||||
transformParams: async (params: any, context: AiRequestContext) => {
|
||||
const { providerId } = context
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
|
||||
// cherryin.gemini
|
||||
const _providerId = params.model.provider.split('.')[1]
|
||||
switchWebSearchTool(_providerId, config, params)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
})
|
||||
|
||||
// 导出类型定义供开发者使用
|
||||
export type { WebSearchPluginConfig, WebSearchToolOutputSchema } from './helper'
|
||||
export * from './helper'
|
||||
|
||||
// 默认导出
|
||||
export default webSearchPlugin
|
||||
|
||||
@@ -44,7 +44,7 @@ export {
|
||||
// ==================== 基础数据和类型 ====================
|
||||
|
||||
// 基础Provider数据源
|
||||
export { baseProviderIds, baseProviders } from './schemas'
|
||||
export { baseProviderIds, baseProviders, isBaseProvider } from './schemas'
|
||||
|
||||
// 类型定义和Schema
|
||||
export type {
|
||||
|
||||
@@ -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,7 +31,8 @@ export const baseProviderIds = [
|
||||
'azure-responses',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'huggingface'
|
||||
'cherryin',
|
||||
'cherryin-chat'
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -137,9 +138,23 @@ export const baseProviders = [
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'huggingface',
|
||||
name: 'HuggingFace',
|
||||
creator: createHuggingFace,
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
creator: createCherryIn,
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'cherryin-chat',
|
||||
name: 'CherryIN Chat',
|
||||
creator: (options: CherryInProviderSettings) => {
|
||||
const provider = createCherryIn(options)
|
||||
return customProvider({
|
||||
fallbackProvider: {
|
||||
...provider,
|
||||
languageModel: (modelId: string) => provider.chat(modelId)
|
||||
}
|
||||
})
|
||||
},
|
||||
supportsImageGeneration: true
|
||||
}
|
||||
] as const satisfies BaseProvider[]
|
||||
|
||||
@@ -41,6 +41,7 @@ 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',
|
||||
@@ -189,6 +190,7 @@ 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',
|
||||
|
||||
@@ -197,12 +197,22 @@ 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']
|
||||
|
||||
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `sessions` ADD `slash_commands` text;
|
||||
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
|
||||
"prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session_messages": {
|
||||
"name": "session_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"executed_at": {
|
||||
"name": "executed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slash_commands": {
|
||||
"name": "slash_commands",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1758187378775,
|
||||
"tag": "0001_woozy_captain_flint",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1762526423527,
|
||||
"tag": "0002_wealthy_naoko",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
532
scripts/update-app-upgrade-config.ts
Normal file
532
scripts/update-app-upgrade-config.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
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)
|
||||
})
|
||||
@@ -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 } from 'electron'
|
||||
import { app, crashReporter } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import { isDev, isLinux, isWin } from './constant'
|
||||
|
||||
@@ -37,6 +37,14 @@ 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
|
||||
*/
|
||||
|
||||
@@ -551,6 +551,7 @@ 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))
|
||||
@@ -1037,4 +1038,8 @@ 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type ApiResponse<T> = {
|
||||
type BatchUploadResponse = {
|
||||
batch_id: string
|
||||
file_urls: string[]
|
||||
headers?: Record<string, string>[]
|
||||
}
|
||||
|
||||
type ExtractProgress = {
|
||||
@@ -55,7 +56,7 @@ type QuotaResponse = {
|
||||
export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider, userId?: string) {
|
||||
super(provider, userId)
|
||||
// todo:免费期结束后删除
|
||||
// TODO: remove after free period ends
|
||||
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
|
||||
}
|
||||
|
||||
@@ -68,21 +69,21 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
logger.info(`MinerU preprocess processing started: ${filePath}`)
|
||||
await this.validateFile(filePath)
|
||||
|
||||
// 1. 获取上传URL并上传文件
|
||||
// 1. Get upload URL and upload file
|
||||
const batchId = await this.uploadFile(file)
|
||||
logger.info(`MinerU file upload completed: batch_id=${batchId}`)
|
||||
|
||||
// 2. 等待处理完成并获取结果
|
||||
// 2. Wait for completion and fetch results
|
||||
const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name)
|
||||
logger.info(`MinerU processing completed for batch: ${batchId}`)
|
||||
|
||||
// 3. 下载并解压文件
|
||||
// 3. Download and extract output
|
||||
const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file)
|
||||
|
||||
// 4. check quota
|
||||
const quota = await this.checkQuota()
|
||||
|
||||
// 5. 创建处理后的文件信息
|
||||
// 5. Create processed file metadata
|
||||
return {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath),
|
||||
quota
|
||||
@@ -115,23 +116,48 @@ 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)
|
||||
|
||||
const doc = await this.readPdf(pdfBuffer)
|
||||
try {
|
||||
const doc = await this.readPdf(pdfBuffer)
|
||||
|
||||
// 文件页数小于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`)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||
// 查找解压后的主要文件
|
||||
// Locate the main extracted file
|
||||
let finalPath = ''
|
||||
let finalName = file.origin_name.replace('.pdf', '.md')
|
||||
|
||||
@@ -143,14 +169,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
|
||||
}
|
||||
@@ -178,7 +204,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
logger.info(`Downloading MinerU result to: ${zipPath}`)
|
||||
|
||||
try {
|
||||
// 下载ZIP文件
|
||||
// Download the ZIP file
|
||||
const response = await net.fetch(zipUrl, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
@@ -187,17 +213,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}`)
|
||||
|
||||
// 删除临时ZIP文件
|
||||
// Remove the temporary ZIP file
|
||||
fs.unlinkSync(zipPath)
|
||||
|
||||
return { path: extractPath }
|
||||
@@ -209,11 +235,11 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
|
||||
private async uploadFile(file: FileMetadata): Promise<string> {
|
||||
try {
|
||||
// 步骤1: 获取上传URL
|
||||
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
|
||||
// 步骤2: 上传文件到获取的URL
|
||||
// Step 1: obtain the upload URL
|
||||
const { batchId, fileUrls, uploadHeaders } = await this.getBatchUploadUrls(file)
|
||||
// Step 2: upload the file to the obtained URL
|
||||
const filePath = fileStorage.getFilePathById(file)
|
||||
await this.putFileToUrl(filePath, fileUrls[0])
|
||||
await this.putFileToUrl(filePath, fileUrls[0], file.origin_name, uploadHeaders?.[0])
|
||||
logger.info(`File uploaded successfully: ${filePath}`, { batchId, fileUrls })
|
||||
|
||||
return batchId
|
||||
@@ -223,7 +249,9 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async getBatchUploadUrls(file: FileMetadata): Promise<{ batchId: string; fileUrls: string[] }> {
|
||||
private async getBatchUploadUrls(
|
||||
file: FileMetadata
|
||||
): Promise<{ batchId: string; fileUrls: string[]; uploadHeaders?: Record<string, string>[] }> {
|
||||
const endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch`
|
||||
|
||||
const payload = {
|
||||
@@ -254,10 +282,11 @@ 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 } = data.data
|
||||
const { batch_id, file_urls, headers: uploadHeaders } = data.data
|
||||
return {
|
||||
batchId: batch_id,
|
||||
fileUrls: file_urls
|
||||
fileUrls: file_urls,
|
||||
uploadHeaders
|
||||
}
|
||||
} else {
|
||||
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
||||
@@ -271,23 +300,28 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private async putFileToUrl(filePath: string, uploadUrl: string): Promise<void> {
|
||||
private async putFileToUrl(
|
||||
filePath: string,
|
||||
uploadUrl: string,
|
||||
fileName?: string,
|
||||
headers?: Record<string, 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',
|
||||
body: fileBuffer,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf'
|
||||
}
|
||||
// headers: {
|
||||
// 'Content-Length': fileBuffer.length.toString()
|
||||
// }
|
||||
headers,
|
||||
body: new Uint8Array(fileBuffer)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// 克隆 response 以避免消费 body stream
|
||||
// Clone the response to avoid consuming the body stream
|
||||
const responseClone = response.clone()
|
||||
|
||||
try {
|
||||
@@ -358,20 +392,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
|
||||
@@ -379,7 +413,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...`)
|
||||
}
|
||||
|
||||
@@ -53,18 +53,43 @@ 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)
|
||||
|
||||
const doc = await this.readPdf(pdfBuffer)
|
||||
try {
|
||||
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`)
|
||||
}
|
||||
// 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`)
|
||||
// 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.`
|
||||
)
|
||||
// 不抛出错误,允许继续处理
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +97,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 name
|
||||
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
|
||||
// Find the corresponding folder by file id
|
||||
outputPath = path.join(outputPath, file.id)
|
||||
try {
|
||||
const files = fs.readdirSync(outputPath)
|
||||
|
||||
@@ -125,7 +150,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
||||
formData.append('return_md', 'true')
|
||||
formData.append('response_format_zip', 'true')
|
||||
formData.append('files', fileBuffer, {
|
||||
filename: file.origin_name
|
||||
filename: file.name
|
||||
})
|
||||
|
||||
while (retries < maxRetries) {
|
||||
@@ -139,7 +164,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
||||
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
|
||||
...formData.getHeaders()
|
||||
},
|
||||
body: formData.getBuffer()
|
||||
body: new Uint8Array(formData.getBuffer())
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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, UpgradeChannel } from '@shared/config/constant'
|
||||
import { FeedUrl, UpdateConfigUrl, UpdateMirror, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { UpdateInfo } from 'builder-util-runtime'
|
||||
import { CancellationToken } from 'builder-util-runtime'
|
||||
@@ -22,7 +22,29 @@ const LANG_MARKERS = {
|
||||
EN_START: '<!--LANG:en-->',
|
||||
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||
END: '<!--LANG:END-->'
|
||||
} as const
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
@@ -37,7 +59,9 @@ export default class AppUpdater {
|
||||
autoUpdater.requestHeaders = {
|
||||
...autoUpdater.requestHeaders,
|
||||
'User-Agent': generateUserAgent(),
|
||||
'X-Client-Id': configManager.getClientId()
|
||||
'X-Client-Id': configManager.getClientId(),
|
||||
// no-cache
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -75,61 +99,6 @@ 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
|
||||
@@ -161,6 +130,88 @@ 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)
|
||||
@@ -172,33 +223,42 @@ export default class AppUpdater {
|
||||
}
|
||||
|
||||
private async _setFeedUrl() {
|
||||
const currentVersion = app.getVersion()
|
||||
const testPlan = configManager.getTestPlan()
|
||||
if (testPlan) {
|
||||
const channel = this._getTestChannel()
|
||||
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
|
||||
// Determine mirror based on IP country
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
public cancelDownload() {
|
||||
@@ -320,8 +380,3 @@ export default class AppUpdater {
|
||||
return processedInfo
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
@@ -30,6 +31,73 @@ 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)[]
|
||||
@@ -54,6 +122,26 @@ 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()
|
||||
@@ -748,6 +836,284 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectory = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
dirPath: string,
|
||||
options?: DirectoryListOptions
|
||||
): Promise<string[]> => {
|
||||
const mergedOptions: Required<DirectoryListOptions> = {
|
||||
...DEFAULT_DIRECTORY_LIST_OPTIONS,
|
||||
...options
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(dirPath)
|
||||
|
||||
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
|
||||
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolvedPath}`)
|
||||
}
|
||||
|
||||
// Use ripgrep for file listing with relevance-based sorting
|
||||
if (!getRipgrepBinaryPath()) {
|
||||
throw new Error('Ripgrep binary not available')
|
||||
}
|
||||
|
||||
return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search directories by name pattern
|
||||
*/
|
||||
private async searchDirectories(
|
||||
resolvedPath: string,
|
||||
options: Required<DirectoryListOptions>,
|
||||
currentDepth: number = 0
|
||||
): Promise<string[]> {
|
||||
if (!options.includeDirectories) return []
|
||||
if (!options.recursive && currentDepth > 0) return []
|
||||
if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return []
|
||||
|
||||
const directories: string[] = []
|
||||
const excludedDirs = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'coverage',
|
||||
'.cache'
|
||||
])
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true })
|
||||
const searchPatternLower = options.searchPattern.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
// Skip hidden directories unless explicitly included
|
||||
if (!options.includeHidden && entry.name.startsWith('.')) continue
|
||||
|
||||
// Skip excluded directories
|
||||
if (excludedDirs.has(entry.name)) continue
|
||||
|
||||
const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/')
|
||||
|
||||
// Check if directory name matches search pattern
|
||||
if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) {
|
||||
directories.push(fullPath)
|
||||
}
|
||||
|
||||
// Recursively search subdirectories
|
||||
if (options.recursive && currentDepth < options.maxDepth) {
|
||||
const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1)
|
||||
directories.push(...subDirs)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error)
|
||||
}
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files by filename pattern
|
||||
*/
|
||||
private async searchByFilename(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
// Search for files using ripgrep
|
||||
if (options.includeFiles) {
|
||||
const args: string[] = ['--files']
|
||||
|
||||
// Handle hidden files
|
||||
if (!options.includeHidden) {
|
||||
args.push('--glob', '!.*')
|
||||
}
|
||||
|
||||
// Use --iglob to let ripgrep filter filenames (case-insensitive)
|
||||
if (options.searchPattern && options.searchPattern !== '.') {
|
||||
args.push('--iglob', `*${options.searchPattern}*`)
|
||||
}
|
||||
|
||||
// Exclude common hidden directories and large directories
|
||||
args.push('-g', '!**/node_modules/**')
|
||||
args.push('-g', '!**/.git/**')
|
||||
args.push('-g', '!**/.idea/**')
|
||||
args.push('-g', '!**/.vscode/**')
|
||||
args.push('-g', '!**/.DS_Store')
|
||||
args.push('-g', '!**/dist/**')
|
||||
args.push('-g', '!**/build/**')
|
||||
args.push('-g', '!**/.next/**')
|
||||
args.push('-g', '!**/.nuxt/**')
|
||||
args.push('-g', '!**/coverage/**')
|
||||
args.push('-g', '!**/.cache/**')
|
||||
|
||||
// Handle max depth
|
||||
if (!options.recursive) {
|
||||
args.push('--max-depth', '1')
|
||||
} else if (options.maxDepth > 0) {
|
||||
args.push('--max-depth', options.maxDepth.toString())
|
||||
}
|
||||
|
||||
// Add the directory path
|
||||
args.push(resolvedPath)
|
||||
|
||||
const { exitCode, output } = await executeRipgrep(args)
|
||||
|
||||
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||
if (exitCode >= 2) {
|
||||
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||
}
|
||||
|
||||
// Parse ripgrep output (no need to filter by filename - ripgrep already did it)
|
||||
files.push(
|
||||
...output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
)
|
||||
}
|
||||
|
||||
// Search for directories
|
||||
if (options.includeDirectories) {
|
||||
directories.push(...(await this.searchDirectories(resolvedPath, options)))
|
||||
}
|
||||
|
||||
// Combine and sort: directories first (alphabetically), then files (alphabetically)
|
||||
const sortedDirectories = directories.sort((a, b) => {
|
||||
const aName = path.basename(a)
|
||||
const bName = path.basename(b)
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
|
||||
const sortedFiles = files.sort((a, b) => {
|
||||
const aName = path.basename(a)
|
||||
const bName = path.basename(b)
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
|
||||
return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files by content pattern
|
||||
*/
|
||||
private async searchByContent(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||
const args: string[] = ['-l']
|
||||
|
||||
// Handle hidden files
|
||||
if (!options.includeHidden) {
|
||||
args.push('--glob', '!.*')
|
||||
}
|
||||
|
||||
// Exclude common hidden directories and large directories
|
||||
args.push('-g', '!**/node_modules/**')
|
||||
args.push('-g', '!**/.git/**')
|
||||
args.push('-g', '!**/.idea/**')
|
||||
args.push('-g', '!**/.vscode/**')
|
||||
args.push('-g', '!**/.DS_Store')
|
||||
args.push('-g', '!**/dist/**')
|
||||
args.push('-g', '!**/build/**')
|
||||
args.push('-g', '!**/.next/**')
|
||||
args.push('-g', '!**/.nuxt/**')
|
||||
args.push('-g', '!**/coverage/**')
|
||||
args.push('-g', '!**/.cache/**')
|
||||
|
||||
// Handle max depth
|
||||
if (!options.recursive) {
|
||||
args.push('--max-depth', '1')
|
||||
} else if (options.maxDepth > 0) {
|
||||
args.push('--max-depth', options.maxDepth.toString())
|
||||
}
|
||||
|
||||
// Handle max count
|
||||
if (options.maxEntries > 0) {
|
||||
args.push('--max-count', options.maxEntries.toString())
|
||||
}
|
||||
|
||||
// Add search pattern (search in content)
|
||||
args.push(options.searchPattern)
|
||||
|
||||
// Add the directory path
|
||||
args.push(resolvedPath)
|
||||
|
||||
const { exitCode, output } = await executeRipgrep(args)
|
||||
|
||||
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||
if (exitCode >= 2) {
|
||||
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||
}
|
||||
|
||||
// Parse ripgrep output (already sorted by relevance)
|
||||
const results = output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
.slice(0, options.maxEntries)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async listDirectoryWithRipgrep(
|
||||
resolvedPath: string,
|
||||
options: Required<DirectoryListOptions>
|
||||
): Promise<string[]> {
|
||||
const maxEntries = options.maxEntries
|
||||
|
||||
// Step 1: Search by filename first
|
||||
logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath })
|
||||
const filenameResults = await this.searchByFilename(resolvedPath, options)
|
||||
|
||||
logger.debug('Found matches by filename', { count: filenameResults.length })
|
||||
|
||||
// If we have enough filename matches, return them
|
||||
if (filenameResults.length >= maxEntries) {
|
||||
return filenameResults.slice(0, maxEntries)
|
||||
}
|
||||
|
||||
// Step 2: If filename matches are less than maxEntries, search by content to fill up
|
||||
logger.debug('Filename matches insufficient, searching by content to fill up', {
|
||||
filenameCount: filenameResults.length,
|
||||
needed: maxEntries - filenameResults.length
|
||||
})
|
||||
|
||||
// Adjust maxEntries for content search to get enough results
|
||||
const contentOptions = {
|
||||
...options,
|
||||
maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates
|
||||
}
|
||||
|
||||
const contentResults = await this.searchByContent(resolvedPath, contentOptions)
|
||||
|
||||
logger.debug('Found matches by content', { count: contentResults.length })
|
||||
|
||||
// Combine results: filename matches first, then content matches (deduplicated)
|
||||
const combined = [...filenameResults]
|
||||
const filenameSet = new Set(filenameResults)
|
||||
|
||||
for (const filePath of contentResults) {
|
||||
if (!filenameSet.has(filePath)) {
|
||||
combined.push(filePath)
|
||||
if (combined.length >= maxEntries) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length })
|
||||
return combined.slice(0, maxEntries)
|
||||
}
|
||||
|
||||
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
|
||||
try {
|
||||
if (!dirPath || typeof dirPath !== 'string') {
|
||||
|
||||
@@ -375,13 +375,16 @@ export class WindowService {
|
||||
|
||||
mainWindow.hide()
|
||||
|
||||
// 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()
|
||||
// }
|
||||
//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()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
|
||||
@@ -85,6 +85,9 @@ 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'
|
||||
|
||||
@@ -274,4 +277,711 @@ 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,14 @@ 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']
|
||||
protected jsonFields: string[] = [
|
||||
'tools',
|
||||
'mcps',
|
||||
'configuration',
|
||||
'accessible_paths',
|
||||
'allowed_tools',
|
||||
'slash_commands'
|
||||
]
|
||||
|
||||
/**
|
||||
* Initialize database with retry logic and proper error handling
|
||||
|
||||
@@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', {
|
||||
|
||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
|
||||
|
||||
configuration: text('configuration'), // JSON, extensible settings
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UpdateSessionResponse } from '@types'
|
||||
import { loggerService } from '@logger'
|
||||
import type { SlashCommand, UpdateSessionResponse } from '@types'
|
||||
import {
|
||||
AgentBaseSchema,
|
||||
type AgentEntity,
|
||||
@@ -13,6 +14,10 @@ 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
|
||||
@@ -29,6 +34,52 @@ 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> = {}
|
||||
@@ -111,7 +162,13 @@ export class SessionService extends BaseService {
|
||||
|
||||
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
|
||||
session.slash_commands = await this.listSlashCommands(session.agent_type)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
||||
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
|
||||
|
||||
const baseStreamMetadata = {
|
||||
parent_tool_use_id: null,
|
||||
@@ -10,9 +10,22 @@ 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()
|
||||
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
|
||||
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||
|
||||
const messages: SDKMessage[] = [
|
||||
@@ -169,14 +182,14 @@ describe('Claude → AiSDK transform', () => {
|
||||
(typeof parts)[number],
|
||||
{ type: 'tool-result' }
|
||||
>
|
||||
expect(toolResult.toolCallId).toBe('tool-1')
|
||||
expect(toolResult.toolCallId).toBe('session-123: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()
|
||||
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
|
||||
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||
|
||||
const messages: SDKMessage[] = [
|
||||
|
||||
@@ -10,8 +10,21 @@
|
||||
* 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).
|
||||
*/
|
||||
@@ -34,6 +47,7 @@ type ReasoningBlockState = BaseBlockState & {
|
||||
type ToolBlockState = BaseBlockState & {
|
||||
kind: 'tool'
|
||||
toolCallId: string
|
||||
rawToolCallId: string
|
||||
toolName: string
|
||||
inputBuffer: string
|
||||
providerMetadata?: ProviderMetadata
|
||||
@@ -48,12 +62,17 @@ 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
|
||||
@@ -61,12 +80,20 @@ type PendingToolCall = {
|
||||
* usage/finish metadata once Anthropic closes a message.
|
||||
*/
|
||||
export class ClaudeStreamState {
|
||||
private logger
|
||||
private readonly agentSessionId: string
|
||||
private blocksByIndex = new Map<number, BlockState>()
|
||||
private toolIndexById = new Map<string, number>()
|
||||
private toolIndexByNamespacedId = 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
|
||||
@@ -104,19 +131,21 @@ export class ClaudeStreamState {
|
||||
/** Caches tool metadata so subsequent input deltas and results can find it. */
|
||||
openToolBlock(
|
||||
index: number,
|
||||
params: { toolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
|
||||
params: { rawToolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
|
||||
): ToolBlockState {
|
||||
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, params.rawToolCallId)
|
||||
const block: ToolBlockState = {
|
||||
kind: 'tool',
|
||||
id: params.toolCallId,
|
||||
id: toolCallId,
|
||||
index,
|
||||
toolCallId: params.toolCallId,
|
||||
toolCallId,
|
||||
rawToolCallId: params.rawToolCallId,
|
||||
toolName: params.toolName,
|
||||
inputBuffer: '',
|
||||
providerMetadata: params.providerMetadata
|
||||
}
|
||||
this.blocksByIndex.set(index, block)
|
||||
this.toolIndexById.set(params.toolCallId, index)
|
||||
this.toolIndexByNamespacedId.set(toolCallId, index)
|
||||
return block
|
||||
}
|
||||
|
||||
@@ -125,13 +154,17 @@ export class ClaudeStreamState {
|
||||
}
|
||||
|
||||
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
|
||||
const index = this.toolIndexById.get(toolCallId)
|
||||
const index = this.toolIndexByNamespacedId.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)
|
||||
@@ -158,10 +191,12 @@ export class ClaudeStreamState {
|
||||
|
||||
/** Records a tool call to be consumed once its result arrives from the user. */
|
||||
registerToolCall(
|
||||
toolCallId: string,
|
||||
rawToolCallId: string,
|
||||
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
|
||||
): void {
|
||||
this.pendingToolCalls.set(toolCallId, {
|
||||
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
|
||||
this.pendingToolCalls.set(rawToolCallId, {
|
||||
rawToolCallId,
|
||||
toolCallId,
|
||||
toolName: payload.toolName,
|
||||
input: payload.input,
|
||||
@@ -170,10 +205,10 @@ export class ClaudeStreamState {
|
||||
}
|
||||
|
||||
/** Retrieves and clears the buffered tool call metadata for the given id. */
|
||||
consumePendingToolCall(toolCallId: string): PendingToolCall | undefined {
|
||||
const entry = this.pendingToolCalls.get(toolCallId)
|
||||
consumePendingToolCall(rawToolCallId: string): PendingToolCall | undefined {
|
||||
const entry = this.pendingToolCalls.get(rawToolCallId)
|
||||
if (entry) {
|
||||
this.pendingToolCalls.delete(toolCallId)
|
||||
this.pendingToolCalls.delete(rawToolCallId)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
@@ -183,12 +218,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: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
|
||||
toolName: block?.toolName ?? 'unknown',
|
||||
input,
|
||||
providerMetadata
|
||||
})
|
||||
const block = this.getToolBlockById(toolCallId)
|
||||
if (block) {
|
||||
block.resolvedInput = input
|
||||
}
|
||||
@@ -200,7 +235,7 @@ export class ClaudeStreamState {
|
||||
if (!block) return undefined
|
||||
this.blocksByIndex.delete(index)
|
||||
if (block.kind === 'tool') {
|
||||
this.toolIndexById.delete(block.toolCallId)
|
||||
this.toolIndexByNamespacedId.delete(block.toolCallId)
|
||||
}
|
||||
return block
|
||||
}
|
||||
@@ -227,7 +262,7 @@ export class ClaudeStreamState {
|
||||
/** Drops cached block metadata for the currently active message. */
|
||||
resetBlocks(): void {
|
||||
this.blocksByIndex.clear()
|
||||
this.toolIndexById.clear()
|
||||
this.toolIndexByNamespacedId.clear()
|
||||
}
|
||||
|
||||
/** Resets the entire step lifecycle after emitting a terminal frame. */
|
||||
@@ -236,6 +271,10 @@ export class ClaudeStreamState {
|
||||
this.resetPendingUsage()
|
||||
this.stepActive = false
|
||||
}
|
||||
|
||||
getNamespacedToolCallId(rawToolCallId: string): string {
|
||||
return buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
export type { PendingToolCall }
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
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: '/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' }
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
@@ -12,6 +12,8 @@ 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'
|
||||
|
||||
@@ -19,6 +21,7 @@ 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'
|
||||
@@ -148,7 +151,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
return promptForToolApproval(toolName, input, options)
|
||||
return promptForToolApproval(toolName, input, {
|
||||
...options,
|
||||
toolCallId: buildNamespacedToolCallId(session.id, options.toolUseID)
|
||||
})
|
||||
}
|
||||
|
||||
// Build SDK options from parameters
|
||||
@@ -197,7 +203,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
options.strictMcpConfig = true
|
||||
}
|
||||
|
||||
if (lastAgentSessionId) {
|
||||
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
|
||||
options.resume = lastAgentSessionId
|
||||
// TODO: use fork session when we support branching sessions
|
||||
// options.forkSession = true
|
||||
@@ -220,7 +226,15 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
// Start async processing on the next tick so listeners can subscribe first
|
||||
setImmediate(() => {
|
||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
||||
this.processSDKQuery(
|
||||
userInputStream,
|
||||
closeUserStream,
|
||||
options,
|
||||
aiStream,
|
||||
errorChunks,
|
||||
session.agent_id,
|
||||
session.id
|
||||
).catch((error) => {
|
||||
logger.error('Unhandled Claude Code stream error', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
@@ -329,12 +343,14 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
closePromptStream: () => void,
|
||||
options: Options,
|
||||
stream: ClaudeCodeStream,
|
||||
errorChunks: string[]
|
||||
errorChunks: string[],
|
||||
agentId: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const jsonOutput: SDKMessage[] = []
|
||||
let hasCompleted = false
|
||||
const startTime = Date.now()
|
||||
const streamState = new ClaudeStreamState()
|
||||
const streamState = new ClaudeStreamState({ agentSessionId: sessionId })
|
||||
|
||||
try {
|
||||
for await (const message of query({ prompt: promptStream, options })) {
|
||||
@@ -342,6 +358,62 @@ 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,
|
||||
@@ -378,7 +450,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
hasCompleted = true
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.debug('SDK query completed successfully', {
|
||||
|
||||
@@ -37,6 +37,7 @@ type RendererPermissionRequestPayload = {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolId: string
|
||||
toolCallId: string
|
||||
description?: string
|
||||
requiresPermissions: boolean
|
||||
input: Record<string, unknown>
|
||||
@@ -206,10 +207,19 @@ 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?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
|
||||
options: PromptForToolApprovalOptions
|
||||
): Promise<PermissionResult> {
|
||||
if (shouldAutoApproveTools) {
|
||||
logger.debug('promptForToolApproval auto-approving tool for test', {
|
||||
@@ -245,6 +255,7 @@ export async function promptForToolApproval(
|
||||
logger.info('Requesting user approval for tool usage', {
|
||||
requestId,
|
||||
toolName,
|
||||
toolCallId: options.toolCallId,
|
||||
description: toolMetadata?.description
|
||||
})
|
||||
|
||||
@@ -252,6 +263,7 @@ export async function promptForToolApproval(
|
||||
requestId,
|
||||
toolName,
|
||||
toolId: toolMetadata?.id ?? toolName,
|
||||
toolCallId: options.toolCallId,
|
||||
description: toolMetadata?.description,
|
||||
requiresPermissions: toolMetadata?.requirePermissions ?? false,
|
||||
input: sanitizedInput,
|
||||
@@ -266,6 +278,7 @@ 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
|
||||
@@ -273,7 +286,11 @@ export async function promptForToolApproval(
|
||||
|
||||
return new Promise<PermissionResult>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.info('User tool permission request timed out', { requestId, toolName })
|
||||
logger.info('User tool permission request timed out', {
|
||||
requestId,
|
||||
toolName,
|
||||
toolCallId: options.toolCallId
|
||||
})
|
||||
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
|
||||
}, TOOL_APPROVAL_TIMEOUT_MS)
|
||||
|
||||
@@ -287,7 +304,11 @@ export async function promptForToolApproval(
|
||||
|
||||
if (options?.signal) {
|
||||
const abortListener = () => {
|
||||
logger.info('Tool permission request aborted before user responded', { requestId, toolName })
|
||||
logger.info('Tool permission request aborted before user responded', {
|
||||
requestId,
|
||||
toolName,
|
||||
toolCallId: options.toolCallId
|
||||
})
|
||||
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
|
||||
}
|
||||
|
||||
|
||||
@@ -73,13 +73,21 @@ 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 => {
|
||||
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
const withoutLocalCommandTags = stripLocalCommandTags(text)
|
||||
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +110,7 @@ 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)
|
||||
@@ -135,7 +144,8 @@ function handleAssistantMessage(
|
||||
const isStreamingActive = state.hasActiveStep()
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (!content) {
|
||||
const sanitizedContent = stripLocalCommandTags(content)
|
||||
if (!sanitizedContent) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
@@ -157,7 +167,7 @@ function handleAssistantMessage(
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: textId,
|
||||
text: content,
|
||||
text: sanitizedContent,
|
||||
providerMetadata
|
||||
})
|
||||
chunks.push({
|
||||
@@ -178,7 +188,10 @@ function handleAssistantMessage(
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (!isStreamingActive) {
|
||||
textBlocks.push(block.text)
|
||||
const sanitizedText = stripLocalCommandTags(block.text)
|
||||
if (sanitizedText) {
|
||||
textBlocks.push(sanitizedText)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool_use':
|
||||
@@ -230,9 +243,10 @@ function handleAssistantToolUse(
|
||||
state: ClaudeStreamState,
|
||||
chunks: AgentStreamPart[]
|
||||
): void {
|
||||
const toolCallId = state.getNamespacedToolCallId(block.id)
|
||||
chunks.push({
|
||||
type: 'tool-call',
|
||||
toolCallId: block.id,
|
||||
toolCallId,
|
||||
toolName: block.name,
|
||||
input: block.input,
|
||||
providerExecuted: true,
|
||||
@@ -318,10 +332,11 @@ 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: toolResult.tool_use_id,
|
||||
toolCallId,
|
||||
toolName: pendingCall?.toolName ?? 'unknown',
|
||||
input: pendingCall?.input,
|
||||
error: toolResult.content,
|
||||
@@ -330,7 +345,7 @@ function handleUserMessage(
|
||||
} else {
|
||||
chunks.push({
|
||||
type: 'tool-result',
|
||||
toolCallId: toolResult.tool_use_id,
|
||||
toolCallId,
|
||||
toolName: pendingCall?.toolName ?? 'unknown',
|
||||
input: pendingCall?.input,
|
||||
output: toolResult.content,
|
||||
@@ -501,7 +516,7 @@ function handleContentBlockStart(
|
||||
}
|
||||
case 'tool_use': {
|
||||
const block = state.openToolBlock(index, {
|
||||
toolCallId: contentBlock.id,
|
||||
rawToolCallId: contentBlock.id,
|
||||
toolName: contentBlock.name,
|
||||
providerMetadata
|
||||
})
|
||||
@@ -537,6 +552,10 @@ 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,
|
||||
|
||||
@@ -48,6 +48,16 @@ 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 }
|
||||
@@ -101,6 +111,7 @@ 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)
|
||||
@@ -201,6 +212,8 @@ 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),
|
||||
|
||||
@@ -30,18 +30,22 @@ 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
|
||||
onSessionUpdate?: (sessionId: string) => void,
|
||||
getSessionWasCleared?: () => boolean
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
this.onSessionUpdate = onSessionUpdate
|
||||
this.getSessionWasCleared = getSessionWasCleared
|
||||
}
|
||||
|
||||
private markFirstTokenIfNeeded() {
|
||||
@@ -84,8 +88,9 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
this.resetTimingState()
|
||||
this.responseStartTimestamp = Date.now()
|
||||
// Reset link converter state at the start of stream
|
||||
// Reset state at the start of stream
|
||||
this.isFirstChunk = true
|
||||
this.hasTextContent = false
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -129,6 +134,8 @@ 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,
|
||||
@@ -143,6 +150,7 @@ export class AiSdkToChunkAdapter {
|
||||
})
|
||||
break
|
||||
case 'text-delta': {
|
||||
this.hasTextContent = true
|
||||
const processedText = chunk.text || ''
|
||||
let finalText: string
|
||||
|
||||
@@ -301,6 +309,25 @@ 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,
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
* 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, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } 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 { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
import { gateway, type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
|
||||
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||
import LegacyAiProvider from './legacy/index'
|
||||
@@ -439,6 +440,18 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
getModelSupportedVerbosity,
|
||||
isFunctionCallingModel,
|
||||
isNotSupportTemperatureAndTopP,
|
||||
isOpenAIModel,
|
||||
@@ -242,12 +243,18 @@ export abstract class BaseApiClient<
|
||||
return serviceTierSetting
|
||||
}
|
||||
|
||||
protected getVerbosity(): OpenAIVerbosity {
|
||||
protected getVerbosity(model?: Model): 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) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isSupportedThinkingTokenModel,
|
||||
isSupportedThinkingTokenQwenModel,
|
||||
isSupportedThinkingTokenZhipuModel,
|
||||
isSupportVerbosityModel,
|
||||
isVisionModel,
|
||||
MODEL_SUPPORTED_REASONING_EFFORT,
|
||||
ZHIPU_RESULT_TOKENS
|
||||
@@ -733,6 +734,13 @@ 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),
|
||||
|
||||
@@ -48,9 +48,8 @@ export abstract class OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 仅适用于openai
|
||||
override getBaseURL(): string {
|
||||
const host = this.provider.apiHost
|
||||
return formatApiHost(host)
|
||||
override getBaseURL(isSupportedAPIVerion: boolean = true): string {
|
||||
return formatApiHost(this.provider.apiHost, isSupportedAPIVerion)
|
||||
}
|
||||
|
||||
override async generateImage({
|
||||
@@ -144,6 +143,11 @@ 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
|
||||
@@ -151,6 +155,11 @@ 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') {
|
||||
@@ -164,12 +173,8 @@ export abstract class OpenAIBaseClient<
|
||||
this.sdkInstance = new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: apiKeyForSdkInstance,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers,
|
||||
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
|
||||
}
|
||||
baseURL: baseURLForSdkInstance,
|
||||
defaultHeaders: headersForSdkInstance
|
||||
}) as TSdkInstance
|
||||
}
|
||||
return this.sdkInstance
|
||||
|
||||
@@ -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') {
|
||||
if (this.provider.apiVersion === 'preview' || this.provider.apiVersion === 'v1') {
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
@@ -297,7 +297,31 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
|
||||
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
|
||||
const content: OpenAI.Responses.ResponseInput = []
|
||||
content.push(...response.output)
|
||||
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.')
|
||||
}
|
||||
}
|
||||
})
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -496,7 +520,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
...(isSupportVerbosityModel(model)
|
||||
? {
|
||||
text: {
|
||||
verbosity: this.getVerbosity()
|
||||
verbosity: this.getVerbosity(model)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
|
||||
13
src/renderer/src/aiCore/prepareParams/header.ts
Normal file
13
src/renderer/src/aiCore/prepareParams/header.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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
|
||||
}
|
||||
@@ -7,10 +7,12 @@ 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,
|
||||
@@ -19,6 +21,8 @@ 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'
|
||||
@@ -34,6 +38,7 @@ 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'
|
||||
|
||||
@@ -172,13 +177,21 @@ 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: options.requestOptions?.headers,
|
||||
headers,
|
||||
providerOptions,
|
||||
stopWhen: stepCountIs(20),
|
||||
maxRetries: 0
|
||||
|
||||
@@ -84,6 +84,8 @@ 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)
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export function providerToAiSdkConfig(
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai') {
|
||||
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
|
||||
@@ -189,9 +189,11 @@ 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 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview') {
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion === 'preview' ? 'v1' : actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview' || actualProvider.apiVersion === 'v1') {
|
||||
extraOptions.mode = 'responses'
|
||||
} else {
|
||||
extraOptions.mode = 'chat'
|
||||
|
||||
@@ -71,6 +71,21 @@ 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
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-core/provider'
|
||||
import { isOpenAIModel, isQwenMTModel, isSupportFlexServiceTierModel } from '@renderer/config/models'
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
getModelSupportedVerbosity,
|
||||
isOpenAIModel,
|
||||
isQwenMTModel,
|
||||
isSupportFlexServiceTierModel,
|
||||
isSupportVerbosityModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportServiceTierProvider } from '@renderer/config/providers'
|
||||
import { mapLanguageToQwenMTModel } from '@renderer/config/translate'
|
||||
import type { Assistant, Model, Provider } from '@renderer/types'
|
||||
@@ -26,6 +33,8 @@ 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
|
||||
@@ -70,6 +79,7 @@ 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> = {}
|
||||
@@ -89,9 +99,6 @@ export function buildProviderOptions(
|
||||
serviceTier: serviceTierSetting
|
||||
}
|
||||
break
|
||||
case 'huggingface':
|
||||
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
|
||||
break
|
||||
case 'anthropic':
|
||||
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
|
||||
break
|
||||
@@ -113,6 +120,9 @@ export function buildProviderOptions(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'cherryin':
|
||||
providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported base provider ${baseProviderId}`)
|
||||
}
|
||||
@@ -131,6 +141,9 @@ export function buildProviderOptions(
|
||||
case 'bedrock':
|
||||
providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities)
|
||||
break
|
||||
case 'huggingface':
|
||||
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
|
||||
break
|
||||
default:
|
||||
// 对于其他 provider,使用通用的构建逻辑
|
||||
providerSpecificOptions = {
|
||||
@@ -148,13 +161,18 @@ export function buildProviderOptions(
|
||||
...providerSpecificOptions,
|
||||
...getCustomParameters(assistant)
|
||||
}
|
||||
// vertex需要映射到google或anthropic
|
||||
const rawProviderKey =
|
||||
|
||||
let rawProviderKey =
|
||||
{
|
||||
'google-vertex': 'google',
|
||||
'google-vertex-anthropic': 'anthropic'
|
||||
'google-vertex-anthropic': 'anthropic',
|
||||
'ai-gateway': 'gateway'
|
||||
}[rawProviderId] || rawProviderId
|
||||
|
||||
if (rawProviderKey === 'cherryin') {
|
||||
rawProviderKey = { gemini: 'google' }[actualProvider.type] || actualProvider.type
|
||||
}
|
||||
|
||||
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
|
||||
return {
|
||||
[rawProviderKey]: providerSpecificOptions
|
||||
@@ -183,6 +201,23 @@ 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
|
||||
}
|
||||
|
||||
@@ -270,6 +305,34 @@ 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
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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 {
|
||||
@@ -7,6 +11,7 @@ import {
|
||||
isDeepSeekHybridInferenceModel,
|
||||
isDoubaoSeedAfter251015,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGPT51SeriesModel,
|
||||
isGrok4FastReasoningModel,
|
||||
isGrokReasoningModel,
|
||||
isOpenAIDeepResearchModel,
|
||||
@@ -56,13 +61,20 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
if (!reasoningEffort) {
|
||||
// Handle undefined and 'none' reasoningEffort.
|
||||
// TODO: They should be separated.
|
||||
if (!reasoningEffort || reasoningEffort === 'none') {
|
||||
// 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) ||
|
||||
@@ -109,9 +121,21 @@ 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 {}
|
||||
}
|
||||
|
||||
@@ -306,6 +330,9 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
if (provider.id === SystemProviderIds.cerebras) {
|
||||
return {}
|
||||
}
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
|
||||
@@ -363,7 +390,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) {
|
||||
if (reasoningEffort === undefined || reasoningEffort === 'none') {
|
||||
return 0
|
||||
}
|
||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||
@@ -385,14 +412,17 @@ export function getAnthropicThinkingBudget(assistant: Assistant, model: Model):
|
||||
* 获取 Anthropic 推理参数
|
||||
* 从 AnthropicAPIClient 中提取的逻辑
|
||||
*/
|
||||
export function getAnthropicReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
export function getAnthropicReasoningParams(
|
||||
assistant: Assistant,
|
||||
model: Model
|
||||
): Pick<AnthropicProviderOptions, 'thinking'> {
|
||||
if (!isReasoningModel(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
if (reasoningEffort === undefined) {
|
||||
if (reasoningEffort === undefined || reasoningEffort === 'none') {
|
||||
return {
|
||||
thinking: {
|
||||
type: 'disabled'
|
||||
@@ -421,7 +451,10 @@ export function getAnthropicReasoningParams(assistant: Assistant, model: Model):
|
||||
* 注意:Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递
|
||||
* 而在 Google 官方提供的 OpenAI 兼容端点中则使用蛇形命名法 thinking_budget
|
||||
*/
|
||||
export function getGeminiReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
export function getGeminiReasoningParams(
|
||||
assistant: Assistant,
|
||||
model: Model
|
||||
): Pick<GoogleGenerativeAIProviderOptions, 'thinkingConfig'> {
|
||||
if (!isReasoningModel(model)) {
|
||||
return {}
|
||||
}
|
||||
@@ -430,7 +463,7 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
||||
|
||||
// Gemini 推理参数
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
if (reasoningEffort === undefined) {
|
||||
if (reasoningEffort === undefined || reasoningEffort === 'none') {
|
||||
return {
|
||||
thinkingConfig: {
|
||||
includeThoughts: false,
|
||||
@@ -470,27 +503,35 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
||||
* @param model - The model being used
|
||||
* @returns XAI-specific reasoning parameters
|
||||
*/
|
||||
export function getXAIReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
export function getXAIReasoningParams(assistant: Assistant, model: Model): Pick<XaiProviderOptions, 'reasoningEffort'> {
|
||||
if (!isSupportedReasoningEffortGrokModel(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||
|
||||
if (!reasoningEffort) {
|
||||
if (!reasoningEffort || reasoningEffort === 'none') {
|
||||
return {}
|
||||
}
|
||||
|
||||
// For XAI provider Grok models, use reasoningEffort parameter directly
|
||||
return {
|
||||
reasoningEffort
|
||||
switch (reasoningEffort) {
|
||||
case 'auto':
|
||||
case 'minimal':
|
||||
case 'medium':
|
||||
return { reasoningEffort: 'low' }
|
||||
case 'low':
|
||||
case 'high':
|
||||
return { reasoningEffort }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bedrock reasoning parameters
|
||||
*/
|
||||
export function getBedrockReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
||||
export function getBedrockReasoningParams(
|
||||
assistant: Assistant,
|
||||
model: Model
|
||||
): Pick<BedrockProviderOptions, 'reasoningConfig'> {
|
||||
if (!isReasoningModel(model)) {
|
||||
return {}
|
||||
}
|
||||
@@ -501,6 +542,14 @@ export function getBedrockReasoningParams(assistant: Assistant, model: Model): R
|
||||
return {}
|
||||
}
|
||||
|
||||
if (reasoningEffort === 'none') {
|
||||
return {
|
||||
reasoningConfig: {
|
||||
type: 'disabled'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply thinking budget for Claude reasoning models
|
||||
if (!isSupportedThinkingTokenClaudeModel(model)) {
|
||||
return {}
|
||||
|
||||
@@ -107,6 +107,11 @@ 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 {}
|
||||
}
|
||||
|
||||
BIN
src/renderer/src/assets/images/models/gpt-5.1-chat.png
Normal file
BIN
src/renderer/src/assets/images/models/gpt-5.1-chat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
src/renderer/src/assets/images/models/gpt-5.1-codex-mini.png
Normal file
BIN
src/renderer/src/assets/images/models/gpt-5.1-codex-mini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
src/renderer/src/assets/images/models/gpt-5.1-codex.png
Normal file
BIN
src/renderer/src/assets/images/models/gpt-5.1-codex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
src/renderer/src/assets/images/models/gpt-5.1.png
Normal file
BIN
src/renderer/src/assets/images/models/gpt-5.1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
src/renderer/src/assets/images/providers/cerebras.webp
Normal file
BIN
src/renderer/src/assets/images/providers/cerebras.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
1
src/renderer/src/assets/images/providers/vercel.svg
Normal file
1
src/renderer/src/assets/images/providers/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -181,17 +182,14 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
// 3. 将当前项滚动到视图中
|
||||
// 获取第一个文本节点的父元素来进行滚动
|
||||
const parentElement = currentMatchRange.startContainer.parentElement
|
||||
if (shouldScroll) {
|
||||
parentElement?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
if (shouldScroll && parentElement) {
|
||||
// 优先在指定的滚动容器内滚动,避免滚动整个页面导致索引错乱/看起来"跳到第一条"
|
||||
scrollElementIntoView(parentElement, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[allRanges, currentIndex]
|
||||
[allRanges, currentIndex, target]
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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'
|
||||
@@ -9,7 +8,6 @@ import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAg
|
||||
import type {
|
||||
AddAgentForm,
|
||||
AgentEntity,
|
||||
AgentType,
|
||||
ApiModel,
|
||||
BaseAgentForm,
|
||||
PermissionMode,
|
||||
@@ -17,30 +15,22 @@ import type {
|
||||
UpdateAgentForm
|
||||
} from '@renderer/types'
|
||||
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
|
||||
import { Avatar, Button, Input, Modal, Select } from 'antd'
|
||||
import { 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 ?? 'Claude Code',
|
||||
name: existing?.name ?? 'Agent',
|
||||
description: existing?.description,
|
||||
instructions: existing?.instructions,
|
||||
model: existing?.model ?? '',
|
||||
@@ -100,54 +90,6 @@ 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,
|
||||
@@ -155,12 +97,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) => ({
|
||||
@@ -334,16 +276,6 @@ 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>
|
||||
@@ -363,7 +295,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
|
||||
avatarSize={24}
|
||||
iconSize={16}
|
||||
buttonStyle={{
|
||||
padding: '8px 12px',
|
||||
padding: '3px 8px',
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
@@ -382,7 +314,6 @@ 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)}>
|
||||
@@ -438,10 +369,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={2} value={form.description ?? ''} onChange={onDescChange} />
|
||||
</FormItem>
|
||||
<TextArea rows={1} value={form.description ?? ''} onChange={onDescChange} />
|
||||
</FormItem> */}
|
||||
</FormContent>
|
||||
|
||||
<FormFooter>
|
||||
@@ -575,14 +506,7 @@ const FormFooter = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const OptionWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
`
|
||||
|
||||
const PermissionOptionWrapper = styled.div`
|
||||
|
||||
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
|
||||
|
||||
/**
|
||||
* Default filter function
|
||||
* Implements standard filtering logic with pinyin support
|
||||
*/
|
||||
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
|
||||
if (!searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Direct substring match
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match for Chinese characters
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
let pinyinText = pinyinCache.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCache.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score for sorting
|
||||
* Higher score = better match
|
||||
*/
|
||||
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Exact match (highest priority)
|
||||
if (lowerFilterText === lowerSearchText) {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Label exact match (very high priority)
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) {
|
||||
return 900
|
||||
}
|
||||
|
||||
// Starts with search text (high priority)
|
||||
if (lowerFilterText.startsWith(lowerSearchText)) {
|
||||
return 800
|
||||
}
|
||||
|
||||
// Label starts with search text
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) {
|
||||
return 700
|
||||
}
|
||||
|
||||
// Contains search text (medium priority)
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
// Earlier position = higher score
|
||||
const position = lowerFilterText.indexOf(lowerSearchText)
|
||||
return 600 - position
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match (lower priority)
|
||||
return 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort function
|
||||
* Sorts items by match score in descending order
|
||||
*/
|
||||
export const defaultSortFn: QuickPanelSortFn = (items, searchText) => {
|
||||
if (!searchText) return items
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const scoreA = calculateMatchScore(a, searchText)
|
||||
const scoreB = calculateMatchScore(b, searchText)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './defaultStrategies'
|
||||
export * from './hook'
|
||||
export * from './provider'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,11 +4,12 @@ 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 }) => {
|
||||
@@ -17,19 +18,39 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
|
||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||
const [title, setTitle] = useState<string | undefined>()
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
|
||||
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) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
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
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 添加更新整个列表的方法
|
||||
@@ -43,17 +64,23 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
clearTimer.current = null
|
||||
}
|
||||
|
||||
setLastCloseAction(undefined)
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
|
||||
setDefaultIndex(nextDefaultIndex)
|
||||
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)
|
||||
}, [])
|
||||
@@ -61,6 +88,8 @@ 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(() => {
|
||||
@@ -68,9 +97,13 @@ 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]
|
||||
@@ -100,10 +133,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
afterAction,
|
||||
onSearchChange
|
||||
}),
|
||||
[
|
||||
open,
|
||||
@@ -117,10 +155,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
afterAction,
|
||||
onSearchChange
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export enum QuickPanelReservedSymbol {
|
||||
WebSearch = '?',
|
||||
Mcp = 'mcp',
|
||||
McpPrompt = 'mcp-prompt',
|
||||
McpResource = 'mcp-resource'
|
||||
McpResource = 'mcp-resource',
|
||||
SlashCommands = 'slash-commands'
|
||||
}
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
@@ -27,6 +28,29 @@ 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
|
||||
@@ -48,6 +72,14 @@ 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 = {
|
||||
@@ -88,10 +120,15 @@ 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'
|
||||
|
||||
@@ -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,21 +62,50 @@ 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
|
||||
@@ -86,52 +115,35 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
||||
|
||||
// 拆分:固定显示项(不参与过滤)与普通项
|
||||
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
|
||||
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
|
||||
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
|
||||
const normalItems = baseList.filter((item) => !item.alwaysVisible)
|
||||
|
||||
// Filter normal items using injected filter function
|
||||
const filteredNormalItems = normalItems.filter((item) => {
|
||||
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()
|
||||
|
||||
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())
|
||||
}
|
||||
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
|
||||
})
|
||||
|
||||
// Sort filtered items using injected sort function
|
||||
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
|
||||
|
||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
|
||||
if (isSearchChanged || isSymbolChanged) {
|
||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
||||
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) // 搜索文本变化时不默认高亮
|
||||
}
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
||||
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
@@ -142,10 +154,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
// 固定项置顶 + 过滤后的普通项
|
||||
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
|
||||
return pinnedFiltered.filter((item) => !item.hidden)
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
||||
// 固定项置顶 + 排序后的普通项
|
||||
return [...pinnedItems, ...sortedNormalItems]
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
@@ -179,19 +190,64 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
if (deleteStart >= deleteEnd) return
|
||||
|
||||
// 删除文本
|
||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
||||
setInputText(newText)
|
||||
const activeSearchText = searchTextRef.current ?? ''
|
||||
|
||||
// 设置光标位置
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
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
|
||||
})
|
||||
|
||||
setSearchText('')
|
||||
},
|
||||
@@ -211,11 +267,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
|
||||
clearSearchText(true)
|
||||
} else if (
|
||||
action &&
|
||||
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
|
||||
ctx.triggerInfo?.type === 'input'
|
||||
) {
|
||||
setTimeoutTimer(
|
||||
'quickpanel_deferred_clear',
|
||||
() => {
|
||||
clearSearchText(true)
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText, searchText]
|
||||
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
@@ -285,12 +351,86 @@ 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
|
||||
@@ -305,6 +445,8 @@ 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')
|
||||
@@ -328,16 +470,17 @@ 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
|
||||
) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (ctx.isVisible) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [ctx.isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -545,19 +688,7 @@ 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 = 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 collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||
|
||||
@@ -616,7 +747,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
{!collapsed && (
|
||||
{collapsed ? (
|
||||
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
|
||||
) : (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
@@ -726,6 +859,13 @@ 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%;
|
||||
|
||||
@@ -81,6 +81,16 @@ 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>) {
|
||||
@@ -95,6 +105,8 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
itemContainerStyle,
|
||||
scrollerStyle,
|
||||
autoHideScrollbar = false,
|
||||
header,
|
||||
className,
|
||||
...restOptions
|
||||
} = props
|
||||
|
||||
@@ -189,7 +201,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="dynamic-virtual-list"
|
||||
className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'}
|
||||
role="region"
|
||||
aria-label="Dynamic Virtual List"
|
||||
aria-hidden={!showScrollbar}
|
||||
@@ -200,6 +212,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
||||
...scrollerStyle
|
||||
}}>
|
||||
{header}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isLingReasoningModel } from '../models/reasoning'
|
||||
import {
|
||||
isDoubaoSeedAfter251015,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isGeminiReasoningModel,
|
||||
isLingReasoningModel,
|
||||
isSupportedThinkingTokenGeminiModel
|
||||
} from '../models/reasoning'
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
default: {
|
||||
@@ -231,3 +237,284 @@ 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
167
src/renderer/src/config/__test__/vision.test.ts
Normal file
167
src/renderer/src/config/__test__/vision.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
64
src/renderer/src/config/__test__/websearch.test.ts
Normal file
64
src/renderer/src/config/__test__/websearch.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1003,6 +1003,18 @@ 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: [
|
||||
@@ -1840,5 +1852,26 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
group: 'LongCat'
|
||||
}
|
||||
],
|
||||
huggingface: []
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ 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'
|
||||
@@ -182,6 +186,10 @@ 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,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
||||
|
||||
import { isEmbeddingModel, isRerankModel } from './embedding'
|
||||
import { isGPT5SeriesModel } from './utils'
|
||||
import { isGPT5ProModel, isGPT5SeriesModel, isGPT51SeriesModel } from './utils'
|
||||
import { isTextToImageModel } from './vision'
|
||||
import { GEMINI_FLASH_MODEL_REGEX, isOpenAIDeepResearchModel } from './websearch'
|
||||
|
||||
@@ -24,6 +24,9 @@ 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,
|
||||
@@ -41,24 +44,27 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||
|
||||
// 模型类型到支持选项的映射表
|
||||
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||
default: ['none', ...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: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
|
||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||
grok4_fast: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
|
||||
gemini: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
||||
qwen: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
||||
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
|
||||
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
||||
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
||||
doubao: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
||||
doubao_no_auto: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
||||
doubao_after_251015: MODEL_SUPPORTED_REASONING_EFFORT.doubao_after_251015,
|
||||
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||
hunyuan: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||
zhipu: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
||||
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
|
||||
deepseek_hybrid: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
|
||||
} as const
|
||||
|
||||
const withModelIdAndNameAsId = <T>(model: Model, fn: (model: Model) => T): { idResult: T; nameResult: T } => {
|
||||
@@ -75,11 +81,20 @@ const _getThinkModelType = (model: Model): ThinkingModelType => {
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
return 'openai_deep_research'
|
||||
}
|
||||
if (isGPT5SeriesModel(model)) {
|
||||
if (isGPT51SeriesModel(model)) {
|
||||
if (modelId.includes('codex')) {
|
||||
thinkingModelType = 'gpt5_1_codex'
|
||||
} else {
|
||||
thinkingModelType = 'gpt5_1'
|
||||
}
|
||||
} else if (isGPT5SeriesModel(model)) {
|
||||
if (modelId.includes('codex')) {
|
||||
thinkingModelType = 'gpt5_codex'
|
||||
} else {
|
||||
thinkingModelType = 'gpt5'
|
||||
if (isGPT5ProModel(model)) {
|
||||
thinkingModelType = 'gpt5pro'
|
||||
}
|
||||
}
|
||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||
thinkingModelType = 'o'
|
||||
@@ -239,7 +254,7 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
||||
|
||||
// Gemini 支持思考模式的模型正则
|
||||
export const GEMINI_THINKING_MODEL_REGEX =
|
||||
/gemini-(?:2\.5.*(?:-latest)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
|
||||
/gemini-(?:2\.5.*(?:-latest)?|3-(?:flash|pro)(?:-preview)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\w-]+)*$/i
|
||||
|
||||
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
|
||||
const modelId = getLowerBaseModelName(model.id, '/')
|
||||
@@ -526,7 +541,7 @@ export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean {
|
||||
modelId.includes('o3') ||
|
||||
modelId.includes('o4') ||
|
||||
modelId.includes('gpt-oss') ||
|
||||
(isGPT5SeriesModel(model) && !modelId.includes('chat'))
|
||||
((isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat'))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function isSupportedFlexServiceTier(model: Model): boolean {
|
||||
|
||||
export function isSupportVerbosityModel(model: Model): boolean {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return isGPT5SeriesModel(model) && !modelId.includes('chat')
|
||||
return (isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat')
|
||||
}
|
||||
|
||||
export function isOpenAIChatCompletionOnlyModel(model: Model): boolean {
|
||||
@@ -227,12 +227,32 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => {
|
||||
|
||||
export const isGPT5SeriesModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gpt-5')
|
||||
return modelId.includes('gpt-5') && !modelId.includes('gpt-5.1')
|
||||
}
|
||||
|
||||
export const isGPT5SeriesReasoningModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return modelId.includes('gpt-5') && !modelId.includes('chat')
|
||||
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
|
||||
}
|
||||
|
||||
export const isGeminiModel = (model: Model) => {
|
||||
@@ -251,3 +271,8 @@ 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')
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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',
|
||||
@@ -64,13 +65,13 @@ const visionExcludedModels = [
|
||||
'o1-preview',
|
||||
'AIDC-AI/Marco-o1'
|
||||
]
|
||||
export const VISION_REGEX = new RegExp(
|
||||
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
|
||||
export const DEDICATED_IMAGE_MODELS = [
|
||||
const DEDICATED_IMAGE_MODELS = [
|
||||
'grok-2-image',
|
||||
'grok-2-image-1212',
|
||||
'grok-2-image-latest',
|
||||
@@ -79,7 +80,7 @@ export const DEDICATED_IMAGE_MODELS = [
|
||||
'gpt-image-1'
|
||||
]
|
||||
|
||||
export const IMAGE_ENHANCEMENT_MODELS = [
|
||||
const IMAGE_ENHANCEMENT_MODELS = [
|
||||
'grok-2-image(?:-[\\w-]+)?',
|
||||
'qwen-image-edit',
|
||||
'gpt-image-1',
|
||||
@@ -90,9 +91,9 @@ export 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
|
||||
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
|
||||
const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image', ...DEDICATED_IMAGE_MODELS]
|
||||
|
||||
export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
||||
const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
||||
'o3',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -102,9 +103,9 @@ export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
||||
'gpt-5'
|
||||
]
|
||||
|
||||
export const OPENAI_IMAGE_GENERATION_MODELS = [...OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS, 'gpt-image-1']
|
||||
const OPENAI_IMAGE_GENERATION_MODELS = [...OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS, 'gpt-image-1']
|
||||
|
||||
export const GENERATE_IMAGE_MODELS = [
|
||||
const GENERATE_IMAGE_MODELS = [
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
@@ -169,22 +170,23 @@ export function isPureGenerateImageModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
// Text to image models
|
||||
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|image|gpt-image/i
|
||||
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)
|
||||
}
|
||||
|
||||
export function isNotSupportedImageSizeModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
// It's not used now
|
||||
// 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')
|
||||
// }
|
||||
|
||||
/**
|
||||
* 判断模型是否支持图片增强(包括编辑、增强、修复等)
|
||||
|
||||
@@ -3,7 +3,13 @@ import type { Model } from '@renderer/types'
|
||||
import { SystemProviderIds } from '@renderer/types'
|
||||
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
||||
|
||||
import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider } from '../providers'
|
||||
import {
|
||||
isGeminiProvider,
|
||||
isNewApiProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isOpenAIProvider,
|
||||
isVertexAiProvider
|
||||
} from '../providers'
|
||||
import { isEmbeddingModel, isRerankModel } from './embedding'
|
||||
import { isAnthropicModel } from './utils'
|
||||
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
|
||||
@@ -16,7 +22,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)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
|
||||
'gemini-(?:2.*(?:-latest)?|3-(?:flash|pro)(?:-preview)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$',
|
||||
'i'
|
||||
)
|
||||
|
||||
@@ -70,7 +76,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)
|
||||
}
|
||||
@@ -107,7 +113,7 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGeminiProvider(provider) || provider.id === SystemProviderIds.vertexai) {
|
||||
if (isGeminiProvider(provider) || isVertexAiProvider(provider)) {
|
||||
return GEMINI_SEARCH_REGEX.test(modelId)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
@@ -51,6 +52,7 @@ 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'
|
||||
@@ -65,7 +67,7 @@ import type {
|
||||
SystemProvider,
|
||||
SystemProviderId
|
||||
} from '@renderer/types'
|
||||
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
|
||||
import { isSystemProvider, OpenAIServiceTiers, SystemProviderIds } from '@renderer/types'
|
||||
|
||||
import { TOKENFLUX_HOST } from './constant'
|
||||
import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models'
|
||||
@@ -273,6 +275,7 @@ 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
|
||||
@@ -470,7 +473,8 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
name: 'MiniMax',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.minimax.chat/v1/',
|
||||
apiHost: 'https://api.minimaxi.com/v1',
|
||||
anthropicApiHost: 'https://api.minimaxi.com/anthropic',
|
||||
models: SYSTEM_MODELS.minimax,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@@ -662,6 +666,7 @@ 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
|
||||
@@ -675,6 +680,26 @@ 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
|
||||
|
||||
@@ -741,7 +766,9 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
aionly: AiOnlyProviderLogo,
|
||||
longcat: LongCatProviderLogo,
|
||||
huggingface: HuggingfaceProviderLogo,
|
||||
sophnet: SophnetProviderLogo
|
||||
sophnet: SophnetProviderLogo,
|
||||
'ai-gateway': AIGatewayProviderLogo,
|
||||
cerebras: CerebrasProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -1048,7 +1075,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
minimax: {
|
||||
api: {
|
||||
url: 'https://api.minimax.chat/v1/'
|
||||
url: 'https://api.minimaxi.com/v1/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://platform.minimaxi.com/',
|
||||
@@ -1390,6 +1417,28 @@ 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,7 +1501,7 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
|
||||
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
* 判断提供商是否支持 service_tier 设置。 Only for OpenAI API.
|
||||
@@ -1472,7 +1521,10 @@ 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)
|
||||
return (
|
||||
SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type) ||
|
||||
provider.id === SystemProviderIds.cherryin
|
||||
)
|
||||
}
|
||||
|
||||
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
|
||||
@@ -1519,6 +1571,18 @@ 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) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -6,6 +7,8 @@ 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.
|
||||
*/
|
||||
@@ -37,6 +40,9 @@ export const useCreateDefaultSession = (agentId: string | null) => {
|
||||
}
|
||||
|
||||
return created
|
||||
} catch (error) {
|
||||
logger.error('Error creating default session:', error as Error)
|
||||
return null
|
||||
} finally {
|
||||
setCreatingSession(false)
|
||||
}
|
||||
|
||||
@@ -123,9 +123,9 @@ export function useAssistant(id: string) {
|
||||
}
|
||||
|
||||
updateAssistantSettings({
|
||||
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
|
||||
reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
|
||||
qwenThinkMode: fallbackOption === 'off' ? undefined : true
|
||||
reasoning_effort: fallbackOption === 'none' ? undefined : fallbackOption,
|
||||
reasoning_effort_cache: fallbackOption === 'none' ? undefined : fallbackOption,
|
||||
qwenThinkMode: fallbackOption === 'none' ? undefined : true
|
||||
})
|
||||
} else {
|
||||
// 对于支持的选项, 不再更新 cache.
|
||||
|
||||
63
src/renderer/src/hooks/useInputText.ts
Normal file
63
src/renderer/src/hooks/useInputText.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
94
src/renderer/src/hooks/useKeyboardHandler.ts
Normal file
94
src/renderer/src/hooks/useKeyboardHandler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
125
src/renderer/src/hooks/useTextareaResize.ts
Normal file
125
src/renderer/src/hooks/useTextareaResize.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ const providerKeyMap = {
|
||||
aionly: 'provider.aionly',
|
||||
longcat: 'provider.longcat',
|
||||
huggingface: 'provider.huggingface',
|
||||
sophnet: 'provider.sophnet'
|
||||
sophnet: 'provider.sophnet',
|
||||
'ai-gateway': 'provider.ai-gateway',
|
||||
cerebras: 'provider.cerebras'
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -309,7 +311,7 @@ export const getHttpMessageLabel = (key: string): string => {
|
||||
}
|
||||
|
||||
const reasoningEffortOptionsKeyMap: Record<ThinkingOption, string> = {
|
||||
off: 'assistants.settings.reasoning_effort.off',
|
||||
none: 'assistants.settings.reasoning_effort.off',
|
||||
minimal: 'assistants.settings.reasoning_effort.minimal',
|
||||
high: 'assistants.settings.reasoning_effort.high',
|
||||
low: 'assistants.settings.reasoning_effort.low',
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"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."
|
||||
@@ -631,6 +634,15 @@
|
||||
"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?",
|
||||
@@ -654,6 +666,7 @@
|
||||
"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",
|
||||
@@ -661,6 +674,10 @@
|
||||
"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",
|
||||
@@ -1771,6 +1788,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Code style",
|
||||
"compact": {
|
||||
"title": "Conversation Compacted"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Are you sure you want to delete this message?",
|
||||
"title": "Delete Message"
|
||||
@@ -2467,6 +2487,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2498,7 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4324,7 +4346,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the preview version"
|
||||
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the v1 version"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4481,7 @@
|
||||
"confirm": "Confirm",
|
||||
"forward": "Forward",
|
||||
"multiple": "Multiple Select",
|
||||
"noResult": "No results found",
|
||||
"page": "Page",
|
||||
"select": "Select",
|
||||
"title": "Quick Menu"
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"null_id": "智能体 ID 为空。"
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择路径, / 选择命令"
|
||||
},
|
||||
"list": {
|
||||
"error": {
|
||||
"failed": "获取智能体列表失败"
|
||||
@@ -631,6 +634,15 @@
|
||||
"view_full_content": "查看完整内容"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "从活动目录中选择文件",
|
||||
"loading": "正在加载文件...",
|
||||
"no_file_found": {
|
||||
"description": "可访问目录中没有可用文件",
|
||||
"label": "未找到文件"
|
||||
},
|
||||
"title": "活动目录"
|
||||
},
|
||||
"auto_resize": "自动调整高度",
|
||||
"clear": {
|
||||
"content": "确定要清除当前会话所有消息吗?",
|
||||
@@ -654,6 +666,7 @@
|
||||
"new": {
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_session": "新会话 {{Command}}",
|
||||
"new_topic": "新话题 {{Command}}",
|
||||
"paste_text_file_confirm": "粘贴到输入框?",
|
||||
"pause": "暂停",
|
||||
@@ -661,6 +674,10 @@
|
||||
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
||||
"send": "发送",
|
||||
"settings": "设置",
|
||||
"slash_commands": {
|
||||
"description": "代理会话斜杠命令",
|
||||
"title": "斜杠命令"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考预算超过最大 Token 数",
|
||||
"label": "思考",
|
||||
@@ -890,7 +907,7 @@
|
||||
"show_line_numbers": "代码显示行号",
|
||||
"temperature": {
|
||||
"label": "模型温度",
|
||||
"tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7"
|
||||
"tip": "模型生成文本的随机程度。值越大,回复内容越富有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7"
|
||||
},
|
||||
"thought_auto_collapse": {
|
||||
"label": "思考内容自动折叠",
|
||||
@@ -1771,6 +1788,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "代码风格",
|
||||
"compact": {
|
||||
"title": "对话已压缩"
|
||||
},
|
||||
"delete": {
|
||||
"content": "确定要删除此消息吗?",
|
||||
"title": "删除消息"
|
||||
@@ -2467,6 +2487,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "唯一AI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2498,7 @@
|
||||
"baidu-cloud": "百度云千帆",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "阿里云百炼",
|
||||
@@ -4324,7 +4346,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 preview 版本"
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 v1 版本"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4481,7 @@
|
||||
"confirm": "确认",
|
||||
"forward": "前进",
|
||||
"multiple": "多选",
|
||||
"noResult": "未找到结果",
|
||||
"page": "翻页",
|
||||
"select": "选择",
|
||||
"title": "快捷菜单"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user