Compare commits
40 Commits
fix/openmi
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3093a9e5d0 | ||
|
|
3274723b1e | ||
|
|
5c724a03a6 | ||
|
|
a95e776699 | ||
|
|
be99f4df71 | ||
|
|
0f1a487bb0 | ||
|
|
2df8bb58df | ||
|
|
62976f6fe0 | ||
|
|
77529b3cd3 | ||
|
|
c8e9a10190 | ||
|
|
0e011ff35f | ||
|
|
40a64a7c92 | ||
|
|
dc9503ef8b | ||
|
|
f2c8484c48 | ||
|
|
a9c9224835 | ||
|
|
43223fd1f5 | ||
|
|
4bac843b37 | ||
|
|
34723934f4 | ||
|
|
096c36caf8 | ||
|
|
139950e193 | ||
|
|
31eec403f7 | ||
|
|
7fd4837a47 | ||
|
|
90b0c8b4a6 | ||
|
|
556353e910 | ||
|
|
11fb730b4d | ||
|
|
2511113b62 | ||
|
|
a29b2bb3d6 | ||
|
|
d2be450906 | ||
|
|
9c020f0d56 | ||
|
|
e033eb5b5c | ||
|
|
073d43c7cb | ||
|
|
fa7646e18f | ||
|
|
038d30831c | ||
|
|
68ee5164f0 | ||
|
|
a1a3b9bd96 | ||
|
|
4e699c48bc | ||
|
|
75fcf8fbb5 | ||
|
|
35aa9d7355 | ||
|
|
b08aecb22b | ||
|
|
45fc6c2afd |
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-->
|
||||||
|
|||||||
37
package.json
37
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",
|
||||||
@@ -107,11 +109,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 +125,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 +139,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 +169,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",
|
||||||
@@ -210,8 +215,8 @@
|
|||||||
"@types/mime-types": "^3",
|
"@types/mime-types": "^3",
|
||||||
"@types/node": "^22.17.1",
|
"@types/node": "^22.17.1",
|
||||||
"@types/pako": "^1.0.2",
|
"@types/pako": "^1.0.2",
|
||||||
"@types/react": "^19.0.12",
|
"@types/react": "^19.2.6",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
"@types/react-window": "^1",
|
"@types/react-window": "^1",
|
||||||
@@ -259,12 +264,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 +386,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 +412,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',
|
||||||
@@ -195,6 +196,9 @@ export enum IpcChannel {
|
|||||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||||
File_StartWatcher = 'file:startWatcher',
|
File_StartWatcher = 'file:startWatcher',
|
||||||
File_StopWatcher = 'file:stopWatcher',
|
File_StopWatcher = 'file:stopWatcher',
|
||||||
|
File_PauseWatcher = 'file:pauseWatcher',
|
||||||
|
File_ResumeWatcher = 'file:resumeWatcher',
|
||||||
|
File_BatchUploadMarkdown = 'file:batchUploadMarkdown',
|
||||||
File_ShowInFolder = 'file:showInFolder',
|
File_ShowInFolder = 'file:showInFolder',
|
||||||
|
|
||||||
// file service
|
// file service
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type LoaderReturn = {
|
|||||||
messageSource?: 'preprocess' | 'embedding' | 'validation'
|
messageSource?: 'preprocess' | 'embedding' | 'validation'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'refresh'
|
||||||
|
|
||||||
export type FileChangeEvent = {
|
export type FileChangeEvent = {
|
||||||
eventType: FileChangeEventType
|
eventType: FileChangeEventType
|
||||||
|
|||||||
532
scripts/update-app-upgrade-config.ts
Normal file
532
scripts/update-app-upgrade-config.ts
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import semver from 'semver'
|
||||||
|
|
||||||
|
type UpgradeChannel = 'latest' | 'rc' | 'beta'
|
||||||
|
type UpdateMirror = 'github' | 'gitcode'
|
||||||
|
|
||||||
|
const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta']
|
||||||
|
const MIRRORS: UpdateMirror[] = ['github', 'gitcode']
|
||||||
|
const GITHUB_REPO = 'CherryHQ/cherry-studio'
|
||||||
|
const GITCODE_REPO = 'CherryHQ/cherry-studio'
|
||||||
|
const DEFAULT_FEED_TEMPLATES: Record<UpdateMirror, string> = {
|
||||||
|
github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`,
|
||||||
|
gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}`
|
||||||
|
}
|
||||||
|
const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com'
|
||||||
|
|
||||||
|
interface CliOptions {
|
||||||
|
tag?: string
|
||||||
|
configPath?: string
|
||||||
|
segmentsPath?: string
|
||||||
|
dryRun?: boolean
|
||||||
|
skipReleaseChecks?: boolean
|
||||||
|
isPrerelease?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelTemplateConfig {
|
||||||
|
feedTemplates?: Partial<Record<UpdateMirror, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentMatchRule {
|
||||||
|
range?: string
|
||||||
|
exact?: string[]
|
||||||
|
excludeExact?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentDefinition {
|
||||||
|
id: string
|
||||||
|
type: 'legacy' | 'breaking' | 'latest'
|
||||||
|
match: SegmentMatchRule
|
||||||
|
lockedVersion?: string
|
||||||
|
minCompatibleVersion: string
|
||||||
|
description: string
|
||||||
|
channelTemplates?: Partial<Record<UpgradeChannel, ChannelTemplateConfig>>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentMetadataFile {
|
||||||
|
segments: SegmentDefinition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelConfig {
|
||||||
|
version: string
|
||||||
|
feedUrls: Record<UpdateMirror, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionMetadata {
|
||||||
|
segmentId: string
|
||||||
|
segmentType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionEntry {
|
||||||
|
metadata?: VersionMetadata
|
||||||
|
minCompatibleVersion: string
|
||||||
|
description: string
|
||||||
|
channels: Record<UpgradeChannel, ChannelConfig | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpgradeConfigFile {
|
||||||
|
lastUpdated: string
|
||||||
|
versions: Record<string, VersionEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseInfo {
|
||||||
|
tag: string
|
||||||
|
version: string
|
||||||
|
channel: UpgradeChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateVersionsResult {
|
||||||
|
versions: Record<string, VersionEntry>
|
||||||
|
updated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOT_DIR = path.resolve(__dirname, '..')
|
||||||
|
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json')
|
||||||
|
const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs()
|
||||||
|
const releaseTag = resolveTag(options)
|
||||||
|
const normalizedVersion = normalizeVersion(releaseTag)
|
||||||
|
const releaseChannel = detectChannel(normalizedVersion)
|
||||||
|
if (!releaseChannel) {
|
||||||
|
console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate version format matches prerelease status
|
||||||
|
if (options.isPrerelease !== undefined) {
|
||||||
|
const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc'
|
||||||
|
|
||||||
|
if (options.isPrerelease && !hasPrereleaseSuffix) {
|
||||||
|
console.warn(
|
||||||
|
`[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.isPrerelease && hasPrereleaseSuffix) {
|
||||||
|
console.warn(
|
||||||
|
`[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [config, segmentFile] = await Promise.all([
|
||||||
|
readJson<UpgradeConfigFile>(options.configPath ?? DEFAULT_CONFIG_PATH),
|
||||||
|
readJson<SegmentMetadataFile>(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH)
|
||||||
|
])
|
||||||
|
|
||||||
|
const segment = pickSegment(segmentFile.segments, normalizedVersion)
|
||||||
|
if (!segment) {
|
||||||
|
throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) {
|
||||||
|
throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseInfo: ReleaseInfo = {
|
||||||
|
tag: formatTag(releaseTag),
|
||||||
|
version: normalizedVersion,
|
||||||
|
channel: releaseChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versions: updatedVersions, updated } = await updateVersions(
|
||||||
|
config.versions,
|
||||||
|
segment,
|
||||||
|
releaseInfo,
|
||||||
|
Boolean(options.skipReleaseChecks)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(
|
||||||
|
`[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedConfig: UpgradeConfigFile = {
|
||||||
|
...config,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
versions: updatedVersions
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = JSON.stringify(updatedConfig, null, 2) + '\n'
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log('Dry run enabled. Generated configuration:\n')
|
||||||
|
console.log(output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8')
|
||||||
|
console.log(
|
||||||
|
`✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): CliOptions {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const options: CliOptions = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i]
|
||||||
|
if (arg === '--tag') {
|
||||||
|
options.tag = args[i + 1]
|
||||||
|
i += 1
|
||||||
|
} else if (arg === '--config') {
|
||||||
|
options.configPath = args[i + 1]
|
||||||
|
i += 1
|
||||||
|
} else if (arg === '--segments') {
|
||||||
|
options.segmentsPath = args[i + 1]
|
||||||
|
i += 1
|
||||||
|
} else if (arg === '--dry-run') {
|
||||||
|
options.dryRun = true
|
||||||
|
} else if (arg === '--skip-release-checks') {
|
||||||
|
options.skipReleaseChecks = true
|
||||||
|
} else if (arg === '--is-prerelease') {
|
||||||
|
options.isPrerelease = args[i + 1] === 'true'
|
||||||
|
i += 1
|
||||||
|
} else if (arg === '--help') {
|
||||||
|
printHelp()
|
||||||
|
process.exit(0)
|
||||||
|
} else {
|
||||||
|
console.warn(`Ignoring unknown argument "${arg}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.skipReleaseChecks && !options.dryRun) {
|
||||||
|
throw new Error('--skip-release-checks can only be used together with --dry-run')
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--tag <tag> Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG.
|
||||||
|
--config <path> Path to app-upgrade-config.json.
|
||||||
|
--segments <path> Path to app-upgrade-segments.json.
|
||||||
|
--is-prerelease <true|false> Whether this is a prerelease (validates version format).
|
||||||
|
--dry-run Print the result without writing to disk.
|
||||||
|
--skip-release-checks Skip release page availability checks (only valid with --dry-run).
|
||||||
|
--help Show this help message.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTag(options: CliOptions): string {
|
||||||
|
const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME
|
||||||
|
const tag = options.tag ?? envTag
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVersion(tag: string): string {
|
||||||
|
const cleaned = semver.clean(tag, { loose: true })
|
||||||
|
if (!cleaned) {
|
||||||
|
throw new Error(`Tag "${tag}" is not a valid semantic version`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = semver.valid(cleaned, { loose: true })
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectChannel(version: string): UpgradeChannel | null {
|
||||||
|
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
|
||||||
|
if (!parsed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.prerelease.length === 0) {
|
||||||
|
return 'latest'
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = String(parsed.prerelease[0]).toLowerCase()
|
||||||
|
if (label === 'beta') {
|
||||||
|
return 'beta'
|
||||||
|
}
|
||||||
|
if (label === 'rc') {
|
||||||
|
return 'rc'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(filePath: string): Promise<T> {
|
||||||
|
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
|
||||||
|
const data = await fs.readFile(absolute, 'utf-8')
|
||||||
|
return JSON.parse(data) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null {
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (matchesSegment(segment.match, version)) {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean {
|
||||||
|
if (matchRule.exact && matchRule.exact.includes(version)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchRule.exact) {
|
||||||
|
return matchRule.exact.includes(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(matchRule.range)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTag(tag: string): string {
|
||||||
|
if (tag.startsWith('refs/tags/')) {
|
||||||
|
return tag.replace('refs/tags/', '')
|
||||||
|
}
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateVersions(
|
||||||
|
versions: Record<string, VersionEntry>,
|
||||||
|
segment: SegmentDefinition,
|
||||||
|
releaseInfo: ReleaseInfo,
|
||||||
|
skipReleaseValidation: boolean
|
||||||
|
): Promise<UpdateVersionsResult> {
|
||||||
|
const versionsCopy: Record<string, VersionEntry> = { ...versions }
|
||||||
|
const existingKey = findVersionKeyBySegment(versionsCopy, segment.id)
|
||||||
|
const targetKey = resolveVersionKey(existingKey, segment, releaseInfo)
|
||||||
|
const shouldRename = existingKey && existingKey !== targetKey
|
||||||
|
|
||||||
|
let entry: VersionEntry
|
||||||
|
if (existingKey) {
|
||||||
|
entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } }
|
||||||
|
} else {
|
||||||
|
entry = createEmptyVersionEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.channels = ensureChannelSlots(entry.channels)
|
||||||
|
|
||||||
|
const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation)
|
||||||
|
if (!channelUpdated) {
|
||||||
|
return { versions, updated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRename && existingKey) {
|
||||||
|
delete versionsCopy[existingKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.metadata = {
|
||||||
|
segmentId: segment.id,
|
||||||
|
segmentType: segment.type
|
||||||
|
}
|
||||||
|
entry.minCompatibleVersion = segment.minCompatibleVersion
|
||||||
|
entry.description = segment.description
|
||||||
|
|
||||||
|
versionsCopy[targetKey] = entry
|
||||||
|
return {
|
||||||
|
versions: sortVersionMap(versionsCopy),
|
||||||
|
updated: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVersionKeyBySegment(versions: Record<string, VersionEntry>, segmentId: string): string | null {
|
||||||
|
for (const [key, value] of Object.entries(versions)) {
|
||||||
|
if (value.metadata?.segmentId === segmentId) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string {
|
||||||
|
if (segment.lockedVersion) {
|
||||||
|
return segment.lockedVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseInfo.channel === 'latest') {
|
||||||
|
return releaseInfo.version
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingKey) {
|
||||||
|
return existingKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseVersion = getBaseVersion(releaseInfo.version)
|
||||||
|
return baseVersion ?? releaseInfo.version
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseVersion(version: string): string | null {
|
||||||
|
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
|
||||||
|
if (!parsed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `${parsed.major}.${parsed.minor}.${parsed.patch}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyVersionEntry(): VersionEntry {
|
||||||
|
return {
|
||||||
|
minCompatibleVersion: '',
|
||||||
|
description: '',
|
||||||
|
channels: {
|
||||||
|
latest: null,
|
||||||
|
rc: null,
|
||||||
|
beta: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChannelSlots(
|
||||||
|
channels: Record<UpgradeChannel, ChannelConfig | null>
|
||||||
|
): Record<UpgradeChannel, ChannelConfig | null> {
|
||||||
|
return CHANNELS.reduce(
|
||||||
|
(acc, channel) => {
|
||||||
|
acc[channel] = channels[channel] ?? null
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<UpgradeChannel, ChannelConfig | null>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChannelUpdate(
|
||||||
|
entry: VersionEntry,
|
||||||
|
segment: SegmentDefinition,
|
||||||
|
releaseInfo: ReleaseInfo,
|
||||||
|
skipReleaseValidation: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!CHANNELS.includes(releaseInfo.channel)) {
|
||||||
|
throw new Error(`Unsupported channel "${releaseInfo.channel}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedUrls = buildFeedUrls(segment, releaseInfo)
|
||||||
|
|
||||||
|
if (skipReleaseValidation) {
|
||||||
|
console.warn(
|
||||||
|
`[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const availability = await ensureReleaseAvailability(releaseInfo)
|
||||||
|
if (!availability.github) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (releaseInfo.channel === 'latest' && !availability.gitcode) {
|
||||||
|
console.warn(
|
||||||
|
`[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.`
|
||||||
|
)
|
||||||
|
feedUrls.gitcode = GITCODE_LATEST_FALLBACK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.channels[releaseInfo.channel] = {
|
||||||
|
version: releaseInfo.version,
|
||||||
|
feedUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record<UpdateMirror, string> {
|
||||||
|
return MIRRORS.reduce(
|
||||||
|
(acc, mirror) => {
|
||||||
|
const template = resolveFeedTemplate(segment, releaseInfo, mirror)
|
||||||
|
acc[mirror] = applyTemplate(template, releaseInfo)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<UpdateMirror, string>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string {
|
||||||
|
if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') {
|
||||||
|
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(template: string, releaseInfo: ReleaseInfo): string {
|
||||||
|
return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVersionMap(versions: Record<string, VersionEntry>): Record<string, VersionEntry> {
|
||||||
|
const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b))
|
||||||
|
return sorted.reduce(
|
||||||
|
(acc, [version, entry]) => {
|
||||||
|
acc[version] = entry
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, VersionEntry>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseAvailability {
|
||||||
|
github: boolean
|
||||||
|
gitcode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise<ReleaseAvailability> {
|
||||||
|
const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github']
|
||||||
|
const availability: ReleaseAvailability = {
|
||||||
|
github: false,
|
||||||
|
gitcode: releaseInfo.channel === 'latest' ? false : true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mirror of mirrorsToCheck) {
|
||||||
|
const url = getReleasePageUrl(mirror, releaseInfo.tag)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: mirror === 'github' ? 'HEAD' : 'GET',
|
||||||
|
redirect: 'follow'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
availability[mirror] = true
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).`
|
||||||
|
)
|
||||||
|
availability[mirror] = false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
availability[mirror] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReleasePageUrl(mirror: UpdateMirror, tag: string): string {
|
||||||
|
if (mirror === 'github') {
|
||||||
|
return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`
|
||||||
|
}
|
||||||
|
// Use latest.yml download URL for GitCode to check if release exists
|
||||||
|
// Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability
|
||||||
|
return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml`
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('❌ Failed to update app-upgrade-config:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ import '@main/config'
|
|||||||
import { loggerService } from '@logger'
|
import { 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -557,6 +557,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||||
|
ipcMain.handle(IpcChannel.File_PauseWatcher, fileManager.pauseFileWatcher.bind(fileManager))
|
||||||
|
ipcMain.handle(IpcChannel.File_ResumeWatcher, fileManager.resumeFileWatcher.bind(fileManager))
|
||||||
|
ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager))
|
||||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
||||||
|
|
||||||
// file service
|
// file service
|
||||||
@@ -1038,4 +1041,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)
|
||||||
|
|
||||||
@@ -124,8 +149,8 @@ export default class OpenMineruPreprocessProvider extends BasePreprocessProvider
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('return_md', 'true')
|
formData.append('return_md', 'true')
|
||||||
formData.append('response_format_zip', 'true')
|
formData.append('response_format_zip', 'true')
|
||||||
formData.append('file', 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1605,6 +1605,164 @@ class FileStorage {
|
|||||||
logger.error('Failed to show item in folder:', error as Error)
|
logger.error('Failed to show item in folder:', error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch upload markdown files from native File objects
|
||||||
|
* This handles all I/O operations in the Main process to avoid blocking Renderer
|
||||||
|
*/
|
||||||
|
public batchUploadMarkdownFiles = async (
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
filePaths: string[],
|
||||||
|
targetPath: string
|
||||||
|
): Promise<{
|
||||||
|
fileCount: number
|
||||||
|
folderCount: number
|
||||||
|
skippedFiles: number
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
logger.info('Starting batch upload', { fileCount: filePaths.length, targetPath })
|
||||||
|
|
||||||
|
const basePath = path.resolve(targetPath)
|
||||||
|
const MARKDOWN_EXTS = ['.md', '.markdown']
|
||||||
|
|
||||||
|
// Filter markdown files
|
||||||
|
const markdownFiles = filePaths.filter((filePath) => {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
return MARKDOWN_EXTS.includes(ext)
|
||||||
|
})
|
||||||
|
|
||||||
|
const skippedFiles = filePaths.length - markdownFiles.length
|
||||||
|
|
||||||
|
if (markdownFiles.length === 0) {
|
||||||
|
return { fileCount: 0, folderCount: 0, skippedFiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique folders needed
|
||||||
|
const foldersSet = new Set<string>()
|
||||||
|
const fileOperations: Array<{ sourcePath: string; targetPath: string }> = []
|
||||||
|
|
||||||
|
for (const filePath of markdownFiles) {
|
||||||
|
try {
|
||||||
|
// Get relative path if file is from a directory upload
|
||||||
|
const fileName = path.basename(filePath)
|
||||||
|
const relativePath = path.dirname(filePath)
|
||||||
|
|
||||||
|
// Determine target directory structure
|
||||||
|
let targetDir = basePath
|
||||||
|
const folderParts: string[] = []
|
||||||
|
|
||||||
|
// Extract folder structure from file path for nested uploads
|
||||||
|
// This is a simplified version - in real scenario we'd need the original directory structure
|
||||||
|
if (relativePath && relativePath !== '.') {
|
||||||
|
const parts = relativePath.split(path.sep)
|
||||||
|
// Get the last few parts that represent the folder structure within upload
|
||||||
|
const relevantParts = parts.slice(Math.max(0, parts.length - 3))
|
||||||
|
folderParts.push(...relevantParts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build target directory path
|
||||||
|
for (const part of folderParts) {
|
||||||
|
targetDir = path.join(targetDir, part)
|
||||||
|
foldersSet.add(targetDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine final file name
|
||||||
|
const nameWithoutExt = fileName.endsWith('.md')
|
||||||
|
? fileName.slice(0, -3)
|
||||||
|
: fileName.endsWith('.markdown')
|
||||||
|
? fileName.slice(0, -9)
|
||||||
|
: fileName
|
||||||
|
|
||||||
|
const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true)
|
||||||
|
const finalPath = path.join(targetDir, safeName + '.md')
|
||||||
|
|
||||||
|
fileOperations.push({ sourcePath: filePath, targetPath: finalPath })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to prepare file operation:', error as Error, { filePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create folders in order (shallow to deep)
|
||||||
|
const sortedFolders = Array.from(foldersSet).sort((a, b) => a.length - b.length)
|
||||||
|
for (const folder of sortedFolders) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(folder)) {
|
||||||
|
await fs.promises.mkdir(folder, { recursive: true })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Folder already exists or creation failed', { folder, error: (error as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process files in batches
|
||||||
|
const BATCH_SIZE = 10 // Higher batch size since we're in Main process
|
||||||
|
let successCount = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < fileOperations.length; i += BATCH_SIZE) {
|
||||||
|
const batch = fileOperations.slice(i, i + BATCH_SIZE)
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async (op) => {
|
||||||
|
// Read from source and write to target in Main process
|
||||||
|
const content = await fs.promises.readFile(op.sourcePath, 'utf-8')
|
||||||
|
await fs.promises.writeFile(op.targetPath, content, 'utf-8')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
successCount++
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to upload file:', result.reason, {
|
||||||
|
file: batch[index].sourcePath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Batch upload completed', {
|
||||||
|
successCount,
|
||||||
|
folderCount: foldersSet.size,
|
||||||
|
skippedFiles
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileCount: successCount,
|
||||||
|
folderCount: foldersSet.size,
|
||||||
|
skippedFiles
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Batch upload failed:', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause file watcher to prevent events during batch operations
|
||||||
|
*/
|
||||||
|
public pauseFileWatcher = async (): Promise<void> => {
|
||||||
|
if (this.watcher) {
|
||||||
|
logger.debug('Pausing file watcher')
|
||||||
|
// Chokidar doesn't have pause, so we temporarily set a flag
|
||||||
|
// We'll handle this by clearing the debounce timer
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer)
|
||||||
|
this.debounceTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume file watcher and trigger a refresh
|
||||||
|
*/
|
||||||
|
public resumeFileWatcher = async (): Promise<void> => {
|
||||||
|
if (this.watcher && this.currentWatchPath) {
|
||||||
|
logger.debug('Resuming file watcher')
|
||||||
|
// Send a synthetic refresh event to trigger tree reload
|
||||||
|
this.notifyChange('refresh', this.currentWatchPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fileStorage = new FileStorage()
|
export const fileStorage = new FileStorage()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('stripLocalCommandTags', () => {
|
|||||||
|
|
||||||
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 +182,14 @@ 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 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[] = [
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,13 +154,17 @@ export class ClaudeStreamState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +191,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 +205,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
|
||||||
}
|
}
|
||||||
@@ -183,12 +218,12 @@ export class ClaudeStreamState {
|
|||||||
* 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, input: unknown, providerMetadata?: ProviderMetadata): void {
|
||||||
|
const block = this.getToolBlockByRawId(toolCallId)
|
||||||
this.registerToolCall(toolCallId, {
|
this.registerToolCall(toolCallId, {
|
||||||
toolName: this.getToolBlockById(toolCallId)?.toolName ?? 'unknown',
|
toolName: block?.toolName ?? 'unknown',
|
||||||
input,
|
input,
|
||||||
providerMetadata
|
providerMetadata
|
||||||
})
|
})
|
||||||
const block = this.getToolBlockById(toolCallId)
|
|
||||||
if (block) {
|
if (block) {
|
||||||
block.resolvedInput = input
|
block.resolvedInput = input
|
||||||
}
|
}
|
||||||
@@ -200,7 +235,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 +262,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 +271,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 }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { app } from 'electron'
|
|||||||
import type { GetAgentSessionResponse } from '../..'
|
import 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 +151,10 @@ 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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SDK options from parameters
|
// Build SDK options from parameters
|
||||||
@@ -346,7 +350,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 })) {
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,9 +243,10 @@ 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,
|
||||||
@@ -331,10 +332,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 +345,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,
|
||||||
@@ -514,7 +516,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)
|
||||||
@@ -219,6 +220,10 @@ const api = {
|
|||||||
startFileWatcher: (dirPath: string, config?: any) =>
|
startFileWatcher: (dirPath: string, config?: any) =>
|
||||||
ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config),
|
ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config),
|
||||||
stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher),
|
stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher),
|
||||||
|
pauseFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_PauseWatcher),
|
||||||
|
resumeFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_ResumeWatcher),
|
||||||
|
batchUploadMarkdown: (filePaths: string[], targetPath: string) =>
|
||||||
|
ipcRenderer.invoke(IpcChannel.File_BatchUploadMarkdown, filePaths, targetPath),
|
||||||
onFileChange: (callback: (data: FileChangeEvent) => void) => {
|
onFileChange: (callback: (data: FileChangeEvent) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
|
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
|
||||||
if (data && typeof data === 'object') {
|
if (data && typeof data === 'object') {
|
||||||
|
|||||||
@@ -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,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`
|
||||||
|
|||||||
@@ -140,11 +140,11 @@ describe('DynamicVirtualList', () => {
|
|||||||
// Should call isSticky function during rendering
|
// Should call isSticky function during rendering
|
||||||
expect(isSticky).toHaveBeenCalled()
|
expect(isSticky).toHaveBeenCalled()
|
||||||
|
|
||||||
// Should apply sticky styles to sticky items
|
// Sticky items within visible range should have proper z-index but may be absolute until scrolled
|
||||||
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
|
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
|
||||||
expect(stickyItem).toBeInTheDocument()
|
expect(stickyItem).toBeInTheDocument()
|
||||||
expect(stickyItem).toHaveStyle('position: sticky')
|
// When sticky item is in visible range, it gets z-index but may not be sticky yet
|
||||||
expect(stickyItem).toHaveStyle('z-index: 1')
|
expect(stickyItem).toHaveStyle('z-index: 999')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply absolute positioning to non-sticky items', () => {
|
it('should apply absolute positioning to non-sticky items', () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-index="0"
|
data-index="0"
|
||||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(0px); width: 100%;"
|
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(0px); width: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="item-0"
|
data-testid="item-0"
|
||||||
@@ -34,7 +34,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-index="1"
|
data-index="1"
|
||||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(50px); width: 100%;"
|
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(50px); width: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="item-1"
|
data-testid="item-1"
|
||||||
@@ -44,7 +44,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-index="2"
|
data-index="2"
|
||||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(100px); width: 100%;"
|
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(100px); width: 100%;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="item-2"
|
data-testid="item-2"
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
|
|||||||
*/
|
*/
|
||||||
isSticky?: (index: number) => boolean
|
isSticky?: (index: number) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the depth/level of an item for hierarchical sticky positioning
|
||||||
|
* Used with isSticky to determine ancestor relationships
|
||||||
|
*/
|
||||||
|
getItemDepth?: (index: number) => number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Range extractor function, cannot be used with isSticky
|
* Range extractor function, cannot be used with isSticky
|
||||||
*/
|
*/
|
||||||
@@ -81,6 +87,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>) {
|
||||||
@@ -91,10 +107,13 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
size,
|
size,
|
||||||
estimateSize,
|
estimateSize,
|
||||||
isSticky,
|
isSticky,
|
||||||
|
getItemDepth,
|
||||||
rangeExtractor: customRangeExtractor,
|
rangeExtractor: customRangeExtractor,
|
||||||
itemContainerStyle,
|
itemContainerStyle,
|
||||||
scrollerStyle,
|
scrollerStyle,
|
||||||
autoHideScrollbar = false,
|
autoHideScrollbar = false,
|
||||||
|
header,
|
||||||
|
className,
|
||||||
...restOptions
|
...restOptions
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
@@ -103,7 +122,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
const internalScrollerRef = useRef<HTMLDivElement>(null)
|
const internalScrollerRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollerRef = internalScrollerRef
|
const scrollerRef = internalScrollerRef
|
||||||
|
|
||||||
const activeStickyIndexRef = useRef(0)
|
const activeStickyIndexesRef = useRef<number[]>([])
|
||||||
|
|
||||||
const stickyIndexes = useMemo(() => {
|
const stickyIndexes = useMemo(() => {
|
||||||
if (!isSticky) return []
|
if (!isSticky) return []
|
||||||
@@ -112,21 +131,54 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
|
|
||||||
const internalStickyRangeExtractor = useCallback(
|
const internalStickyRangeExtractor = useCallback(
|
||||||
(range: Range) => {
|
(range: Range) => {
|
||||||
// The active sticky index is the last one that is before or at the start of the visible range
|
const activeStickies: number[] = []
|
||||||
const newActiveStickyIndex =
|
|
||||||
[...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0
|
|
||||||
|
|
||||||
if (newActiveStickyIndex !== activeStickyIndexRef.current) {
|
if (getItemDepth) {
|
||||||
activeStickyIndexRef.current = newActiveStickyIndex
|
// With depth information, we can build a proper ancestor chain
|
||||||
|
// Find all sticky items before the visible range
|
||||||
|
const stickiesBeforeRange = stickyIndexes.filter((index) => index < range.startIndex)
|
||||||
|
|
||||||
|
if (stickiesBeforeRange.length > 0) {
|
||||||
|
// Find the depth of the first visible item (or last sticky before it)
|
||||||
|
const firstVisibleIndex = range.startIndex
|
||||||
|
const referenceDepth = getItemDepth(firstVisibleIndex)
|
||||||
|
|
||||||
|
// Build ancestor chain: include all sticky parents
|
||||||
|
const ancestorChain: number[] = []
|
||||||
|
let minDepth = referenceDepth
|
||||||
|
|
||||||
|
// Walk backwards from the last sticky before visible range
|
||||||
|
for (let i = stickiesBeforeRange.length - 1; i >= 0; i--) {
|
||||||
|
const stickyIndex = stickiesBeforeRange[i]
|
||||||
|
const stickyDepth = getItemDepth(stickyIndex)
|
||||||
|
|
||||||
|
// Include this sticky if it's a parent (smaller depth) of our reference
|
||||||
|
if (stickyDepth < minDepth) {
|
||||||
|
ancestorChain.unshift(stickyIndex)
|
||||||
|
minDepth = stickyDepth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStickies.push(...ancestorChain)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: without depth info, just use the last sticky before range
|
||||||
|
const lastStickyBeforeRange = [...stickyIndexes].reverse().find((index) => index < range.startIndex)
|
||||||
|
if (lastStickyBeforeRange !== undefined) {
|
||||||
|
activeStickies.push(lastStickyBeforeRange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge the active sticky index and the default range extractor
|
// Update the ref with current active stickies
|
||||||
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
|
activeStickyIndexesRef.current = activeStickies
|
||||||
|
|
||||||
|
// Merge the active sticky indexes and the default range extractor
|
||||||
|
const next = new Set([...activeStickyIndexesRef.current, ...defaultRangeExtractor(range)])
|
||||||
|
|
||||||
// Sort the set to maintain proper order
|
// Sort the set to maintain proper order
|
||||||
return [...next].sort((a, b) => a - b)
|
return [...next].sort((a, b) => a - b)
|
||||||
},
|
},
|
||||||
[stickyIndexes]
|
[stickyIndexes, getItemDepth]
|
||||||
)
|
)
|
||||||
|
|
||||||
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
|
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
|
||||||
@@ -189,7 +241,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 +252,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',
|
||||||
@@ -208,14 +261,47 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
|||||||
}}>
|
}}>
|
||||||
{virtualItems.map((virtualItem) => {
|
{virtualItems.map((virtualItem) => {
|
||||||
const isItemSticky = stickyIndexes.includes(virtualItem.index)
|
const isItemSticky = stickyIndexes.includes(virtualItem.index)
|
||||||
const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index
|
const isItemActiveSticky = isItemSticky && activeStickyIndexesRef.current.includes(virtualItem.index)
|
||||||
|
|
||||||
|
// Calculate the sticky offset for multi-level sticky headers
|
||||||
|
const activeStickyIndex = isItemActiveSticky ? activeStickyIndexesRef.current.indexOf(virtualItem.index) : -1
|
||||||
|
|
||||||
|
// Calculate cumulative offset based on actual sizes of previous sticky items
|
||||||
|
let stickyOffset = 0
|
||||||
|
if (activeStickyIndex >= 0) {
|
||||||
|
for (let i = 0; i < activeStickyIndex; i++) {
|
||||||
|
const prevStickyIndex = activeStickyIndexesRef.current[i]
|
||||||
|
stickyOffset += estimateSize(prevStickyIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this item is visually covered by sticky items
|
||||||
|
// If covered, disable pointer events to prevent hover/click bleeding through
|
||||||
|
const isCoveredBySticky = (() => {
|
||||||
|
if (!activeStickyIndexesRef.current.length) return false
|
||||||
|
if (isItemActiveSticky) return false // Sticky items themselves are not covered
|
||||||
|
|
||||||
|
// Calculate if this item's visual position is under any sticky header
|
||||||
|
const itemVisualTop = virtualItem.start
|
||||||
|
let totalStickyHeight = 0
|
||||||
|
for (const stickyIdx of activeStickyIndexesRef.current) {
|
||||||
|
totalStickyHeight += estimateSize(stickyIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If item starts within the sticky area, it's covered
|
||||||
|
return itemVisualTop < totalStickyHeight
|
||||||
|
})()
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
...itemContainerStyle,
|
...itemContainerStyle,
|
||||||
position: isItemActiveSticky ? 'sticky' : 'absolute',
|
position: isItemActiveSticky ? 'sticky' : 'absolute',
|
||||||
top: 0,
|
top: isItemActiveSticky ? stickyOffset : 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
zIndex: isItemSticky ? 1 : undefined,
|
zIndex: isItemActiveSticky ? 1000 + (100 - activeStickyIndex) : isItemSticky ? 999 : 0,
|
||||||
|
pointerEvents: isCoveredBySticky ? 'none' : 'auto',
|
||||||
|
...(isItemActiveSticky && {
|
||||||
|
backgroundColor: 'var(--color-background)'
|
||||||
|
}),
|
||||||
...(horizontal
|
...(horizontal
|
||||||
? {
|
? {
|
||||||
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,
|
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { loggerService } from '@logger'
|
||||||
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { useTimer } from './useTimer'
|
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('useInPlaceEdit')
|
||||||
export interface UseInPlaceEditOptions {
|
export interface UseInPlaceEditOptions {
|
||||||
onSave: ((value: string) => void) | ((value: string) => Promise<void>)
|
onSave: ((value: string) => void) | ((value: string) => Promise<void>)
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
@@ -12,14 +12,10 @@ export interface UseInPlaceEditOptions {
|
|||||||
export interface UseInPlaceEditReturn {
|
export interface UseInPlaceEditReturn {
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
editValue: string
|
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>
|
|
||||||
startEdit: (initialValue: string) => void
|
startEdit: (initialValue: string) => void
|
||||||
saveEdit: () => void
|
saveEdit: () => void
|
||||||
cancelEdit: () => void
|
cancelEdit: () => void
|
||||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.RefObject<HTMLInputElement | null> }
|
||||||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
handleValueChange: (value: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,58 +33,55 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editValue, setEditValue] = useState('')
|
const [editValue, setEditValue] = useState('')
|
||||||
const [originalValue, setOriginalValue] = useState('')
|
const originalValueRef = useRef('')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const { setTimeoutTimer } = useTimer()
|
|
||||||
|
|
||||||
const startEdit = useCallback(
|
const startEdit = useCallback((initialValue: string) => {
|
||||||
(initialValue: string) => {
|
setIsEditing(true)
|
||||||
setIsEditing(true)
|
setEditValue(initialValue)
|
||||||
setEditValue(initialValue)
|
originalValueRef.current = initialValue
|
||||||
setOriginalValue(initialValue)
|
}, [])
|
||||||
|
|
||||||
setTimeoutTimer(
|
useLayoutEffect(() => {
|
||||||
'startEdit',
|
if (isEditing) {
|
||||||
() => {
|
inputRef.current?.focus()
|
||||||
inputRef.current?.focus()
|
if (autoSelectOnStart) {
|
||||||
if (autoSelectOnStart) {
|
inputRef.current?.select()
|
||||||
inputRef.current?.select()
|
}
|
||||||
}
|
}
|
||||||
},
|
}, [autoSelectOnStart, isEditing])
|
||||||
0
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[autoSelectOnStart, setTimeoutTimer]
|
|
||||||
)
|
|
||||||
|
|
||||||
const saveEdit = useCallback(async () => {
|
const saveEdit = useCallback(async () => {
|
||||||
if (isSaving) return
|
if (isSaving) return
|
||||||
|
|
||||||
|
const finalValue = trimOnSave ? editValue.trim() : editValue
|
||||||
|
if (finalValue === originalValueRef.current) {
|
||||||
|
setIsEditing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const finalValue = trimOnSave ? editValue.trim() : editValue
|
await onSave(finalValue)
|
||||||
if (finalValue !== originalValue) {
|
|
||||||
await onSave(finalValue)
|
|
||||||
}
|
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setEditValue('')
|
setEditValue('')
|
||||||
setOriginalValue('')
|
} catch (error) {
|
||||||
} finally {
|
logger.error('Error saving in-place edit', { error })
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}, [isSaving, trimOnSave, editValue, originalValue, onSave])
|
}, [isSaving, trimOnSave, editValue, onSave])
|
||||||
|
|
||||||
const cancelEdit = useCallback(() => {
|
const cancelEdit = useCallback(() => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setEditValue('')
|
setEditValue('')
|
||||||
setOriginalValue('')
|
|
||||||
onCancel?.()
|
onCancel?.()
|
||||||
}, [onCancel])
|
}, [onCancel])
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
if (e.nativeEvent.isComposing) return
|
||||||
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
saveEdit()
|
saveEdit()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
@@ -104,37 +97,29 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
|||||||
setEditValue(e.target.value)
|
setEditValue(e.target.value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleValueChange = useCallback((value: string) => {
|
const handleBlur = useCallback(() => {
|
||||||
setEditValue(value)
|
// 这里的逻辑需要注意:
|
||||||
}, [])
|
// 如果点击了“取消”按钮,可能会先触发 Blur 保存。
|
||||||
|
// 通常 InPlaceEdit 的逻辑是 Blur 即 Save。
|
||||||
// Handle clicks outside the input to save
|
// 如果不想 Blur 保存,可以去掉这一行,或者判断 relatedTarget。
|
||||||
useEffect(() => {
|
if (!isSaving) {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
saveEdit()
|
||||||
if (isEditing && inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
|
||||||
saveEdit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [saveEdit, isSaving])
|
||||||
if (isEditing) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}, [isEditing, saveEdit])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEditing,
|
isEditing,
|
||||||
isSaving,
|
isSaving,
|
||||||
editValue,
|
|
||||||
inputRef,
|
|
||||||
startEdit,
|
startEdit,
|
||||||
saveEdit,
|
saveEdit,
|
||||||
cancelEdit,
|
cancelEdit,
|
||||||
handleKeyDown,
|
inputProps: {
|
||||||
handleInputChange,
|
ref: inputRef,
|
||||||
handleValueChange
|
value: editValue,
|
||||||
|
onChange: handleInputChange,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
onBlur: handleBlur,
|
||||||
|
disabled: isSaving // 保存时禁用输入
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "New Folder",
|
"untitled_folder": "New Folder",
|
||||||
"untitled_note": "Untitled Note",
|
"untitled_note": "Untitled Note",
|
||||||
"upload_failed": "Note upload failed",
|
"upload_failed": "Note upload failed",
|
||||||
"upload_success": "Note uploaded success"
|
"upload_files": "Upload Files",
|
||||||
|
"upload_folder": "Upload Folder",
|
||||||
|
"upload_success": "Note uploaded success",
|
||||||
|
"uploading_files": "Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Assistant Response",
|
"assistant": "Assistant Response",
|
||||||
@@ -4343,7 +4349,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": "获取智能体列表失败"
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "新文件夹",
|
"untitled_folder": "新文件夹",
|
||||||
"untitled_note": "无标题笔记",
|
"untitled_note": "无标题笔记",
|
||||||
"upload_failed": "笔记上传失败",
|
"upload_failed": "笔记上传失败",
|
||||||
"upload_success": "笔记上传成功"
|
"upload_files": "上传文件",
|
||||||
|
"upload_folder": "上传文件夹",
|
||||||
|
"upload_success": "笔记上传成功",
|
||||||
|
"uploading_files": "正在上传 {{count}} 个文件..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "助手响应",
|
"assistant": "助手响应",
|
||||||
@@ -4343,7 +4349,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 +4484,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": "無法列出代理程式。"
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "新資料夾",
|
"untitled_folder": "新資料夾",
|
||||||
"untitled_note": "無標題筆記",
|
"untitled_note": "無標題筆記",
|
||||||
"upload_failed": "筆記上傳失敗",
|
"upload_failed": "筆記上傳失敗",
|
||||||
"upload_success": "筆記上傳成功"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "筆記上傳成功",
|
||||||
|
"uploading_files": "正在上傳 {{count}} 個檔案..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "助手回應",
|
"assistant": "助手回應",
|
||||||
@@ -4343,7 +4349,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 +4484,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"
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "Neuer Ordner",
|
"untitled_folder": "Neuer Ordner",
|
||||||
"untitled_note": "Unbenannte Notiz",
|
"untitled_note": "Unbenannte Notiz",
|
||||||
"upload_failed": "Notizen-Upload fehlgeschlagen",
|
"upload_failed": "Notizen-Upload fehlgeschlagen",
|
||||||
"upload_success": "Notizen erfolgreich hochgeladen"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "Notizen erfolgreich hochgeladen",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Assistenten-Antwort",
|
"assistant": "Assistenten-Antwort",
|
||||||
@@ -4478,7 +4484,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": "Αποτυχία καταχώρησης πρακτόρων."
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "Νέος φάκελος",
|
"untitled_folder": "Νέος φάκελος",
|
||||||
"untitled_note": "σημείωση χωρίς τίτλο",
|
"untitled_note": "σημείωση χωρίς τίτλο",
|
||||||
"upload_failed": "Η σημείωση δεν ανέβηκε",
|
"upload_failed": "Η σημείωση δεν ανέβηκε",
|
||||||
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Απάντηση Βοηθού",
|
"assistant": "Απάντηση Βοηθού",
|
||||||
@@ -4343,7 +4349,7 @@
|
|||||||
},
|
},
|
||||||
"azure": {
|
"azure": {
|
||||||
"apiversion": {
|
"apiversion": {
|
||||||
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια προεπισκόπηση έκδοσης"
|
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια v1 έκδοσης"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"basic_auth": {
|
"basic_auth": {
|
||||||
@@ -4478,7 +4484,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."
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "Nueva carpeta",
|
"untitled_folder": "Nueva carpeta",
|
||||||
"untitled_note": "Nota sin título",
|
"untitled_note": "Nota sin título",
|
||||||
"upload_failed": "Error al cargar la nota",
|
"upload_failed": "Error al cargar la nota",
|
||||||
"upload_success": "Nota cargada con éxito"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "Nota cargada con éxito",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Respuesta del asistente",
|
"assistant": "Respuesta del asistente",
|
||||||
@@ -4478,7 +4484,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."
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "nouveau dossier",
|
"untitled_folder": "nouveau dossier",
|
||||||
"untitled_note": "Note sans titre",
|
"untitled_note": "Note sans titre",
|
||||||
"upload_failed": "Échec du téléchargement de la note",
|
"upload_failed": "Échec du téléchargement de la note",
|
||||||
"upload_success": "Note téléchargée avec succès"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "Note téléchargée avec succès",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Réponse de l'assistant",
|
"assistant": "Réponse de l'assistant",
|
||||||
@@ -4343,7 +4349,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 +4484,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": "エージェントの一覧取得に失敗しました。"
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "新ファイル夹",
|
"untitled_folder": "新ファイル夹",
|
||||||
"untitled_note": "無題のメモ",
|
"untitled_note": "無題のメモ",
|
||||||
"upload_failed": "ノートのアップロードに失敗しました",
|
"upload_failed": "ノートのアップロードに失敗しました",
|
||||||
"upload_success": "ノートのアップロードが成功しました"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "ノートのアップロードが成功しました",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "助手回應",
|
"assistant": "助手回應",
|
||||||
@@ -4343,7 +4349,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 +4484,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."
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "Nova pasta",
|
"untitled_folder": "Nova pasta",
|
||||||
"untitled_note": "Nota sem título",
|
"untitled_note": "Nota sem título",
|
||||||
"upload_failed": "Falha ao carregar a nota",
|
"upload_failed": "Falha ao carregar a nota",
|
||||||
"upload_success": "Nota carregada com sucesso"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "Nota carregada com sucesso",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Resposta do assistente",
|
"assistant": "Resposta do assistente",
|
||||||
@@ -4343,7 +4349,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 +4484,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": "Не удалось получить список агентов."
|
||||||
@@ -2172,7 +2175,10 @@
|
|||||||
"untitled_folder": "Новая папка",
|
"untitled_folder": "Новая папка",
|
||||||
"untitled_note": "Незаглавленная заметка",
|
"untitled_note": "Незаглавленная заметка",
|
||||||
"upload_failed": "Не удалось загрузить заметку",
|
"upload_failed": "Не удалось загрузить заметку",
|
||||||
"upload_success": "Заметка успешно загружена"
|
"upload_files": "[to be translated]:Upload Files",
|
||||||
|
"upload_folder": "[to be translated]:Upload Folder",
|
||||||
|
"upload_success": "Заметка успешно загружена",
|
||||||
|
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"assistant": "Ответ ассистента",
|
"assistant": "Ответ ассистента",
|
||||||
@@ -4343,7 +4349,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 +4484,7 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"forward": "Вперед",
|
"forward": "Вперед",
|
||||||
"multiple": "Множественный выбор",
|
"multiple": "Множественный выбор",
|
||||||
"noResult": "[to be translated]:No results found",
|
"noResult": "Результаты не найдены",
|
||||||
"page": "Страница",
|
"page": "Страница",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"title": "Быстрое меню"
|
"title": "Быстрое меню"
|
||||||
|
|||||||
@@ -470,7 +470,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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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} />
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||||
import type { NormalToolResponse } from '@renderer/types'
|
import type { NormalToolResponse } from '@renderer/types'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
|
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
|
||||||
@@ -17,9 +17,7 @@ interface Props {
|
|||||||
export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const request = useAppSelector((state) =>
|
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId))
|
||||||
selectPendingPermissionByToolName(state.toolPermissions, toolResponse.tool.name)
|
|
||||||
)
|
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Selector from '@renderer/components/Selector'
|
import Selector from '@renderer/components/Selector'
|
||||||
import {
|
import {
|
||||||
|
getModelSupportedVerbosity,
|
||||||
isSupportedReasoningEffortOpenAIModel,
|
isSupportedReasoningEffortOpenAIModel,
|
||||||
isSupportFlexServiceTierModel,
|
isSupportFlexServiceTierModel,
|
||||||
isSupportVerbosityModel
|
isSupportVerbosityModel
|
||||||
@@ -80,20 +81,24 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const verbosityOptions = [
|
const verbosityOptions = useMemo(() => {
|
||||||
{
|
const allOptions = [
|
||||||
value: 'low',
|
{
|
||||||
label: t('settings.openai.verbosity.low')
|
value: 'low',
|
||||||
},
|
label: t('settings.openai.verbosity.low')
|
||||||
{
|
},
|
||||||
value: 'medium',
|
{
|
||||||
label: t('settings.openai.verbosity.medium')
|
value: 'medium',
|
||||||
},
|
label: t('settings.openai.verbosity.medium')
|
||||||
{
|
},
|
||||||
value: 'high',
|
{
|
||||||
label: t('settings.openai.verbosity.high')
|
value: 'high',
|
||||||
}
|
label: t('settings.openai.verbosity.high')
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
const supportedVerbosityLevels = getModelSupportedVerbosity(model)
|
||||||
|
return allOptions.filter((option) => supportedVerbosityLevels.includes(option.value as any))
|
||||||
|
}, [model, t])
|
||||||
|
|
||||||
const serviceTierOptions = useMemo(() => {
|
const serviceTierOptions = useMemo(() => {
|
||||||
let baseOptions: { value: ServiceTier; label: string }[]
|
let baseOptions: { value: ServiceTier; label: string }[]
|
||||||
@@ -155,6 +160,15 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
|
|||||||
}
|
}
|
||||||
}, [provider.id, serviceTierMode, serviceTierOptions, setServiceTierMode])
|
}, [provider.id, serviceTierMode, serviceTierOptions, setServiceTierMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (verbosity && !verbosityOptions.some((option) => option.value === verbosity)) {
|
||||||
|
const supportedVerbosityLevels = getModelSupportedVerbosity(model)
|
||||||
|
// Default to the highest supported verbosity level
|
||||||
|
const defaultVerbosity = supportedVerbosityLevels[supportedVerbosityLevels.length - 1]
|
||||||
|
setVerbosity(defaultVerbosity)
|
||||||
|
}
|
||||||
|
}, [model, verbosity, verbosityOptions, setVerbosity])
|
||||||
|
|
||||||
if (!isOpenAIReasoning && !isSupportServiceTier && !isSupportVerbosity) {
|
if (!isOpenAIReasoning && !isSupportServiceTier && !isSupportVerbosity) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
|
|||||||
const targetSession = useDeferredValue(_targetSession)
|
const targetSession = useDeferredValue(_targetSession)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({
|
const { isEditing, isSaving, startEdit, inputProps } = useInPlaceEdit({
|
||||||
onSave: async (value) => {
|
onSave: async (value) => {
|
||||||
if (value !== session.name) {
|
if (value !== session.name) {
|
||||||
await updateSession({ id: session.id, name: value })
|
await updateSession({ id: session.id, name: value })
|
||||||
@@ -179,14 +179,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
|
|||||||
{isFulfilled && !isActive && <FulfilledIndicator />}
|
{isFulfilled && !isActive && <FulfilledIndicator />}
|
||||||
<SessionNameContainer>
|
<SessionNameContainer>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<SessionEditInput
|
<SessionEditInput {...inputProps} style={{ opacity: isSaving ? 0.5 : 1 }} />
|
||||||
ref={inputRef}
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
||||||
style={{ opacity: isSaving ? 0.5 : 1 }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SessionName>
|
<SessionName>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
|
||||||
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
import { DynamicVirtualList } from '@renderer/components/VirtualList'
|
||||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||||
@@ -99,38 +98,36 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="sessions-tab">
|
<StyledVirtualList
|
||||||
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
|
className="sessions-tab"
|
||||||
{t('agent.session.add.title')}
|
list={sessions}
|
||||||
</AddButton>
|
estimateSize={() => 9 * 4}
|
||||||
{/* h-9 */}
|
// FIXME: This component only supports CSSProperties
|
||||||
<DynamicVirtualList
|
scrollerStyle={{ overflowX: 'hidden' }}
|
||||||
list={sessions}
|
autoHideScrollbar
|
||||||
estimateSize={() => 9 * 4}
|
header={
|
||||||
scrollerStyle={{
|
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
|
||||||
// FIXME: This component only supports CSSProperties
|
{t('agent.session.add.title')}
|
||||||
overflowX: 'hidden'
|
</AddButton>
|
||||||
}}
|
}>
|
||||||
autoHideScrollbar>
|
{(session) => (
|
||||||
{(session) => (
|
<SessionItem
|
||||||
<SessionItem
|
key={session.id}
|
||||||
key={session.id}
|
session={session}
|
||||||
session={session}
|
agentId={agentId}
|
||||||
agentId={agentId}
|
onDelete={() => handleDeleteSession(session.id)}
|
||||||
onDelete={() => handleDeleteSession(session.id)}
|
onPress={() => setActiveSessionId(agentId, session.id)}
|
||||||
onPress={() => setActiveSessionId(agentId, session.id)}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</StyledVirtualList>
|
||||||
</DynamicVirtualList>
|
|
||||||
</Container>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled(Scrollbar)`
|
const StyledVirtualList = styled(DynamicVirtualList)`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
overflow-x: hidden;
|
height: 100%;
|
||||||
`
|
` as typeof DynamicVirtualList
|
||||||
|
|
||||||
export default memo(Sessions)
|
export default memo(Sessions)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
|||||||
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
||||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
||||||
|
|
||||||
const topicEdit = useInPlaceEdit({
|
const { startEdit, isEditing, inputProps } = useInPlaceEdit({
|
||||||
onSave: (name: string) => {
|
onSave: (name: string) => {
|
||||||
const topic = assistant.topics.find((t) => t.id === editingTopicId)
|
const topic = assistant.topics.find((t) => t.id === editingTopicId)
|
||||||
if (topic && name !== topic.name) {
|
if (topic && name !== topic.name) {
|
||||||
@@ -520,29 +520,23 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
|||||||
<TopicListItem
|
<TopicListItem
|
||||||
onContextMenu={() => setTargetTopic(topic)}
|
onContextMenu={() => setTargetTopic(topic)}
|
||||||
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
||||||
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
|
onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)}
|
||||||
style={{
|
style={{
|
||||||
borderRadius,
|
borderRadius,
|
||||||
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
|
cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer'
|
||||||
}}>
|
}}>
|
||||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||||
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
||||||
<TopicNameContainer>
|
<TopicNameContainer>
|
||||||
{editingTopicId === topic.id && topicEdit.isEditing ? (
|
{editingTopicId === topic.id && isEditing ? (
|
||||||
<TopicEditInput
|
<TopicEditInput {...inputProps} onClick={(e) => e.stopPropagation()} />
|
||||||
ref={topicEdit.inputRef}
|
|
||||||
value={topicEdit.editValue}
|
|
||||||
onChange={topicEdit.handleInputChange}
|
|
||||||
onKeyDown={topicEdit.handleKeyDown}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<TopicName
|
<TopicName
|
||||||
className={getTopicNameClassName()}
|
className={getTopicNameClassName()}
|
||||||
title={topicName}
|
title={topicName}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
setEditingTopicId(topic.id)
|
setEditingTopicId(topic.id)
|
||||||
topicEdit.startEdit(topic.name)
|
startEdit(topic.name)
|
||||||
}}>
|
}}>
|
||||||
{topicName}
|
{topicName}
|
||||||
</TopicName>
|
</TopicName>
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ const NotesPage: FC = () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'refresh': {
|
||||||
|
// 批量操作完成后的单次刷新
|
||||||
|
logger.debug('Received refresh event, triggering tree refresh')
|
||||||
|
const refresh = refreshTreeRef.current
|
||||||
|
if (refresh) {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'add':
|
case 'add':
|
||||||
case 'addDir':
|
case 'addDir':
|
||||||
case 'unlink':
|
case 'unlink':
|
||||||
@@ -621,7 +631,27 @@ const NotesPage: FC = () => {
|
|||||||
throw new Error('No folder path selected')
|
throw new Error('No folder path selected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await uploadNotes(files, targetFolderPath)
|
// Validate uploadNotes function is available
|
||||||
|
if (typeof uploadNotes !== 'function') {
|
||||||
|
logger.error('uploadNotes function is not available', { uploadNotes })
|
||||||
|
window.toast.error(t('notes.upload_failed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof uploadNotes>>
|
||||||
|
try {
|
||||||
|
result = await uploadNotes(files, targetFolderPath)
|
||||||
|
} catch (uploadError) {
|
||||||
|
logger.error('Upload operation failed:', uploadError as Error)
|
||||||
|
throw uploadError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate result object
|
||||||
|
if (!result || typeof result !== 'object') {
|
||||||
|
logger.error('Invalid upload result:', { result })
|
||||||
|
window.toast.error(t('notes.upload_failed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 检查上传结果
|
// 检查上传结果
|
||||||
if (result.fileCount === 0) {
|
if (result.fileCount === 0) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
498
src/renderer/src/pages/notes/components/TreeNode.tsx
Normal file
498
src/renderer/src/pages/notes/components/TreeNode.tsx
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import HighlightText from '@renderer/components/HighlightText'
|
||||||
|
import {
|
||||||
|
useNotesActions,
|
||||||
|
useNotesDrag,
|
||||||
|
useNotesEditing,
|
||||||
|
useNotesSearch,
|
||||||
|
useNotesSelection,
|
||||||
|
useNotesUI
|
||||||
|
} from '@renderer/pages/notes/context/NotesContexts'
|
||||||
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
|
import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService'
|
||||||
|
import type { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { Dropdown } from 'antd'
|
||||||
|
import { ChevronDown, ChevronRight, File, FilePlus, Folder, FolderOpen } from 'lucide-react'
|
||||||
|
import { memo, useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: NotesTreeNode | SearchResult
|
||||||
|
depth: number
|
||||||
|
renderChildren?: boolean
|
||||||
|
onHintClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TreeNode = memo<TreeNodeProps>(({ node, depth, renderChildren = true, onHintClick }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// Use split contexts - only subscribe to what this node needs
|
||||||
|
const { selectedFolderId, activeNodeId } = useNotesSelection()
|
||||||
|
const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit } = useNotesEditing()
|
||||||
|
const { draggedNodeId, dragOverNodeId, dragPosition, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd } =
|
||||||
|
useNotesDrag()
|
||||||
|
const { searchKeyword, showMatches } = useNotesSearch()
|
||||||
|
const { openDropdownKey } = useNotesUI()
|
||||||
|
const { getMenuItems, onSelectNode, onToggleExpanded, onDropdownOpenChange } = useNotesActions()
|
||||||
|
|
||||||
|
const [showAllMatches, setShowAllMatches] = useState(false)
|
||||||
|
const { isEditing: isInputEditing, inputProps } = inPlaceEdit
|
||||||
|
|
||||||
|
// 检查是否是 hint 节点
|
||||||
|
const isHintNode = node.type === 'hint'
|
||||||
|
|
||||||
|
// 检查是否是搜索结果
|
||||||
|
const searchResult = 'matchType' in node ? (node as SearchResult) : null
|
||||||
|
const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0
|
||||||
|
|
||||||
|
// 处理匹配项点击
|
||||||
|
const handleMatchClick = useCallback(
|
||||||
|
(match: SearchMatch) => {
|
||||||
|
// 发送定位事件
|
||||||
|
EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, {
|
||||||
|
noteId: node.id,
|
||||||
|
lineNumber: match.lineNumber,
|
||||||
|
lineContent: match.lineContent
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId : node.id === activeNodeId
|
||||||
|
const isEditing = editingNodeId === node.id && isInputEditing
|
||||||
|
const isRenaming = renamingNodeIds.has(node.id)
|
||||||
|
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
|
||||||
|
const hasChildren = node.children && node.children.length > 0
|
||||||
|
const isDragging = draggedNodeId === node.id
|
||||||
|
const isDragOver = dragOverNodeId === node.id
|
||||||
|
const isDragBefore = isDragOver && dragPosition === 'before'
|
||||||
|
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||||
|
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||||
|
|
||||||
|
const getNodeNameClassName = () => {
|
||||||
|
if (isRenaming) return 'shimmer'
|
||||||
|
if (isNewlyRenamed) return 'typing'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = useMemo(() => {
|
||||||
|
if (!searchKeyword) {
|
||||||
|
return node.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = node.name ?? ''
|
||||||
|
if (!name) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = searchKeyword
|
||||||
|
const nameLower = name.toLowerCase()
|
||||||
|
const keywordLower = keyword.toLowerCase()
|
||||||
|
const matchStart = nameLower.indexOf(keywordLower)
|
||||||
|
|
||||||
|
if (matchStart === -1) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchEnd = matchStart + keyword.length
|
||||||
|
const beforeMatch = Math.min(2, matchStart)
|
||||||
|
const contextStart = matchStart - beforeMatch
|
||||||
|
const contextLength = 50
|
||||||
|
const contextEnd = Math.min(name.length, matchEnd + contextLength)
|
||||||
|
|
||||||
|
const prefix = contextStart > 0 ? '...' : ''
|
||||||
|
const suffix = contextEnd < name.length ? '...' : ''
|
||||||
|
|
||||||
|
return prefix + name.substring(contextStart, contextEnd) + suffix
|
||||||
|
}, [node.name, searchKeyword])
|
||||||
|
|
||||||
|
// Special render for hint nodes
|
||||||
|
if (isHintNode) {
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
<TreeNodeContainer active={false} depth={depth}>
|
||||||
|
<TreeNodeContent>
|
||||||
|
<NodeIcon>
|
||||||
|
<FilePlus size={16} />
|
||||||
|
</NodeIcon>
|
||||||
|
<DropHintText onClick={onHintClick}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||||
|
</TreeNodeContent>
|
||||||
|
</TreeNodeContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.id}>
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: getMenuItems(node as NotesTreeNode) }}
|
||||||
|
trigger={['contextMenu']}
|
||||||
|
open={openDropdownKey === node.id}
|
||||||
|
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
|
||||||
|
<div onContextMenu={(e) => e.stopPropagation()}>
|
||||||
|
<TreeNodeContainer
|
||||||
|
active={isActive}
|
||||||
|
depth={depth}
|
||||||
|
isDragging={isDragging}
|
||||||
|
isDragOver={isDragOver}
|
||||||
|
isDragBefore={isDragBefore}
|
||||||
|
isDragInside={isDragInside}
|
||||||
|
isDragAfter={isDragAfter}
|
||||||
|
draggable={!isEditing}
|
||||||
|
data-node-id={node.id}
|
||||||
|
onDragStart={(e) => onDragStart(e, node as NotesTreeNode)}
|
||||||
|
onDragOver={(e) => onDragOver(e, node as NotesTreeNode)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, node as NotesTreeNode)}
|
||||||
|
onDragEnd={onDragEnd}>
|
||||||
|
<TreeNodeContent onClick={() => onSelectNode(node as NotesTreeNode)}>
|
||||||
|
<NodeIndent depth={depth} />
|
||||||
|
|
||||||
|
{node.type === 'folder' && (
|
||||||
|
<ExpandIcon
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleExpanded(node.id)
|
||||||
|
}}
|
||||||
|
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
|
||||||
|
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</ExpandIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NodeIcon>
|
||||||
|
{node.type === 'folder' ? (
|
||||||
|
node.expanded ? (
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
) : (
|
||||||
|
<Folder size={16} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<File size={16} />
|
||||||
|
)}
|
||||||
|
</NodeIcon>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<EditInput {...inputProps} onClick={(e) => e.stopPropagation()} autoFocus />
|
||||||
|
) : (
|
||||||
|
<NodeNameContainer>
|
||||||
|
<NodeName className={getNodeNameClassName()}>
|
||||||
|
{searchKeyword ? <HighlightText text={displayName} keyword={searchKeyword} /> : node.name}
|
||||||
|
</NodeName>
|
||||||
|
{searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && (
|
||||||
|
<MatchBadge matchType={searchResult.matchType}>
|
||||||
|
{searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')}
|
||||||
|
</MatchBadge>
|
||||||
|
)}
|
||||||
|
</NodeNameContainer>
|
||||||
|
)}
|
||||||
|
</TreeNodeContent>
|
||||||
|
</TreeNodeContainer>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{showMatches && hasMatches && (
|
||||||
|
<SearchMatchesContainer depth={depth}>
|
||||||
|
{(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => (
|
||||||
|
<MatchItem key={idx} onClick={() => handleMatchClick(match)}>
|
||||||
|
<MatchLineNumber>{match.lineNumber}</MatchLineNumber>
|
||||||
|
<MatchContext>
|
||||||
|
<HighlightText text={match.context} keyword={searchKeyword} />
|
||||||
|
</MatchContext>
|
||||||
|
</MatchItem>
|
||||||
|
))}
|
||||||
|
{searchResult!.matches!.length > 3 && (
|
||||||
|
<MoreMatches
|
||||||
|
depth={depth}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowAllMatches(!showAllMatches)
|
||||||
|
}}>
|
||||||
|
{showAllMatches ? (
|
||||||
|
<>
|
||||||
|
<ChevronDown size={12} style={{ marginRight: 4 }} />
|
||||||
|
{t('notes.search.show_less')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronRight size={12} style={{ marginRight: 4 }} />+{searchResult!.matches!.length - 3}{' '}
|
||||||
|
{t('notes.search.more_matches')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MoreMatches>
|
||||||
|
)}
|
||||||
|
</SearchMatchesContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
|
||||||
|
<div>
|
||||||
|
{node.children!.map((child) => (
|
||||||
|
<TreeNode key={child.id} node={child} depth={depth + 1} renderChildren={renderChildren} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TreeNodeContainer = styled.div<{
|
||||||
|
active: boolean
|
||||||
|
depth: number
|
||||||
|
isDragging?: boolean
|
||||||
|
isDragOver?: boolean
|
||||||
|
isDragBefore?: boolean
|
||||||
|
isDragInside?: boolean
|
||||||
|
isDragAfter?: boolean
|
||||||
|
}>`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
/* CRITICAL: Must have fully opaque background for sticky to work properly */
|
||||||
|
/* Transparent/semi-transparent backgrounds will show content bleeding through when sticky */
|
||||||
|
background-color: ${(props) => {
|
||||||
|
if (props.isDragInside) return 'var(--color-primary-background)'
|
||||||
|
// Use hover color for active state - it's guaranteed to be opaque
|
||||||
|
if (props.active) return 'var(--color-hover, var(--color-background-mute))'
|
||||||
|
return 'var(--color-background)'
|
||||||
|
}};
|
||||||
|
border: 0.5px solid
|
||||||
|
${(props) => {
|
||||||
|
if (props.isDragInside) return 'var(--color-primary)'
|
||||||
|
if (props.active) return 'var(--color-border)'
|
||||||
|
return 'transparent'
|
||||||
|
}};
|
||||||
|
opacity: ${(props) => (props.isDragging ? 0.5 : 1)};
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加拖拽指示线 */
|
||||||
|
${(props) =>
|
||||||
|
props.isDragBefore &&
|
||||||
|
`
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.isDragAfter &&
|
||||||
|
`
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const TreeNodeContent = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const NodeIndent = styled.div<{ depth: number }>`
|
||||||
|
width: ${(props) => props.depth * 16}px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ExpandIcon = styled.div`
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
margin-right: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const NodeIcon = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const NodeName = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text);
|
||||||
|
position: relative;
|
||||||
|
will-change: background-position, width;
|
||||||
|
|
||||||
|
--color-shimmer-mid: var(--color-text-1);
|
||||||
|
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||||
|
|
||||||
|
&.shimmer {
|
||||||
|
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
animation: shimmer 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.typing {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: typewriter 0.5s steps(40, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typewriter {
|
||||||
|
from {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SearchMatchesContainer = styled.div<{ depth: number }>`
|
||||||
|
margin-left: ${(props) => props.depth * 16 + 40}px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 2px solid var(--color-primary-soft);
|
||||||
|
`
|
||||||
|
|
||||||
|
export const NodeNameContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MatchBadge = styled.span<{ matchType: string }>`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: ${(props) =>
|
||||||
|
props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'};
|
||||||
|
color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')};
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MatchItem = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
margin-right: -6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MatchLineNumber = styled.span`
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-family: monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MatchContext = styled.div`
|
||||||
|
color: var(--color-text-2);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MoreMatches = styled.div<{ depth: number }>`
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
margin-right: -6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const EditInput = styled.input`
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const DropHintText = styled.div`
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default TreeNode
|
||||||
109
src/renderer/src/pages/notes/context/NotesContexts.tsx
Normal file
109
src/renderer/src/pages/notes/context/NotesContexts.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { UseInPlaceEditReturn } from '@renderer/hooks/useInPlaceEdit'
|
||||||
|
import type { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import type { MenuProps } from 'antd'
|
||||||
|
import { createContext, use } from 'react'
|
||||||
|
|
||||||
|
// ==================== 1. Actions Context (Static, rarely changes) ====================
|
||||||
|
export interface NotesActionsContextType {
|
||||||
|
getMenuItems: (node: NotesTreeNode) => MenuProps['items']
|
||||||
|
onSelectNode: (node: NotesTreeNode) => void
|
||||||
|
onToggleExpanded: (nodeId: string) => void
|
||||||
|
onDropdownOpenChange: (key: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesActionsContext = createContext<NotesActionsContextType | null>(null)
|
||||||
|
|
||||||
|
export const useNotesActions = () => {
|
||||||
|
const context = use(NotesActionsContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotesActions must be used within NotesActionsContext.Provider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 2. Selection Context (Low frequency updates) ====================
|
||||||
|
export interface NotesSelectionContextType {
|
||||||
|
selectedFolderId?: string | null
|
||||||
|
activeNodeId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesSelectionContext = createContext<NotesSelectionContextType | null>(null)
|
||||||
|
|
||||||
|
export const useNotesSelection = () => {
|
||||||
|
const context = use(NotesSelectionContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotesSelection must be used within NotesSelectionContext.Provider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 3. Editing Context (Medium frequency updates) ====================
|
||||||
|
export interface NotesEditingContextType {
|
||||||
|
editingNodeId: string | null
|
||||||
|
renamingNodeIds: Set<string>
|
||||||
|
newlyRenamedNodeIds: Set<string>
|
||||||
|
inPlaceEdit: UseInPlaceEditReturn
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesEditingContext = createContext<NotesEditingContextType | null>(null)
|
||||||
|
|
||||||
|
export const useNotesEditing = () => {
|
||||||
|
const context = use(NotesEditingContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotesEditing must be used within NotesEditingContext.Provider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 4. Drag Context (High frequency updates) ====================
|
||||||
|
export interface NotesDragContextType {
|
||||||
|
draggedNodeId: string | null
|
||||||
|
dragOverNodeId: string | null
|
||||||
|
dragPosition: 'before' | 'inside' | 'after'
|
||||||
|
onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||||
|
onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesDragContext = createContext<NotesDragContextType | null>(null)
|
||||||
|
|
||||||
|
export const useNotesDrag = () => {
|
||||||
|
const context = use(NotesDragContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotesDrag must be used within NotesDragContext.Provider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 5. Search Context (Medium frequency updates) ====================
|
||||||
|
export interface NotesSearchContextType {
|
||||||
|
searchKeyword: string
|
||||||
|
showMatches: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesSearchContext = createContext<NotesSearchContextType | null>(null)
|
||||||
|
|
||||||
|
export const useNotesSearch = () => {
|
||||||
|
const context = use(NotesSearchContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotesSearch must be used within NotesSearchContext.Provider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 6. UI Context (Medium frequency updates) ====================
|
||||||
|
export interface NotesUIContextType {
|
||||||
|
openDropdownKey: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotesUIContext = createContext<NotesUIContextType | null>(null)
|
||||||
|
|
||||||
|
export const useNotesUI = () => {
|
||||||
|
const context = use(NotesUIContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotesUI must be used within NotesUIContext.Provider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
101
src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts
Normal file
101
src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface UseNotesDragAndDropProps {
|
||||||
|
onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotesDragAndDrop = ({ onMoveNode }: UseNotesDragAndDropProps) => {
|
||||||
|
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||||
|
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||||
|
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||||
|
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
||||||
|
setDraggedNodeId(node.id)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('text/plain', node.id)
|
||||||
|
|
||||||
|
dragNodeRef.current = e.currentTarget as HTMLDivElement
|
||||||
|
|
||||||
|
// Create ghost element
|
||||||
|
if (e.currentTarget.parentElement) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement
|
||||||
|
ghostElement.style.width = `${rect.width}px`
|
||||||
|
ghostElement.style.opacity = '0.7'
|
||||||
|
ghostElement.style.position = 'absolute'
|
||||||
|
ghostElement.style.top = '-1000px'
|
||||||
|
document.body.appendChild(ghostElement)
|
||||||
|
e.dataTransfer.setDragImage(ghostElement, 10, 10)
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(ghostElement)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, node: NotesTreeNode) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
|
||||||
|
if (draggedNodeId === node.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragOverNodeId(node.id)
|
||||||
|
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const mouseY = e.clientY
|
||||||
|
const thresholdTop = rect.top + rect.height * 0.3
|
||||||
|
const thresholdBottom = rect.bottom - rect.height * 0.3
|
||||||
|
|
||||||
|
if (mouseY < thresholdTop) {
|
||||||
|
setDragPosition('before')
|
||||||
|
} else if (mouseY > thresholdBottom) {
|
||||||
|
setDragPosition('after')
|
||||||
|
} else {
|
||||||
|
setDragPosition(node.type === 'folder' ? 'inside' : 'after')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[draggedNodeId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setDragOverNodeId(null)
|
||||||
|
setDragPosition('inside')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent, targetNode: NotesTreeNode) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const draggedId = e.dataTransfer.getData('text/plain')
|
||||||
|
|
||||||
|
if (draggedId && draggedId !== targetNode.id) {
|
||||||
|
onMoveNode(draggedId, targetNode.id, dragPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggedNodeId(null)
|
||||||
|
setDragOverNodeId(null)
|
||||||
|
setDragPosition('inside')
|
||||||
|
},
|
||||||
|
[onMoveNode, dragPosition]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
setDraggedNodeId(null)
|
||||||
|
setDragOverNodeId(null)
|
||||||
|
setDragPosition('inside')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
draggedNodeId,
|
||||||
|
dragOverNodeId,
|
||||||
|
dragPosition,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
handleDragEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/renderer/src/pages/notes/hooks/useNotesEditing.ts
Normal file
94
src/renderer/src/pages/notes/hooks/useNotesEditing.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
||||||
|
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||||
|
import type { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('UseNotesEditing')
|
||||||
|
|
||||||
|
interface UseNotesEditingProps {
|
||||||
|
onRenameNode: (nodeId: string, newName: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotesEditing = ({ onRenameNode }: UseNotesEditingProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
|
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
|
||||||
|
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const inPlaceEdit = useInPlaceEdit({
|
||||||
|
onSave: (newName: string) => {
|
||||||
|
if (editingNodeId && newName) {
|
||||||
|
onRenameNode(editingNodeId, newName)
|
||||||
|
window.toast.success(t('common.saved'))
|
||||||
|
logger.debug(`Renamed node ${editingNodeId} to "${newName}"`)
|
||||||
|
}
|
||||||
|
setEditingNodeId(null)
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
setEditingNodeId(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleStartEdit = useCallback(
|
||||||
|
(node: NotesTreeNode) => {
|
||||||
|
setEditingNodeId(node.id)
|
||||||
|
inPlaceEdit.startEdit(node.name)
|
||||||
|
},
|
||||||
|
[inPlaceEdit]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAutoRename = useCallback(
|
||||||
|
async (note: NotesTreeNode) => {
|
||||||
|
if (note.type !== 'file') return
|
||||||
|
|
||||||
|
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
|
||||||
|
try {
|
||||||
|
const content = await window.api.file.readExternal(note.externalPath)
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
window.toast.warning(t('notes.auto_rename.empty_note'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryText = await fetchNoteSummary({ content })
|
||||||
|
if (summaryText) {
|
||||||
|
onRenameNode(note.id, summaryText)
|
||||||
|
window.toast.success(t('notes.auto_rename.success'))
|
||||||
|
} else {
|
||||||
|
window.toast.error(t('notes.auto_rename.failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(t('notes.auto_rename.failed'))
|
||||||
|
logger.error(`Failed to auto-rename note: ${error}`)
|
||||||
|
} finally {
|
||||||
|
setRenamingNodeIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNewlyRenamedNodeIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(note.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 700)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRenameNode, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
editingNodeId,
|
||||||
|
renamingNodeIds,
|
||||||
|
newlyRenamedNodeIds,
|
||||||
|
inPlaceEdit,
|
||||||
|
handleStartEdit,
|
||||||
|
handleAutoRename,
|
||||||
|
setEditingNodeId
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts
Normal file
112
src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
interface UseNotesFileUploadProps {
|
||||||
|
onUploadFiles: (files: File[]) => void
|
||||||
|
setIsDragOverSidebar: (isDragOver: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseNotesFileUploadProps) => {
|
||||||
|
const handleDropFiles = useCallback(
|
||||||
|
async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragOverSidebar(false)
|
||||||
|
|
||||||
|
// 处理文件夹拖拽:从 dataTransfer.items 获取完整文件路径信息
|
||||||
|
const items = Array.from(e.dataTransfer.items)
|
||||||
|
const files: File[] = []
|
||||||
|
|
||||||
|
const processEntry = async (entry: FileSystemEntry, path: string = '') => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const fileEntry = entry as FileSystemFileEntry
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
fileEntry.file((file) => {
|
||||||
|
// 手动设置 webkitRelativePath 以保持文件夹结构
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', {
|
||||||
|
value: path + file.name,
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
files.push(file)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
const dirEntry = entry as FileSystemDirectoryEntry
|
||||||
|
const reader = dirEntry.createReader()
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
reader.readEntries(async (entries) => {
|
||||||
|
const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/'))
|
||||||
|
await Promise.all(promises)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果支持 DataTransferItem API(文件夹拖拽)
|
||||||
|
if (items.length > 0 && items[0].webkitGetAsEntry()) {
|
||||||
|
const promises = items.map((item) => {
|
||||||
|
const entry = item.webkitGetAsEntry()
|
||||||
|
return entry ? processEntry(entry) : Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
onUploadFiles(files)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const regularFiles = Array.from(e.dataTransfer.files)
|
||||||
|
if (regularFiles.length > 0) {
|
||||||
|
onUploadFiles(regularFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onUploadFiles, setIsDragOverSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectFiles = useCallback(() => {
|
||||||
|
const fileInput = document.createElement('input')
|
||||||
|
fileInput.type = 'file'
|
||||||
|
fileInput.multiple = true
|
||||||
|
fileInput.accept = '.md,.markdown'
|
||||||
|
fileInput.webkitdirectory = false
|
||||||
|
|
||||||
|
fileInput.onchange = (e) => {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
const selectedFiles = Array.from(target.files)
|
||||||
|
onUploadFiles(selectedFiles)
|
||||||
|
}
|
||||||
|
fileInput.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.click()
|
||||||
|
}, [onUploadFiles])
|
||||||
|
|
||||||
|
const handleSelectFolder = useCallback(() => {
|
||||||
|
const folderInput = document.createElement('input')
|
||||||
|
folderInput.type = 'file'
|
||||||
|
// @ts-ignore - webkitdirectory is a non-standard attribute
|
||||||
|
folderInput.webkitdirectory = true
|
||||||
|
// @ts-ignore - directory is a non-standard attribute
|
||||||
|
folderInput.directory = true
|
||||||
|
folderInput.multiple = true
|
||||||
|
|
||||||
|
folderInput.onchange = (e) => {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
const selectedFiles = Array.from(target.files)
|
||||||
|
onUploadFiles(selectedFiles)
|
||||||
|
}
|
||||||
|
folderInput.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
folderInput.click()
|
||||||
|
}, [onUploadFiles])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDropFiles,
|
||||||
|
handleSelectFiles,
|
||||||
|
handleSelectFolder
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/renderer/src/pages/notes/hooks/useNotesMenu.tsx
Normal file
263
src/renderer/src/pages/notes/hooks/useNotesMenu.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { DeleteIcon } from '@renderer/components/Icons'
|
||||||
|
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||||
|
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||||
|
import type { RootState } from '@renderer/store'
|
||||||
|
import type { NotesTreeNode } from '@renderer/types/note'
|
||||||
|
import { exportNote } from '@renderer/utils/export'
|
||||||
|
import type { MenuProps } from 'antd'
|
||||||
|
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
|
import { Edit3, FilePlus, FileSearch, Folder, FolderOpen, Sparkles, Star, StarOff, UploadIcon } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('UseNotesMenu')
|
||||||
|
|
||||||
|
interface UseNotesMenuProps {
|
||||||
|
renamingNodeIds: Set<string>
|
||||||
|
onCreateNote: (name: string, targetFolderId?: string) => void
|
||||||
|
onCreateFolder: (name: string, targetFolderId?: string) => void
|
||||||
|
onRenameNode: (nodeId: string, newName: string) => void
|
||||||
|
onToggleStar: (nodeId: string) => void
|
||||||
|
onDeleteNode: (nodeId: string) => void
|
||||||
|
onSelectNode: (node: NotesTreeNode) => void
|
||||||
|
handleStartEdit: (node: NotesTreeNode) => void
|
||||||
|
handleAutoRename: (node: NotesTreeNode) => void
|
||||||
|
activeNode?: NotesTreeNode | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotesMenu = ({
|
||||||
|
renamingNodeIds,
|
||||||
|
onCreateNote,
|
||||||
|
onCreateFolder,
|
||||||
|
onToggleStar,
|
||||||
|
onDeleteNode,
|
||||||
|
onSelectNode,
|
||||||
|
handleStartEdit,
|
||||||
|
handleAutoRename,
|
||||||
|
activeNode
|
||||||
|
}: UseNotesMenuProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { bases } = useKnowledgeBases()
|
||||||
|
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||||
|
|
||||||
|
const handleExportKnowledge = useCallback(
|
||||||
|
async (note: NotesTreeNode) => {
|
||||||
|
try {
|
||||||
|
if (bases.length === 0) {
|
||||||
|
window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await SaveToKnowledgePopup.showForNote(note)
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
window.toast.success(t('notes.export_success', { count: result.savedCount }))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.toast.error(t('notes.export_failed'))
|
||||||
|
logger.error(`Failed to export note to knowledge base: ${error}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[bases.length, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleImageAction = useCallback(
|
||||||
|
async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => {
|
||||||
|
try {
|
||||||
|
if (activeNode?.id !== node.id) {
|
||||||
|
onSelectNode(node)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
await exportNote({ node, platform })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error)
|
||||||
|
window.toast.error(t('common.copy_failed'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeNode, onSelectNode, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteNodeWrapper = useCallback(
|
||||||
|
(node: NotesTreeNode) => {
|
||||||
|
const confirmText =
|
||||||
|
node.type === 'folder'
|
||||||
|
? t('notes.delete_folder_confirm', { name: node.name })
|
||||||
|
: t('notes.delete_note_confirm', { name: node.name })
|
||||||
|
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('notes.delete'),
|
||||||
|
content: confirmText,
|
||||||
|
centered: true,
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: () => {
|
||||||
|
onDeleteNode(node.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[onDeleteNode, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getMenuItems = useCallback(
|
||||||
|
(node: NotesTreeNode) => {
|
||||||
|
const baseMenuItems: MenuProps['items'] = []
|
||||||
|
|
||||||
|
// only show auto rename for file for now
|
||||||
|
if (node.type !== 'folder') {
|
||||||
|
baseMenuItems.push({
|
||||||
|
label: t('notes.auto_rename.label'),
|
||||||
|
key: 'auto-rename',
|
||||||
|
icon: <Sparkles size={14} />,
|
||||||
|
disabled: renamingNodeIds.has(node.id),
|
||||||
|
onClick: () => {
|
||||||
|
handleAutoRename(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
baseMenuItems.push(
|
||||||
|
{
|
||||||
|
label: t('notes.new_note'),
|
||||||
|
key: 'new_note',
|
||||||
|
icon: <FilePlus size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onCreateNote(t('notes.untitled_note'), node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.new_folder'),
|
||||||
|
key: 'new_folder',
|
||||||
|
icon: <Folder size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onCreateFolder(t('notes.untitled_folder'), node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'divider' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseMenuItems.push(
|
||||||
|
{
|
||||||
|
label: t('notes.rename'),
|
||||||
|
key: 'rename',
|
||||||
|
icon: <Edit3 size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
handleStartEdit(node)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.open_outside'),
|
||||||
|
key: 'open_outside',
|
||||||
|
icon: <FolderOpen size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
window.api.openPath(node.externalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (node.type !== 'folder') {
|
||||||
|
baseMenuItems.push(
|
||||||
|
{
|
||||||
|
label: node.isStarred ? t('notes.unstar') : t('notes.star'),
|
||||||
|
key: 'star',
|
||||||
|
icon: node.isStarred ? <StarOff size={14} /> : <Star size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
onToggleStar(node.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.export_knowledge'),
|
||||||
|
key: 'export_knowledge',
|
||||||
|
icon: <FileSearch size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
handleExportKnowledge(node)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.export.title'),
|
||||||
|
key: 'export',
|
||||||
|
icon: <UploadIcon size={14} />,
|
||||||
|
children: [
|
||||||
|
exportMenuOptions.image && {
|
||||||
|
label: t('chat.topics.copy.image'),
|
||||||
|
key: 'copy-image',
|
||||||
|
onClick: () => handleImageAction(node, 'copyImage')
|
||||||
|
},
|
||||||
|
exportMenuOptions.image && {
|
||||||
|
label: t('chat.topics.export.image'),
|
||||||
|
key: 'export-image',
|
||||||
|
onClick: () => handleImageAction(node, 'exportImage')
|
||||||
|
},
|
||||||
|
exportMenuOptions.markdown && {
|
||||||
|
label: t('chat.topics.export.md.label'),
|
||||||
|
key: 'markdown',
|
||||||
|
onClick: () => exportNote({ node, platform: 'markdown' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.docx && {
|
||||||
|
label: t('chat.topics.export.word'),
|
||||||
|
key: 'word',
|
||||||
|
onClick: () => exportNote({ node, platform: 'docx' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.notion && {
|
||||||
|
label: t('chat.topics.export.notion'),
|
||||||
|
key: 'notion',
|
||||||
|
onClick: () => exportNote({ node, platform: 'notion' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.yuque && {
|
||||||
|
label: t('chat.topics.export.yuque'),
|
||||||
|
key: 'yuque',
|
||||||
|
onClick: () => exportNote({ node, platform: 'yuque' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.obsidian && {
|
||||||
|
label: t('chat.topics.export.obsidian'),
|
||||||
|
key: 'obsidian',
|
||||||
|
onClick: () => exportNote({ node, platform: 'obsidian' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.joplin && {
|
||||||
|
label: t('chat.topics.export.joplin'),
|
||||||
|
key: 'joplin',
|
||||||
|
onClick: () => exportNote({ node, platform: 'joplin' })
|
||||||
|
},
|
||||||
|
exportMenuOptions.siyuan && {
|
||||||
|
label: t('chat.topics.export.siyuan'),
|
||||||
|
key: 'siyuan',
|
||||||
|
onClick: () => exportNote({ node, platform: 'siyuan' })
|
||||||
|
}
|
||||||
|
].filter(Boolean) as ItemType<MenuItemType>[]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
baseMenuItems.push(
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
label: t('notes.delete'),
|
||||||
|
danger: true,
|
||||||
|
key: 'delete',
|
||||||
|
icon: <DeleteIcon size={14} className="lucide-custom" />,
|
||||||
|
onClick: () => {
|
||||||
|
handleDeleteNodeWrapper(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return baseMenuItems
|
||||||
|
},
|
||||||
|
[
|
||||||
|
t,
|
||||||
|
handleStartEdit,
|
||||||
|
onToggleStar,
|
||||||
|
handleExportKnowledge,
|
||||||
|
handleImageAction,
|
||||||
|
handleDeleteNodeWrapper,
|
||||||
|
renamingNodeIds,
|
||||||
|
handleAutoRename,
|
||||||
|
exportMenuOptions,
|
||||||
|
onCreateNote,
|
||||||
|
onCreateFolder
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { getMenuItems }
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { getAgentTypeAvatar } from '@renderer/config/agent'
|
|
||||||
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
|
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
|
||||||
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
|
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
|
||||||
import { getAgentTypeLabel } from '@renderer/i18n/label'
|
|
||||||
import type { GetAgentResponse, GetAgentSessionResponse } from '@renderer/types'
|
import type { GetAgentResponse, GetAgentSessionResponse } from '@renderer/types'
|
||||||
import { isAgentEntity } from '@renderer/types'
|
|
||||||
import { Avatar } from 'antd'
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
|
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
|
||||||
import { AvatarSetting } from './AvatarSetting'
|
|
||||||
import { DescriptionSetting } from './DescriptionSetting'
|
import { DescriptionSetting } from './DescriptionSetting'
|
||||||
import { ModelSetting } from './ModelSetting'
|
import { ModelSetting } from './ModelSetting'
|
||||||
import { NameSetting } from './NameSetting'
|
import { NameSetting } from './NameSetting'
|
||||||
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
|
import { SettingsContainer } from './shared'
|
||||||
|
|
||||||
// const logger = loggerService.withContext('AgentEssentialSettings')
|
|
||||||
|
|
||||||
type EssentialSettingsProps =
|
type EssentialSettingsProps =
|
||||||
| {
|
| {
|
||||||
@@ -30,26 +22,10 @@ type EssentialSettingsProps =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, showModelSetting = true }) => {
|
const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, showModelSetting = true }) => {
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
if (!agentBase) return null
|
if (!agentBase) return null
|
||||||
|
|
||||||
const isAgent = isAgentEntity(agentBase)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{isAgent && (
|
|
||||||
<SettingsItem inline>
|
|
||||||
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Avatar size={24} src={getAgentTypeAvatar(agentBase.type)} className="h-6 w-6 text-lg" />
|
|
||||||
<span>{(agentBase?.name ?? agentBase?.type) ? getAgentTypeLabel(agentBase.type) : ''}</span>
|
|
||||||
</div>
|
|
||||||
</SettingsItem>
|
|
||||||
)}
|
|
||||||
{isAgent && (
|
|
||||||
<AvatarSetting agent={agentBase} update={update as ReturnType<typeof useUpdateAgent>['updateAgent']} />
|
|
||||||
)}
|
|
||||||
<NameSetting base={agentBase} update={update} />
|
<NameSetting base={agentBase} update={update} />
|
||||||
{showModelSetting && <ModelSetting base={agentBase} update={update} />}
|
{showModelSetting && <ModelSetting base={agentBase} update={update} />}
|
||||||
<AccessibleDirsSetting base={agentBase} update={update} />
|
<AccessibleDirsSetting base={agentBase} update={update} />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
|
||||||
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
|
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
|
||||||
|
import { AgentConfigurationSchema, isAgentEntity, isAgentType } from '@renderer/types'
|
||||||
import { Input } from 'antd'
|
import { Input } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { SettingsItem, SettingsTitle } from './shared'
|
import { SettingsItem, SettingsTitle } from './shared'
|
||||||
@@ -13,26 +15,61 @@ export interface NameSettingsProps {
|
|||||||
export const NameSetting = ({ base, update }: NameSettingsProps) => {
|
export const NameSetting = ({ base, update }: NameSettingsProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [name, setName] = useState<string | undefined>(base?.name?.trim())
|
const [name, setName] = useState<string | undefined>(base?.name?.trim())
|
||||||
|
|
||||||
const updateName = async (name: UpdateAgentBaseForm['name']) => {
|
const updateName = async (name: UpdateAgentBaseForm['name']) => {
|
||||||
if (!base) return
|
if (!base) return
|
||||||
return update({ id: base.id, name: name?.trim() })
|
return update({ id: base.id, name: name?.trim() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avatar logic
|
||||||
|
const isAgent = isAgentEntity(base)
|
||||||
|
const isDefault = isAgent ? isAgentType(base.configuration?.avatar) : false
|
||||||
|
const [emoji, setEmoji] = useState(isAgent && !isDefault ? (base.configuration?.avatar ?? '⭐️') : '⭐️')
|
||||||
|
|
||||||
|
const updateAvatar = useCallback(
|
||||||
|
(avatar: string) => {
|
||||||
|
if (!isAgent || !base) return
|
||||||
|
const parsedConfiguration = AgentConfigurationSchema.parse(base.configuration ?? {})
|
||||||
|
const payload = {
|
||||||
|
id: base.id,
|
||||||
|
configuration: {
|
||||||
|
...parsedConfiguration,
|
||||||
|
avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(payload)
|
||||||
|
},
|
||||||
|
[base, update, isAgent]
|
||||||
|
)
|
||||||
|
|
||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsItem inline>
|
<SettingsItem inline>
|
||||||
<SettingsTitle>{t('common.name')}</SettingsTitle>
|
<SettingsTitle>{t('common.name')}</SettingsTitle>
|
||||||
<Input
|
<div className="flex max-w-70 flex-1 items-center gap-1">
|
||||||
placeholder={t('common.agent_one') + t('common.name')}
|
{isAgent && (
|
||||||
value={name}
|
<EmojiAvatarWithPicker
|
||||||
onChange={(e) => setName(e.target.value)}
|
emoji={emoji}
|
||||||
onBlur={() => {
|
onPick={(emoji: string) => {
|
||||||
if (name !== base.name) {
|
setEmoji(emoji)
|
||||||
updateName(name)
|
if (isAgent && emoji === base?.configuration?.avatar) return
|
||||||
}
|
updateAvatar(emoji)
|
||||||
}}
|
}}
|
||||||
className="max-w-70 flex-1"
|
/>
|
||||||
/>
|
)}
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.agent_one') + t('common.name')}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
if (name !== base.name) {
|
||||||
|
updateName(name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, MAX_CONTEXT_COUNT } from '@r
|
|||||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { SettingRow } from '@renderer/pages/settings'
|
import { SettingRow } from '@renderer/pages/settings'
|
||||||
|
import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService'
|
||||||
import type { Assistant, AssistantSettingCustomParameters, AssistantSettings, Model } from '@renderer/types'
|
import type { Assistant, AssistantSettingCustomParameters, AssistantSettings, Model } from '@renderer/types'
|
||||||
import { modalConfirm } from '@renderer/utils'
|
import { modalConfirm } from '@renderer/utils'
|
||||||
import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
|
import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
|
||||||
@@ -31,7 +32,9 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
|||||||
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
|
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
|
||||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
|
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput)
|
||||||
const [toolUseMode, setToolUseMode] = useState(assistant?.settings?.toolUseMode ?? 'prompt')
|
const [toolUseMode, setToolUseMode] = useState<AssistantSettings['toolUseMode']>(
|
||||||
|
assistant?.settings?.toolUseMode ?? 'function'
|
||||||
|
)
|
||||||
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
||||||
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
||||||
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? false)
|
const [enableTopP, setEnableTopP] = useState(assistant?.settings?.enableTopP ?? false)
|
||||||
@@ -158,28 +161,17 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
setTemperature(DEFAULT_TEMPERATURE)
|
setTemperature(DEFAULT_ASSISTANT_SETTINGS.temperature)
|
||||||
setEnableTemperature(true)
|
setEnableTemperature(DEFAULT_ASSISTANT_SETTINGS.enableTemperature ?? true)
|
||||||
setContextCount(DEFAULT_CONTEXTCOUNT)
|
setContextCount(DEFAULT_ASSISTANT_SETTINGS.contextCount)
|
||||||
setEnableMaxTokens(false)
|
setEnableMaxTokens(DEFAULT_ASSISTANT_SETTINGS.enableMaxTokens ?? false)
|
||||||
setMaxTokens(0)
|
setMaxTokens(DEFAULT_ASSISTANT_SETTINGS.maxTokens ?? 0)
|
||||||
setStreamOutput(true)
|
setStreamOutput(DEFAULT_ASSISTANT_SETTINGS.streamOutput)
|
||||||
setTopP(1)
|
setTopP(DEFAULT_ASSISTANT_SETTINGS.topP)
|
||||||
setEnableTopP(false)
|
setEnableTopP(DEFAULT_ASSISTANT_SETTINGS.enableTopP ?? false)
|
||||||
setCustomParameters([])
|
setCustomParameters(DEFAULT_ASSISTANT_SETTINGS.customParameters ?? [])
|
||||||
setToolUseMode('prompt')
|
setToolUseMode(DEFAULT_ASSISTANT_SETTINGS.toolUseMode)
|
||||||
updateAssistantSettings({
|
updateAssistantSettings(DEFAULT_ASSISTANT_SETTINGS)
|
||||||
temperature: DEFAULT_TEMPERATURE,
|
|
||||||
enableTemperature: true,
|
|
||||||
contextCount: DEFAULT_CONTEXTCOUNT,
|
|
||||||
enableMaxTokens: false,
|
|
||||||
maxTokens: 0,
|
|
||||||
streamOutput: true,
|
|
||||||
topP: 1,
|
|
||||||
enableTopP: false,
|
|
||||||
customParameters: [],
|
|
||||||
toolUseMode: 'prompt'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
|
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user