Compare commits
39 Commits
copilot/fi
...
feat/sub_a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da8b63673 | ||
|
|
dcdd1bf852 | ||
|
|
a12b6bfeca | ||
|
|
0f1a487bb0 | ||
|
|
2df8bb58df | ||
|
|
62976f6fe0 | ||
|
|
77529b3cd3 | ||
|
|
c8e9a10190 | ||
|
|
0e011ff35f | ||
|
|
40a64a7c92 | ||
|
|
1175823ab8 | ||
|
|
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 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -3,3 +3,4 @@
|
|||||||
/src/main/services/ConfigManager.ts @0xfullex
|
/src/main/services/ConfigManager.ts @0xfullex
|
||||||
/packages/shared/IpcChannel.ts @0xfullex
|
/packages/shared/IpcChannel.ts @0xfullex
|
||||||
/src/main/ipc.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"
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
diff --git a/dist/index.js b/dist/index.js
|
|
||||||
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 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 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 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,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
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
notarize: false
|
notarize: false
|
||||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||||
minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0
|
|
||||||
extendInfo:
|
extendInfo:
|
||||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||||
@@ -135,42 +134,58 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
|||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
<!--LANG:en-->
|
<!--LANG:en-->
|
||||||
What's New in v1.7.0-beta.6
|
What's New in v1.7.0-rc.1
|
||||||
|
|
||||||
New Features:
|
🎉 MAJOR NEW FEATURE: AI Agents
|
||||||
- Enhanced Input Bar: Completely redesigned input bar with improved responsiveness and functionality
|
- Create and manage custom AI agents with specialized tools and permissions
|
||||||
- Better File Handling: Improved drag-and-drop and paste support for images and documents
|
- Dedicated agent sessions with persistent SQLite storage, separate from regular chats
|
||||||
- Smart Tool Suggestions: Enhanced quick panel with better item selection and keyboard shortcuts
|
- 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:
|
✨ New Features:
|
||||||
- Smoother Input Experience: Better auto-resizing and text handling in chat input
|
- AI Providers: Added support for Hugging Face, Mistral, Perplexity, and SophNet
|
||||||
- Enhanced AI Performance: Improved connection stability and response speed
|
- Knowledge Base: OpenMinerU document preprocessor, full-text search in notes, enhanced tool selection
|
||||||
- More Reliable File Uploads: Better support for various file types and upload scenarios
|
- Image & OCR: Intel OVMS painting provider and Intel OpenVINO (NPU) OCR support
|
||||||
- Cleaner Interface: Optimized UI elements for better visual consistency
|
- MCP Management: Redesigned interface with dual-column layout for easier management
|
||||||
|
- Languages: Added German language support
|
||||||
|
|
||||||
Bug Fixes:
|
⚡ Improvements:
|
||||||
- Fixed image selection issue when adding custom AI providers
|
- Upgraded to Electron 38.7.0
|
||||||
- Fixed file upload problems with certain API configurations
|
- Enhanced system shutdown handling and automatic update checks
|
||||||
- Fixed input bar responsiveness issues
|
- Improved proxy bypass rules
|
||||||
- Fixed quick panel not working properly in some situations
|
|
||||||
|
🐛 Important Bug Fixes:
|
||||||
|
- Fixed streaming response issues across multiple AI providers
|
||||||
|
- Fixed session list scrolling problems
|
||||||
|
- Fixed knowledge base deletion errors
|
||||||
|
|
||||||
<!--LANG:zh-CN-->
|
<!--LANG:zh-CN-->
|
||||||
v1.7.0-beta.6 新特性
|
v1.7.0-rc.1 新特性
|
||||||
|
|
||||||
新功能:
|
🎉 重大更新:AI Agent 智能体系统
|
||||||
- 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大
|
- 创建和管理专属 AI Agent,配置专用工具和权限
|
||||||
- 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档
|
- 独立的 Agent 会话,使用 SQLite 持久化存储,与普通聊天分离
|
||||||
- 智能工具建议:增强的快速面板,更好的项目选择和键盘快捷键
|
- 实时工具审批系统 - 动态审查和批准 Agent 操作
|
||||||
|
- MCP(模型上下文协议)集成,连接外部工具
|
||||||
|
- 支持斜杠命令快速交互
|
||||||
|
- 兼容 OpenAI 的 REST API 访问
|
||||||
|
|
||||||
改进:
|
✨ 新功能:
|
||||||
- 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳
|
- AI 提供商:新增 Hugging Face、Mistral、Perplexity 和 SophNet 支持
|
||||||
- 增强 AI 性能:改进连接稳定性和响应速度
|
- 知识库:OpenMinerU 文档预处理器、笔记全文搜索、增强的工具选择
|
||||||
- 更可靠的文件上传:更好地支持各种文件类型和上传场景
|
- 图像与 OCR:Intel OVMS 绘图提供商和 Intel OpenVINO (NPU) OCR 支持
|
||||||
- 更简洁的界面:优化 UI 元素,视觉一致性更好
|
- MCP 管理:重构管理界面,采用双列布局,更加方便管理
|
||||||
|
- 语言:新增德语支持
|
||||||
|
|
||||||
问题修复:
|
⚡ 改进:
|
||||||
- 修复添加自定义 AI 提供商时的图片选择问题
|
- 升级到 Electron 38.7.0
|
||||||
- 修复某些 API 配置下的文件上传问题
|
- 增强的系统关机处理和自动更新检查
|
||||||
- 修复输入栏响应性问题
|
- 改进的代理绕过规则
|
||||||
- 修复快速面板在某些情况下无法正常工作的问题
|
|
||||||
|
🐛 重要修复:
|
||||||
|
- 修复多个 AI 提供商的流式响应问题
|
||||||
|
- 修复会话列表滚动问题
|
||||||
|
- 修复知识库删除错误
|
||||||
<!--LANG:END-->
|
<!--LANG:END-->
|
||||||
|
|||||||
34
package.json
34
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.7.0-beta.3",
|
"version": "1.7.0-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||||
"update:languages": "tsx scripts/update-languages.ts",
|
"update:languages": "tsx scripts/update-languages.ts",
|
||||||
|
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
|
||||||
"test": "vitest run --silent",
|
"test": "vitest run --silent",
|
||||||
"test:main": "vitest run --project main",
|
"test:main": "vitest run --project main",
|
||||||
"test:renderer": "vitest run --project renderer",
|
"test:renderer": "vitest run --project renderer",
|
||||||
@@ -73,9 +74,10 @@
|
|||||||
"format:check": "biome format && biome lint",
|
"format:check": "biome format && biome lint",
|
||||||
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
||||||
"claude": "dotenv -e .env -- claude",
|
"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: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 --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag beta --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 npm publish --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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
|
"@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",
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
"@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",
|
"@paymoapp/electron-shutdown-handler": "^1.1.2",
|
||||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||||
|
"emoji-picker-element-data": "^1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"font-list": "^2.0.0",
|
"font-list": "^2.0.0",
|
||||||
"graceful-fs": "^4.2.11",
|
"graceful-fs": "^4.2.11",
|
||||||
@@ -107,11 +110,14 @@
|
|||||||
"@agentic/searxng": "^7.3.3",
|
"@agentic/searxng": "^7.3.3",
|
||||||
"@agentic/tavily": "^7.3.3",
|
"@agentic/tavily": "^7.3.3",
|
||||||
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
||||||
|
"@ai-sdk/anthropic": "^2.0.44",
|
||||||
"@ai-sdk/cerebras": "^1.0.31",
|
"@ai-sdk/cerebras": "^1.0.31",
|
||||||
"@ai-sdk/gateway": "^2.0.9",
|
"@ai-sdk/gateway": "^2.0.9",
|
||||||
"@ai-sdk/google-vertex": "^3.0.62",
|
"@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/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/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",
|
"@ai-sdk/perplexity": "^2.0.17",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@anthropic-ai/sdk": "^0.41.0",
|
"@anthropic-ai/sdk": "^0.41.0",
|
||||||
@@ -120,7 +126,7 @@
|
|||||||
"@aws-sdk/client-bedrock-runtime": "^3.910.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.910.0",
|
||||||
"@aws-sdk/client-s3": "^3.910.0",
|
"@aws-sdk/client-s3": "^3.910.0",
|
||||||
"@biomejs/biome": "2.2.4",
|
"@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": "^0.1.31",
|
||||||
"@cherrystudio/embedjs-libsql": "^0.1.31",
|
"@cherrystudio/embedjs-libsql": "^0.1.31",
|
||||||
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
|
"@cherrystudio/embedjs-loader-csv": "^0.1.31",
|
||||||
@@ -134,7 +140,7 @@
|
|||||||
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
"@cherrystudio/embedjs-ollama": "^0.1.31",
|
||||||
"@cherrystudio/embedjs-openai": "^0.1.31",
|
"@cherrystudio/embedjs-openai": "^0.1.31",
|
||||||
"@cherrystudio/extension-table-plus": "workspace:^",
|
"@cherrystudio/extension-table-plus": "workspace:^",
|
||||||
"@cherrystudio/openai": "^6.5.0",
|
"@cherrystudio/openai": "^6.9.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -164,7 +170,7 @@
|
|||||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||||
"@opentelemetry/sdk-trace-web": "^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",
|
"@playwright/test": "^1.52.0",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@reduxjs/toolkit": "^2.2.5",
|
"@reduxjs/toolkit": "^2.2.5",
|
||||||
@@ -259,12 +265,12 @@
|
|||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"drizzle-orm": "^0.44.5",
|
"drizzle-orm": "^0.44.5",
|
||||||
"electron": "38.4.0",
|
"electron": "38.7.0",
|
||||||
"electron-builder": "26.0.15",
|
"electron-builder": "26.1.0",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-reload": "^2.0.0-alpha.1",
|
"electron-reload": "^2.0.0-alpha.1",
|
||||||
"electron-store": "^8.2.0",
|
"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-vite": "4.0.1",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"emittery": "^1.0.3",
|
"emittery": "^1.0.3",
|
||||||
@@ -381,13 +387,11 @@
|
|||||||
"@codemirror/lint": "6.8.5",
|
"@codemirror/lint": "6.8.5",
|
||||||
"@codemirror/view": "6.38.1",
|
"@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",
|
"@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",
|
|
||||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||||
"esbuild": "^0.25.0",
|
"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",
|
"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",
|
"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.77.0": "npm:@cherrystudio/openai@6.5.0",
|
||||||
"openai@npm:^4.87.3": "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",
|
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||||
@@ -409,7 +413,7 @@
|
|||||||
"@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.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/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.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.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",
|
"packageManager": "yarn@4.9.1",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cherrystudio/ai-sdk-provider",
|
"name": "@cherrystudio/ai-sdk-provider",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
|
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai-sdk",
|
"ai-sdk",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Cherry Studio AI Core 是一个基于 Vercel AI SDK 的统一 AI Provider 接口
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @cherrystudio/ai-core ai
|
npm install @cherrystudio/ai-core ai @ai-sdk/google @ai-sdk/openai
|
||||||
```
|
```
|
||||||
|
|
||||||
### React Native
|
### React Native
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cherrystudio/ai-core",
|
"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",
|
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
@@ -33,19 +33,19 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
|
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@ai-sdk/google": "^2.0.36",
|
||||||
|
"@ai-sdk/openai": "^2.0.64",
|
||||||
|
"@cherrystudio/ai-sdk-provider": "^0.1.2",
|
||||||
"ai": "^5.0.26"
|
"ai": "^5.0.26"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.43",
|
"@ai-sdk/anthropic": "^2.0.43",
|
||||||
"@ai-sdk/azure": "^2.0.66",
|
"@ai-sdk/azure": "^2.0.66",
|
||||||
"@ai-sdk/deepseek": "^1.0.27",
|
"@ai-sdk/deepseek": "^1.0.27",
|
||||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch",
|
|
||||||
"@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
|
||||||
"@ai-sdk/openai-compatible": "^1.0.26",
|
"@ai-sdk/openai-compatible": "^1.0.26",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
"@ai-sdk/provider-utils": "^3.0.16",
|
"@ai-sdk/provider-utils": "^3.0.16",
|
||||||
"@ai-sdk/xai": "^2.0.31",
|
"@ai-sdk/xai": "^2.0.31",
|
||||||
"@cherrystudio/ai-sdk-provider": "workspace:*",
|
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -4,12 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
export const BUILT_IN_PLUGIN_PREFIX = 'built-in:'
|
export const BUILT_IN_PLUGIN_PREFIX = 'built-in:'
|
||||||
|
|
||||||
export { googleToolsPlugin } from './googleToolsPlugin'
|
export * from './googleToolsPlugin'
|
||||||
export { createLoggingPlugin } from './logging'
|
export * from './toolUsePlugin/promptToolUsePlugin'
|
||||||
export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin'
|
export * from './toolUsePlugin/type'
|
||||||
export type {
|
export * from './webSearchPlugin'
|
||||||
PromptToolUseConfig,
|
|
||||||
ToolUseRequestContext,
|
|
||||||
ToolUseResult
|
|
||||||
} from './toolUsePlugin/type'
|
|
||||||
export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin'
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 导出类型定义供开发者使用
|
// 导出类型定义供开发者使用
|
||||||
export type { WebSearchPluginConfig, WebSearchToolOutputSchema } from './helper'
|
export * from './helper'
|
||||||
|
|
||||||
// 默认导出
|
// 默认导出
|
||||||
export default webSearchPlugin
|
export default webSearchPlugin
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export {
|
|||||||
// ==================== 基础数据和类型 ====================
|
// ==================== 基础数据和类型 ====================
|
||||||
|
|
||||||
// 基础Provider数据源
|
// 基础Provider数据源
|
||||||
export { baseProviderIds, baseProviders } from './schemas'
|
export { baseProviderIds, baseProviders, isBaseProvider } from './schemas'
|
||||||
|
|
||||||
// 类型定义和Schema
|
// 类型定义和Schema
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { createAzure } from '@ai-sdk/azure'
|
|||||||
import { type AzureOpenAIProviderSettings } from '@ai-sdk/azure'
|
import { type AzureOpenAIProviderSettings } from '@ai-sdk/azure'
|
||||||
import { createDeepSeek } from '@ai-sdk/deepseek'
|
import { createDeepSeek } from '@ai-sdk/deepseek'
|
||||||
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
||||||
import { createHuggingFace } from '@ai-sdk/huggingface'
|
|
||||||
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
|
import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||||
import type { LanguageModelV2 } from '@ai-sdk/provider'
|
import type { LanguageModelV2 } from '@ai-sdk/provider'
|
||||||
@@ -33,8 +32,7 @@ export const baseProviderIds = [
|
|||||||
'deepseek',
|
'deepseek',
|
||||||
'openrouter',
|
'openrouter',
|
||||||
'cherryin',
|
'cherryin',
|
||||||
'cherryin-chat',
|
'cherryin-chat'
|
||||||
'huggingface'
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,12 +156,6 @@ export const baseProviders = [
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
supportsImageGeneration: true
|
supportsImageGeneration: true
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'huggingface',
|
|
||||||
name: 'HuggingFace',
|
|
||||||
creator: createHuggingFace,
|
|
||||||
supportsImageGeneration: true
|
|
||||||
}
|
}
|
||||||
] as const satisfies BaseProvider[]
|
] as const satisfies BaseProvider[]
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export enum IpcChannel {
|
|||||||
App_SetFullScreen = 'app:set-full-screen',
|
App_SetFullScreen = 'app:set-full-screen',
|
||||||
App_IsFullScreen = 'app:is-full-screen',
|
App_IsFullScreen = 'app:is-full-screen',
|
||||||
App_GetSystemFonts = 'app:get-system-fonts',
|
App_GetSystemFonts = 'app:get-system-fonts',
|
||||||
|
APP_CrashRenderProcess = 'app:crash-render-process',
|
||||||
|
|
||||||
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
|
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
|
||||||
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
|
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
|
||||||
|
|||||||
@@ -197,12 +197,22 @@ export enum FeedUrl {
|
|||||||
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
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 {
|
export enum UpgradeChannel {
|
||||||
LATEST = 'latest', // 最新稳定版本
|
LATEST = 'latest', // 最新稳定版本
|
||||||
RC = 'rc', // 公测版本
|
RC = 'rc', // 公测版本
|
||||||
BETA = 'beta' // 预览版本
|
BETA = 'beta' // 预览版本
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum UpdateMirror {
|
||||||
|
GITHUB = 'github',
|
||||||
|
GITCODE = 'gitcode'
|
||||||
|
}
|
||||||
|
|
||||||
export const defaultTimeout = 10 * 1000 * 60
|
export const defaultTimeout = 10 * 1000 * 60
|
||||||
|
|
||||||
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
|
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
|
||||||
|
|||||||
2
resources/database/drizzle/0003_smooth_talkback.sql
Normal file
2
resources/database/drizzle/0003_smooth_talkback.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `agents` ADD `sub_agents` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `sub_agents` text;
|
||||||
360
resources/database/drizzle/meta/0003_snapshot.json
Normal file
360
resources/database/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "9aeb5f21-fed7-4dbf-973d-c344681b71c2",
|
||||||
|
"prevId": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"sub_agents": {
|
||||||
|
"name": "sub_agents",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"sub_agents": {
|
||||||
|
"name": "sub_agents",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1762526423527,
|
"when": 1762526423527,
|
||||||
"tag": "0002_wealthy_naoko",
|
"tag": "0002_wealthy_naoko",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1763500397620,
|
||||||
|
"tag": "0003_smooth_talkback",
|
||||||
|
"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)
|
||||||
|
})
|
||||||
@@ -104,12 +104,6 @@ const router = express
|
|||||||
logger.warn('No models available from providers', { filter })
|
logger.warn('No models available from providers', { filter })
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Models response ready', {
|
|
||||||
filter,
|
|
||||||
total: response.total,
|
|
||||||
modelIds: response.data.map((m) => m.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
return res.json(response satisfies ApiModelsResponse)
|
return res.json(response satisfies ApiModelsResponse)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error fetching models', { error })
|
logger.error('Error fetching models', { error })
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class ModelsService {
|
|||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
const provider = providers.find((p) => p.id === model.provider)
|
const provider = providers.find((p) => p.id === model.provider)
|
||||||
logger.debug(`Processing model ${model.id}`)
|
// logger.debug(`Processing model ${model.id}`)
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
|
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '@main/config'
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
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 installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||||
import { isDev, isLinux, isWin } from './constant'
|
import { isDev, isLinux, isWin } from './constant'
|
||||||
|
|
||||||
@@ -37,6 +37,14 @@ import { initWebviewHotkeys } from './services/WebviewService'
|
|||||||
|
|
||||||
const logger = loggerService.withContext('MainEntry')
|
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
|
* Disable hardware acceleration if setting is enabled
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1038,4 +1038,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
|
ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus)
|
||||||
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
|
ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile)
|
||||||
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
|
ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates)
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => {
|
||||||
|
mainWindow.webContents.forcefullyCrashRenderer()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ApiResponse<T> = {
|
|||||||
type BatchUploadResponse = {
|
type BatchUploadResponse = {
|
||||||
batch_id: string
|
batch_id: string
|
||||||
file_urls: string[]
|
file_urls: string[]
|
||||||
|
headers?: Record<string, string>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtractProgress = {
|
type ExtractProgress = {
|
||||||
@@ -55,7 +56,7 @@ type QuotaResponse = {
|
|||||||
export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||||
constructor(provider: PreprocessProvider, userId?: string) {
|
constructor(provider: PreprocessProvider, userId?: string) {
|
||||||
super(provider, userId)
|
super(provider, userId)
|
||||||
// todo:免费期结束后删除
|
// TODO: remove after free period ends
|
||||||
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
|
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}`)
|
logger.info(`MinerU preprocess processing started: ${filePath}`)
|
||||||
await this.validateFile(filePath)
|
await this.validateFile(filePath)
|
||||||
|
|
||||||
// 1. 获取上传URL并上传文件
|
// 1. Get upload URL and upload file
|
||||||
const batchId = await this.uploadFile(file)
|
const batchId = await this.uploadFile(file)
|
||||||
logger.info(`MinerU file upload completed: batch_id=${batchId}`)
|
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)
|
const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name)
|
||||||
logger.info(`MinerU processing completed for batch: ${batchId}`)
|
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)
|
const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file)
|
||||||
|
|
||||||
// 4. check quota
|
// 4. check quota
|
||||||
const quota = await this.checkQuota()
|
const quota = await this.checkQuota()
|
||||||
|
|
||||||
// 5. 创建处理后的文件信息
|
// 5. Create processed file metadata
|
||||||
return {
|
return {
|
||||||
processedFile: this.createProcessedFileInfo(file, outputPath),
|
processedFile: this.createProcessedFileInfo(file, outputPath),
|
||||||
quota
|
quota
|
||||||
@@ -115,23 +116,48 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async validateFile(filePath: string): Promise<void> {
|
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 pdfBuffer = await fs.promises.readFile(filePath)
|
||||||
|
|
||||||
const doc = await this.readPdf(pdfBuffer)
|
try {
|
||||||
|
const doc = await this.readPdf(pdfBuffer)
|
||||||
|
|
||||||
// 文件页数小于600页
|
// Ensure page count is under 600 pages
|
||||||
if (doc.numPages >= 600) {
|
if (doc.numPages >= 600) {
|
||||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
||||||
}
|
}
|
||||||
// 文件大小小于200MB
|
|
||||||
if (pdfBuffer.length >= 200 * 1024 * 1024) {
|
logger.info(`PDF validation passed: ${doc.numPages} pages, ${Math.round(fileSizeBytes / (1024 * 1024))}MB`)
|
||||||
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
} catch (error: any) {
|
||||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
|
// 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 {
|
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||||
// 查找解压后的主要文件
|
// Locate the main extracted file
|
||||||
let finalPath = ''
|
let finalPath = ''
|
||||||
let finalName = file.origin_name.replace('.pdf', '.md')
|
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 originalMdPath = path.join(outputPath, mdFile)
|
||||||
const newMdPath = path.join(outputPath, finalName)
|
const newMdPath = path.join(outputPath, finalName)
|
||||||
|
|
||||||
// 重命名文件为原始文件名
|
// Rename the file to match the original name
|
||||||
try {
|
try {
|
||||||
fs.renameSync(originalMdPath, newMdPath)
|
fs.renameSync(originalMdPath, newMdPath)
|
||||||
finalPath = newMdPath
|
finalPath = newMdPath
|
||||||
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
|
logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
|
||||||
} catch (renameError) {
|
} catch (renameError) {
|
||||||
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
|
logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
|
||||||
// 如果重命名失败,使用原文件
|
// If renaming fails, fall back to the original file
|
||||||
finalPath = originalMdPath
|
finalPath = originalMdPath
|
||||||
finalName = mdFile
|
finalName = mdFile
|
||||||
}
|
}
|
||||||
@@ -178,7 +204,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
logger.info(`Downloading MinerU result to: ${zipPath}`)
|
logger.info(`Downloading MinerU result to: ${zipPath}`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 下载ZIP文件
|
// Download the ZIP file
|
||||||
const response = await net.fetch(zipUrl, { method: 'GET' })
|
const response = await net.fetch(zipUrl, { method: 'GET' })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
@@ -187,17 +213,17 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
|
fs.writeFileSync(zipPath, Buffer.from(arrayBuffer))
|
||||||
logger.info(`Downloaded ZIP file: ${zipPath}`)
|
logger.info(`Downloaded ZIP file: ${zipPath}`)
|
||||||
|
|
||||||
// 确保提取目录存在
|
// Ensure the extraction directory exists
|
||||||
if (!fs.existsSync(extractPath)) {
|
if (!fs.existsSync(extractPath)) {
|
||||||
fs.mkdirSync(extractPath, { recursive: true })
|
fs.mkdirSync(extractPath, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解压文件
|
// Extract the ZIP contents
|
||||||
const zip = new AdmZip(zipPath)
|
const zip = new AdmZip(zipPath)
|
||||||
zip.extractAllTo(extractPath, true)
|
zip.extractAllTo(extractPath, true)
|
||||||
logger.info(`Extracted files to: ${extractPath}`)
|
logger.info(`Extracted files to: ${extractPath}`)
|
||||||
|
|
||||||
// 删除临时ZIP文件
|
// Remove the temporary ZIP file
|
||||||
fs.unlinkSync(zipPath)
|
fs.unlinkSync(zipPath)
|
||||||
|
|
||||||
return { path: extractPath }
|
return { path: extractPath }
|
||||||
@@ -209,11 +235,11 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
|
|
||||||
private async uploadFile(file: FileMetadata): Promise<string> {
|
private async uploadFile(file: FileMetadata): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// 步骤1: 获取上传URL
|
// Step 1: obtain the upload URL
|
||||||
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
|
const { batchId, fileUrls, uploadHeaders } = await this.getBatchUploadUrls(file)
|
||||||
// 步骤2: 上传文件到获取的URL
|
// Step 2: upload the file to the obtained URL
|
||||||
const filePath = fileStorage.getFilePathById(file)
|
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 })
|
logger.info(`File uploaded successfully: ${filePath}`, { batchId, fileUrls })
|
||||||
|
|
||||||
return batchId
|
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 endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch`
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -254,10 +282,11 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: ApiResponse<BatchUploadResponse> = await response.json()
|
const data: ApiResponse<BatchUploadResponse> = await response.json()
|
||||||
if (data.code === 0 && data.data) {
|
if (data.code === 0 && data.data) {
|
||||||
const { batch_id, file_urls } = data.data
|
const { batch_id, file_urls, headers: uploadHeaders } = data.data
|
||||||
return {
|
return {
|
||||||
batchId: batch_id,
|
batchId: batch_id,
|
||||||
fileUrls: file_urls
|
fileUrls: file_urls,
|
||||||
|
uploadHeaders
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
||||||
@@ -271,18 +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 {
|
try {
|
||||||
const fileBuffer = await fs.promises.readFile(filePath)
|
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
|
// https://mineru.net/apiManage/docs
|
||||||
const response = await net.fetch(uploadUrl, {
|
const response = await net.fetch(uploadUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: fileBuffer
|
headers,
|
||||||
|
body: new Uint8Array(fileBuffer)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// 克隆 response 以避免消费 body stream
|
// Clone the response to avoid consuming the body stream
|
||||||
const responseClone = response.clone()
|
const responseClone = response.clone()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -353,20 +392,20 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
try {
|
try {
|
||||||
const result = await this.getExtractResults(batchId)
|
const result = await this.getExtractResults(batchId)
|
||||||
|
|
||||||
// 查找对应文件的处理结果
|
// Find the corresponding file result
|
||||||
const fileResult = result.extract_result.find((item) => item.file_name === fileName)
|
const fileResult = result.extract_result.find((item) => item.file_name === fileName)
|
||||||
if (!fileResult) {
|
if (!fileResult) {
|
||||||
throw new Error(`File ${fileName} not found in batch results`)
|
throw new Error(`File ${fileName} not found in batch results`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查处理状态
|
// Check the processing state
|
||||||
if (fileResult.state === 'done' && fileResult.full_zip_url) {
|
if (fileResult.state === 'done' && fileResult.full_zip_url) {
|
||||||
logger.info(`Processing completed for file: ${fileName}`)
|
logger.info(`Processing completed for file: ${fileName}`)
|
||||||
return fileResult
|
return fileResult
|
||||||
} else if (fileResult.state === 'failed') {
|
} else if (fileResult.state === 'failed') {
|
||||||
throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`)
|
throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`)
|
||||||
} else if (fileResult.state === 'running') {
|
} else if (fileResult.state === 'running') {
|
||||||
// 发送进度更新
|
// Send progress updates
|
||||||
if (fileResult.extract_progress) {
|
if (fileResult.extract_progress) {
|
||||||
const progress = Math.round(
|
const progress = Math.round(
|
||||||
(fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100
|
(fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100
|
||||||
@@ -374,7 +413,7 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
|||||||
await this.sendPreprocessProgress(sourceId, progress)
|
await this.sendPreprocessProgress(sourceId, progress)
|
||||||
logger.info(`File ${fileName} processing progress: ${progress}%`)
|
logger.info(`File ${fileName} processing progress: ${progress}%`)
|
||||||
} else {
|
} else {
|
||||||
// 如果没有具体进度信息,发送一个通用进度
|
// If no detailed progress information is available, send a generic update
|
||||||
await this.sendPreprocessProgress(sourceId, 50)
|
await this.sendPreprocessProgress(sourceId, 50)
|
||||||
logger.info(`File ${fileName} is still processing...`)
|
logger.info(`File ${fileName} is still processing...`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,18 +53,43 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async validateFile(filePath: string): Promise<void> {
|
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 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
|
// File page count must be less than 600 pages
|
||||||
if (doc.numPages >= 600) {
|
if (doc.numPages >= 600) {
|
||||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
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) {
|
logger.info(`PDF validation passed: ${doc.numPages} pages, ${Math.round(fileSizeBytes / (1024 * 1024))}MB`)
|
||||||
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
} catch (error: any) {
|
||||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
|
// 如果是页数超限错误,直接抛出
|
||||||
|
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
|
// Find the main file after extraction
|
||||||
let finalPath = ''
|
let finalPath = ''
|
||||||
let finalName = file.origin_name.replace('.pdf', '.md')
|
let finalName = file.origin_name.replace('.pdf', '.md')
|
||||||
// Find the corresponding folder by file name
|
// Find the corresponding folder by file id
|
||||||
outputPath = path.join(outputPath, `${file.origin_name.replace('.pdf', '')}`)
|
outputPath = path.join(outputPath, file.id)
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(outputPath)
|
const files = fs.readdirSync(outputPath)
|
||||||
|
|
||||||
@@ -125,7 +150,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
|||||||
formData.append('return_md', 'true')
|
formData.append('return_md', 'true')
|
||||||
formData.append('response_format_zip', 'true')
|
formData.append('response_format_zip', 'true')
|
||||||
formData.append('files', fileBuffer, {
|
formData.append('files', fileBuffer, {
|
||||||
filename: file.origin_name
|
filename: file.name
|
||||||
})
|
})
|
||||||
|
|
||||||
while (retries < maxRetries) {
|
while (retries < maxRetries) {
|
||||||
@@ -139,7 +164,7 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
|||||||
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
|
...(this.provider.apiKey ? { Authorization: `Bearer ${this.provider.apiKey}` } : {}),
|
||||||
...formData.getHeaders()
|
...formData.getHeaders()
|
||||||
},
|
},
|
||||||
body: formData.getBuffer()
|
body: new Uint8Array(formData.getBuffer())
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
|
|||||||
import { isWin } from '@main/constant'
|
import { isWin } from '@main/constant'
|
||||||
import { getIpCountry } from '@main/utils/ipService'
|
import { getIpCountry } from '@main/utils/ipService'
|
||||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
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 { IpcChannel } from '@shared/IpcChannel'
|
||||||
import type { UpdateInfo } from 'builder-util-runtime'
|
import type { UpdateInfo } from 'builder-util-runtime'
|
||||||
import { CancellationToken } from 'builder-util-runtime'
|
import { CancellationToken } from 'builder-util-runtime'
|
||||||
@@ -22,7 +22,29 @@ const LANG_MARKERS = {
|
|||||||
EN_START: '<!--LANG:en-->',
|
EN_START: '<!--LANG:en-->',
|
||||||
ZH_CN_START: '<!--LANG:zh-CN-->',
|
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||||
END: '<!--LANG:END-->'
|
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 {
|
export default class AppUpdater {
|
||||||
autoUpdater: _AppUpdater = autoUpdater
|
autoUpdater: _AppUpdater = autoUpdater
|
||||||
@@ -37,7 +59,9 @@ export default class AppUpdater {
|
|||||||
autoUpdater.requestHeaders = {
|
autoUpdater.requestHeaders = {
|
||||||
...autoUpdater.requestHeaders,
|
...autoUpdater.requestHeaders,
|
||||||
'User-Agent': generateUserAgent(),
|
'User-Agent': generateUserAgent(),
|
||||||
'X-Client-Id': configManager.getClientId()
|
'X-Client-Id': configManager.getClientId(),
|
||||||
|
// no-cache
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.on('error', (error) => {
|
autoUpdater.on('error', (error) => {
|
||||||
@@ -75,61 +99,6 @@ export default class AppUpdater {
|
|||||||
this.autoUpdater = autoUpdater
|
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) {
|
public setAutoUpdate(isActive: boolean) {
|
||||||
autoUpdater.autoDownload = isActive
|
autoUpdater.autoDownload = isActive
|
||||||
autoUpdater.autoInstallOnAppQuit = isActive
|
autoUpdater.autoInstallOnAppQuit = isActive
|
||||||
@@ -161,6 +130,88 @@ export default class AppUpdater {
|
|||||||
return UpgradeChannel.LATEST
|
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) {
|
private _setChannel(channel: UpgradeChannel, feedUrl: string) {
|
||||||
this.autoUpdater.channel = channel
|
this.autoUpdater.channel = channel
|
||||||
this.autoUpdater.setFeedURL(feedUrl)
|
this.autoUpdater.setFeedURL(feedUrl)
|
||||||
@@ -172,33 +223,42 @@ export default class AppUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _setFeedUrl() {
|
private async _setFeedUrl() {
|
||||||
|
const currentVersion = app.getVersion()
|
||||||
const testPlan = configManager.getTestPlan()
|
const testPlan = configManager.getTestPlan()
|
||||||
if (testPlan) {
|
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
|
||||||
const channel = this._getTestChannel()
|
|
||||||
|
|
||||||
if (channel === UpgradeChannel.LATEST) {
|
// Determine mirror based on IP country
|
||||||
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)
|
|
||||||
const ipCountry = await getIpCountry()
|
const ipCountry = await getIpCountry()
|
||||||
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
|
const mirror = ipCountry.toLowerCase() === 'cn' ? UpdateMirror.GITCODE : UpdateMirror.GITHUB
|
||||||
if (ipCountry.toLowerCase() !== 'cn') {
|
|
||||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
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() {
|
public cancelDownload() {
|
||||||
@@ -320,8 +380,3 @@ export default class AppUpdater {
|
|||||||
return processedInfo
|
return processedInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
interface GithubReleaseInfo {
|
|
||||||
draft: boolean
|
|
||||||
prerelease: boolean
|
|
||||||
tag_name: string
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -849,7 +849,7 @@ class FileStorage {
|
|||||||
const resolvedPath = path.resolve(dirPath)
|
const resolvedPath = path.resolve(dirPath)
|
||||||
|
|
||||||
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
|
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
|
||||||
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
|
logger.error(`Failed to access directory: ${resolvedPath}`, error as Error)
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -375,13 +375,16 @@ export class WindowService {
|
|||||||
|
|
||||||
mainWindow.hide()
|
mainWindow.hide()
|
||||||
|
|
||||||
// TODO: don't hide dock icon when close to tray
|
//for mac users, should hide dock icon if close to tray
|
||||||
// will cause the cmd+h behavior not working
|
if (isMac && isTrayOnClose) {
|
||||||
// after the electron fix the bug, we can restore this code
|
app.dock?.hide()
|
||||||
// //for mac users, should hide dock icon if close to tray
|
|
||||||
// if (isMac && isTrayOnClose) {
|
mainWindow.once('show', () => {
|
||||||
// app.dock?.hide()
|
//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', () => {
|
mainWindow.on('closed', () => {
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ vi.mock('electron-updater', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Import after mocks
|
// Import after mocks
|
||||||
|
import { UpdateMirror } from '@shared/config/constant'
|
||||||
|
import { app, net } from 'electron'
|
||||||
|
|
||||||
import AppUpdater from '../AppUpdater'
|
import AppUpdater from '../AppUpdater'
|
||||||
import { configManager } from '../ConfigManager'
|
import { configManager } from '../ConfigManager'
|
||||||
|
|
||||||
@@ -274,4 +277,711 @@ describe('AppUpdater', () => {
|
|||||||
expect(result.releaseNotes).toBeNull()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export abstract class BaseService {
|
|||||||
'configuration',
|
'configuration',
|
||||||
'accessible_paths',
|
'accessible_paths',
|
||||||
'allowed_tools',
|
'allowed_tools',
|
||||||
|
'sub_agents',
|
||||||
'slash_commands'
|
'slash_commands'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const agentsTable = sqliteTable('agents', {
|
|||||||
|
|
||||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||||
|
sub_agents: text('sub_agents'), // JSON array of sub-agent IDs
|
||||||
|
|
||||||
configuration: text('configuration'), // JSON, extensible settings
|
configuration: text('configuration'), // JSON, extensible settings
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', {
|
|||||||
|
|
||||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||||
|
sub_agents: text('sub_agents'), // JSON array of sub-agent IDs
|
||||||
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
|
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
|
||||||
|
|
||||||
configuration: text('configuration'), // JSON, extensible settings
|
configuration: text('configuration'), // JSON, extensible settings
|
||||||
|
|||||||
@@ -117,6 +117,19 @@ export class AgentService extends BaseService {
|
|||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAgentConfigForSDK(id: string): Promise<AgentEntity | null> {
|
||||||
|
this.ensureInitialized()
|
||||||
|
|
||||||
|
const result = await this.database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
|
||||||
|
|
||||||
|
if (!result[0]) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = this.deserializeJsonFields(result[0]) as AgentEntity
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
|
async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
|
||||||
this.ensureInitialized() // Build query with pagination
|
this.ensureInitialized() // Build query with pagination
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export class SessionService extends BaseService {
|
|||||||
small_model: serializedData.small_model || null,
|
small_model: serializedData.small_model || null,
|
||||||
mcps: serializedData.mcps || null,
|
mcps: serializedData.mcps || null,
|
||||||
allowed_tools: serializedData.allowed_tools || null,
|
allowed_tools: serializedData.allowed_tools || null,
|
||||||
|
sub_agents: serializedData.sub_agents || null,
|
||||||
configuration: serializedData.configuration || null,
|
configuration: serializedData.configuration || null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
@@ -169,6 +170,22 @@ export class SessionService extends BaseService {
|
|||||||
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
|
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load installed plugins from cache file
|
||||||
|
const workdir = session.accessible_paths?.[0]
|
||||||
|
if (workdir) {
|
||||||
|
try {
|
||||||
|
session.plugins = await pluginService.listInstalledFromCache(workdir)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to load installed plugins for session ${id}`, {
|
||||||
|
workdir,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
session.plugins = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.plugins = []
|
||||||
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,16 @@ describe('stripLocalCommandTags', () => {
|
|||||||
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
|
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
|
||||||
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
|
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('if no tags present, returns original string', () => {
|
||||||
|
const input = 'just some normal text'
|
||||||
|
expect(stripLocalCommandTags(input)).toBe(input)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Claude → AiSDK transform', () => {
|
describe('Claude → AiSDK transform', () => {
|
||||||
it('handles tool call streaming lifecycle', () => {
|
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 parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||||
|
|
||||||
const messages: SDKMessage[] = [
|
const messages: SDKMessage[] = [
|
||||||
@@ -182,14 +187,119 @@ describe('Claude → AiSDK transform', () => {
|
|||||||
(typeof parts)[number],
|
(typeof parts)[number],
|
||||||
{ type: 'tool-result' }
|
{ type: 'tool-result' }
|
||||||
>
|
>
|
||||||
expect(toolResult.toolCallId).toBe('tool-1')
|
expect(toolResult.toolCallId).toBe('session-123:tool-1')
|
||||||
expect(toolResult.toolName).toBe('Bash')
|
expect(toolResult.toolName).toBe('Bash')
|
||||||
expect(toolResult.input).toEqual({ command: 'ls' })
|
expect(toolResult.input).toEqual({ command: 'ls' })
|
||||||
expect(toolResult.output).toBe('ok')
|
expect(toolResult.output).toBe('ok')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('handles tool calls without streaming events (no content_block_start/stop)', () => {
|
||||||
|
const state = new ClaudeStreamState({ agentSessionId: '12344' })
|
||||||
|
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||||
|
|
||||||
|
const messages: SDKMessage[] = [
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: uuid(20),
|
||||||
|
message: {
|
||||||
|
id: 'msg-tool-no-stream',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-read',
|
||||||
|
name: 'Read',
|
||||||
|
input: { file_path: '/test.txt' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-bash',
|
||||||
|
name: 'Bash',
|
||||||
|
input: { command: 'ls -la' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'user',
|
||||||
|
uuid: uuid(21),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-read',
|
||||||
|
content: 'file contents',
|
||||||
|
is_error: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'user',
|
||||||
|
uuid: uuid(22),
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-bash',
|
||||||
|
content: 'total 42\n...',
|
||||||
|
is_error: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as SDKMessage
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const transformed = transformSDKMessageToStreamParts(message, state)
|
||||||
|
parts.push(...transformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = parts.map((part) => part.type)
|
||||||
|
expect(types).toEqual(['tool-call', 'tool-call', 'tool-result', 'tool-result'])
|
||||||
|
|
||||||
|
const toolCalls = parts.filter((part) => part.type === 'tool-call') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'tool-call' }
|
||||||
|
>[]
|
||||||
|
expect(toolCalls).toHaveLength(2)
|
||||||
|
expect(toolCalls[0].toolName).toBe('Read')
|
||||||
|
expect(toolCalls[0].toolCallId).toBe('12344:tool-read')
|
||||||
|
expect(toolCalls[1].toolName).toBe('Bash')
|
||||||
|
expect(toolCalls[1].toolCallId).toBe('12344:tool-bash')
|
||||||
|
|
||||||
|
const toolResults = parts.filter((part) => part.type === 'tool-result') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'tool-result' }
|
||||||
|
>[]
|
||||||
|
expect(toolResults).toHaveLength(2)
|
||||||
|
// This is the key assertion - toolName should NOT be 'unknown'
|
||||||
|
expect(toolResults[0].toolName).toBe('Read')
|
||||||
|
expect(toolResults[0].toolCallId).toBe('12344:tool-read')
|
||||||
|
expect(toolResults[0].input).toEqual({ file_path: '/test.txt' })
|
||||||
|
expect(toolResults[0].output).toBe('file contents')
|
||||||
|
|
||||||
|
expect(toolResults[1].toolName).toBe('Bash')
|
||||||
|
expect(toolResults[1].toolCallId).toBe('12344:tool-bash')
|
||||||
|
expect(toolResults[1].input).toEqual({ command: 'ls -la' })
|
||||||
|
expect(toolResults[1].output).toBe('total 42\n...')
|
||||||
|
})
|
||||||
|
|
||||||
it('handles streaming text completion', () => {
|
it('handles streaming text completion', () => {
|
||||||
const state = new ClaudeStreamState()
|
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
|
||||||
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||||
|
|
||||||
const messages: SDKMessage[] = [
|
const messages: SDKMessage[] = [
|
||||||
@@ -300,4 +410,87 @@ describe('Claude → AiSDK transform', () => {
|
|||||||
expect(finishStep.finishReason).toBe('stop')
|
expect(finishStep.finishReason).toBe('stop')
|
||||||
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
|
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits fallback text when Claude sends a snapshot instead of deltas', () => {
|
||||||
|
const state = new ClaudeStreamState({ agentSessionId: '12344' })
|
||||||
|
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||||
|
|
||||||
|
const messages: SDKMessage[] = [
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(30),
|
||||||
|
event: {
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: 'msg-fallback',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [],
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: uuid(31),
|
||||||
|
event: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: 0,
|
||||||
|
content_block: {
|
||||||
|
type: 'text',
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
{
|
||||||
|
...baseStreamMetadata,
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: uuid(32),
|
||||||
|
message: {
|
||||||
|
id: 'msg-fallback-content',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Final answer without streaming deltas.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 3,
|
||||||
|
output_tokens: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as SDKMessage
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const transformed = transformSDKMessageToStreamParts(message, state)
|
||||||
|
parts.push(...transformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = parts.map((part) => part.type)
|
||||||
|
expect(types).toEqual(['start-step', 'text-start', 'text-delta', 'text-end', 'finish-step'])
|
||||||
|
|
||||||
|
const delta = parts.find((part) => part.type === 'text-delta') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'text-delta' }
|
||||||
|
>
|
||||||
|
expect(delta.text).toBe('Final answer without streaming deltas.')
|
||||||
|
|
||||||
|
const finish = parts.find((part) => part.type === 'finish-step') as Extract<
|
||||||
|
(typeof parts)[number],
|
||||||
|
{ type: 'finish-step' }
|
||||||
|
>
|
||||||
|
expect(finish.usage).toEqual({ inputTokens: 3, outputTokens: 7, totalTokens: 10 })
|
||||||
|
expect(finish.finishReason).toBe('stop')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,8 +10,21 @@
|
|||||||
* Every Claude turn gets its own instance. `resetStep` should be invoked once the finish event has
|
* 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.
|
* been emitted to avoid leaking state into the next turn.
|
||||||
*/
|
*/
|
||||||
|
import { loggerService } from '@logger'
|
||||||
import type { FinishReason, LanguageModelUsage, ProviderMetadata } from 'ai'
|
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).
|
* Shared fields for every block that Claude can stream (text, reasoning, tool).
|
||||||
*/
|
*/
|
||||||
@@ -34,6 +47,7 @@ type ReasoningBlockState = BaseBlockState & {
|
|||||||
type ToolBlockState = BaseBlockState & {
|
type ToolBlockState = BaseBlockState & {
|
||||||
kind: 'tool'
|
kind: 'tool'
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
|
rawToolCallId: string
|
||||||
toolName: string
|
toolName: string
|
||||||
inputBuffer: string
|
inputBuffer: string
|
||||||
providerMetadata?: ProviderMetadata
|
providerMetadata?: ProviderMetadata
|
||||||
@@ -48,12 +62,17 @@ type PendingUsageState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PendingToolCall = {
|
type PendingToolCall = {
|
||||||
|
rawToolCallId: string
|
||||||
toolCallId: string
|
toolCallId: string
|
||||||
toolName: string
|
toolName: string
|
||||||
input: unknown
|
input: unknown
|
||||||
providerMetadata?: ProviderMetadata
|
providerMetadata?: ProviderMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClaudeStreamStateOptions = {
|
||||||
|
agentSessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the lifecycle of Claude streaming blocks (text, thinking, tool calls)
|
* Tracks the lifecycle of Claude streaming blocks (text, thinking, tool calls)
|
||||||
* across individual websocket events. The transformer relies on this class to
|
* 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.
|
* usage/finish metadata once Anthropic closes a message.
|
||||||
*/
|
*/
|
||||||
export class ClaudeStreamState {
|
export class ClaudeStreamState {
|
||||||
|
private logger
|
||||||
|
private readonly agentSessionId: string
|
||||||
private blocksByIndex = new Map<number, BlockState>()
|
private blocksByIndex = new Map<number, BlockState>()
|
||||||
private toolIndexById = new Map<string, number>()
|
private toolIndexByNamespacedId = new Map<string, number>()
|
||||||
private pendingUsage: PendingUsageState = {}
|
private pendingUsage: PendingUsageState = {}
|
||||||
private pendingToolCalls = new Map<string, PendingToolCall>()
|
private pendingToolCalls = new Map<string, PendingToolCall>()
|
||||||
private stepActive = false
|
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. */
|
/** Marks the beginning of a new AiSDK step. */
|
||||||
beginStep(): void {
|
beginStep(): void {
|
||||||
this.stepActive = true
|
this.stepActive = true
|
||||||
@@ -104,19 +131,21 @@ export class ClaudeStreamState {
|
|||||||
/** Caches tool metadata so subsequent input deltas and results can find it. */
|
/** Caches tool metadata so subsequent input deltas and results can find it. */
|
||||||
openToolBlock(
|
openToolBlock(
|
||||||
index: number,
|
index: number,
|
||||||
params: { toolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
|
params: { rawToolCallId: string; toolName: string; providerMetadata?: ProviderMetadata }
|
||||||
): ToolBlockState {
|
): ToolBlockState {
|
||||||
|
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, params.rawToolCallId)
|
||||||
const block: ToolBlockState = {
|
const block: ToolBlockState = {
|
||||||
kind: 'tool',
|
kind: 'tool',
|
||||||
id: params.toolCallId,
|
id: toolCallId,
|
||||||
index,
|
index,
|
||||||
toolCallId: params.toolCallId,
|
toolCallId,
|
||||||
|
rawToolCallId: params.rawToolCallId,
|
||||||
toolName: params.toolName,
|
toolName: params.toolName,
|
||||||
inputBuffer: '',
|
inputBuffer: '',
|
||||||
providerMetadata: params.providerMetadata
|
providerMetadata: params.providerMetadata
|
||||||
}
|
}
|
||||||
this.blocksByIndex.set(index, block)
|
this.blocksByIndex.set(index, block)
|
||||||
this.toolIndexById.set(params.toolCallId, index)
|
this.toolIndexByNamespacedId.set(toolCallId, index)
|
||||||
return block
|
return block
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +153,32 @@ export class ClaudeStreamState {
|
|||||||
return this.blocksByIndex.get(index)
|
return this.blocksByIndex.get(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFirstOpenTextBlock(): TextBlockState | undefined {
|
||||||
|
const candidates: TextBlockState[] = []
|
||||||
|
for (const block of this.blocksByIndex.values()) {
|
||||||
|
if (block.kind === 'text') {
|
||||||
|
candidates.push(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a.index - b.index)
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
|
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
|
||||||
const index = this.toolIndexById.get(toolCallId)
|
const index = this.toolIndexByNamespacedId.get(toolCallId)
|
||||||
if (index === undefined) return undefined
|
if (index === undefined) return undefined
|
||||||
const block = this.blocksByIndex.get(index)
|
const block = this.blocksByIndex.get(index)
|
||||||
if (!block || block.kind !== 'tool') return undefined
|
if (!block || block.kind !== 'tool') return undefined
|
||||||
return block
|
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. */
|
/** Appends streamed text to a text block, returning the updated state when present. */
|
||||||
appendTextDelta(index: number, text: string): TextBlockState | undefined {
|
appendTextDelta(index: number, text: string): TextBlockState | undefined {
|
||||||
const block = this.blocksByIndex.get(index)
|
const block = this.blocksByIndex.get(index)
|
||||||
@@ -158,10 +205,12 @@ export class ClaudeStreamState {
|
|||||||
|
|
||||||
/** Records a tool call to be consumed once its result arrives from the user. */
|
/** Records a tool call to be consumed once its result arrives from the user. */
|
||||||
registerToolCall(
|
registerToolCall(
|
||||||
toolCallId: string,
|
rawToolCallId: string,
|
||||||
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
|
payload: { toolName: string; input: unknown; providerMetadata?: ProviderMetadata }
|
||||||
): void {
|
): void {
|
||||||
this.pendingToolCalls.set(toolCallId, {
|
const toolCallId = buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
|
||||||
|
this.pendingToolCalls.set(rawToolCallId, {
|
||||||
|
rawToolCallId,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: payload.toolName,
|
toolName: payload.toolName,
|
||||||
input: payload.input,
|
input: payload.input,
|
||||||
@@ -170,10 +219,10 @@ export class ClaudeStreamState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieves and clears the buffered tool call metadata for the given id. */
|
/** Retrieves and clears the buffered tool call metadata for the given id. */
|
||||||
consumePendingToolCall(toolCallId: string): PendingToolCall | undefined {
|
consumePendingToolCall(rawToolCallId: string): PendingToolCall | undefined {
|
||||||
const entry = this.pendingToolCalls.get(toolCallId)
|
const entry = this.pendingToolCalls.get(rawToolCallId)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
this.pendingToolCalls.delete(toolCallId)
|
this.pendingToolCalls.delete(rawToolCallId)
|
||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
@@ -182,13 +231,13 @@ export class ClaudeStreamState {
|
|||||||
* Persists the final input payload for a tool block once the provider signals
|
* Persists the final input payload for a tool block once the provider signals
|
||||||
* completion so that downstream tool results can reference the original call.
|
* completion so that downstream tool results can reference the original call.
|
||||||
*/
|
*/
|
||||||
completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void {
|
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
|
||||||
|
const block = this.getToolBlockByRawId(toolCallId)
|
||||||
this.registerToolCall(toolCallId, {
|
this.registerToolCall(toolCallId, {
|
||||||
toolName: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
|
toolName,
|
||||||
input,
|
input,
|
||||||
providerMetadata
|
providerMetadata
|
||||||
})
|
})
|
||||||
const block = this.getToolBlockById(toolCallId)
|
|
||||||
if (block) {
|
if (block) {
|
||||||
block.resolvedInput = input
|
block.resolvedInput = input
|
||||||
}
|
}
|
||||||
@@ -200,7 +249,7 @@ export class ClaudeStreamState {
|
|||||||
if (!block) return undefined
|
if (!block) return undefined
|
||||||
this.blocksByIndex.delete(index)
|
this.blocksByIndex.delete(index)
|
||||||
if (block.kind === 'tool') {
|
if (block.kind === 'tool') {
|
||||||
this.toolIndexById.delete(block.toolCallId)
|
this.toolIndexByNamespacedId.delete(block.toolCallId)
|
||||||
}
|
}
|
||||||
return block
|
return block
|
||||||
}
|
}
|
||||||
@@ -227,7 +276,7 @@ export class ClaudeStreamState {
|
|||||||
/** Drops cached block metadata for the currently active message. */
|
/** Drops cached block metadata for the currently active message. */
|
||||||
resetBlocks(): void {
|
resetBlocks(): void {
|
||||||
this.blocksByIndex.clear()
|
this.blocksByIndex.clear()
|
||||||
this.toolIndexById.clear()
|
this.toolIndexByNamespacedId.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resets the entire step lifecycle after emitting a terminal frame. */
|
/** Resets the entire step lifecycle after emitting a terminal frame. */
|
||||||
@@ -236,6 +285,10 @@ export class ClaudeStreamState {
|
|||||||
this.resetPendingUsage()
|
this.resetPendingUsage()
|
||||||
this.stepActive = false
|
this.stepActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNamespacedToolCallId(rawToolCallId: string): string {
|
||||||
|
return buildNamespacedToolCallId(this.agentSessionId, rawToolCallId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { PendingToolCall }
|
export type { PendingToolCall }
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
import type {
|
||||||
|
AgentDefinition,
|
||||||
|
CanUseTool,
|
||||||
|
McpHttpServerConfig,
|
||||||
|
Options,
|
||||||
|
SDKMessage
|
||||||
|
} from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk'
|
import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { config as apiConfigService } from '@main/apiServer/config'
|
import { config as apiConfigService } from '@main/apiServer/config'
|
||||||
@@ -10,9 +16,10 @@ import { validateModelId } from '@main/apiServer/utils'
|
|||||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
import getLoginShellEnvironment from '@main/utils/shell-env'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
import type { GetAgentSessionResponse } from '../..'
|
import { agentService, type GetAgentSessionResponse } from '../..'
|
||||||
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||||
import { sessionService } from '../SessionService'
|
import { sessionService } from '../SessionService'
|
||||||
|
import { buildNamespacedToolCallId } from './claude-stream-state'
|
||||||
import { promptForToolApproval } from './tool-permissions'
|
import { promptForToolApproval } from './tool-permissions'
|
||||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
||||||
|
|
||||||
@@ -150,7 +157,36 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
return { behavior: 'allow', updatedInput: input }
|
return { behavior: 'allow', updatedInput: input }
|
||||||
}
|
}
|
||||||
|
|
||||||
return promptForToolApproval(toolName, input, options)
|
return promptForToolApproval(toolName, input, {
|
||||||
|
...options,
|
||||||
|
toolCallId: buildNamespacedToolCallId(session.id, options.toolUseID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subAgents: Record<string, AgentDefinition> = {}
|
||||||
|
if (session.sub_agents && session.sub_agents.length > 0) {
|
||||||
|
for (const subAgentId of session.sub_agents) {
|
||||||
|
try {
|
||||||
|
const agentConfig = await agentService.getAgentConfigForSDK(subAgentId)
|
||||||
|
if (agentConfig) {
|
||||||
|
subAgents[subAgentId] = {
|
||||||
|
// TODO: support custom model for sub-agents
|
||||||
|
model: 'inherit',
|
||||||
|
description: agentConfig.description ?? '',
|
||||||
|
prompt: agentConfig.instructions ?? '',
|
||||||
|
tools: agentConfig.allowed_tools
|
||||||
|
}
|
||||||
|
logger.info('Loaded sub-agent', { subAgentId })
|
||||||
|
} else {
|
||||||
|
logger.warn('Sub-agent not found', { subAgentId })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load sub-agent config', {
|
||||||
|
subAgentId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SDK options from parameters
|
// Build SDK options from parameters
|
||||||
@@ -346,7 +382,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
const jsonOutput: SDKMessage[] = []
|
const jsonOutput: SDKMessage[] = []
|
||||||
let hasCompleted = false
|
let hasCompleted = false
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const streamState = new ClaudeStreamState()
|
const streamState = new ClaudeStreamState({ agentSessionId: sessionId })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const message of query({ prompt: promptStream, options })) {
|
for await (const message of query({ prompt: promptStream, options })) {
|
||||||
@@ -410,23 +446,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'assistant' || message.type === 'user') {
|
|
||||||
logger.silly('claude response', {
|
|
||||||
message,
|
|
||||||
content: JSON.stringify(message.message.content)
|
|
||||||
})
|
|
||||||
} else if (message.type === 'stream_event') {
|
|
||||||
// logger.silly('Claude stream event', {
|
|
||||||
// message,
|
|
||||||
// event: JSON.stringify(message.event)
|
|
||||||
// })
|
|
||||||
} else {
|
|
||||||
logger.silly('Claude response', {
|
|
||||||
message,
|
|
||||||
event: JSON.stringify(message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
stream.emit('data', {
|
stream.emit('data', {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type RendererPermissionRequestPayload = {
|
|||||||
requestId: string
|
requestId: string
|
||||||
toolName: string
|
toolName: string
|
||||||
toolId: string
|
toolId: string
|
||||||
|
toolCallId: string
|
||||||
description?: string
|
description?: string
|
||||||
requiresPermissions: boolean
|
requiresPermissions: boolean
|
||||||
input: Record<string, unknown>
|
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(
|
export async function promptForToolApproval(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
options?: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
|
options: PromptForToolApprovalOptions
|
||||||
): Promise<PermissionResult> {
|
): Promise<PermissionResult> {
|
||||||
if (shouldAutoApproveTools) {
|
if (shouldAutoApproveTools) {
|
||||||
logger.debug('promptForToolApproval auto-approving tool for test', {
|
logger.debug('promptForToolApproval auto-approving tool for test', {
|
||||||
@@ -245,6 +255,7 @@ export async function promptForToolApproval(
|
|||||||
logger.info('Requesting user approval for tool usage', {
|
logger.info('Requesting user approval for tool usage', {
|
||||||
requestId,
|
requestId,
|
||||||
toolName,
|
toolName,
|
||||||
|
toolCallId: options.toolCallId,
|
||||||
description: toolMetadata?.description
|
description: toolMetadata?.description
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -252,6 +263,7 @@ export async function promptForToolApproval(
|
|||||||
requestId,
|
requestId,
|
||||||
toolName,
|
toolName,
|
||||||
toolId: toolMetadata?.id ?? toolName,
|
toolId: toolMetadata?.id ?? toolName,
|
||||||
|
toolCallId: options.toolCallId,
|
||||||
description: toolMetadata?.description,
|
description: toolMetadata?.description,
|
||||||
requiresPermissions: toolMetadata?.requirePermissions ?? false,
|
requiresPermissions: toolMetadata?.requirePermissions ?? false,
|
||||||
input: sanitizedInput,
|
input: sanitizedInput,
|
||||||
@@ -266,6 +278,7 @@ export async function promptForToolApproval(
|
|||||||
logger.debug('Registering tool permission request', {
|
logger.debug('Registering tool permission request', {
|
||||||
requestId,
|
requestId,
|
||||||
toolName,
|
toolName,
|
||||||
|
toolCallId: options.toolCallId,
|
||||||
requiresPermissions: requestPayload.requiresPermissions,
|
requiresPermissions: requestPayload.requiresPermissions,
|
||||||
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
|
timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
|
||||||
suggestionCount: sanitizedSuggestions.length
|
suggestionCount: sanitizedSuggestions.length
|
||||||
@@ -273,7 +286,11 @@ export async function promptForToolApproval(
|
|||||||
|
|
||||||
return new Promise<PermissionResult>((resolve) => {
|
return new Promise<PermissionResult>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
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')
|
finalizeRequest(requestId, { behavior: 'deny', message: 'Timed out waiting for approval' }, 'timeout')
|
||||||
}, TOOL_APPROVAL_TIMEOUT_MS)
|
}, TOOL_APPROVAL_TIMEOUT_MS)
|
||||||
|
|
||||||
@@ -287,7 +304,11 @@ export async function promptForToolApproval(
|
|||||||
|
|
||||||
if (options?.signal) {
|
if (options?.signal) {
|
||||||
const abortListener = () => {
|
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')
|
finalizeRequest(requestId, defaultDenyUpdate, 'aborted')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
|
|||||||
* blocks across calls so that incremental deltas can be correlated correctly.
|
* blocks across calls so that incremental deltas can be correlated correctly.
|
||||||
*/
|
*/
|
||||||
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
||||||
logger.silly('Transforming SDKMessage', { message: sdkMessage })
|
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
|
||||||
switch (sdkMessage.type) {
|
switch (sdkMessage.type) {
|
||||||
case 'assistant':
|
case 'assistant':
|
||||||
return handleAssistantMessage(sdkMessage, state)
|
return handleAssistantMessage(sdkMessage, state)
|
||||||
@@ -186,14 +186,13 @@ function handleAssistantMessage(
|
|||||||
|
|
||||||
for (const block of content) {
|
for (const block of content) {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case 'text':
|
case 'text': {
|
||||||
if (!isStreamingActive) {
|
const sanitizedText = stripLocalCommandTags(block.text)
|
||||||
const sanitizedText = stripLocalCommandTags(block.text)
|
if (sanitizedText) {
|
||||||
if (sanitizedText) {
|
textBlocks.push(sanitizedText)
|
||||||
textBlocks.push(sanitizedText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'tool_use':
|
case 'tool_use':
|
||||||
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
|
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
|
||||||
break
|
break
|
||||||
@@ -203,7 +202,16 @@ function handleAssistantMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStreamingActive && textBlocks.length > 0) {
|
if (textBlocks.length === 0) {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedText = textBlocks.join('')
|
||||||
|
if (!combinedText) {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStreamingActive) {
|
||||||
const id = message.uuid?.toString() || generateMessageId()
|
const id = message.uuid?.toString() || generateMessageId()
|
||||||
state.beginStep()
|
state.beginStep()
|
||||||
chunks.push({
|
chunks.push({
|
||||||
@@ -219,7 +227,7 @@ function handleAssistantMessage(
|
|||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'text-delta',
|
type: 'text-delta',
|
||||||
id,
|
id,
|
||||||
text: textBlocks.join(''),
|
text: combinedText,
|
||||||
providerMetadata
|
providerMetadata
|
||||||
})
|
})
|
||||||
chunks.push({
|
chunks.push({
|
||||||
@@ -230,7 +238,27 @@ function handleAssistantMessage(
|
|||||||
return finalizeNonStreamingStep(message, state, chunks)
|
return finalizeNonStreamingStep(message, state, chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks
|
const existingTextBlock = state.getFirstOpenTextBlock()
|
||||||
|
const fallbackId = existingTextBlock?.id || message.uuid?.toString() || generateMessageId()
|
||||||
|
if (!existingTextBlock) {
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-start',
|
||||||
|
id: fallbackId,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-delta',
|
||||||
|
id: fallbackId,
|
||||||
|
text: combinedText,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
chunks.push({
|
||||||
|
type: 'text-end',
|
||||||
|
id: fallbackId,
|
||||||
|
providerMetadata
|
||||||
|
})
|
||||||
|
return finalizeNonStreamingStep(message, state, chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,15 +271,16 @@ function handleAssistantToolUse(
|
|||||||
state: ClaudeStreamState,
|
state: ClaudeStreamState,
|
||||||
chunks: AgentStreamPart[]
|
chunks: AgentStreamPart[]
|
||||||
): void {
|
): void {
|
||||||
|
const toolCallId = state.getNamespacedToolCallId(block.id)
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
toolCallId: block.id,
|
toolCallId,
|
||||||
toolName: block.name,
|
toolName: block.name,
|
||||||
input: block.input,
|
input: block.input,
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata
|
providerMetadata
|
||||||
})
|
})
|
||||||
state.completeToolBlock(block.id, block.input, providerMetadata)
|
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,10 +360,11 @@ function handleUserMessage(
|
|||||||
if (block.type === 'tool_result') {
|
if (block.type === 'tool_result') {
|
||||||
const toolResult = block as ToolResultContent
|
const toolResult = block as ToolResultContent
|
||||||
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
|
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
|
||||||
|
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
|
||||||
if (toolResult.is_error) {
|
if (toolResult.is_error) {
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'tool-error',
|
type: 'tool-error',
|
||||||
toolCallId: toolResult.tool_use_id,
|
toolCallId,
|
||||||
toolName: pendingCall?.toolName ?? 'unknown',
|
toolName: pendingCall?.toolName ?? 'unknown',
|
||||||
input: pendingCall?.input,
|
input: pendingCall?.input,
|
||||||
error: toolResult.content,
|
error: toolResult.content,
|
||||||
@@ -343,7 +373,7 @@ function handleUserMessage(
|
|||||||
} else {
|
} else {
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'tool-result',
|
type: 'tool-result',
|
||||||
toolCallId: toolResult.tool_use_id,
|
toolCallId,
|
||||||
toolName: pendingCall?.toolName ?? 'unknown',
|
toolName: pendingCall?.toolName ?? 'unknown',
|
||||||
input: pendingCall?.input,
|
input: pendingCall?.input,
|
||||||
output: toolResult.content,
|
output: toolResult.content,
|
||||||
@@ -457,6 +487,9 @@ function handleStreamEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'message_stop': {
|
case 'message_stop': {
|
||||||
|
if (!state.hasActiveStep()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
const pending = state.getPendingUsage()
|
const pending = state.getPendingUsage()
|
||||||
chunks.push({
|
chunks.push({
|
||||||
type: 'finish-step',
|
type: 'finish-step',
|
||||||
@@ -514,7 +547,7 @@ function handleContentBlockStart(
|
|||||||
}
|
}
|
||||||
case 'tool_use': {
|
case 'tool_use': {
|
||||||
const block = state.openToolBlock(index, {
|
const block = state.openToolBlock(index, {
|
||||||
toolCallId: contentBlock.id,
|
rawToolCallId: contentBlock.id,
|
||||||
toolName: contentBlock.name,
|
toolName: contentBlock.name,
|
||||||
providerMetadata
|
providerMetadata
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const api = {
|
|||||||
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
|
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
|
||||||
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
|
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
|
||||||
getSystemFonts: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts),
|
getSystemFonts: (): Promise<string[]> => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts),
|
||||||
|
mockCrashRenderProcess: () => ipcRenderer.invoke(IpcChannel.APP_CrashRenderProcess),
|
||||||
mac: {
|
mac: {
|
||||||
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||||
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import {
|
import {
|
||||||
|
getModelSupportedVerbosity,
|
||||||
isFunctionCallingModel,
|
isFunctionCallingModel,
|
||||||
isNotSupportTemperatureAndTopP,
|
isNotSupportTemperatureAndTopP,
|
||||||
isOpenAIModel,
|
isOpenAIModel,
|
||||||
@@ -242,12 +243,18 @@ export abstract class BaseApiClient<
|
|||||||
return serviceTierSetting
|
return serviceTierSetting
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getVerbosity(): OpenAIVerbosity {
|
protected getVerbosity(model?: Model): OpenAIVerbosity {
|
||||||
try {
|
try {
|
||||||
const state = window.store?.getState()
|
const state = window.store?.getState()
|
||||||
const verbosity = state?.settings?.openAI?.verbosity
|
const verbosity = state?.settings?.openAI?.verbosity
|
||||||
|
|
||||||
if (verbosity && ['low', 'medium', 'high'].includes(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
|
return verbosity
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
isSupportedThinkingTokenModel,
|
isSupportedThinkingTokenModel,
|
||||||
isSupportedThinkingTokenQwenModel,
|
isSupportedThinkingTokenQwenModel,
|
||||||
isSupportedThinkingTokenZhipuModel,
|
isSupportedThinkingTokenZhipuModel,
|
||||||
|
isSupportVerbosityModel,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
MODEL_SUPPORTED_REASONING_EFFORT,
|
MODEL_SUPPORTED_REASONING_EFFORT,
|
||||||
ZHIPU_RESULT_TOKENS
|
ZHIPU_RESULT_TOKENS
|
||||||
@@ -733,6 +734,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
...modalities,
|
...modalities,
|
||||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||||
|
...(isSupportVerbosityModel(model)
|
||||||
|
? {
|
||||||
|
text: {
|
||||||
|
verbosity: this.getVerbosity(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...this.getProviderSpecificParameters(assistant, model),
|
...this.getProviderSpecificParameters(assistant, model),
|
||||||
...reasoningEffort,
|
...reasoningEffort,
|
||||||
...getOpenAIWebSearchParams(model, enableWebSearch),
|
...getOpenAIWebSearchParams(model, enableWebSearch),
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ export abstract class OpenAIBaseClient<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 仅适用于openai
|
// 仅适用于openai
|
||||||
override getBaseURL(): string {
|
override getBaseURL(isSupportedAPIVerion: boolean = true): string {
|
||||||
const host = this.provider.apiHost
|
return formatApiHost(this.provider.apiHost, isSupportedAPIVerion)
|
||||||
return formatApiHost(host)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async generateImage({
|
override async generateImage({
|
||||||
@@ -144,6 +143,11 @@ export abstract class OpenAIBaseClient<
|
|||||||
}
|
}
|
||||||
|
|
||||||
let apiKeyForSdkInstance = this.apiKey
|
let apiKeyForSdkInstance = this.apiKey
|
||||||
|
let baseURLForSdkInstance = this.getBaseURL()
|
||||||
|
let headersForSdkInstance = {
|
||||||
|
...this.defaultHeaders(),
|
||||||
|
...this.provider.extra_headers
|
||||||
|
}
|
||||||
|
|
||||||
if (this.provider.id === 'copilot') {
|
if (this.provider.id === 'copilot') {
|
||||||
const defaultHeaders = store.getState().copilot.defaultHeaders
|
const defaultHeaders = store.getState().copilot.defaultHeaders
|
||||||
@@ -151,6 +155,11 @@ export abstract class OpenAIBaseClient<
|
|||||||
// this.provider.apiKey不允许修改
|
// this.provider.apiKey不允许修改
|
||||||
// this.provider.apiKey = token
|
// this.provider.apiKey = token
|
||||||
apiKeyForSdkInstance = token
|
apiKeyForSdkInstance = token
|
||||||
|
baseURLForSdkInstance = this.getBaseURL(false)
|
||||||
|
headersForSdkInstance = {
|
||||||
|
...headersForSdkInstance,
|
||||||
|
...COPILOT_DEFAULT_HEADERS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||||
@@ -164,12 +173,8 @@ export abstract class OpenAIBaseClient<
|
|||||||
this.sdkInstance = new OpenAI({
|
this.sdkInstance = new OpenAI({
|
||||||
dangerouslyAllowBrowser: true,
|
dangerouslyAllowBrowser: true,
|
||||||
apiKey: apiKeyForSdkInstance,
|
apiKey: apiKeyForSdkInstance,
|
||||||
baseURL: this.getBaseURL(),
|
baseURL: baseURLForSdkInstance,
|
||||||
defaultHeaders: {
|
defaultHeaders: headersForSdkInstance
|
||||||
...this.defaultHeaders(),
|
|
||||||
...this.provider.extra_headers,
|
|
||||||
...(this.provider.id === 'copilot' ? COPILOT_DEFAULT_HEADERS : {})
|
|
||||||
}
|
|
||||||
}) as TSdkInstance
|
}) as TSdkInstance
|
||||||
}
|
}
|
||||||
return this.sdkInstance
|
return this.sdkInstance
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
|||||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||||
this.provider = { ...this.provider, apiHost: this.formatApiHost() }
|
this.provider = { ...this.provider, apiHost: this.formatApiHost() }
|
||||||
if (this.provider.apiVersion === 'preview') {
|
if (this.provider.apiVersion === 'preview' || this.provider.apiVersion === 'v1') {
|
||||||
return this
|
return this
|
||||||
} else {
|
} else {
|
||||||
return this.client
|
return this.client
|
||||||
@@ -297,7 +297,31 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
|||||||
|
|
||||||
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
|
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
|
||||||
const content: OpenAI.Responses.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
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +520,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
|||||||
...(isSupportVerbosityModel(model)
|
...(isSupportVerbosityModel(model)
|
||||||
? {
|
? {
|
||||||
text: {
|
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 { google } from '@ai-sdk/google'
|
||||||
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
|
import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge'
|
||||||
import { vertex } from '@ai-sdk/google-vertex/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 type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins'
|
||||||
import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas'
|
import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import {
|
import {
|
||||||
|
isAnthropicModel,
|
||||||
isGenerateImageModel,
|
isGenerateImageModel,
|
||||||
isOpenRouterBuiltInWebSearchModel,
|
isOpenRouterBuiltInWebSearchModel,
|
||||||
isReasoningModel,
|
isReasoningModel,
|
||||||
@@ -19,6 +21,8 @@ import {
|
|||||||
isSupportedThinkingTokenModel,
|
isSupportedThinkingTokenModel,
|
||||||
isWebSearchModel
|
isWebSearchModel
|
||||||
} from '@renderer/config/models'
|
} from '@renderer/config/models'
|
||||||
|
import { isAwsBedrockProvider } from '@renderer/config/providers'
|
||||||
|
import { isVertexProvider } from '@renderer/hooks/useVertexAI'
|
||||||
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
|
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
|
||||||
import store from '@renderer/store'
|
import store from '@renderer/store'
|
||||||
import type { CherryWebSearchConfig } from '@renderer/store/websearch'
|
import type { CherryWebSearchConfig } from '@renderer/store/websearch'
|
||||||
@@ -34,6 +38,7 @@ import { setupToolsConfig } from '../utils/mcp'
|
|||||||
import { buildProviderOptions } from '../utils/options'
|
import { buildProviderOptions } from '../utils/options'
|
||||||
import { getAnthropicThinkingBudget } from '../utils/reasoning'
|
import { getAnthropicThinkingBudget } from '../utils/reasoning'
|
||||||
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch'
|
||||||
|
import { addAnthropicHeaders } from './header'
|
||||||
import { supportsTopP } from './modelCapabilities'
|
import { supportsTopP } from './modelCapabilities'
|
||||||
import { getTemperature, getTopP } from './modelParameters'
|
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 = {
|
const params: StreamTextParams = {
|
||||||
messages: sdkMessages,
|
messages: sdkMessages,
|
||||||
maxOutputTokens: maxTokens,
|
maxOutputTokens: maxTokens,
|
||||||
temperature: getTemperature(assistant, model),
|
temperature: getTemperature(assistant, model),
|
||||||
abortSignal: options.requestOptions?.signal,
|
abortSignal: options.requestOptions?.signal,
|
||||||
headers: options.requestOptions?.headers,
|
headers,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
stopWhen: stepCountIs(20),
|
stopWhen: stepCountIs(20),
|
||||||
maxRetries: 0
|
maxRetries: 0
|
||||||
|
|||||||
@@ -189,9 +189,11 @@ export function providerToAiSdkConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// azure
|
// 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') {
|
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
||||||
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
// extraOptions.apiVersion = actualProvider.apiVersion === 'preview' ? 'v1' : actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||||
if (actualProvider.apiVersion === 'preview') {
|
if (actualProvider.apiVersion === 'preview' || actualProvider.apiVersion === 'v1') {
|
||||||
extraOptions.mode = 'responses'
|
extraOptions.mode = 'responses'
|
||||||
} else {
|
} else {
|
||||||
extraOptions.mode = 'chat'
|
extraOptions.mode = 'chat'
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { baseProviderIdSchema, customProviderIdSchema } from '@cherrystudio/ai-core/provider'
|
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 { isSupportServiceTierProvider } from '@renderer/config/providers'
|
||||||
import { mapLanguageToQwenMTModel } from '@renderer/config/translate'
|
import { mapLanguageToQwenMTModel } from '@renderer/config/translate'
|
||||||
import type { Assistant, Model, Provider } from '@renderer/types'
|
import type { Assistant, Model, Provider } from '@renderer/types'
|
||||||
@@ -26,6 +33,8 @@ import {
|
|||||||
} from './reasoning'
|
} from './reasoning'
|
||||||
import { getWebSearchParams } from './websearch'
|
import { getWebSearchParams } from './websearch'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('aiCore.utils.options')
|
||||||
|
|
||||||
// copy from BaseApiClient.ts
|
// copy from BaseApiClient.ts
|
||||||
const getServiceTier = (model: Model, provider: Provider) => {
|
const getServiceTier = (model: Model, provider: Provider) => {
|
||||||
const serviceTierSetting = provider.serviceTier
|
const serviceTierSetting = provider.serviceTier
|
||||||
@@ -70,6 +79,7 @@ export function buildProviderOptions(
|
|||||||
enableGenerateImage: boolean
|
enableGenerateImage: boolean
|
||||||
}
|
}
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
|
logger.debug('buildProviderOptions', { assistant, model, actualProvider, capabilities })
|
||||||
const rawProviderId = getAiSdkProviderId(actualProvider)
|
const rawProviderId = getAiSdkProviderId(actualProvider)
|
||||||
// 构建 provider 特定的选项
|
// 构建 provider 特定的选项
|
||||||
let providerSpecificOptions: Record<string, any> = {}
|
let providerSpecificOptions: Record<string, any> = {}
|
||||||
@@ -89,9 +99,6 @@ export function buildProviderOptions(
|
|||||||
serviceTier: serviceTierSetting
|
serviceTier: serviceTierSetting
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'huggingface':
|
|
||||||
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
|
|
||||||
break
|
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
|
providerSpecificOptions = buildAnthropicProviderOptions(assistant, model, capabilities)
|
||||||
break
|
break
|
||||||
@@ -134,6 +141,9 @@ export function buildProviderOptions(
|
|||||||
case 'bedrock':
|
case 'bedrock':
|
||||||
providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities)
|
providerSpecificOptions = buildBedrockProviderOptions(assistant, model, capabilities)
|
||||||
break
|
break
|
||||||
|
case 'huggingface':
|
||||||
|
providerSpecificOptions = buildOpenAIProviderOptions(assistant, model, capabilities)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
// 对于其他 provider,使用通用的构建逻辑
|
// 对于其他 provider,使用通用的构建逻辑
|
||||||
providerSpecificOptions = {
|
providerSpecificOptions = {
|
||||||
@@ -152,13 +162,17 @@ export function buildProviderOptions(
|
|||||||
...getCustomParameters(assistant)
|
...getCustomParameters(assistant)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawProviderKey =
|
let rawProviderKey =
|
||||||
{
|
{
|
||||||
'google-vertex': 'google',
|
'google-vertex': 'google',
|
||||||
'google-vertex-anthropic': 'anthropic',
|
'google-vertex-anthropic': 'anthropic',
|
||||||
'ai-gateway': 'gateway'
|
'ai-gateway': 'gateway'
|
||||||
}[rawProviderId] || rawProviderId
|
}[rawProviderId] || rawProviderId
|
||||||
|
|
||||||
|
if (rawProviderKey === 'cherryin') {
|
||||||
|
rawProviderKey = { gemini: 'google' }[actualProvider.type] || actualProvider.type
|
||||||
|
}
|
||||||
|
|
||||||
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
|
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
|
||||||
return {
|
return {
|
||||||
[rawProviderKey]: providerSpecificOptions
|
[rawProviderKey]: providerSpecificOptions
|
||||||
@@ -187,6 +201,23 @@ function buildOpenAIProviderOptions(
|
|||||||
...reasoningParams
|
...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
|
return 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 { loggerService } from '@logger'
|
||||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +11,7 @@ import {
|
|||||||
isDeepSeekHybridInferenceModel,
|
isDeepSeekHybridInferenceModel,
|
||||||
isDoubaoSeedAfter251015,
|
isDoubaoSeedAfter251015,
|
||||||
isDoubaoThinkingAutoModel,
|
isDoubaoThinkingAutoModel,
|
||||||
|
isGPT51SeriesModel,
|
||||||
isGrok4FastReasoningModel,
|
isGrok4FastReasoningModel,
|
||||||
isGrokReasoningModel,
|
isGrokReasoningModel,
|
||||||
isOpenAIDeepResearchModel,
|
isOpenAIDeepResearchModel,
|
||||||
@@ -56,13 +61,20 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
}
|
}
|
||||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
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
|
// openrouter: use reasoning
|
||||||
if (model.provider === SystemProviderIds.openrouter) {
|
if (model.provider === SystemProviderIds.openrouter) {
|
||||||
// Don't disable reasoning for Gemini models that support thinking tokens
|
// Don't disable reasoning for Gemini models that support thinking tokens
|
||||||
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||||
return {}
|
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
|
// Don't disable reasoning for models that require it
|
||||||
if (
|
if (
|
||||||
isGrokReasoningModel(model) ||
|
isGrokReasoningModel(model) ||
|
||||||
@@ -117,6 +129,13 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
|||||||
return { thinking: { type: 'disabled' } }
|
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 {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +390,7 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re
|
|||||||
|
|
||||||
export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number {
|
export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number {
|
||||||
const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||||
if (reasoningEffort === undefined) {
|
if (reasoningEffort === undefined || reasoningEffort === 'none') {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
const effortRatio = EFFORT_RATIO[reasoningEffort]
|
||||||
@@ -393,14 +412,17 @@ export function getAnthropicThinkingBudget(assistant: Assistant, model: Model):
|
|||||||
* 获取 Anthropic 推理参数
|
* 获取 Anthropic 推理参数
|
||||||
* 从 AnthropicAPIClient 中提取的逻辑
|
* 从 AnthropicAPIClient 中提取的逻辑
|
||||||
*/
|
*/
|
||||||
export function getAnthropicReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
|
export function getAnthropicReasoningParams(
|
||||||
|
assistant: Assistant,
|
||||||
|
model: Model
|
||||||
|
): Pick<AnthropicProviderOptions, 'thinking'> {
|
||||||
if (!isReasoningModel(model)) {
|
if (!isReasoningModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||||
|
|
||||||
if (reasoningEffort === undefined) {
|
if (reasoningEffort === undefined || reasoningEffort === 'none') {
|
||||||
return {
|
return {
|
||||||
thinking: {
|
thinking: {
|
||||||
type: 'disabled'
|
type: 'disabled'
|
||||||
@@ -429,7 +451,10 @@ export function getAnthropicReasoningParams(assistant: Assistant, model: Model):
|
|||||||
* 注意:Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递
|
* 注意:Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递
|
||||||
* 而在 Google 官方提供的 OpenAI 兼容端点中则使用蛇形命名法 thinking_budget
|
* 而在 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)) {
|
if (!isReasoningModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -438,7 +463,7 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
|||||||
|
|
||||||
// Gemini 推理参数
|
// Gemini 推理参数
|
||||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||||
if (reasoningEffort === undefined) {
|
if (reasoningEffort === undefined || reasoningEffort === 'none') {
|
||||||
return {
|
return {
|
||||||
thinkingConfig: {
|
thinkingConfig: {
|
||||||
includeThoughts: false,
|
includeThoughts: false,
|
||||||
@@ -478,27 +503,35 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
|
|||||||
* @param model - The model being used
|
* @param model - The model being used
|
||||||
* @returns XAI-specific reasoning parameters
|
* @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)) {
|
if (!isSupportedReasoningEffortGrokModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant)
|
||||||
|
|
||||||
if (!reasoningEffort) {
|
if (!reasoningEffort || reasoningEffort === 'none') {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For XAI provider Grok models, use reasoningEffort parameter directly
|
switch (reasoningEffort) {
|
||||||
return {
|
case 'auto':
|
||||||
reasoningEffort
|
case 'minimal':
|
||||||
|
case 'medium':
|
||||||
|
return { reasoningEffort: 'low' }
|
||||||
|
case 'low':
|
||||||
|
case 'high':
|
||||||
|
return { reasoningEffort }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Bedrock reasoning parameters
|
* 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)) {
|
if (!isReasoningModel(model)) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -509,6 +542,14 @@ export function getBedrockReasoningParams(assistant: Assistant, model: Model): R
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reasoningEffort === 'none') {
|
||||||
|
return {
|
||||||
|
reasoningConfig: {
|
||||||
|
type: 'disabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only apply thinking budget for Claude reasoning models
|
// Only apply thinking budget for Claude reasoning models
|
||||||
if (!isSupportedThinkingTokenClaudeModel(model)) {
|
if (!isSupportedThinkingTokenClaudeModel(model)) {
|
||||||
return {}
|
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 |
@@ -1,5 +1,6 @@
|
|||||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||||
|
import { scrollElementIntoView } from '@renderer/utils'
|
||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||||
@@ -181,17 +182,14 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
|||||||
// 3. 将当前项滚动到视图中
|
// 3. 将当前项滚动到视图中
|
||||||
// 获取第一个文本节点的父元素来进行滚动
|
// 获取第一个文本节点的父元素来进行滚动
|
||||||
const parentElement = currentMatchRange.startContainer.parentElement
|
const parentElement = currentMatchRange.startContainer.parentElement
|
||||||
if (shouldScroll) {
|
if (shouldScroll && parentElement) {
|
||||||
parentElement?.scrollIntoView({
|
// 优先在指定的滚动容器内滚动,避免滚动整个页面导致索引错乱/看起来"跳到第一条"
|
||||||
behavior: 'smooth',
|
scrollElementIntoView(parentElement, target)
|
||||||
block: 'center',
|
|
||||||
inline: 'nearest'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allRanges, currentIndex]
|
[allRanges, currentIndex, target]
|
||||||
)
|
)
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
|
|||||||
@@ -1,35 +1,120 @@
|
|||||||
|
import 'emoji-picker-element'
|
||||||
|
|
||||||
import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url'
|
import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import type { LanguageVarious } from '@renderer/types'
|
||||||
import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
|
import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
|
||||||
|
// i18n translations from emoji-picker-element
|
||||||
|
import de from 'emoji-picker-element/i18n/de'
|
||||||
|
import en from 'emoji-picker-element/i18n/en'
|
||||||
|
import es from 'emoji-picker-element/i18n/es'
|
||||||
|
import fr from 'emoji-picker-element/i18n/fr'
|
||||||
|
import ja from 'emoji-picker-element/i18n/ja'
|
||||||
|
import pt_PT from 'emoji-picker-element/i18n/pt_PT'
|
||||||
|
import ru_RU from 'emoji-picker-element/i18n/ru_RU'
|
||||||
|
import zh_CN from 'emoji-picker-element/i18n/zh_CN'
|
||||||
|
import type Picker from 'emoji-picker-element/picker'
|
||||||
|
import type { EmojiClickEvent, NativeEmoji } from 'emoji-picker-element/shared'
|
||||||
|
// Emoji data from emoji-picker-element-data (local, no CDN)
|
||||||
|
// Using CLDR format for full multi-language search support (28 languages)
|
||||||
|
import dataDE from 'emoji-picker-element-data/de/cldr/data.json?url'
|
||||||
|
import dataEN from 'emoji-picker-element-data/en/cldr/data.json?url'
|
||||||
|
import dataES from 'emoji-picker-element-data/es/cldr/data.json?url'
|
||||||
|
import dataFR from 'emoji-picker-element-data/fr/cldr/data.json?url'
|
||||||
|
import dataJA from 'emoji-picker-element-data/ja/cldr/data.json?url'
|
||||||
|
import dataPT from 'emoji-picker-element-data/pt/cldr/data.json?url'
|
||||||
|
import dataRU from 'emoji-picker-element-data/ru/cldr/data.json?url'
|
||||||
|
import dataZH from 'emoji-picker-element-data/zh/cldr/data.json?url'
|
||||||
|
import dataZH_HANT from 'emoji-picker-element-data/zh-hant/cldr/data.json?url'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEmojiClick: (emoji: string) => void
|
onEmojiClick: (emoji: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mapping from app locale to emoji-picker-element i18n
|
||||||
|
const i18nMap: Record<LanguageVarious, typeof en> = {
|
||||||
|
'en-US': en,
|
||||||
|
'zh-CN': zh_CN,
|
||||||
|
'zh-TW': zh_CN, // Closest available
|
||||||
|
'de-DE': de,
|
||||||
|
'el-GR': en, // No Greek available, fallback to English
|
||||||
|
'es-ES': es,
|
||||||
|
'fr-FR': fr,
|
||||||
|
'ja-JP': ja,
|
||||||
|
'pt-PT': pt_PT,
|
||||||
|
'ru-RU': ru_RU
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping from app locale to emoji data URL
|
||||||
|
// Using CLDR format provides native language search support for all locales
|
||||||
|
const dataSourceMap: Record<LanguageVarious, string> = {
|
||||||
|
'en-US': dataEN,
|
||||||
|
'zh-CN': dataZH,
|
||||||
|
'zh-TW': dataZH_HANT,
|
||||||
|
'de-DE': dataDE,
|
||||||
|
'el-GR': dataEN, // No Greek CLDR available, fallback to English
|
||||||
|
'es-ES': dataES,
|
||||||
|
'fr-FR': dataFR,
|
||||||
|
'ja-JP': dataJA,
|
||||||
|
'pt-PT': dataPT,
|
||||||
|
'ru-RU': dataRU
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping from app locale to emoji-picker-element locale string
|
||||||
|
// Must match the data source locale for proper IndexedDB caching
|
||||||
|
const localeMap: Record<LanguageVarious, string> = {
|
||||||
|
'en-US': 'en',
|
||||||
|
'zh-CN': 'zh',
|
||||||
|
'zh-TW': 'zh-hant',
|
||||||
|
'de-DE': 'de',
|
||||||
|
'el-GR': 'en',
|
||||||
|
'es-ES': 'es',
|
||||||
|
'fr-FR': 'fr',
|
||||||
|
'ja-JP': 'ja',
|
||||||
|
'pt-PT': 'pt',
|
||||||
|
'ru-RU': 'ru'
|
||||||
|
}
|
||||||
|
|
||||||
const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
|
const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const { i18n } = useTranslation()
|
||||||
|
const ref = useRef<Picker>(null)
|
||||||
|
const currentLocale = i18n.language as LanguageVarious
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
|
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Configure picker with i18n and dataSource
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refValue = ref.current
|
const picker = ref.current
|
||||||
|
if (picker) {
|
||||||
|
picker.i18n = i18nMap[currentLocale] || en
|
||||||
|
picker.dataSource = dataSourceMap[currentLocale] || dataEN
|
||||||
|
picker.locale = localeMap[currentLocale] || 'en'
|
||||||
|
}
|
||||||
|
}, [currentLocale])
|
||||||
|
|
||||||
if (refValue) {
|
useEffect(() => {
|
||||||
const handleEmojiClick = (event: any) => {
|
const picker = ref.current
|
||||||
|
|
||||||
|
if (picker) {
|
||||||
|
const handleEmojiClick = (event: EmojiClickEvent) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
|
const { detail } = event
|
||||||
|
// Use detail.unicode (processed with skin tone) or fallback to emoji's unicode for native emoji
|
||||||
|
const unicode = detail.unicode || ('unicode' in detail.emoji ? (detail.emoji as NativeEmoji).unicode : '')
|
||||||
|
onEmojiClick(unicode)
|
||||||
}
|
}
|
||||||
// 添加事件监听器
|
// 添加事件监听器
|
||||||
refValue.addEventListener('emoji-click', handleEmojiClick)
|
picker.addEventListener('emoji-click', handleEmojiClick)
|
||||||
|
|
||||||
// 清理事件监听器
|
// 清理事件监听器
|
||||||
return () => {
|
return () => {
|
||||||
refValue.removeEventListener('emoji-click', handleEmojiClick)
|
picker.removeEventListener('emoji-click', handleEmojiClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
|
|
||||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { permissionModeCards } from '@renderer/config/agent'
|
import { permissionModeCards } from '@renderer/config/agent'
|
||||||
@@ -9,7 +8,6 @@ import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAg
|
|||||||
import type {
|
import type {
|
||||||
AddAgentForm,
|
AddAgentForm,
|
||||||
AgentEntity,
|
AgentEntity,
|
||||||
AgentType,
|
|
||||||
ApiModel,
|
ApiModel,
|
||||||
BaseAgentForm,
|
BaseAgentForm,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
@@ -17,30 +15,22 @@ import type {
|
|||||||
UpdateAgentForm
|
UpdateAgentForm
|
||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
import { AgentConfigurationSchema, isAgentType } 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 { AlertTriangleIcon } from 'lucide-react'
|
||||||
import type { ChangeEvent, FormEvent } from 'react'
|
import type { ChangeEvent, FormEvent } from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import type { BaseOption } from './shared'
|
|
||||||
|
|
||||||
const { TextArea } = Input
|
const { TextArea } = Input
|
||||||
|
|
||||||
const logger = loggerService.withContext('AddAgentPopup')
|
const logger = loggerService.withContext('AddAgentPopup')
|
||||||
|
|
||||||
interface AgentTypeOption extends BaseOption {
|
|
||||||
type: 'type'
|
|
||||||
key: AgentEntity['type']
|
|
||||||
name: AgentEntity['name']
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentWithTools = AgentEntity & { tools?: Tool[] }
|
type AgentWithTools = AgentEntity & { tools?: Tool[] }
|
||||||
|
|
||||||
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
|
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
|
||||||
type: existing?.type ?? 'claude-code',
|
type: existing?.type ?? 'claude-code',
|
||||||
name: existing?.name ?? 'Claude Code',
|
name: existing?.name ?? 'Agent',
|
||||||
description: existing?.description,
|
description: existing?.description,
|
||||||
instructions: existing?.instructions,
|
instructions: existing?.instructions,
|
||||||
model: existing?.model ?? '',
|
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>) => {
|
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -155,12 +97,12 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
|
|||||||
}))
|
}))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
// const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setForm((prev) => ({
|
// setForm((prev) => ({
|
||||||
...prev,
|
// ...prev,
|
||||||
description: e.target.value
|
// description: e.target.value
|
||||||
}))
|
// }))
|
||||||
}, [])
|
// }, [])
|
||||||
|
|
||||||
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
@@ -334,16 +276,6 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
|
|||||||
<StyledForm onSubmit={onSubmit}>
|
<StyledForm onSubmit={onSubmit}>
|
||||||
<FormContent>
|
<FormContent>
|
||||||
<FormRow>
|
<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 }}>
|
<FormItem style={{ flex: 1 }}>
|
||||||
<Label>
|
<Label>
|
||||||
{t('common.name')} <RequiredMark>*</RequiredMark>
|
{t('common.name')} <RequiredMark>*</RequiredMark>
|
||||||
@@ -363,7 +295,7 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
|
|||||||
avatarSize={24}
|
avatarSize={24}
|
||||||
iconSize={16}
|
iconSize={16}
|
||||||
buttonStyle={{
|
buttonStyle={{
|
||||||
padding: '8px 12px',
|
padding: '3px 8px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
@@ -382,7 +314,6 @@ const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
|
|||||||
onChange={onPermissionModeChange}
|
onChange={onPermissionModeChange}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
|
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
|
||||||
dropdownStyle={{ minWidth: '500px' }}
|
|
||||||
optionLabelProp="label">
|
optionLabelProp="label">
|
||||||
{permissionModeCards.map((item) => (
|
{permissionModeCards.map((item) => (
|
||||||
<Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
|
<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} />
|
<TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
<FormItem>
|
{/* <FormItem>
|
||||||
<Label>{t('common.description')}</Label>
|
<Label>{t('common.description')}</Label>
|
||||||
<TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
|
<TextArea rows={1} value={form.description ?? ''} onChange={onDescChange} />
|
||||||
</FormItem>
|
</FormItem> */}
|
||||||
</FormContent>
|
</FormContent>
|
||||||
|
|
||||||
<FormFooter>
|
<FormFooter>
|
||||||
@@ -575,14 +506,7 @@ const FormFooter = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-top: 16px;
|
padding: 10px;
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
`
|
|
||||||
|
|
||||||
const OptionWrapper = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const PermissionOptionWrapper = styled.div`
|
const PermissionOptionWrapper = styled.div`
|
||||||
|
|||||||
@@ -81,6 +81,16 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
|
|||||||
* Hide the scrollbar automatically when scrolling is stopped
|
* Hide the scrollbar automatically when scrolling is stopped
|
||||||
*/
|
*/
|
||||||
autoHideScrollbar?: boolean
|
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>) {
|
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||||
@@ -95,6 +105,8 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
itemContainerStyle,
|
itemContainerStyle,
|
||||||
scrollerStyle,
|
scrollerStyle,
|
||||||
autoHideScrollbar = false,
|
autoHideScrollbar = false,
|
||||||
|
header,
|
||||||
|
className,
|
||||||
...restOptions
|
...restOptions
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
@@ -189,7 +201,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
return (
|
return (
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
className="dynamic-virtual-list"
|
className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Dynamic Virtual List"
|
aria-label="Dynamic Virtual List"
|
||||||
aria-hidden={!showScrollbar}
|
aria-hidden={!showScrollbar}
|
||||||
@@ -200,6 +212,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
||||||
...scrollerStyle
|
...scrollerStyle
|
||||||
}}>
|
}}>
|
||||||
|
{header}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
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', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
default: {
|
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',
|
provider: 'minimax',
|
||||||
name: 'minimax-01',
|
name: 'minimax-01',
|
||||||
group: '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: [
|
hyperbolic: [
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ import {
|
|||||||
} from '@renderer/assets/images/models/gpt_dark.png'
|
} from '@renderer/assets/images/models/gpt_dark.png'
|
||||||
import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.png'
|
import ChatGPTImageModelLogo from '@renderer/assets/images/models/gpt_image_1.png'
|
||||||
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.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 GPT5ModelLogo from '@renderer/assets/images/models/gpt-5.png'
|
||||||
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
|
import GPT5ChatModelLogo from '@renderer/assets/images/models/gpt-5-chat.png'
|
||||||
import GPT5CodexModelLogo from '@renderer/assets/images/models/gpt-5-codex.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-nano': GPT5NanoModelLogo,
|
||||||
'gpt-5-chat': GPT5ChatModelLogo,
|
'gpt-5-chat': GPT5ChatModelLogo,
|
||||||
'gpt-5-codex': GPT5CodexModelLogo,
|
'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,
|
'gpt-5': GPT5ModelLogo,
|
||||||
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
gpts: isLight ? ChatGPT4ModelLogo : ChatGPT4ModelLogoDark,
|
||||||
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
'gpt-oss(?:-[\\w-]+)': isLight ? ChatGptModelLogo : ChatGptModelLogoDark,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
||||||
|
|
||||||
import { isEmbeddingModel, isRerankModel } from './embedding'
|
import { isEmbeddingModel, isRerankModel } from './embedding'
|
||||||
import { isGPT5SeriesModel } from './utils'
|
import { isGPT5ProModel, isGPT5SeriesModel, isGPT51SeriesModel } from './utils'
|
||||||
import { isTextToImageModel } from './vision'
|
import { isTextToImageModel } from './vision'
|
||||||
import { GEMINI_FLASH_MODEL_REGEX, isOpenAIDeepResearchModel } from './websearch'
|
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,
|
openai_deep_research: ['medium'] as const,
|
||||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||||
gpt5_codex: ['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,
|
grok: ['low', 'high'] as const,
|
||||||
grok4_fast: ['auto'] as const,
|
grok4_fast: ['auto'] as const,
|
||||||
gemini: ['low', 'medium', 'high', '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 = {
|
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,
|
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||||
openai_deep_research: MODEL_SUPPORTED_REASONING_EFFORT.openai_deep_research,
|
openai_deep_research: MODEL_SUPPORTED_REASONING_EFFORT.openai_deep_research,
|
||||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||||
|
gpt5pro: MODEL_SUPPORTED_REASONING_EFFORT.gpt5pro,
|
||||||
gpt5_codex: MODEL_SUPPORTED_REASONING_EFFORT.gpt5_codex,
|
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,
|
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
||||||
grok4_fast: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
|
grok4_fast: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const,
|
||||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
gemini: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
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,
|
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
|
||||||
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
doubao: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
||||||
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] 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,
|
doubao_after_251015: MODEL_SUPPORTED_REASONING_EFFORT.doubao_after_251015,
|
||||||
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
hunyuan: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||||
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
zhipu: ['none', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
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
|
} as const
|
||||||
|
|
||||||
const withModelIdAndNameAsId = <T>(model: Model, fn: (model: Model) => T): { idResult: T; nameResult: T } => {
|
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)) {
|
if (isOpenAIDeepResearchModel(model)) {
|
||||||
return 'openai_deep_research'
|
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')) {
|
if (modelId.includes('codex')) {
|
||||||
thinkingModelType = 'gpt5_codex'
|
thinkingModelType = 'gpt5_codex'
|
||||||
} else {
|
} else {
|
||||||
thinkingModelType = 'gpt5'
|
thinkingModelType = 'gpt5'
|
||||||
|
if (isGPT5ProModel(model)) {
|
||||||
|
thinkingModelType = 'gpt5pro'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||||
thinkingModelType = 'o'
|
thinkingModelType = 'o'
|
||||||
@@ -239,7 +254,7 @@ export function isGeminiReasoningModel(model?: Model): boolean {
|
|||||||
|
|
||||||
// Gemini 支持思考模式的模型正则
|
// Gemini 支持思考模式的模型正则
|
||||||
export const GEMINI_THINKING_MODEL_REGEX =
|
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 => {
|
export const isSupportedThinkingTokenGeminiModel = (model: Model): boolean => {
|
||||||
const modelId = getLowerBaseModelName(model.id, '/')
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
@@ -526,7 +541,7 @@ export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean {
|
|||||||
modelId.includes('o3') ||
|
modelId.includes('o3') ||
|
||||||
modelId.includes('o4') ||
|
modelId.includes('o4') ||
|
||||||
modelId.includes('gpt-oss') ||
|
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 {
|
export function isSupportVerbosityModel(model: Model): boolean {
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
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 {
|
export function isOpenAIChatCompletionOnlyModel(model: Model): boolean {
|
||||||
@@ -227,12 +227,32 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => {
|
|||||||
|
|
||||||
export const isGPT5SeriesModel = (model: Model) => {
|
export const isGPT5SeriesModel = (model: Model) => {
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
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) => {
|
export const isGPT5SeriesReasoningModel = (model: Model) => {
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
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) => {
|
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 => {
|
export const agentModelFilter = (model: Model): boolean => {
|
||||||
return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model)
|
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-1\\.5',
|
||||||
'gemini-2\\.0',
|
'gemini-2\\.0',
|
||||||
'gemini-2\\.5',
|
'gemini-2\\.5',
|
||||||
|
'gemini-3-(?:flash|pro)(?:-preview)?',
|
||||||
'gemini-(flash|pro|flash-lite)-latest',
|
'gemini-(flash|pro|flash-lite)-latest',
|
||||||
'gemini-exp',
|
'gemini-exp',
|
||||||
'claude-3',
|
'claude-3',
|
||||||
@@ -64,13 +65,13 @@ const visionExcludedModels = [
|
|||||||
'o1-preview',
|
'o1-preview',
|
||||||
'AIDC-AI/Marco-o1'
|
'AIDC-AI/Marco-o1'
|
||||||
]
|
]
|
||||||
export const VISION_REGEX = new RegExp(
|
const VISION_REGEX = new RegExp(
|
||||||
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
|
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
|
||||||
'i'
|
'i'
|
||||||
)
|
)
|
||||||
|
|
||||||
// For middleware to identify models that must use the dedicated Image API
|
// 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',
|
||||||
'grok-2-image-1212',
|
'grok-2-image-1212',
|
||||||
'grok-2-image-latest',
|
'grok-2-image-latest',
|
||||||
@@ -79,7 +80,7 @@ export const DEDICATED_IMAGE_MODELS = [
|
|||||||
'gpt-image-1'
|
'gpt-image-1'
|
||||||
]
|
]
|
||||||
|
|
||||||
export const IMAGE_ENHANCEMENT_MODELS = [
|
const IMAGE_ENHANCEMENT_MODELS = [
|
||||||
'grok-2-image(?:-[\\w-]+)?',
|
'grok-2-image(?:-[\\w-]+)?',
|
||||||
'qwen-image-edit',
|
'qwen-image-edit',
|
||||||
'gpt-image-1',
|
'gpt-image-1',
|
||||||
@@ -90,9 +91,9 @@ export const IMAGE_ENHANCEMENT_MODELS = [
|
|||||||
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
|
const IMAGE_ENHANCEMENT_MODELS_REGEX = new RegExp(IMAGE_ENHANCEMENT_MODELS.join('|'), 'i')
|
||||||
|
|
||||||
// Models that should auto-enable image generation button when selected
|
// 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',
|
'o3',
|
||||||
'gpt-4o',
|
'gpt-4o',
|
||||||
'gpt-4o-mini',
|
'gpt-4o-mini',
|
||||||
@@ -102,9 +103,9 @@ export const OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS = [
|
|||||||
'gpt-5'
|
'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',
|
||||||
'gemini-2.0-flash-exp-image-generation',
|
'gemini-2.0-flash-exp-image-generation',
|
||||||
'gemini-2.0-flash-preview-image-generation',
|
'gemini-2.0-flash-preview-image-generation',
|
||||||
@@ -169,22 +170,23 @@ export function isPureGenerateImageModel(model: Model): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Text to image models
|
// 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 {
|
export function isTextToImageModel(model: Model): boolean {
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
return TEXT_TO_IMAGE_REGEX.test(modelId)
|
return TEXT_TO_IMAGE_REGEX.test(modelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNotSupportedImageSizeModel(model?: Model): boolean {
|
// It's not used now
|
||||||
if (!model) {
|
// export function isNotSupportedImageSizeModel(model?: Model): boolean {
|
||||||
return false
|
// 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 { SystemProviderIds } from '@renderer/types'
|
||||||
import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils'
|
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 { isEmbeddingModel, isRerankModel } from './embedding'
|
||||||
import { isAnthropicModel } from './utils'
|
import { isAnthropicModel } from './utils'
|
||||||
import { isPureGenerateImageModel, isTextToImageModel } from './vision'
|
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_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$')
|
||||||
|
|
||||||
export const GEMINI_SEARCH_REGEX = new RegExp(
|
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'
|
'i'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ export function isWebSearchModel(model: Model): boolean {
|
|||||||
// bedrock和vertex不支持
|
// bedrock和vertex不支持
|
||||||
if (
|
if (
|
||||||
isAnthropicModel(model) &&
|
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)
|
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)
|
return GEMINI_SEARCH_REGEX.test(modelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import type {
|
|||||||
SystemProvider,
|
SystemProvider,
|
||||||
SystemProviderId
|
SystemProviderId
|
||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
|
import { isSystemProvider, OpenAIServiceTiers, SystemProviderIds } from '@renderer/types'
|
||||||
|
|
||||||
import { TOKENFLUX_HOST } from './constant'
|
import { TOKENFLUX_HOST } from './constant'
|
||||||
import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models'
|
import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models'
|
||||||
@@ -275,6 +275,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
type: 'openai',
|
type: 'openai',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
apiHost: 'https://api.qnaigc.com',
|
apiHost: 'https://api.qnaigc.com',
|
||||||
|
anthropicApiHost: 'https://api.qnaigc.com',
|
||||||
models: SYSTEM_MODELS.qiniu,
|
models: SYSTEM_MODELS.qiniu,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -472,7 +473,8 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
name: 'MiniMax',
|
name: 'MiniMax',
|
||||||
type: 'openai',
|
type: 'openai',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
apiHost: 'https://api.minimax.com/v1/',
|
apiHost: 'https://api.minimaxi.com/v1',
|
||||||
|
anthropicApiHost: 'https://api.minimaxi.com/anthropic',
|
||||||
models: SYSTEM_MODELS.minimax,
|
models: SYSTEM_MODELS.minimax,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -664,6 +666,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
type: 'openai',
|
type: 'openai',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
apiHost: 'https://api.longcat.chat/openai',
|
apiHost: 'https://api.longcat.chat/openai',
|
||||||
|
anthropicApiHost: 'https://api.longcat.chat/anthropic',
|
||||||
models: SYSTEM_MODELS.longcat,
|
models: SYSTEM_MODELS.longcat,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -683,7 +686,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
|||||||
name: 'AI Gateway',
|
name: 'AI Gateway',
|
||||||
type: 'ai-gateway',
|
type: 'ai-gateway',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
apiHost: 'https://ai-gateway.vercel.sh/v1',
|
apiHost: 'https://ai-gateway.vercel.sh/v1/ai',
|
||||||
models: [],
|
models: [],
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -1072,7 +1075,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
|||||||
},
|
},
|
||||||
minimax: {
|
minimax: {
|
||||||
api: {
|
api: {
|
||||||
url: 'https://api.minimax.com/v1/'
|
url: 'https://api.minimaxi.com/v1/'
|
||||||
},
|
},
|
||||||
websites: {
|
websites: {
|
||||||
official: 'https://platform.minimaxi.com/',
|
official: 'https://platform.minimaxi.com/',
|
||||||
@@ -1518,7 +1521,10 @@ const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
|
|||||||
] as const satisfies ProviderType[]
|
] as const satisfies ProviderType[]
|
||||||
|
|
||||||
export const isSupportUrlContextProvider = (provider: Provider) => {
|
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[]
|
const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[]
|
||||||
@@ -1565,10 +1571,18 @@ export function isGeminiProvider(provider: Provider): boolean {
|
|||||||
return provider.type === 'gemini'
|
return provider.type === 'gemini'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isVertexAiProvider(provider: Provider): boolean {
|
||||||
|
return provider.type === 'vertexai'
|
||||||
|
}
|
||||||
|
|
||||||
export function isAIGatewayProvider(provider: Provider): boolean {
|
export function isAIGatewayProvider(provider: Provider): boolean {
|
||||||
return provider.type === 'ai-gateway'
|
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[]
|
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[]
|
||||||
|
|
||||||
export const isSupportAPIVersionProvider = (provider: Provider) => {
|
export const isSupportAPIVersionProvider = (provider: Provider) => {
|
||||||
|
|||||||
@@ -123,9 +123,9 @@ export function useAssistant(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateAssistantSettings({
|
updateAssistantSettings({
|
||||||
reasoning_effort: fallbackOption === 'off' ? undefined : fallbackOption,
|
reasoning_effort: fallbackOption === 'none' ? undefined : fallbackOption,
|
||||||
reasoning_effort_cache: fallbackOption === 'off' ? undefined : fallbackOption,
|
reasoning_effort_cache: fallbackOption === 'none' ? undefined : fallbackOption,
|
||||||
qwenThinkMode: fallbackOption === 'off' ? undefined : true
|
qwenThinkMode: fallbackOption === 'none' ? undefined : true
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 对于支持的选项, 不再更新 cache.
|
// 对于支持的选项, 不再更新 cache.
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ export const getHttpMessageLabel = (key: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reasoningEffortOptionsKeyMap: Record<ThinkingOption, string> = {
|
const reasoningEffortOptionsKeyMap: Record<ThinkingOption, string> = {
|
||||||
off: 'assistants.settings.reasoning_effort.off',
|
none: 'assistants.settings.reasoning_effort.off',
|
||||||
minimal: 'assistants.settings.reasoning_effort.minimal',
|
minimal: 'assistants.settings.reasoning_effort.minimal',
|
||||||
high: 'assistants.settings.reasoning_effort.high',
|
high: 'assistants.settings.reasoning_effort.high',
|
||||||
low: 'assistants.settings.reasoning_effort.low',
|
low: 'assistants.settings.reasoning_effort.low',
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "Agent ID is null."
|
"null_id": "Agent ID is null."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Failed to list agents."
|
"failed": "Failed to list agents."
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Uninstalling..."
|
"uninstalling": "Uninstalling..."
|
||||||
},
|
},
|
||||||
"prompt": "Prompt Settings",
|
"prompt": "Prompt Settings",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "Select sub agents",
|
||||||
|
"tab": "Sub Agents",
|
||||||
|
"title": "Sub Agents",
|
||||||
|
"tooltip": "Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Connect MCP servers to unlock additional tools you can approve above.",
|
"description": "Connect MCP servers to unlock additional tools you can approve above.",
|
||||||
@@ -638,6 +647,7 @@
|
|||||||
"description": "No files available in accessible directories",
|
"description": "No files available in accessible directories",
|
||||||
"label": "No File Found"
|
"label": "No File Found"
|
||||||
},
|
},
|
||||||
|
"sub_agent": "Sub-Agent",
|
||||||
"title": "Activity Directory"
|
"title": "Activity Directory"
|
||||||
},
|
},
|
||||||
"auto_resize": "Auto resize height",
|
"auto_resize": "Auto resize height",
|
||||||
@@ -4343,7 +4353,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"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": {
|
"basic_auth": {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "智能体 ID 为空。"
|
"null_id": "智能体 ID 为空。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择路径, / 选择命令"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "获取智能体列表失败"
|
"failed": "获取智能体列表失败"
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "卸载中..."
|
"uninstalling": "卸载中..."
|
||||||
},
|
},
|
||||||
"prompt": "提示词设置",
|
"prompt": "提示词设置",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "选择子智能体",
|
||||||
|
"tab": "子智能体",
|
||||||
|
"title": "子智能体",
|
||||||
|
"tooltip": "选择可以被此智能体委派任务的其他智能体"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "连接 MCP 服务器即可解锁更多可在上方预先授权的工具。",
|
"description": "连接 MCP 服务器即可解锁更多可在上方预先授权的工具。",
|
||||||
@@ -638,6 +647,7 @@
|
|||||||
"description": "可访问目录中没有可用文件",
|
"description": "可访问目录中没有可用文件",
|
||||||
"label": "未找到文件"
|
"label": "未找到文件"
|
||||||
},
|
},
|
||||||
|
"sub_agent": "子代理",
|
||||||
"title": "活动目录"
|
"title": "活动目录"
|
||||||
},
|
},
|
||||||
"auto_resize": "自动调整高度",
|
"auto_resize": "自动调整高度",
|
||||||
@@ -4343,7 +4353,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 preview 版本"
|
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 v1 版本"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4488,7 @@
|
|||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"forward": "前进",
|
"forward": "前进",
|
||||||
"multiple": "多选",
|
"multiple": "多选",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "未找到结果",
|
||||||
"page": "翻页",
|
"page": "翻页",
|
||||||
"select": "选择",
|
"select": "选择",
|
||||||
"title": "快捷菜单"
|
"title": "快捷菜单"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "代理程式 ID 為空。"
|
"null_id": "代理程式 ID 為空。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "無法列出代理程式。"
|
"failed": "無法列出代理程式。"
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "解除安裝中..."
|
"uninstalling": "解除安裝中..."
|
||||||
},
|
},
|
||||||
"prompt": "提示設定",
|
"prompt": "提示設定",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "選擇子助手",
|
||||||
|
"tab": "子助手",
|
||||||
|
"title": "子助手",
|
||||||
|
"tooltip": "選擇可以被此助手委派任務的其他助手"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "連線 MCP 伺服器即可解鎖更多可在上方預先授權的工具。",
|
"description": "連線 MCP 伺服器即可解鎖更多可在上方預先授權的工具。",
|
||||||
@@ -638,6 +647,7 @@
|
|||||||
"description": "可存取的目錄中沒有檔案",
|
"description": "可存取的目錄中沒有檔案",
|
||||||
"label": "找不到檔案"
|
"label": "找不到檔案"
|
||||||
},
|
},
|
||||||
|
"sub_agent": "子代理",
|
||||||
"title": "活動目錄"
|
"title": "活動目錄"
|
||||||
},
|
},
|
||||||
"auto_resize": "自動調整高度",
|
"auto_resize": "自動調整高度",
|
||||||
@@ -4343,7 +4353,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 preview 版本"
|
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 v1 版本"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4488,7 @@
|
|||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"forward": "前進",
|
"forward": "前進",
|
||||||
"multiple": "多選",
|
"multiple": "多選",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "未找到結果",
|
||||||
"page": "翻頁",
|
"page": "翻頁",
|
||||||
"select": "選擇",
|
"select": "選擇",
|
||||||
"title": "快捷選單"
|
"title": "快捷選單"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "Agent ID ist leer."
|
"null_id": "Agent ID ist leer."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Agent-Liste abrufen fehlgeschlagen"
|
"failed": "Agent-Liste abrufen fehlgeschlagen"
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Deinstallation läuft..."
|
"uninstalling": "Deinstallation läuft..."
|
||||||
},
|
},
|
||||||
"prompt": "Prompt-Einstellungen",
|
"prompt": "Prompt-Einstellungen",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Verbinden Sie MCP-Server, um weitere Tools freizuschalten, die oben vorab autorisiert werden können.",
|
"description": "Verbinden Sie MCP-Server, um weitere Tools freizuschalten, die oben vorab autorisiert werden können.",
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
"forward": "Vorwärts",
|
"forward": "Vorwärts",
|
||||||
"multiple": "Mehrfachauswahl",
|
"multiple": "Mehrfachauswahl",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "Keine Ergebnisse gefunden",
|
||||||
"page": "Seite umblättern",
|
"page": "Seite umblättern",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"title": "Schnellmenü"
|
"title": "Schnellmenü"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "Το ID του πράκτορα είναι null."
|
"null_id": "Το ID του πράκτορα είναι null."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Αποτυχία καταχώρησης πρακτόρων."
|
"failed": "Αποτυχία καταχώρησης πρακτόρων."
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Απεγκατάσταση..."
|
"uninstalling": "Απεγκατάσταση..."
|
||||||
},
|
},
|
||||||
"prompt": "Ρυθμίσεις Προτροπής",
|
"prompt": "Ρυθμίσεις Προτροπής",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Συνδέστε διακομιστές MCP για να ξεκλειδώσετε πρόσθετα εργαλεία που μπορείτε να εγκρίνετε παραπάνω.",
|
"description": "Συνδέστε διακομιστές MCP για να ξεκλειδώσετε πρόσθετα εργαλεία που μπορείτε να εγκρίνετε παραπάνω.",
|
||||||
@@ -4343,7 +4352,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια προεπισκόπηση έκδοσης"
|
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια v1 έκδοσης"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "Επιβεβαίωση",
|
"confirm": "Επιβεβαίωση",
|
||||||
"forward": "Μπρος",
|
"forward": "Μπρος",
|
||||||
"multiple": "Πολλαπλή επιλογή",
|
"multiple": "Πολλαπλή επιλογή",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "Δεν βρέθηκαν αποτελέσματα",
|
||||||
"page": "Σελίδα",
|
"page": "Σελίδα",
|
||||||
"select": "Επιλογή",
|
"select": "Επιλογή",
|
||||||
"title": "Γρήγορη Πρόσβαση"
|
"title": "Γρήγορη Πρόσβαση"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "El ID del agente es nulo."
|
"null_id": "El ID del agente es nulo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Error al listar agentes."
|
"failed": "Error al listar agentes."
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Desinstalando..."
|
"uninstalling": "Desinstalando..."
|
||||||
},
|
},
|
||||||
"prompt": "Configuración de indicaciones",
|
"prompt": "Configuración de indicaciones",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Conecta servidores MCP para desbloquear herramientas adicionales que puedes aprobar arriba.",
|
"description": "Conecta servidores MCP para desbloquear herramientas adicionales que puedes aprobar arriba.",
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"forward": "Adelante",
|
"forward": "Adelante",
|
||||||
"multiple": "Selección múltiple",
|
"multiple": "Selección múltiple",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "No se encontraron resultados",
|
||||||
"page": "Página",
|
"page": "Página",
|
||||||
"select": "Seleccionar",
|
"select": "Seleccionar",
|
||||||
"title": "Menú de acceso rápido"
|
"title": "Menú de acceso rápido"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "L'ID de l'agent est nul."
|
"null_id": "L'ID de l'agent est nul."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Échec de la liste des agents."
|
"failed": "Échec de la liste des agents."
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Désinstallation en cours..."
|
"uninstalling": "Désinstallation en cours..."
|
||||||
},
|
},
|
||||||
"prompt": "Paramètres de l'invite",
|
"prompt": "Paramètres de l'invite",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Connectez des serveurs MCP pour débloquer des outils supplémentaires que vous pouvez approuver ci-dessus.",
|
"description": "Connectez des serveurs MCP pour débloquer des outils supplémentaires que vous pouvez approuver ci-dessus.",
|
||||||
@@ -4343,7 +4352,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Version de l'API Azure OpenAI, veuillez saisir une version preview si vous souhaitez utiliser l'API de réponse"
|
"tip": "Version de l'API Azure OpenAI, veuillez saisir une version v1 si vous souhaitez utiliser l'API de réponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"forward": "Вперед",
|
"forward": "Вперед",
|
||||||
"multiple": "Множественный выбор",
|
"multiple": "Множественный выбор",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "Aucun résultat trouvé",
|
||||||
"page": "Перелистнуть страницу",
|
"page": "Перелистнуть страницу",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"title": "Быстрое меню"
|
"title": "Быстрое меню"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "エージェント ID が null です。"
|
"null_id": "エージェント ID が null です。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "エージェントの一覧取得に失敗しました。"
|
"failed": "エージェントの一覧取得に失敗しました。"
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "アンインストール中..."
|
"uninstalling": "アンインストール中..."
|
||||||
},
|
},
|
||||||
"prompt": "プロンプト設定",
|
"prompt": "プロンプト設定",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "MCPサーバーを接続して、上で承認できる追加ツールを解放します。",
|
"description": "MCPサーバーを接続して、上で承認できる追加ツールを解放します。",
|
||||||
@@ -4343,7 +4352,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、previewバージョンを入力してください"
|
"tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、v1バージョンを入力してください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"forward": "進む",
|
"forward": "進む",
|
||||||
"multiple": "複数選択",
|
"multiple": "複数選択",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "結果が見つかりません",
|
||||||
"page": "ページ",
|
"page": "ページ",
|
||||||
"select": "選択",
|
"select": "選択",
|
||||||
"title": "クイックメニュー"
|
"title": "クイックメニュー"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "O ID do agente é nulo."
|
"null_id": "O ID do agente é nulo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Falha ao listar agentes."
|
"failed": "Falha ao listar agentes."
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Desinstalando..."
|
"uninstalling": "Desinstalando..."
|
||||||
},
|
},
|
||||||
"prompt": "Configurações de Prompt",
|
"prompt": "Configurações de Prompt",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Conecte servidores MCP para desbloquear ferramentas adicionais que você pode aprovar acima.",
|
"description": "Conecte servidores MCP para desbloquear ferramentas adicionais que você pode aprovar acima.",
|
||||||
@@ -4343,7 +4352,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Versão da API do Azure OpenAI. Se desejar usar a API de Resposta, insira a versão de visualização"
|
"tip": "Versão da API do Azure OpenAI. Se desejar usar a API de Resposta, insira a versão de v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
"forward": "Avançar",
|
"forward": "Avançar",
|
||||||
"multiple": "Múltipla Seleção",
|
"multiple": "Múltipla Seleção",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "Nenhum resultado encontrado",
|
||||||
"page": "Página",
|
"page": "Página",
|
||||||
"select": "Selecionar",
|
"select": "Selecionar",
|
||||||
"title": "Menu de Atalho"
|
"title": "Menu de Atalho"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
"null_id": "ID агента равен null."
|
"null_id": "ID агента равен null."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "[to be translated]:Enter your message here, send with {{key}} - @ select path, / select command"
|
||||||
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Не удалось получить список агентов."
|
"failed": "Не удалось получить список агентов."
|
||||||
@@ -153,6 +156,12 @@
|
|||||||
"uninstalling": "Удаление..."
|
"uninstalling": "Удаление..."
|
||||||
},
|
},
|
||||||
"prompt": "Настройки подсказки",
|
"prompt": "Настройки подсказки",
|
||||||
|
"sub_agents": {
|
||||||
|
"placeholder": "[to be translated]:Select sub agents",
|
||||||
|
"tab": "[to be translated]:Sub Agents",
|
||||||
|
"title": "[to be translated]:Sub Agents",
|
||||||
|
"tooltip": "[to be translated]:Select other agents that can be delegated tasks by this agent"
|
||||||
|
},
|
||||||
"tooling": {
|
"tooling": {
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"description": "Подключите серверы MCP, чтобы разблокировать дополнительные инструменты, которые вы можете одобрить выше.",
|
"description": "Подключите серверы MCP, чтобы разблокировать дополнительные инструменты, которые вы можете одобрить выше.",
|
||||||
@@ -4343,7 +4352,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию preview"
|
"tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4487,7 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"forward": "Вперед",
|
"forward": "Вперед",
|
||||||
"multiple": "Множественный выбор",
|
"multiple": "Множественный выбор",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "Результаты не найдены",
|
||||||
"page": "Страница",
|
"page": "Страница",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"title": "Быстрое меню"
|
"title": "Быстрое меню"
|
||||||
|
|||||||
@@ -103,12 +103,23 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
|||||||
// Prepare session data for tools
|
// Prepare session data for tools
|
||||||
const sessionData = useMemo(() => {
|
const sessionData = useMemo(() => {
|
||||||
if (!session) return undefined
|
if (!session) return undefined
|
||||||
|
|
||||||
|
// Get installed agent plugins from session.plugins
|
||||||
|
const agentPlugins = (session.plugins ?? [])
|
||||||
|
.filter((plugin) => plugin.type === 'agent')
|
||||||
|
.map((plugin) => ({
|
||||||
|
id: plugin.filename,
|
||||||
|
name: plugin.metadata.name ?? plugin.filename.replace(/\.md$/i, ''),
|
||||||
|
description: plugin.metadata.description
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agentId,
|
agentId,
|
||||||
sessionId,
|
sessionId,
|
||||||
slashCommands: session.slash_commands,
|
slashCommands: session.slash_commands,
|
||||||
tools: session.tools,
|
tools: session.tools,
|
||||||
accessiblePaths: session.accessible_paths ?? []
|
accessiblePaths: session.accessible_paths ?? [],
|
||||||
|
subAgents: agentPlugins
|
||||||
}
|
}
|
||||||
}, [session, agentId, sessionId])
|
}, [session, agentId, sessionId])
|
||||||
|
|
||||||
@@ -158,6 +169,8 @@ interface InnerProps {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
slashCommands?: Array<{ command: string; description?: string }>
|
slashCommands?: Array<{ command: string; description?: string }>
|
||||||
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||||
|
accessiblePaths?: string[]
|
||||||
|
subAgents?: Array<{ id: string; name: string; description?: string }>
|
||||||
}
|
}
|
||||||
actionsRef: React.MutableRefObject<{
|
actionsRef: React.MutableRefObject<{
|
||||||
resizeTextArea: () => void
|
resizeTextArea: () => void
|
||||||
@@ -470,7 +483,7 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
|||||||
)
|
)
|
||||||
const placeholderText = useMemo(
|
const placeholderText = useMemo(
|
||||||
() =>
|
() =>
|
||||||
t('chat.input.placeholder', {
|
t('agent.input.placeholder', {
|
||||||
key: getSendMessageShortcutLabel(sendMessageShortcut)
|
key: getSendMessageShortcutLabel(sendMessageShortcut)
|
||||||
}),
|
}),
|
||||||
[sendMessageShortcut, t]
|
[sendMessageShortcut, t]
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ export const InputbarCore: FC<InputbarCoreProps> = ({
|
|||||||
|
|
||||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||||
if (isEnterPressed) {
|
if (isEnterPressed) {
|
||||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
if (isSendMessageKeyPressed(event, sendMessageShortcut) && !cannotSend) {
|
||||||
handleSendMessage()
|
handleSendMessage()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
@@ -359,6 +359,7 @@ export const InputbarCore: FC<InputbarCoreProps> = ({
|
|||||||
translate,
|
translate,
|
||||||
handleToggleExpanded,
|
handleToggleExpanded,
|
||||||
sendMessageShortcut,
|
sendMessageShortcut,
|
||||||
|
cannotSend,
|
||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
setText,
|
setText,
|
||||||
setTimeoutTimer,
|
setTimeoutTimer,
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ const activityDirectoryTool = defineTool({
|
|||||||
const { quickPanel, quickPanelController, actions, session } = context
|
const { quickPanel, quickPanelController, actions, session } = context
|
||||||
const { onTextChange } = actions
|
const { onTextChange } = actions
|
||||||
|
|
||||||
// Get accessible paths from session data
|
// Get accessible paths and sub-agents from session data
|
||||||
const accessiblePaths = session?.accessiblePaths ?? []
|
const accessiblePaths = session?.accessiblePaths ?? []
|
||||||
|
const subAgents = session?.subAgents ?? []
|
||||||
|
|
||||||
// Only render if we have accessible paths
|
// Only render if we have accessible paths or sub-agents
|
||||||
if (accessiblePaths.length === 0) {
|
if (accessiblePaths.length === 0 && subAgents.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ const activityDirectoryTool = defineTool({
|
|||||||
quickPanel={quickPanel}
|
quickPanel={quickPanel}
|
||||||
quickPanelController={quickPanelController}
|
quickPanelController={quickPanelController}
|
||||||
accessiblePaths={accessiblePaths}
|
accessiblePaths={accessiblePaths}
|
||||||
|
subAgents={subAgents}
|
||||||
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
|
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ interface Props {
|
|||||||
quickPanel: ToolQuickPanelApi
|
quickPanel: ToolQuickPanelApi
|
||||||
quickPanelController: ToolQuickPanelController
|
quickPanelController: ToolQuickPanelController
|
||||||
accessiblePaths: string[]
|
accessiblePaths: string[]
|
||||||
|
subAgents?: Array<{ id: string; name: string; description?: string }>
|
||||||
setText: React.Dispatch<React.SetStateAction<string>>
|
setText: React.Dispatch<React.SetStateAction<string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController, accessiblePaths, setText }) => {
|
const ActivityDirectoryButton: FC<Props> = ({
|
||||||
|
quickPanel,
|
||||||
|
quickPanelController,
|
||||||
|
accessiblePaths,
|
||||||
|
subAgents,
|
||||||
|
setText
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { handleOpenQuickPanel } = useActivityDirectoryPanel(
|
const { handleOpenQuickPanel } = useActivityDirectoryPanel(
|
||||||
@@ -24,6 +31,7 @@ const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController,
|
|||||||
quickPanel,
|
quickPanel,
|
||||||
quickPanelController,
|
quickPanelController,
|
||||||
accessiblePaths,
|
accessiblePaths,
|
||||||
|
subAgents,
|
||||||
setText
|
setText
|
||||||
},
|
},
|
||||||
'button'
|
'button'
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
|
|||||||
session
|
session
|
||||||
} = context
|
} = context
|
||||||
|
|
||||||
// Get accessible paths from session data
|
// Get accessible paths and sub-agents from session data
|
||||||
const accessiblePaths = session?.accessiblePaths ?? []
|
const accessiblePaths = session?.accessiblePaths ?? []
|
||||||
|
const subAgents = session?.subAgents ?? []
|
||||||
|
|
||||||
// Always call hooks unconditionally (React rules)
|
// Always call hooks unconditionally (React rules)
|
||||||
useActivityDirectoryPanel(
|
useActivityDirectoryPanel(
|
||||||
@@ -24,6 +25,7 @@ const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
|
|||||||
quickPanel,
|
quickPanel,
|
||||||
quickPanelController,
|
quickPanelController,
|
||||||
accessiblePaths,
|
accessiblePaths,
|
||||||
|
subAgents,
|
||||||
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||||
},
|
},
|
||||||
'manager'
|
'manager'
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
|||||||
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
const { assistant, updateAssistantSettings } = useAssistant(assistantId)
|
||||||
|
|
||||||
const currentReasoningEffort = useMemo(() => {
|
const currentReasoningEffort = useMemo(() => {
|
||||||
return assistant.settings?.reasoning_effort || 'off'
|
return assistant.settings?.reasoning_effort || 'none'
|
||||||
}, [assistant.settings?.reasoning_effort])
|
}, [assistant.settings?.reasoning_effort])
|
||||||
|
|
||||||
// 确定当前模型支持的选项类型
|
// 确定当前模型支持的选项类型
|
||||||
@@ -46,21 +46,21 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
|||||||
const supportedOptions: ThinkingOption[] = useMemo(() => {
|
const supportedOptions: ThinkingOption[] = useMemo(() => {
|
||||||
if (modelType === 'doubao') {
|
if (modelType === 'doubao') {
|
||||||
if (isDoubaoThinkingAutoModel(model)) {
|
if (isDoubaoThinkingAutoModel(model)) {
|
||||||
return ['off', 'auto', 'high']
|
return ['none', 'auto', 'high']
|
||||||
}
|
}
|
||||||
return ['off', 'high']
|
return ['none', 'high']
|
||||||
}
|
}
|
||||||
return MODEL_SUPPORTED_OPTIONS[modelType]
|
return MODEL_SUPPORTED_OPTIONS[modelType]
|
||||||
}, [model, modelType])
|
}, [model, modelType])
|
||||||
|
|
||||||
const onThinkingChange = useCallback(
|
const onThinkingChange = useCallback(
|
||||||
(option?: ThinkingOption) => {
|
(option?: ThinkingOption) => {
|
||||||
const isEnabled = option !== undefined && option !== 'off'
|
const isEnabled = option !== undefined && option !== 'none'
|
||||||
// 然后更新设置
|
// 然后更新设置
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
updateAssistantSettings({
|
updateAssistantSettings({
|
||||||
reasoning_effort: undefined,
|
reasoning_effort: option,
|
||||||
reasoning_effort_cache: undefined,
|
reasoning_effort_cache: option,
|
||||||
qwenThinkMode: false
|
qwenThinkMode: false
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -96,10 +96,10 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
|||||||
}))
|
}))
|
||||||
}, [currentReasoningEffort, supportedOptions, onThinkingChange])
|
}, [currentReasoningEffort, supportedOptions, onThinkingChange])
|
||||||
|
|
||||||
const isThinkingEnabled = currentReasoningEffort !== undefined && currentReasoningEffort !== 'off'
|
const isThinkingEnabled = currentReasoningEffort !== undefined && currentReasoningEffort !== 'none'
|
||||||
|
|
||||||
const disableThinking = useCallback(() => {
|
const disableThinking = useCallback(() => {
|
||||||
onThinkingChange('off')
|
onThinkingChange('none')
|
||||||
}, [onThinkingChange])
|
}, [onThinkingChange])
|
||||||
|
|
||||||
const openQuickPanel = useCallback(() => {
|
const openQuickPanel = useCallback(() => {
|
||||||
@@ -116,7 +116,7 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isThinkingEnabled && supportedOptions.includes('off')) {
|
if (isThinkingEnabled && supportedOptions.includes('none')) {
|
||||||
disableThinking()
|
disableThinking()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,13 +146,13 @@ const ThinkingButton: FC<Props> = ({ quickPanel, model, assistantId }): ReactEle
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
placement="top"
|
placement="top"
|
||||||
title={
|
title={
|
||||||
isThinkingEnabled && supportedOptions.includes('off')
|
isThinkingEnabled && supportedOptions.includes('none')
|
||||||
? t('common.close')
|
? t('common.close')
|
||||||
: t('assistants.settings.reasoning_effort.label')
|
: t('assistants.settings.reasoning_effort.label')
|
||||||
}
|
}
|
||||||
mouseLeaveDelay={0}
|
mouseLeaveDelay={0}
|
||||||
arrow>
|
arrow>
|
||||||
<ActionIconButton onClick={handleOpenQuickPanel} active={currentReasoningEffort !== 'off'}>
|
<ActionIconButton onClick={handleOpenQuickPanel} active={currentReasoningEffort !== 'none'}>
|
||||||
{ThinkingIcon(currentReasoningEffort)}
|
{ThinkingIcon(currentReasoningEffort)}
|
||||||
</ActionIconButton>
|
</ActionIconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -178,7 +178,7 @@ const ThinkingIcon = (option?: ThinkingOption) => {
|
|||||||
case 'auto':
|
case 'auto':
|
||||||
IconComponent = MdiLightbulbAutoOutline
|
IconComponent = MdiLightbulbAutoOutline
|
||||||
break
|
break
|
||||||
case 'off':
|
case 'none':
|
||||||
IconComponent = MdiLightbulbOffOutline
|
IconComponent = MdiLightbulbOffOutline
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
|
|||||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||||
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
import { QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||||
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||||
import { File, Folder } from 'lucide-react'
|
import { Bot, File, Folder } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -25,15 +25,22 @@ export type ActivityDirectoryTriggerInfo = {
|
|||||||
symbol?: QuickPanelReservedSymbol
|
symbol?: QuickPanelReservedSymbol
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubAgentInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
quickPanel: ToolQuickPanelApi
|
quickPanel: ToolQuickPanelApi
|
||||||
quickPanelController: ToolQuickPanelController
|
quickPanelController: ToolQuickPanelController
|
||||||
accessiblePaths: string[]
|
accessiblePaths: string[]
|
||||||
|
subAgents?: SubAgentInfo[]
|
||||||
setText: React.Dispatch<React.SetStateAction<string>>
|
setText: React.Dispatch<React.SetStateAction<string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'manager' = 'button') => {
|
export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'manager' = 'button') => {
|
||||||
const { quickPanel, quickPanelController, accessiblePaths, setText } = params
|
const { quickPanel, quickPanelController, accessiblePaths, subAgents = [], setText } = params
|
||||||
const { registerTrigger, registerRootMenu } = quickPanel
|
const { registerTrigger, registerRootMenu } = quickPanel
|
||||||
const { open, close, updateList, isVisible, symbol } = quickPanelController
|
const { open, close, updateList, isVisible, symbol } = quickPanelController
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -238,6 +245,68 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
|
|||||||
[close, insertFilePath]
|
[close, insertFilePath]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert sub-agent name at @ position
|
||||||
|
*/
|
||||||
|
const insertSubAgentName = useCallback(
|
||||||
|
(agentName: string, triggerInfo?: ActivityDirectoryTriggerInfo) => {
|
||||||
|
setText((currentText) => {
|
||||||
|
const symbol = triggerInfo?.symbol ?? QuickPanelReservedSymbol.MentionModels
|
||||||
|
const triggerIndex =
|
||||||
|
triggerInfo?.position !== undefined
|
||||||
|
? triggerInfo.position
|
||||||
|
: symbol === QuickPanelReservedSymbol.Root
|
||||||
|
? currentText.lastIndexOf('/')
|
||||||
|
: currentText.lastIndexOf('@')
|
||||||
|
|
||||||
|
if (triggerIndex !== -1) {
|
||||||
|
let endPos = triggerIndex + 1
|
||||||
|
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||||
|
endPos++
|
||||||
|
}
|
||||||
|
return currentText.slice(0, triggerIndex) + agentName + ' ' + currentText.slice(endPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no trigger found, append at end
|
||||||
|
return currentText + ' ' + agentName + ' '
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[setText]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle sub-agent selection
|
||||||
|
*/
|
||||||
|
const onSelectSubAgent = useCallback(
|
||||||
|
(agentName: string) => {
|
||||||
|
const trigger = triggerInfoRef.current
|
||||||
|
insertSubAgentName(agentName, trigger)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
[close, insertSubAgentName]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sub-agent list items for QuickPanel
|
||||||
|
*/
|
||||||
|
const createSubAgentItems = useCallback(
|
||||||
|
(agents: SubAgentInfo[]): QuickPanelListItem[] => {
|
||||||
|
if (agents.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents.map((agent) => ({
|
||||||
|
label: agent.name,
|
||||||
|
description: agent.description || t('chat.input.activity_directory.sub_agent'),
|
||||||
|
icon: <Bot size={16} />,
|
||||||
|
filterText: `${agent.name} ${agent.description || ''} ${agent.id}`,
|
||||||
|
action: () => onSelectSubAgent(agent.name),
|
||||||
|
isSelected: false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[onSelectSubAgent, t]
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create file list items for QuickPanel from a file list
|
* Create file list items for QuickPanel from a file list
|
||||||
*/
|
*/
|
||||||
@@ -291,12 +360,18 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create file list items for QuickPanel (for current state)
|
* Create combined list items for QuickPanel (sub-agents + files)
|
||||||
*/
|
*/
|
||||||
const fileItems = useMemo<QuickPanelListItem[]>(
|
const combinedItems = useMemo<QuickPanelListItem[]>(() => {
|
||||||
() => createFileItems(fileList, isLoading),
|
const agentItems = createSubAgentItems(subAgents)
|
||||||
[createFileItems, fileList, isLoading]
|
const files = createFileItems(fileList, isLoading)
|
||||||
)
|
|
||||||
|
// Combine: sub-agents first, then files
|
||||||
|
return [...agentItems, ...files]
|
||||||
|
}, [createSubAgentItems, subAgents, createFileItems, fileList, isLoading])
|
||||||
|
|
||||||
|
// Keep fileItems for backward compatibility
|
||||||
|
const fileItems = combinedItems
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle search text change - load files and update list
|
* Handle search text change - load files and update list
|
||||||
@@ -311,11 +386,13 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
|
|||||||
|
|
||||||
const hasChanged = updateFileListState(newFiles)
|
const hasChanged = updateFileListState(newFiles)
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
const newItems = createFileItems(newFiles, false)
|
// Combine sub-agents and files
|
||||||
updateList(newItems)
|
const agentItems = createSubAgentItems(subAgents)
|
||||||
|
const fileItems = createFileItems(newFiles, false)
|
||||||
|
updateList([...agentItems, ...fileItems])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadFiles, createFileItems, updateList, updateFileListState]
|
[loadFiles, createFileItems, createSubAgentItems, subAgents, updateList, updateFileListState]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,8 +413,10 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
|
|||||||
const files = await loadFiles()
|
const files = await loadFiles()
|
||||||
updateFileListState(files)
|
updateFileListState(files)
|
||||||
|
|
||||||
// Create items from the loaded files immediately
|
// Create items from sub-agents and loaded files immediately
|
||||||
const items = createFileItems(files, false)
|
const agentItems = createSubAgentItems(subAgents)
|
||||||
|
const fileItems = createFileItems(files, false)
|
||||||
|
const items = [...agentItems, ...fileItems]
|
||||||
|
|
||||||
open({
|
open({
|
||||||
title: t('chat.input.activity_directory.description'),
|
title: t('chat.input.activity_directory.description'),
|
||||||
@@ -377,7 +456,18 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana
|
|||||||
onSearchChange: handleSearchChange
|
onSearchChange: handleSearchChange
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[loadFiles, open, removeTriggerSymbolAndText, setText, t, handleSearchChange, createFileItems, updateFileListState]
|
[
|
||||||
|
loadFiles,
|
||||||
|
open,
|
||||||
|
removeTriggerSymbolAndText,
|
||||||
|
setText,
|
||||||
|
t,
|
||||||
|
handleSearchChange,
|
||||||
|
createFileItems,
|
||||||
|
createSubAgentItems,
|
||||||
|
subAgents,
|
||||||
|
updateFileListState
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isGeminiModel } from '@renderer/config/models'
|
import { isAnthropicModel, isGeminiModel } from '@renderer/config/models'
|
||||||
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||||
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
@@ -10,9 +10,8 @@ const urlContextTool = defineTool({
|
|||||||
label: (t) => t('chat.input.url_context'),
|
label: (t) => t('chat.input.url_context'),
|
||||||
visibleInScopes: [TopicType.Chat],
|
visibleInScopes: [TopicType.Chat],
|
||||||
condition: ({ model }) => {
|
condition: ({ model }) => {
|
||||||
if (!isGeminiModel(model)) return false
|
|
||||||
const provider = getProviderByModel(model)
|
const provider = getProviderByModel(model)
|
||||||
return !!provider && isSupportUrlContextProvider(provider)
|
return !!provider && isSupportUrlContextProvider(provider) && (isGeminiModel(model) || isAnthropicModel(model))
|
||||||
},
|
},
|
||||||
render: ({ assistant }) => <UrlContextButton assistantId={assistant.id} />
|
render: ({ assistant }) => <UrlContextButton assistantId={assistant.id} />
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface ToolContext {
|
|||||||
slashCommands?: Array<{ command: string; description?: string }>
|
slashCommands?: Array<{ command: string; description?: string }>
|
||||||
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||||
accessiblePaths?: string[]
|
accessiblePaths?: string[]
|
||||||
|
subAgents?: Array<{ id: string; name: string; description?: string }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import type { Topic } from '@renderer/types'
|
|||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Popover } from 'antd'
|
import { Popover } from 'antd'
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { useChatMaxWidth } from '../Chat'
|
import { useChatMaxWidth } from '../Chat'
|
||||||
@@ -43,9 +44,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
|||||||
)
|
)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
|
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
|
||||||
|
|
||||||
// Refs
|
|
||||||
const prevMessageLengthRef = useRef(messageLength)
|
|
||||||
|
|
||||||
// 对于单模型消息,采用简单的样式,避免 overflow 影响内部的 sticky 效果
|
// 对于单模型消息,采用简单的样式,避免 overflow 影响内部的 sticky 效果
|
||||||
const multiModelMessageStyle = useMemo(
|
const multiModelMessageStyle = useMemo(
|
||||||
() => (messageLength < 2 ? 'fold' : _multiModelMessageStyle),
|
() => (messageLength < 2 ? 'fold' : _multiModelMessageStyle),
|
||||||
@@ -83,24 +81,6 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
|||||||
},
|
},
|
||||||
[editMessage, selectedMessageId, setTimeoutTimer]
|
[editMessage, selectedMessageId, setTimeoutTimer]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (messageLength > prevMessageLengthRef.current) {
|
|
||||||
setSelectedIndex(messageLength - 1)
|
|
||||||
const lastMessage = messages[messageLength - 1]
|
|
||||||
if (lastMessage) {
|
|
||||||
setSelectedMessage(lastMessage)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newIndex = messages.findIndex((msg) => msg.id === selectedMessageId)
|
|
||||||
if (newIndex !== -1) {
|
|
||||||
setSelectedIndex(newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevMessageLengthRef.current = messageLength
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [messageLength])
|
|
||||||
|
|
||||||
// 添加对流程图节点点击事件的监听
|
// 添加对流程图节点点击事件的监听
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 只在组件挂载和消息数组变化时添加监听器
|
// 只在组件挂载和消息数组变化时添加监听器
|
||||||
@@ -223,7 +203,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
|||||||
message,
|
message,
|
||||||
topic,
|
topic,
|
||||||
index: message.index
|
index: message.index
|
||||||
}
|
} satisfies ComponentProps<typeof MessageItem>
|
||||||
|
|
||||||
const messageContent = (
|
const messageContent = (
|
||||||
<MessageWrapper
|
<MessageWrapper
|
||||||
@@ -277,7 +257,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
|||||||
isGrouped,
|
isGrouped,
|
||||||
topic,
|
topic,
|
||||||
multiModelMessageStyle,
|
multiModelMessageStyle,
|
||||||
messages.length,
|
messages,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
onUpdateUseful,
|
onUpdateUseful,
|
||||||
groupContextMessageId,
|
groupContextMessageId,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { CollapseProps } from 'antd'
|
import type { CollapseProps } from 'antd'
|
||||||
import { Tag } from 'antd'
|
import { Tag } from 'antd'
|
||||||
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
|
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { ToolTitle } from './GenericTools'
|
import { ToolTitle } from './GenericTools'
|
||||||
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
|
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
|
||||||
@@ -16,6 +15,63 @@ interface ParsedBashOutput {
|
|||||||
tool_use_error?: string
|
tool_use_error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
|
||||||
|
if (!output) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const hasToolError = output.includes('<tool_use_error>')
|
||||||
|
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
|
||||||
|
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
|
||||||
|
const parserError = xmlDoc.querySelector('parsererror')
|
||||||
|
if (parserError) return null
|
||||||
|
|
||||||
|
const getElementText = (tagName: string): string | undefined => {
|
||||||
|
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
||||||
|
return element?.textContent?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: getElementText('status'),
|
||||||
|
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
||||||
|
stdout: getElementText('stdout'),
|
||||||
|
stderr: getElementText('stderr'),
|
||||||
|
timestamp: getElementText('timestamp'),
|
||||||
|
tool_use_error: getElementText('tool_use_error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
|
||||||
|
if (!parsedOutput) return null
|
||||||
|
|
||||||
|
if (parsedOutput.tool_use_error) {
|
||||||
|
return {
|
||||||
|
color: 'danger',
|
||||||
|
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||||
|
text: 'Error'
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCompleted = parsedOutput.status === 'completed'
|
||||||
|
const isSuccess = parsedOutput.exit_code === 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
||||||
|
icon:
|
||||||
|
isCompleted && isSuccess ? (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
) : isCompleted && !isSuccess ? (
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Terminal className="h-3.5 w-3.5" />
|
||||||
|
),
|
||||||
|
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
export function BashOutputTool({
|
export function BashOutputTool({
|
||||||
input,
|
input,
|
||||||
output
|
output
|
||||||
@@ -23,73 +79,8 @@ export function BashOutputTool({
|
|||||||
input: BashOutputToolInput
|
input: BashOutputToolInput
|
||||||
output?: BashOutputToolOutput
|
output?: BashOutputToolOutput
|
||||||
}): NonNullable<CollapseProps['items']>[number] {
|
}): NonNullable<CollapseProps['items']>[number] {
|
||||||
// 解析 XML 输出
|
const parsedOutput = parseBashOutput(output)
|
||||||
const parsedOutput = useMemo(() => {
|
const statusConfig = getStatusConfig(parsedOutput)
|
||||||
if (!output) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parser = new DOMParser()
|
|
||||||
// 检查是否包含 tool_use_error 标签
|
|
||||||
const hasToolError = output.includes('<tool_use_error>')
|
|
||||||
// 包装成有效的 XML(如果还没有根元素)
|
|
||||||
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
|
|
||||||
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
|
|
||||||
|
|
||||||
// 检查是否有解析错误
|
|
||||||
const parserError = xmlDoc.querySelector('parsererror')
|
|
||||||
if (parserError) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getElementText = (tagName: string): string | undefined => {
|
|
||||||
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
|
||||||
return element?.textContent?.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ParsedBashOutput = {
|
|
||||||
status: getElementText('status'),
|
|
||||||
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
|
||||||
stdout: getElementText('stdout'),
|
|
||||||
stderr: getElementText('stderr'),
|
|
||||||
timestamp: getElementText('timestamp'),
|
|
||||||
tool_use_error: getElementText('tool_use_error')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [output])
|
|
||||||
|
|
||||||
// 获取状态配置
|
|
||||||
const statusConfig = useMemo(() => {
|
|
||||||
if (!parsedOutput) return null
|
|
||||||
|
|
||||||
// 如果有 tool_use_error,直接显示错误状态
|
|
||||||
if (parsedOutput.tool_use_error) {
|
|
||||||
return {
|
|
||||||
color: 'danger',
|
|
||||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
|
||||||
text: 'Error'
|
|
||||||
} as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCompleted = parsedOutput.status === 'completed'
|
|
||||||
const isSuccess = parsedOutput.exit_code === 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
|
||||||
icon:
|
|
||||||
isCompleted && isSuccess ? (
|
|
||||||
<CheckCircle className="h-3.5 w-3.5" />
|
|
||||||
) : isCompleted && !isSuccess ? (
|
|
||||||
<XCircle className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Terminal className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
|
||||||
} as const
|
|
||||||
}, [parsedOutput])
|
|
||||||
|
|
||||||
const children = parsedOutput ? (
|
const children = parsedOutput ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
import type { CollapseProps } from 'antd'
|
import type { CollapseProps } from 'antd'
|
||||||
import { FileText } from 'lucide-react'
|
import { FileText } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
|
||||||
import { ToolTitle } from './GenericTools'
|
import { ToolTitle } from './GenericTools'
|
||||||
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
|
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
|
||||||
import { AgentToolsType } from './types'
|
import { AgentToolsType } from './types'
|
||||||
|
|
||||||
|
const removeSystemReminderTags = (text: string): string => {
|
||||||
|
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
|
||||||
|
if (!output) return null
|
||||||
|
|
||||||
|
const toText = (item: TextOutput) => removeSystemReminderTags(item.text)
|
||||||
|
|
||||||
|
if (Array.isArray(output)) {
|
||||||
|
return output
|
||||||
|
.filter((item): item is TextOutput => item.type === 'text')
|
||||||
|
.map(toText)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeSystemReminderTags(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOutputStats = (outputString: string | null) => {
|
||||||
|
if (!outputString) return null
|
||||||
|
|
||||||
|
const bytes = new Blob([outputString]).size
|
||||||
|
const formatSize = (size: number) => {
|
||||||
|
if (size < 1024) return `${size} B`
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineCount: outputString.split('\n').length,
|
||||||
|
fileSize: bytes,
|
||||||
|
formatSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ReadTool({
|
export function ReadTool({
|
||||||
input,
|
input,
|
||||||
output
|
output
|
||||||
@@ -14,50 +49,8 @@ export function ReadTool({
|
|||||||
input: ReadToolInputType
|
input: ReadToolInputType
|
||||||
output?: ReadToolOutputType
|
output?: ReadToolOutputType
|
||||||
}): NonNullable<CollapseProps['items']>[number] {
|
}): NonNullable<CollapseProps['items']>[number] {
|
||||||
// 移除 system-reminder 标签及其内容的辅助函数
|
const outputString = normalizeOutputString(output)
|
||||||
const removeSystemReminderTags = (text: string): string => {
|
const stats = getOutputStats(outputString)
|
||||||
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
|
|
||||||
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 output 统一转换为字符串
|
|
||||||
const outputString = useMemo(() => {
|
|
||||||
if (!output) return null
|
|
||||||
|
|
||||||
let processedOutput: string
|
|
||||||
|
|
||||||
// 如果是 TextOutput[] 类型,提取所有 text 内容
|
|
||||||
if (Array.isArray(output)) {
|
|
||||||
processedOutput = output
|
|
||||||
.filter((item): item is TextOutput => item.type === 'text')
|
|
||||||
.map((item) => removeSystemReminderTags(item.text))
|
|
||||||
.join('')
|
|
||||||
} else {
|
|
||||||
// 如果是字符串,直接使用
|
|
||||||
processedOutput = output
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除 system-reminder 标签及其内容
|
|
||||||
return removeSystemReminderTags(processedOutput)
|
|
||||||
}, [output])
|
|
||||||
|
|
||||||
// 如果有输出,计算统计信息
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
if (!outputString) return null
|
|
||||||
|
|
||||||
const bytes = new Blob([outputString]).size
|
|
||||||
const formatSize = (bytes: number) => {
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
lineCount: outputString.split('\n').length,
|
|
||||||
fileSize: bytes,
|
|
||||||
formatSize
|
|
||||||
}
|
|
||||||
}, [outputString])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: AgentToolsType.Read,
|
key: AgentToolsType.Read,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { cn } from '@renderer/utils'
|
|
||||||
import type { CollapseProps } from 'antd'
|
import type { CollapseProps } from 'antd'
|
||||||
import { Card } from 'antd'
|
import { Card } from 'antd'
|
||||||
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
|
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
|
||||||
@@ -11,23 +10,27 @@ const getStatusConfig = (status: TodoItem['status']) => {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return {
|
return {
|
||||||
color: 'success' as const,
|
color: 'var(--color-status-success)',
|
||||||
icon: <CheckCircle className="h-3 w-3" />
|
opacity: 0.6,
|
||||||
|
icon: <CheckCircle className="h-4 w-4" strokeWidth={2.5} />
|
||||||
}
|
}
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return {
|
return {
|
||||||
color: 'primary' as const,
|
color: 'var(--color-primary)',
|
||||||
icon: <Clock className="h-3 w-3" />
|
opacity: 0.9,
|
||||||
|
icon: <Clock className="h-4 w-4" strokeWidth={2.5} />
|
||||||
}
|
}
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return {
|
return {
|
||||||
color: 'default' as const,
|
color: 'var(--color-border)',
|
||||||
icon: <Circle className="h-3 w-3" />
|
opacity: 0.4,
|
||||||
|
icon: <Circle className="h-4 w-4" strokeWidth={2.5} />
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
color: 'default' as const,
|
color: 'var(--color-border)',
|
||||||
icon: <Circle className="h-3 w-3" />
|
opacity: 0.4,
|
||||||
|
icon: <Circle className="h-4 w-4" strokeWidth={2.5} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,10 +67,8 @@ export function TodoWriteTool({
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex items-center justify-center rounded-full border p-1"
|
||||||
'flex items-center justify-center rounded-full border bg-opacity-50 p-2',
|
style={{ backgroundColor: statusConfig.color, opacity: statusConfig.opacity }}>
|
||||||
`bg-${statusConfig.color}`
|
|
||||||
)}>
|
|
||||||
{statusConfig.icon}
|
{statusConfig.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -11,11 +11,24 @@ interface UnknownToolProps {
|
|||||||
output?: unknown
|
output?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnknownToolRenderer({
|
const getToolDisplayName = (name: string) => {
|
||||||
toolName = '',
|
if (name.startsWith('mcp__')) {
|
||||||
input,
|
const parts = name.substring(5).split('__')
|
||||||
output
|
if (parts.length >= 2) {
|
||||||
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
|
return `${parts[0]}:${parts.slice(1).join(':')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolDescription = (toolName: string) => {
|
||||||
|
if (toolName.startsWith('mcp__')) {
|
||||||
|
return 'MCP Server Tool'
|
||||||
|
}
|
||||||
|
return 'Tool'
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
|
||||||
const { highlightCode } = useCodeStyle()
|
const { highlightCode } = useCodeStyle()
|
||||||
const [inputHtml, setInputHtml] = useState<string>('')
|
const [inputHtml, setInputHtml] = useState<string>('')
|
||||||
const [outputHtml, setOutputHtml] = useState<string>('')
|
const [outputHtml, setOutputHtml] = useState<string>('')
|
||||||
@@ -34,58 +47,49 @@ export function UnknownToolRenderer({
|
|||||||
}
|
}
|
||||||
}, [output, highlightCode])
|
}, [output, highlightCode])
|
||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
if (input === undefined && output === undefined) {
|
||||||
if (name.startsWith('mcp__')) {
|
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
|
||||||
const parts = name.substring(5).split('__')
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return `${parts[0]}:${parts.slice(1).join(':')}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToolDescription = () => {
|
return (
|
||||||
if (toolName.startsWith('mcp__')) {
|
<div className="space-y-3">
|
||||||
return 'MCP Server Tool'
|
{input !== undefined && (
|
||||||
}
|
<div>
|
||||||
return 'Tool'
|
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||||
}
|
<div
|
||||||
|
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||||
|
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{output !== undefined && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||||
|
<div
|
||||||
|
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||||
|
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnknownToolRenderer({
|
||||||
|
toolName = '',
|
||||||
|
input,
|
||||||
|
output
|
||||||
|
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
|
||||||
return {
|
return {
|
||||||
key: 'unknown-tool',
|
key: 'unknown-tool',
|
||||||
label: (
|
label: (
|
||||||
<ToolTitle
|
<ToolTitle
|
||||||
icon={<Wrench className="h-4 w-4" />}
|
icon={<Wrench className="h-4 w-4" />}
|
||||||
label={getToolDisplayName(toolName)}
|
label={getToolDisplayName(toolName)}
|
||||||
params={getToolDescription()}
|
params={getToolDescription(toolName)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
children: (
|
children: <UnknownToolContent input={input} output={output} />
|
||||||
<div className="space-y-3">
|
|
||||||
{input !== undefined && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
|
||||||
<div
|
|
||||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
|
||||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{output !== undefined && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
|
||||||
<div
|
|
||||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
|
||||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{input === undefined && output === undefined && (
|
|
||||||
<div className="text-foreground-500 text-xs">No data available for this tool</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { Collapse } from 'antd'
|
|||||||
// 导出所有类型
|
// 导出所有类型
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
// 导入所有渲染器
|
// 导入所有渲染器
|
||||||
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
|
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
|
||||||
import { BashOutputTool } from './BashOutputTool'
|
import { BashOutputTool } from './BashOutputTool'
|
||||||
@@ -57,22 +55,19 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
|
|||||||
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
|
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一的渲染函数
|
// 统一的渲染组件
|
||||||
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
|
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
|
||||||
const Renderer = toolRenderers[toolName]
|
const Renderer = toolRenderers[toolName]
|
||||||
|
const renderedItem = Renderer
|
||||||
|
? Renderer({ input: input as any, output: output as any })
|
||||||
|
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||||
const toolContentItem = useMemo(() => {
|
...renderedItem,
|
||||||
const rendered = Renderer
|
classNames: {
|
||||||
? Renderer({ input: input as any, output: output as any })
|
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
|
||||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
}
|
||||||
return {
|
}
|
||||||
...rendered,
|
|
||||||
classNames: {
|
|
||||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
|
|
||||||
} as NonNullable<CollapseProps['items']>[number]['classNames']
|
|
||||||
} as NonNullable<CollapseProps['items']>[number]
|
|
||||||
}, [Renderer, input, output, toolName])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
@@ -98,5 +93,7 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
|
|||||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
|
return (
|
||||||
|
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user