Compare commits
22 Commits
copilot/ad
...
x-files/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6908f2ff87 | ||
|
|
3ad156108b | ||
|
|
035709f2d4 | ||
|
|
980de6719a | ||
|
|
3ff0e464af | ||
|
|
c3a22d4ad9 | ||
|
|
073d43c7cb | ||
|
|
fa7646e18f | ||
|
|
038d30831c | ||
|
|
68ee5164f0 | ||
|
|
a1a3b9bd96 | ||
|
|
4e699c48bc | ||
|
|
75fcf8fbb5 | ||
|
|
35aa9d7355 | ||
|
|
b08aecb22b | ||
|
|
45fc6c2afd | ||
|
|
d6e7ce330e | ||
|
|
4f7d8731ea | ||
|
|
2b5ac5ab51 | ||
|
|
060fcd2ce6 | ||
|
|
a6182eaf85 | ||
|
|
649f9420a4 |
212
.github/workflows/update-app-upgrade-config.yml
vendored
Normal file
212
.github/workflows/update-app-upgrade-config.yml
vendored
Normal file
@@ -0,0 +1,212 @@
|
||||
name: Update App Upgrade Config
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
- prereleased
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
is_prerelease:
|
||||
description: "Mark the tag as a prerelease when running manually"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
propose-update:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.draft == false)
|
||||
|
||||
steps:
|
||||
- name: Check if should proceed
|
||||
id: check
|
||||
run: |
|
||||
EVENT="${{ github.event_name }}"
|
||||
|
||||
if [ "$EVENT" = "workflow_dispatch" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
|
||||
latest_tag=$(
|
||||
curl -L \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ github.token }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/${{ github.repository }}/releases/latest \
|
||||
| jq -r '.tag_name'
|
||||
)
|
||||
|
||||
if [ "$EVENT" = "workflow_dispatch" ]; then
|
||||
MANUAL_IS_PRERELEASE="${{ github.event.inputs.is_prerelease }}"
|
||||
if [ -z "$MANUAL_IS_PRERELEASE" ]; then
|
||||
MANUAL_IS_PRERELEASE="false"
|
||||
fi
|
||||
if [ "$MANUAL_IS_PRERELEASE" = "true" ]; then
|
||||
if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then
|
||||
echo "Manual prerelease flag set but tag $TAG lacks beta/rc suffix. Skipping." >&2
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=$MANUAL_IS_PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
if ! echo "$TAG" | grep -E '(-beta([.-][0-9]+)?|-rc([.-][0-9]+)?)' >/dev/null; then
|
||||
echo "Release marked as prerelease but tag $TAG lacks beta/rc suffix. Skipping." >&2
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Release is prerelease, proceeding"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${latest_tag}" == "$TAG" ]]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Release is latest, proceeding"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Release is neither prerelease nor latest, skipping"
|
||||
fi
|
||||
|
||||
- name: Prepare metadata
|
||||
id: meta
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
run: |
|
||||
EVENT="${{ github.event_name }}"
|
||||
LATEST_TAG="${{ steps.check.outputs.latest_tag }}"
|
||||
if [ "$EVENT" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
PRE="${{ github.event.release.prerelease }}"
|
||||
|
||||
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ]; then
|
||||
LATEST="true"
|
||||
else
|
||||
LATEST="false"
|
||||
fi
|
||||
TRIGGER="release"
|
||||
else
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
PRE="${{ github.event.inputs.is_prerelease }}"
|
||||
if [ -z "$PRE" ]; then
|
||||
PRE="false"
|
||||
fi
|
||||
if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" = "$TAG" ] && [ "$PRE" != "true" ]; then
|
||||
LATEST="true"
|
||||
else
|
||||
LATEST="false"
|
||||
fi
|
||||
TRIGGER="manual"
|
||||
fi
|
||||
|
||||
SAFE_TAG=$(echo "$TAG" | sed 's/[^A-Za-z0-9._-]/-/g')
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "safe_tag=$SAFE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
|
||||
echo "trigger=$TRIGGER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout default branch
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
path: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout x-files/app-upgrade-config branch
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: x-files/app-upgrade-config
|
||||
path: cs
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Enable Corepack
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
run: corepack enable && corepack prepare yarn@4.9.1 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
working-directory: main
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Update upgrade config
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
working-directory: main
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
|
||||
IS_PRERELEASE: ${{ steps.check.outputs.is_prerelease }}
|
||||
run: |
|
||||
yarn tsx scripts/update-app-upgrade-config.ts \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--config ../cs/app-upgrade-config.json \
|
||||
--is-prerelease "$IS_PRERELEASE"
|
||||
|
||||
- name: Detect changes
|
||||
if: steps.check.outputs.should_run == 'true'
|
||||
id: diff
|
||||
working-directory: cs
|
||||
run: |
|
||||
if git diff --quiet -- app-upgrade-config.json; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
path: cs
|
||||
base: x-files/app-upgrade-config
|
||||
branch: chore/update-app-upgrade-config/${{ steps.meta.outputs.safe_tag }}
|
||||
commit-message: "🤖 chore: sync app-upgrade-config for ${{ steps.meta.outputs.tag }}"
|
||||
title: "chore: update app-upgrade-config for ${{ steps.meta.outputs.tag }}"
|
||||
body: |
|
||||
Automated update triggered by `${{ steps.meta.outputs.trigger }}`.
|
||||
|
||||
- Source tag: `${{ steps.meta.outputs.tag }}`
|
||||
- Pre-release: `${{ steps.meta.outputs.prerelease }}`
|
||||
- Latest: `${{ steps.meta.outputs.latest }}`
|
||||
- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
labels: |
|
||||
automation
|
||||
app-upgrade
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.check.outputs.should_run == 'true' && steps.diff.outputs.changed != 'true'
|
||||
run: echo "No updates required for x-files/app-upgrade-config/app-upgrade-config.json"
|
||||
@@ -51,6 +51,12 @@
|
||||
"node": true
|
||||
},
|
||||
"files": ["src/preload/**"]
|
||||
},
|
||||
{
|
||||
"files": ["packages/ai-sdk-provider/**"],
|
||||
"globals": {
|
||||
"fetch": "readonly"
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": ["unicorn", "typescript", "oxc", "import"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e8e4d353f 100644
|
||||
index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -12,7 +12,7 @@ index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 0793085005d7968638d355f2f1e127939d965165..1c8bf852baf025d56dc35a0691eb95967de7e5c8 100644
|
||||
index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755
|
||||
index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
@@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8
|
||||
import { createInterface } from "readline";
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6487,14 +6487,11 @@ class ProcessTransport {
|
||||
@@ -6505,14 +6505,11 @@ class ProcessTransport {
|
||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
||||
throw new ReferenceError(errorMessage);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
diff --git a/out/macPackager.js b/out/macPackager.js
|
||||
index 852f6c4d16f86a7bb8a78bf1ed5a14647a279aa1..60e7f5f16a844541eb1909b215fcda1811e924b8 100644
|
||||
--- a/out/macPackager.js
|
||||
+++ b/out/macPackager.js
|
||||
@@ -423,7 +423,7 @@ class MacPackager extends platformPackager_1.PlatformPackager {
|
||||
}
|
||||
appPlist.CFBundleName = appInfo.productName;
|
||||
appPlist.CFBundleDisplayName = appInfo.productName;
|
||||
- const minimumSystemVersion = this.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ const minimumSystemVersion = this.platformSpecificBuildOptions.LSMinimumSystemVersion;
|
||||
if (minimumSystemVersion != null) {
|
||||
appPlist.LSMinimumSystemVersion = minimumSystemVersion;
|
||||
}
|
||||
diff --git a/out/publish/updateInfoBuilder.js b/out/publish/updateInfoBuilder.js
|
||||
index 7924c5b47d01f8dfccccb8f46658015fa66da1f7..1a1588923c3939ae1297b87931ba83f0ebc052d8 100644
|
||||
--- a/out/publish/updateInfoBuilder.js
|
||||
+++ b/out/publish/updateInfoBuilder.js
|
||||
@@ -133,6 +133,7 @@ async function createUpdateInfo(version, event, releaseInfo) {
|
||||
const customUpdateInfo = event.updateInfo;
|
||||
const url = path.basename(event.file);
|
||||
const sha512 = (customUpdateInfo == null ? null : customUpdateInfo.sha512) || (await (0, hash_1.hashFile)(event.file));
|
||||
+ const minimumSystemVersion = customUpdateInfo == null ? null : customUpdateInfo.minimumSystemVersion;
|
||||
const files = [{ url, sha512 }];
|
||||
const result = {
|
||||
// @ts-ignore
|
||||
@@ -143,9 +144,13 @@ async function createUpdateInfo(version, event, releaseInfo) {
|
||||
path: url /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
|
||||
// @ts-ignore
|
||||
sha512 /* backward compatibility, electron-updater 1.x - electron-updater 2.15.0 */,
|
||||
+ minimumSystemVersion,
|
||||
...releaseInfo,
|
||||
};
|
||||
if (customUpdateInfo != null) {
|
||||
+ if (customUpdateInfo.minimumSystemVersion) {
|
||||
+ delete customUpdateInfo.minimumSystemVersion;
|
||||
+ }
|
||||
// file info or nsis web installer packages info
|
||||
Object.assign("sha512" in customUpdateInfo ? files[0] : result, customUpdateInfo);
|
||||
}
|
||||
diff --git a/out/targets/ArchiveTarget.js b/out/targets/ArchiveTarget.js
|
||||
index e1f52a5fa86fff6643b2e57eaf2af318d541f865..47cc347f154a24b365e70ae5e1f6d309f3582ed0 100644
|
||||
--- a/out/targets/ArchiveTarget.js
|
||||
+++ b/out/targets/ArchiveTarget.js
|
||||
@@ -69,6 +69,9 @@ class ArchiveTarget extends core_1.Target {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
|
||||
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ }
|
||||
await packager.info.emitArtifactBuildCompleted({
|
||||
updateInfo,
|
||||
file: artifactPath,
|
||||
diff --git a/out/targets/nsis/NsisTarget.js b/out/targets/nsis/NsisTarget.js
|
||||
index e8bd7bb46c8a54b3f55cf3a853ef924195271e01..f956e9f3fe9eb903c78aef3502553b01de4b89b1 100644
|
||||
--- a/out/targets/nsis/NsisTarget.js
|
||||
+++ b/out/targets/nsis/NsisTarget.js
|
||||
@@ -305,6 +305,9 @@ class NsisTarget extends core_1.Target {
|
||||
if (updateInfo != null && isPerMachine && (oneClick || options.packElevateHelper)) {
|
||||
updateInfo.isAdminRightsRequired = true;
|
||||
}
|
||||
+ if (updateInfo != null && this.packager.platformSpecificBuildOptions.minimumSystemVersion) {
|
||||
+ updateInfo.minimumSystemVersion = this.packager.platformSpecificBuildOptions.minimumSystemVersion;
|
||||
+ }
|
||||
await packager.info.emitArtifactBuildCompleted({
|
||||
file: installerPath,
|
||||
updateInfo,
|
||||
diff --git a/out/util/yarn.js b/out/util/yarn.js
|
||||
index 1ee20f8b252a8f28d0c7b103789cf0a9a427aec1..c2878ec54d57da50bf14225e0c70c9c88664eb8a 100644
|
||||
--- a/out/util/yarn.js
|
||||
+++ b/out/util/yarn.js
|
||||
@@ -140,6 +140,7 @@ async function rebuild(config, { appDir, projectDir }, options) {
|
||||
arch,
|
||||
platform,
|
||||
buildFromSource,
|
||||
+ ignoreModules: config.excludeReBuildModules || undefined,
|
||||
projectRootPath: projectDir,
|
||||
mode: config.nativeRebuilder || "sequential",
|
||||
disablePreGypCopy: true,
|
||||
diff --git a/scheme.json b/scheme.json
|
||||
index 433e2efc9cef156ff5444f0c4520362ed2ef9ea7..0167441bf928a92f59b5dbe70b2317a74dda74c9 100644
|
||||
--- a/scheme.json
|
||||
+++ b/scheme.json
|
||||
@@ -1825,6 +1825,20 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "excludeReBuildModules": {
|
||||
+ "anyOf": [
|
||||
+ {
|
||||
+ "items": {
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ "type": "array"
|
||||
+ },
|
||||
+ {
|
||||
+ "type": "null"
|
||||
+ }
|
||||
+ ],
|
||||
+ "description": "The modules to exclude from the rebuild."
|
||||
+ },
|
||||
"executableArgs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1975,6 +1989,13 @@
|
||||
],
|
||||
"description": "The mime types in addition to specified in the file associations. Use it if you don't want to register a new mime type, but reuse existing."
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"packageCategory": {
|
||||
"description": "backward compatibility + to allow specify fpm-only category for all possible fpm targets in one place",
|
||||
"type": [
|
||||
@@ -2327,6 +2348,13 @@
|
||||
"MacConfiguration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
+ "LSMinimumSystemVersion": {
|
||||
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"additionalArguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2527,6 +2555,20 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "excludeReBuildModules": {
|
||||
+ "anyOf": [
|
||||
+ {
|
||||
+ "items": {
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ "type": "array"
|
||||
+ },
|
||||
+ {
|
||||
+ "type": "null"
|
||||
+ }
|
||||
+ ],
|
||||
+ "description": "The modules to exclude from the rebuild."
|
||||
+ },
|
||||
"executableName": {
|
||||
"description": "The executable name. Defaults to `productName`.",
|
||||
"type": [
|
||||
@@ -2737,7 +2779,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumSystemVersion": {
|
||||
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
@@ -2959,6 +3001,13 @@
|
||||
"MasConfiguration": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
+ "LSMinimumSystemVersion": {
|
||||
+ "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"additionalArguments": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3159,6 +3208,20 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "excludeReBuildModules": {
|
||||
+ "anyOf": [
|
||||
+ {
|
||||
+ "items": {
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ "type": "array"
|
||||
+ },
|
||||
+ {
|
||||
+ "type": "null"
|
||||
+ }
|
||||
+ ],
|
||||
+ "description": "The modules to exclude from the rebuild."
|
||||
+ },
|
||||
"executableName": {
|
||||
"description": "The executable name. Defaults to `productName`.",
|
||||
"type": [
|
||||
@@ -3369,7 +3432,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumSystemVersion": {
|
||||
- "description": "The minimum version of macOS required for the app to run. Corresponds to `LSMinimumSystemVersion`.",
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
@@ -6381,6 +6444,20 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "excludeReBuildModules": {
|
||||
+ "anyOf": [
|
||||
+ {
|
||||
+ "items": {
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ "type": "array"
|
||||
+ },
|
||||
+ {
|
||||
+ "type": "null"
|
||||
+ }
|
||||
+ ],
|
||||
+ "description": "The modules to exclude from the rebuild."
|
||||
+ },
|
||||
"executableName": {
|
||||
"description": "The executable name. Defaults to `productName`.",
|
||||
"type": [
|
||||
@@ -6507,6 +6584,13 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"protocols": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -7153,6 +7237,20 @@
|
||||
"string"
|
||||
]
|
||||
},
|
||||
+ "excludeReBuildModules": {
|
||||
+ "anyOf": [
|
||||
+ {
|
||||
+ "items": {
|
||||
+ "type": "string"
|
||||
+ },
|
||||
+ "type": "array"
|
||||
+ },
|
||||
+ {
|
||||
+ "type": "null"
|
||||
+ }
|
||||
+ ],
|
||||
+ "description": "The modules to exclude from the rebuild."
|
||||
+ },
|
||||
"executableName": {
|
||||
"description": "The executable name. Defaults to `productName`.",
|
||||
"type": [
|
||||
@@ -7376,6 +7474,13 @@
|
||||
],
|
||||
"description": "MAS (Mac Application Store) development options (`mas-dev` target)."
|
||||
},
|
||||
+ "minimumSystemVersion": {
|
||||
+ "description": "The minimum os kernel version required to install the application.",
|
||||
+ "type": [
|
||||
+ "null",
|
||||
+ "string"
|
||||
+ ]
|
||||
+ },
|
||||
"msi": {
|
||||
"anyOf": [
|
||||
{
|
||||
14
.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch
vendored
Normal file
14
.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
diff --git a/out/util.js b/out/util.js
|
||||
index 9294ffd6ca8f02c2e0f90c663e7e9cdc02c1ac37..f52107493e2995320ee4efd0eb2a8c9bf03291a2 100644
|
||||
--- a/out/util.js
|
||||
+++ b/out/util.js
|
||||
@@ -23,7 +23,8 @@ function newUrlFromBase(pathname, baseUrl, addRandomQueryToAvoidCaching = false)
|
||||
result.search = search;
|
||||
}
|
||||
else if (addRandomQueryToAvoidCaching) {
|
||||
- result.search = `noCache=${Date.now().toString(32)}`;
|
||||
+ // use no cache header instead
|
||||
+ // result.search = `noCache=${Date.now().toString(32)}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
49
app-upgrade-config.json
Normal file
49
app-upgrade-config.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"lastUpdated": "2025-11-30T12:19:20.086Z",
|
||||
"versions": {
|
||||
"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
|
||||
}
|
||||
},
|
||||
"1.7.1": {
|
||||
"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.7.1",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.1",
|
||||
"gitcode": "https://releases.cherry-ai.com"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.7.0-rc.3",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-rc.3",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-rc.3"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
config/app-upgrade-segments.json
Normal file
81
config/app-upgrade-segments.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"id": "legacy-v1",
|
||||
"type": "legacy",
|
||||
"match": {
|
||||
"range": ">=1.0.0 <2.0.0"
|
||||
},
|
||||
"minCompatibleVersion": "1.0.0",
|
||||
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
|
||||
"channelTemplates": {
|
||||
"latest": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://releases.cherry-ai.com"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
|
||||
}
|
||||
},
|
||||
"beta": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gateway-v2",
|
||||
"type": "breaking",
|
||||
"match": {
|
||||
"exact": ["2.0.0"]
|
||||
},
|
||||
"lockedVersion": "2.0.0",
|
||||
"minCompatibleVersion": "1.7.0",
|
||||
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
|
||||
"channelTemplates": {
|
||||
"latest": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-v2",
|
||||
"type": "latest",
|
||||
"match": {
|
||||
"range": ">=2.0.0 <3.0.0",
|
||||
"excludeExact": ["2.0.0"]
|
||||
},
|
||||
"minCompatibleVersion": "2.0.0",
|
||||
"description": "Current latest v2.x release",
|
||||
"channelTemplates": {
|
||||
"latest": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
|
||||
}
|
||||
},
|
||||
"beta": {
|
||||
"feedTemplates": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/{{tag}}",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/{{tag}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
430
docs/technical/app-upgrade-config-en.md
Normal file
430
docs/technical/app-upgrade-config-en.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Update Configuration System Design Document
|
||||
|
||||
## Background
|
||||
|
||||
Currently, AppUpdater directly queries the GitHub API to retrieve beta and rc update information. To support users in China, we need to fetch a static JSON configuration file from GitHub/GitCode based on IP geolocation, which contains update URLs for all channels.
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. Support different configuration sources based on IP geolocation (GitHub/GitCode)
|
||||
2. Support version compatibility control (e.g., users below v1.x must upgrade to v1.7.0 before upgrading to v2.0)
|
||||
3. Easy to extend, supporting future multi-major-version upgrade paths (v1.6 → v1.7 → v2.0 → v2.8 → v3.0)
|
||||
4. Maintain compatibility with existing electron-updater mechanism
|
||||
|
||||
## Current Version Strategy
|
||||
|
||||
- **v1.7.x** is the last version of the 1.x series
|
||||
- Users **below v1.7.0** must first upgrade to v1.7.0 (or higher 1.7.x version)
|
||||
- Users **v1.7.0 and above** can directly upgrade to v2.x.x
|
||||
|
||||
## Automation Workflow
|
||||
|
||||
The `x-files/app-upgrade-config/app-upgrade-config.json` file is synchronized by the [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow. The workflow runs the [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) helper so that every release tag automatically updates the JSON in `x-files/app-upgrade-config`.
|
||||
|
||||
### Trigger Conditions
|
||||
|
||||
- **Release events (`release: released/prereleased`)**
|
||||
- Draft releases are ignored.
|
||||
- When GitHub marks the release as _prerelease_, the tag must include `-beta`/`-rc` (with optional numeric suffix). Otherwise the workflow exits early.
|
||||
- When GitHub marks the release as stable, the tag must match the latest release returned by the GitHub API. This prevents out-of-order updates when publishing historical tags.
|
||||
- If the guard clauses pass, the version is tagged as `latest` or `beta/rc` based on its semantic suffix and propagated to the script through the `IS_PRERELEASE` flag.
|
||||
- **Manual dispatch (`workflow_dispatch`)**
|
||||
- Required input: `tag` (e.g., `v2.0.1`). Optional input: `is_prerelease` (defaults to `false`).
|
||||
- When `is_prerelease=true`, the tag must carry a beta/rc suffix, mirroring the automatic validation.
|
||||
- Manual runs still download the latest release metadata so that the workflow knows whether the tag represents the newest stable version (for documentation inside the PR body).
|
||||
|
||||
### Workflow Steps
|
||||
|
||||
1. **Guard + metadata preparation** – the `Check if should proceed` and `Prepare metadata` steps compute the target tag, prerelease flag, whether the tag is the newest release, and a `safe_tag` slug used for branch names. When any rule fails, the workflow stops without touching the config.
|
||||
2. **Checkout source branches** – the default branch is checked out into `main/`, while the long-lived `x-files/app-upgrade-config` branch lives in `cs/`. All modifications happen in the latter directory.
|
||||
3. **Install toolchain** – Node.js 22, Corepack, and frozen Yarn dependencies are installed inside `main/`.
|
||||
4. **Run the update script** – `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>` updates the JSON in-place.
|
||||
- The script normalizes the tag (e.g., strips `v` prefix), detects the release channel (`latest`, `rc`, `beta`), and loads segment rules from `config/app-upgrade-segments.json`.
|
||||
- It validates that prerelease flags and semantic suffixes agree, enforces locked segments, builds mirror feed URLs, and performs release-availability checks (GitHub HEAD request for every channel; GitCode GET for latest channels, falling back to `https://releases.cherry-ai.com` when gitcode is delayed).
|
||||
- After updating the relevant channel entry, the script rewrites the config with semver-sort order and a new `lastUpdated` timestamp.
|
||||
5. **Detect changes + create PR** – if `cs/app-upgrade-config.json` changed, the workflow opens a PR `chore/update-app-upgrade-config/<safe_tag>` against `x-files/app-upgrade-config` with a commit message `🤖 chore: sync app-upgrade-config for <tag>`. Otherwise it logs that no update is required.
|
||||
|
||||
### Manual Trigger Guide
|
||||
|
||||
1. Open the Cherry Studio repository on GitHub → **Actions** tab → select **Update App Upgrade Config**.
|
||||
2. Click **Run workflow**, choose the default branch (usually `main`), and fill in the `tag` input (e.g., `v2.1.0`).
|
||||
3. Toggle `is_prerelease` only when the tag carries a prerelease suffix (`-beta`, `-rc`). Leave it unchecked for stable releases.
|
||||
4. Start the run and wait for it to finish. Check the generated PR in the `x-files/app-upgrade-config` branch, verify the diff in `app-upgrade-config.json`, and merge once validated.
|
||||
|
||||
## JSON Configuration File Format
|
||||
|
||||
### File Location
|
||||
|
||||
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
|
||||
**Note**: Both mirrors provide the same configuration file hosted on the `x-files/app-upgrade-config` branch. The client automatically selects the optimal mirror based on IP geolocation.
|
||||
|
||||
### Configuration Structure (Current Implementation)
|
||||
|
||||
```json
|
||||
{
|
||||
"lastUpdated": "2025-01-05T00:00:00Z",
|
||||
"versions": {
|
||||
"1.6.7": {
|
||||
"minCompatibleVersion": "1.0.0",
|
||||
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
|
||||
"channels": {
|
||||
"latest": {
|
||||
"version": "1.6.7",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.6.0-rc.5",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
|
||||
}
|
||||
},
|
||||
"beta": {
|
||||
"version": "1.6.7-beta.3",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"2.0.0": {
|
||||
"minCompatibleVersion": "1.7.0",
|
||||
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
|
||||
"channels": {
|
||||
"latest": null,
|
||||
"rc": null,
|
||||
"beta": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Future Extension Example
|
||||
|
||||
When releasing v3.0, if users need to first upgrade to v2.8, you can add:
|
||||
|
||||
```json
|
||||
{
|
||||
"2.8.0": {
|
||||
"minCompatibleVersion": "2.0.0",
|
||||
"description": "Stable v2.8 - required for v3 upgrade",
|
||||
"channels": {
|
||||
"latest": {
|
||||
"version": "2.8.0",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0"
|
||||
}
|
||||
},
|
||||
"rc": null,
|
||||
"beta": null
|
||||
}
|
||||
},
|
||||
"3.0.0": {
|
||||
"minCompatibleVersion": "2.8.0",
|
||||
"description": "Major release v3.0",
|
||||
"channels": {
|
||||
"latest": {
|
||||
"version": "3.0.0",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/latest",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "3.0.0-rc.1",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"beta": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
- `lastUpdated`: Last update time of the configuration file (ISO 8601 format)
|
||||
- `versions`: Version configuration object, key is the version number, sorted by semantic versioning
|
||||
- `minCompatibleVersion`: Minimum compatible version that can upgrade to this version
|
||||
- `description`: Version description
|
||||
- `channels`: Update channel configuration
|
||||
- `latest`: Stable release channel
|
||||
- `rc`: Release Candidate channel
|
||||
- `beta`: Beta testing channel
|
||||
- Each channel contains:
|
||||
- `version`: Version number for this channel
|
||||
- `feedUrls`: Multi-mirror URL configuration
|
||||
- `github`: electron-updater feed URL for GitHub mirror
|
||||
- `gitcode`: electron-updater feed URL for GitCode mirror
|
||||
- `metadata`: Stable mapping info for automation
|
||||
- `segmentId`: ID from `config/app-upgrade-segments.json`
|
||||
- `segmentType`: Optional flag (`legacy` | `breaking` | `latest`) for documentation/debugging
|
||||
|
||||
## TypeScript Type Definitions
|
||||
|
||||
```typescript
|
||||
// Mirror enum
|
||||
enum UpdateMirror {
|
||||
GITHUB = 'github',
|
||||
GITCODE = 'gitcode'
|
||||
}
|
||||
|
||||
interface UpdateConfig {
|
||||
lastUpdated: string
|
||||
versions: {
|
||||
[versionKey: string]: VersionConfig
|
||||
}
|
||||
}
|
||||
|
||||
interface VersionConfig {
|
||||
minCompatibleVersion: string
|
||||
description: string
|
||||
channels: {
|
||||
latest: ChannelConfig | null
|
||||
rc: ChannelConfig | null
|
||||
beta: ChannelConfig | null
|
||||
}
|
||||
metadata?: {
|
||||
segmentId: string
|
||||
segmentType?: 'legacy' | 'breaking' | 'latest'
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelConfig {
|
||||
version: string
|
||||
feedUrls: Record<UpdateMirror, string>
|
||||
// Equivalent to:
|
||||
// feedUrls: {
|
||||
// github: string
|
||||
// gitcode: string
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
## Segment Metadata & Breaking Markers
|
||||
|
||||
- **Segment definitions** now live in `config/app-upgrade-segments.json`. Each segment describes a semantic-version range (or exact matches) plus metadata such as `segmentId`, `segmentType`, `minCompatibleVersion`, and per-channel feed URL templates.
|
||||
- Each entry under `versions` carries a `metadata.segmentId`. This acts as the stable key that scripts use to decide which slot to update, even if the actual semantic version string changes.
|
||||
- Mark major upgrade gateways (e.g., `2.0.0`) by giving the related segment a `segmentType: "breaking"` and (optionally) `lockedVersion`. This prevents automation from accidentally moving that entry when other 2.x builds ship.
|
||||
- Adding another breaking hop (e.g., `3.0.0`) only requires defining a new segment in the JSON file; the automation will pick it up on the next run.
|
||||
|
||||
## Automation Workflow
|
||||
|
||||
Starting from this change, `.github/workflows/update-app-upgrade-config.yml` listens to GitHub release events (published + prerelease). The workflow:
|
||||
|
||||
1. Checks out the default branch (for scripts) and the `x-files/app-upgrade-config` branch (where the config is hosted).
|
||||
2. Runs `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json` to regenerate the config directly inside the `x-files/app-upgrade-config` working tree.
|
||||
3. If the file changed, it opens a PR against `x-files/app-upgrade-config` via `peter-evans/create-pull-request`, with the generated diff limited to `app-upgrade-config.json`.
|
||||
|
||||
You can run the same script locally via `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json` (add `--dry-run` to preview) to reproduce or debug whatever the workflow does. Passing `--skip-release-checks` along with `--dry-run` lets you bypass the release-page existence check (useful when the GitHub/GitCode pages aren’t published yet). Running without `--config` continues to update the copy in your current working directory (main branch) for documentation purposes.
|
||||
|
||||
## Version Matching Logic
|
||||
|
||||
### Algorithm Flow
|
||||
|
||||
1. Get user's current version (`currentVersion`) and requested channel (`requestedChannel`)
|
||||
2. Get all version numbers from configuration file, sort in descending order by semantic versioning
|
||||
3. Iterate through the sorted version list:
|
||||
- Check if `currentVersion >= minCompatibleVersion`
|
||||
- Check if the requested `channel` exists and is not `null`
|
||||
- If conditions are met, return the channel configuration
|
||||
4. If no matching version is found, return `null`
|
||||
|
||||
### Pseudocode Implementation
|
||||
|
||||
```typescript
|
||||
function findCompatibleVersion(
|
||||
currentVersion: string,
|
||||
requestedChannel: UpgradeChannel,
|
||||
config: UpdateConfig
|
||||
): ChannelConfig | null {
|
||||
// Get all version numbers and sort in descending order
|
||||
const versions = Object.keys(config.versions).sort(semver.rcompare)
|
||||
|
||||
for (const versionKey of versions) {
|
||||
const versionConfig = config.versions[versionKey]
|
||||
const channelConfig = versionConfig.channels[requestedChannel]
|
||||
|
||||
// Check version compatibility and channel availability
|
||||
if (
|
||||
semver.gte(currentVersion, versionConfig.minCompatibleVersion) &&
|
||||
channelConfig !== null
|
||||
) {
|
||||
return channelConfig
|
||||
}
|
||||
}
|
||||
|
||||
return null // No compatible version found
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrade Path Examples
|
||||
|
||||
### Scenario 1: v1.6.5 User Upgrade (Below 1.7)
|
||||
|
||||
- **Current Version**: 1.6.5
|
||||
- **Requested Channel**: latest
|
||||
- **Match Result**: 1.7.0
|
||||
- **Reason**: 1.6.5 >= 0.0.0 (satisfies 1.7.0's minCompatibleVersion), but doesn't satisfy 2.0.0's minCompatibleVersion (1.7.0)
|
||||
- **Action**: Prompt user to upgrade to 1.7.0, which is the required intermediate version for v2.x upgrade
|
||||
|
||||
### Scenario 2: v1.6.5 User Requests rc/beta
|
||||
|
||||
- **Current Version**: 1.6.5
|
||||
- **Requested Channel**: rc or beta
|
||||
- **Match Result**: 1.7.0 (latest)
|
||||
- **Reason**: 1.7.0 version doesn't provide rc/beta channels (values are null)
|
||||
- **Action**: Upgrade to 1.7.0 stable version
|
||||
|
||||
### Scenario 3: v1.7.0 User Upgrades to Latest
|
||||
|
||||
- **Current Version**: 1.7.0
|
||||
- **Requested Channel**: latest
|
||||
- **Match Result**: 2.0.0
|
||||
- **Reason**: 1.7.0 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion)
|
||||
- **Action**: Directly upgrade to 2.0.0 (current latest stable version)
|
||||
|
||||
### Scenario 4: v1.7.2 User Upgrades to RC Version
|
||||
|
||||
- **Current Version**: 1.7.2
|
||||
- **Requested Channel**: rc
|
||||
- **Match Result**: 2.0.0-rc.1
|
||||
- **Reason**: 1.7.2 >= 1.7.0 (satisfies 2.0.0's minCompatibleVersion), and rc channel exists
|
||||
- **Action**: Upgrade to 2.0.0-rc.1
|
||||
|
||||
### Scenario 5: v1.7.0 User Upgrades to Beta Version
|
||||
|
||||
- **Current Version**: 1.7.0
|
||||
- **Requested Channel**: beta
|
||||
- **Match Result**: 2.0.0-beta.1
|
||||
- **Reason**: 1.7.0 >= 1.7.0, and beta channel exists
|
||||
- **Action**: Upgrade to 2.0.0-beta.1
|
||||
|
||||
### Scenario 6: v2.5.0 User Upgrade (Future)
|
||||
|
||||
Assuming v2.8.0 and v3.0.0 configurations have been added:
|
||||
- **Current Version**: 2.5.0
|
||||
- **Requested Channel**: latest
|
||||
- **Match Result**: 2.8.0
|
||||
- **Reason**: 2.5.0 >= 2.0.0 (satisfies 2.8.0's minCompatibleVersion), but doesn't satisfy 3.0.0's requirement
|
||||
- **Action**: Prompt user to upgrade to 2.8.0, which is the required intermediate version for v3.x upgrade
|
||||
|
||||
## Code Changes
|
||||
|
||||
### Main Modifications
|
||||
|
||||
1. **New Methods**
|
||||
- `_fetchUpdateConfig(ipCountry: string): Promise<UpdateConfig | null>` - Fetch configuration file based on IP
|
||||
- `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - Find compatible channel configuration
|
||||
|
||||
2. **Modified Methods**
|
||||
- `_getReleaseVersionFromGithub()` → Remove or refactor to `_getChannelFeedUrl()`
|
||||
- `_setFeedUrl()` - Use new configuration system to replace existing logic
|
||||
|
||||
3. **New Type Definitions**
|
||||
- `UpdateConfig`
|
||||
- `VersionConfig`
|
||||
- `ChannelConfig`
|
||||
|
||||
### Mirror Selection Logic
|
||||
|
||||
The client automatically selects the optimal mirror based on IP geolocation:
|
||||
|
||||
```typescript
|
||||
private async _setFeedUrl() {
|
||||
const currentVersion = app.getVersion()
|
||||
const testPlan = configManager.getTestPlan()
|
||||
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
|
||||
|
||||
// Determine mirror based on IP country
|
||||
const ipCountry = await getIpCountry()
|
||||
const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github'
|
||||
|
||||
// Fetch update config
|
||||
const config = await this._fetchUpdateConfig(mirror)
|
||||
|
||||
if (config) {
|
||||
const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config)
|
||||
if (channelConfig) {
|
||||
// Select feed URL from the corresponding mirror
|
||||
const feedUrl = channelConfig.feedUrls[mirror]
|
||||
this._setChannel(requestedChannel, feedUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback logic
|
||||
const defaultFeedUrl = mirror === 'gitcode'
|
||||
? FeedUrl.PRODUCTION
|
||||
: FeedUrl.GITHUB_LATEST
|
||||
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
|
||||
}
|
||||
|
||||
private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise<UpdateConfig | null> {
|
||||
const configUrl = mirror === 'gitcode'
|
||||
? UpdateConfigUrl.GITCODE
|
||||
: UpdateConfigUrl.GITHUB
|
||||
|
||||
try {
|
||||
const response = await net.fetch(configUrl, {
|
||||
headers: {
|
||||
'User-Agent': generateUserAgent(),
|
||||
'Accept': 'application/json',
|
||||
'X-Client-Id': configManager.getClientId()
|
||||
}
|
||||
})
|
||||
return await response.json() as UpdateConfig
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch update config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback and Error Handling Strategy
|
||||
|
||||
1. **Configuration file fetch failure**: Log error, return current version, don't offer updates
|
||||
2. **No matching version**: Notify user that current version doesn't support automatic upgrade
|
||||
3. **Network exception**: Cache last successfully fetched configuration (optional)
|
||||
|
||||
## GitHub Release Requirements
|
||||
|
||||
To support intermediate version upgrades, the following files need to be retained:
|
||||
|
||||
- **v1.7.0 release** and its latest*.yml files (as upgrade target for users below v1.7)
|
||||
- Future intermediate versions (e.g., v2.8.0) need to retain corresponding release and latest*.yml files
|
||||
- Complete installation packages for each version
|
||||
|
||||
### Currently Required Releases
|
||||
|
||||
| Version | Purpose | Must Retain |
|
||||
|---------|---------|-------------|
|
||||
| v1.7.0 | Upgrade target for users below 1.7 | ✅ Yes |
|
||||
| v2.0.0-rc.1 | RC testing channel | ❌ Optional |
|
||||
| v2.0.0-beta.1 | Beta testing channel | ❌ Optional |
|
||||
| latest | Latest stable version (automatic) | ✅ Yes |
|
||||
|
||||
## Advantages
|
||||
|
||||
1. **Flexibility**: Supports arbitrarily complex upgrade paths
|
||||
2. **Extensibility**: Adding new versions only requires adding new entries to the configuration file
|
||||
3. **Maintainability**: Configuration is separated from code, allowing upgrade strategy adjustments without releasing new versions
|
||||
4. **Multi-source support**: Automatically selects optimal configuration source based on geolocation
|
||||
5. **Version control**: Enforces intermediate version upgrades, ensuring data migration and compatibility
|
||||
|
||||
## Future Extensions
|
||||
|
||||
- Support more granular version range control (e.g., `>=1.5.0 <1.8.0`)
|
||||
- Support multi-step upgrade path hints (e.g., notify user needs 1.5 → 1.8 → 2.0)
|
||||
- Support A/B testing and gradual rollout
|
||||
- Support local caching and expiration strategy for configuration files
|
||||
430
docs/technical/app-upgrade-config-zh.md
Normal file
430
docs/technical/app-upgrade-config-zh.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# 更新配置系统设计文档
|
||||
|
||||
## 背景
|
||||
|
||||
当前 AppUpdater 直接请求 GitHub API 获取 beta 和 rc 的更新信息。为了支持国内用户,需要根据 IP 地理位置,分别从 GitHub/GitCode 获取一个固定的 JSON 配置文件,该文件包含所有渠道的更新地址。
|
||||
|
||||
## 设计目标
|
||||
|
||||
1. 支持根据 IP 地理位置选择不同的配置源(GitHub/GitCode)
|
||||
2. 支持版本兼容性控制(如 v1.x 以下必须先升级到 v1.7.0 才能升级到 v2.0)
|
||||
3. 易于扩展,支持未来多个主版本的升级路径(v1.6 → v1.7 → v2.0 → v2.8 → v3.0)
|
||||
4. 保持与现有 electron-updater 机制的兼容性
|
||||
|
||||
## 当前版本策略
|
||||
|
||||
- **v1.7.x** 是 1.x 系列的最后版本
|
||||
- **v1.7.0 以下**的用户必须先升级到 v1.7.0(或更高的 1.7.x 版本)
|
||||
- **v1.7.0 及以上**的用户可以直接升级到 v2.x.x
|
||||
|
||||
## 自动化工作流
|
||||
|
||||
`x-files/app-upgrade-config/app-upgrade-config.json` 由 [`Update App Upgrade Config`](../../.github/workflows/update-app-upgrade-config.yml) workflow 自动同步。工作流会调用 [`scripts/update-app-upgrade-config.ts`](../../scripts/update-app-upgrade-config.ts) 脚本,根据指定 tag 更新 `x-files/app-upgrade-config` 分支上的配置文件。
|
||||
|
||||
### 触发条件
|
||||
|
||||
- **Release 事件(`release: released/prereleased`)**
|
||||
- Draft release 会被忽略。
|
||||
- 当 GitHub 将 release 标记为 *prerelease* 时,tag 必须包含 `-beta`/`-rc`(可带序号),否则直接跳过。
|
||||
- 当 release 标记为稳定版时,tag 必须与 GitHub API 返回的最新稳定版本一致,防止发布历史 tag 时意外挂起工作流。
|
||||
- 满足上述条件后,工作流会根据语义化版本判断渠道(`latest`/`beta`/`rc`),并通过 `IS_PRERELEASE` 传递给脚本。
|
||||
- **手动触发(`workflow_dispatch`)**
|
||||
- 必填:`tag`(例:`v2.0.1`);选填:`is_prerelease`(默认 `false`)。
|
||||
- 当 `is_prerelease=true` 时,同样要求 tag 带有 beta/rc 后缀。
|
||||
- 手动运行仍会请求 GitHub 最新 release 信息,用于在 PR 说明中标注该 tag 是否是最新稳定版。
|
||||
|
||||
### 工作流步骤
|
||||
|
||||
1. **检查与元数据准备**:`Check if should proceed` 和 `Prepare metadata` 步骤会计算 tag、prerelease 标志、是否最新版本以及用于分支名的 `safe_tag`。若任意校验失败,工作流立即退出。
|
||||
2. **检出分支**:默认分支被检出到 `main/`,长期维护的 `x-files/app-upgrade-config` 分支则在 `cs/` 中,所有改动都发生在 `cs/`。
|
||||
3. **安装工具链**:安装 Node.js 22、启用 Corepack,并在 `main/` 目录执行 `yarn install --immutable`。
|
||||
4. **运行更新脚本**:执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json --is-prerelease <flag>`。
|
||||
- 脚本会标准化 tag(去掉 `v` 前缀等)、识别渠道、加载 `config/app-upgrade-segments.json` 中的分段规则。
|
||||
- 校验 prerelease 标志与语义后缀是否匹配、强制锁定的 segment 是否满足、生成镜像的下载地址,并检查 release 是否已经在 GitHub/GitCode 可用(latest 渠道在 GitCode 不可用时会回退到 `https://releases.cherry-ai.com`)。
|
||||
- 更新对应的渠道配置后,脚本会按 semver 排序写回 JSON,并刷新 `lastUpdated`。
|
||||
5. **检测变更并创建 PR**:若 `cs/app-upgrade-config.json` 有变更,则创建 `chore/update-app-upgrade-config/<safe_tag>` 分支,提交信息为 `🤖 chore: sync app-upgrade-config for <tag>`,并向 `x-files/app-upgrade-config` 提 PR;无变更则输出提示。
|
||||
|
||||
### 手动触发指南
|
||||
|
||||
1. 进入 Cherry Studio 仓库的 GitHub **Actions** 页面,选择 **Update App Upgrade Config** 工作流。
|
||||
2. 点击 **Run workflow**,保持默认分支(通常为 `main`),填写 `tag`(如 `v2.1.0`)。
|
||||
3. 只有在 tag 带 `-beta`/`-rc` 后缀时才勾选 `is_prerelease`,稳定版保持默认。
|
||||
4. 启动运行并等待完成,随后到 `x-files/app-upgrade-config` 分支的 PR 查看 `app-upgrade-config.json` 的变更并在验证后合并。
|
||||
|
||||
## JSON 配置文件格式
|
||||
|
||||
### 文件位置
|
||||
|
||||
- **GitHub**: `https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
- **GitCode**: `https://gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json`
|
||||
|
||||
**说明**:两个镜像源提供相同的配置文件,统一托管在 `x-files/app-upgrade-config` 分支上。客户端根据 IP 地理位置自动选择最优镜像源。
|
||||
|
||||
### 配置结构(当前实际配置)
|
||||
|
||||
```json
|
||||
{
|
||||
"lastUpdated": "2025-01-05T00:00:00Z",
|
||||
"versions": {
|
||||
"1.6.7": {
|
||||
"minCompatibleVersion": "1.0.0",
|
||||
"description": "Last stable v1.7.x release - required intermediate version for users below v1.7",
|
||||
"channels": {
|
||||
"latest": {
|
||||
"version": "1.6.7",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.7",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v1.6.7"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.6.0-rc.5",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.6.0-rc.5"
|
||||
}
|
||||
},
|
||||
"beta": {
|
||||
"version": "1.6.7-beta.3",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3",
|
||||
"gitcode": "https://github.com/CherryHQ/cherry-studio/releases/download/v1.7.0-beta.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"2.0.0": {
|
||||
"minCompatibleVersion": "1.7.0",
|
||||
"description": "Major release v2.0 - required intermediate version for v2.x upgrades",
|
||||
"channels": {
|
||||
"latest": null,
|
||||
"rc": null,
|
||||
"beta": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 未来扩展示例
|
||||
|
||||
当需要发布 v3.0 时,如果需要强制用户先升级到 v2.8,可以添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"2.8.0": {
|
||||
"minCompatibleVersion": "2.0.0",
|
||||
"description": "Stable v2.8 - required for v3 upgrade",
|
||||
"channels": {
|
||||
"latest": {
|
||||
"version": "2.8.0",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v2.8.0",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v2.8.0"
|
||||
}
|
||||
},
|
||||
"rc": null,
|
||||
"beta": null
|
||||
}
|
||||
},
|
||||
"3.0.0": {
|
||||
"minCompatibleVersion": "2.8.0",
|
||||
"description": "Major release v3.0",
|
||||
"channels": {
|
||||
"latest": {
|
||||
"version": "3.0.0",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/latest",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/latest"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "3.0.0-rc.1",
|
||||
"feedUrls": {
|
||||
"github": "https://github.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1",
|
||||
"gitcode": "https://gitcode.com/CherryHQ/cherry-studio/releases/download/v3.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"beta": null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
- `lastUpdated`: 配置文件最后更新时间(ISO 8601 格式)
|
||||
- `versions`: 版本配置对象,key 为版本号,按语义化版本排序
|
||||
- `minCompatibleVersion`: 可以升级到此版本的最低兼容版本
|
||||
- `description`: 版本描述
|
||||
- `channels`: 更新渠道配置
|
||||
- `latest`: 稳定版渠道
|
||||
- `rc`: Release Candidate 渠道
|
||||
- `beta`: Beta 测试渠道
|
||||
- 每个渠道包含:
|
||||
- `version`: 该渠道的版本号
|
||||
- `feedUrls`: 多镜像源 URL 配置
|
||||
- `github`: GitHub 镜像源的 electron-updater feed URL
|
||||
- `gitcode`: GitCode 镜像源的 electron-updater feed URL
|
||||
- `metadata`: 自动化匹配所需的稳定标识
|
||||
- `segmentId`: 来自 `config/app-upgrade-segments.json` 的段位 ID
|
||||
- `segmentType`: 可选字段(`legacy` | `breaking` | `latest`),便于文档/调试
|
||||
|
||||
## TypeScript 类型定义
|
||||
|
||||
```typescript
|
||||
// 镜像源枚举
|
||||
enum UpdateMirror {
|
||||
GITHUB = 'github',
|
||||
GITCODE = 'gitcode'
|
||||
}
|
||||
|
||||
interface UpdateConfig {
|
||||
lastUpdated: string
|
||||
versions: {
|
||||
[versionKey: string]: VersionConfig
|
||||
}
|
||||
}
|
||||
|
||||
interface VersionConfig {
|
||||
minCompatibleVersion: string
|
||||
description: string
|
||||
channels: {
|
||||
latest: ChannelConfig | null
|
||||
rc: ChannelConfig | null
|
||||
beta: ChannelConfig | null
|
||||
}
|
||||
metadata?: {
|
||||
segmentId: string
|
||||
segmentType?: 'legacy' | 'breaking' | 'latest'
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelConfig {
|
||||
version: string
|
||||
feedUrls: Record<UpdateMirror, string>
|
||||
// 等同于:
|
||||
// feedUrls: {
|
||||
// github: string
|
||||
// gitcode: string
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
## 段位元数据(Break Change 标记)
|
||||
|
||||
- 所有段位定义(如 `legacy-v1`、`gateway-v2` 等)集中在 `config/app-upgrade-segments.json`,用于描述匹配范围、`segmentId`、`segmentType`、默认 `minCompatibleVersion/description` 以及各渠道的 URL 模板。
|
||||
- `versions` 下的每个节点都会带上 `metadata.segmentId`。自动脚本始终依据该 ID 来定位并更新条目,即便 key 从 `2.1.5` 切换到 `2.1.6` 也不会错位。
|
||||
- 如果某段需要锁死在特定版本(例如 `2.0.0` 的 break change),可在段定义中设置 `segmentType: "breaking"` 并提供 `lockedVersion`,脚本在遇到不匹配的 tag 时会短路报错,保证升级路径安全。
|
||||
- 面对未来新的断层(例如 `3.0.0`),只需要在段定义里新增一段,自动化即可识别并更新。
|
||||
|
||||
## 自动化工作流
|
||||
|
||||
`.github/workflows/update-app-upgrade-config.yml` 会在 GitHub Release(包含正常发布与 Pre Release)触发:
|
||||
|
||||
1. 同时 Checkout 仓库默认分支(用于脚本)和 `x-files/app-upgrade-config` 分支(真实托管配置的分支)。
|
||||
2. 在默认分支目录执行 `yarn tsx scripts/update-app-upgrade-config.ts --tag <tag> --config ../cs/app-upgrade-config.json`,直接重写 `x-files/app-upgrade-config` 分支里的配置文件。
|
||||
3. 如果 `app-upgrade-config.json` 有变化,则通过 `peter-evans/create-pull-request` 自动创建一个指向 `x-files/app-upgrade-config` 的 PR,Diff 仅包含该文件。
|
||||
|
||||
如需本地调试,可执行 `yarn update:upgrade-config --tag v2.1.6 --config ../cs/app-upgrade-config.json`(加 `--dry-run` 仅打印结果)来复现 CI 行为。若需要暂时跳过 GitHub/GitCode Release 页面是否就绪的校验,可在 `--dry-run` 的同时附加 `--skip-release-checks`。不加 `--config` 时默认更新当前工作目录(通常是 main 分支)下的副本,方便文档/审查。
|
||||
|
||||
## 版本匹配逻辑
|
||||
|
||||
### 算法流程
|
||||
|
||||
1. 获取用户当前版本(`currentVersion`)和请求的渠道(`requestedChannel`)
|
||||
2. 获取配置文件中所有版本号,按语义化版本从大到小排序
|
||||
3. 遍历排序后的版本列表:
|
||||
- 检查 `currentVersion >= minCompatibleVersion`
|
||||
- 检查请求的 `channel` 是否存在且不为 `null`
|
||||
- 如果满足条件,返回该渠道配置
|
||||
4. 如果没有找到匹配版本,返回 `null`
|
||||
|
||||
### 伪代码实现
|
||||
|
||||
```typescript
|
||||
function findCompatibleVersion(
|
||||
currentVersion: string,
|
||||
requestedChannel: UpgradeChannel,
|
||||
config: UpdateConfig
|
||||
): ChannelConfig | null {
|
||||
// 获取所有版本号并从大到小排序
|
||||
const versions = Object.keys(config.versions).sort(semver.rcompare)
|
||||
|
||||
for (const versionKey of versions) {
|
||||
const versionConfig = config.versions[versionKey]
|
||||
const channelConfig = versionConfig.channels[requestedChannel]
|
||||
|
||||
// 检查版本兼容性和渠道可用性
|
||||
if (
|
||||
semver.gte(currentVersion, versionConfig.minCompatibleVersion) &&
|
||||
channelConfig !== null
|
||||
) {
|
||||
return channelConfig
|
||||
}
|
||||
}
|
||||
|
||||
return null // 没有找到兼容版本
|
||||
}
|
||||
```
|
||||
|
||||
## 升级路径示例
|
||||
|
||||
### 场景 1: v1.6.5 用户升级(低于 1.7)
|
||||
|
||||
- **当前版本**: 1.6.5
|
||||
- **请求渠道**: latest
|
||||
- **匹配结果**: 1.7.0
|
||||
- **原因**: 1.6.5 >= 0.0.0(满足 1.7.0 的 minCompatibleVersion),但不满足 2.0.0 的 minCompatibleVersion (1.7.0)
|
||||
- **操作**: 提示用户升级到 1.7.0,这是升级到 v2.x 的必要中间版本
|
||||
|
||||
### 场景 2: v1.6.5 用户请求 rc/beta
|
||||
|
||||
- **当前版本**: 1.6.5
|
||||
- **请求渠道**: rc 或 beta
|
||||
- **匹配结果**: 1.7.0 (latest)
|
||||
- **原因**: 1.7.0 版本不提供 rc/beta 渠道(值为 null)
|
||||
- **操作**: 升级到 1.7.0 稳定版
|
||||
|
||||
### 场景 3: v1.7.0 用户升级到最新版
|
||||
|
||||
- **当前版本**: 1.7.0
|
||||
- **请求渠道**: latest
|
||||
- **匹配结果**: 2.0.0
|
||||
- **原因**: 1.7.0 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion)
|
||||
- **操作**: 直接升级到 2.0.0(当前最新稳定版)
|
||||
|
||||
### 场景 4: v1.7.2 用户升级到 RC 版本
|
||||
|
||||
- **当前版本**: 1.7.2
|
||||
- **请求渠道**: rc
|
||||
- **匹配结果**: 2.0.0-rc.1
|
||||
- **原因**: 1.7.2 >= 1.7.0(满足 2.0.0 的 minCompatibleVersion),且 rc 渠道存在
|
||||
- **操作**: 升级到 2.0.0-rc.1
|
||||
|
||||
### 场景 5: v1.7.0 用户升级到 Beta 版本
|
||||
|
||||
- **当前版本**: 1.7.0
|
||||
- **请求渠道**: beta
|
||||
- **匹配结果**: 2.0.0-beta.1
|
||||
- **原因**: 1.7.0 >= 1.7.0,且 beta 渠道存在
|
||||
- **操作**: 升级到 2.0.0-beta.1
|
||||
|
||||
### 场景 6: v2.5.0 用户升级(未来)
|
||||
|
||||
假设已添加 v2.8.0 和 v3.0.0 配置:
|
||||
- **当前版本**: 2.5.0
|
||||
- **请求渠道**: latest
|
||||
- **匹配结果**: 2.8.0
|
||||
- **原因**: 2.5.0 >= 2.0.0(满足 2.8.0 的 minCompatibleVersion),但不满足 3.0.0 的要求
|
||||
- **操作**: 提示用户升级到 2.8.0,这是升级到 v3.x 的必要中间版本
|
||||
|
||||
## 代码改动计划
|
||||
|
||||
### 主要修改
|
||||
|
||||
1. **新增方法**
|
||||
- `_fetchUpdateConfig(ipCountry: string): Promise<UpdateConfig | null>` - 根据 IP 获取配置文件
|
||||
- `_findCompatibleChannel(currentVersion: string, channel: UpgradeChannel, config: UpdateConfig): ChannelConfig | null` - 查找兼容的渠道配置
|
||||
|
||||
2. **修改方法**
|
||||
- `_getReleaseVersionFromGithub()` → 移除或重构为 `_getChannelFeedUrl()`
|
||||
- `_setFeedUrl()` - 使用新的配置系统替代现有逻辑
|
||||
|
||||
3. **新增类型定义**
|
||||
- `UpdateConfig`
|
||||
- `VersionConfig`
|
||||
- `ChannelConfig`
|
||||
|
||||
### 镜像源选择逻辑
|
||||
|
||||
客户端根据 IP 地理位置自动选择最优镜像源:
|
||||
|
||||
```typescript
|
||||
private async _setFeedUrl() {
|
||||
const currentVersion = app.getVersion()
|
||||
const testPlan = configManager.getTestPlan()
|
||||
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
|
||||
|
||||
// 根据 IP 国家确定镜像源
|
||||
const ipCountry = await getIpCountry()
|
||||
const mirror = ipCountry.toLowerCase() === 'cn' ? 'gitcode' : 'github'
|
||||
|
||||
// 获取更新配置
|
||||
const config = await this._fetchUpdateConfig(mirror)
|
||||
|
||||
if (config) {
|
||||
const channelConfig = this._findCompatibleChannel(currentVersion, requestedChannel, config)
|
||||
if (channelConfig) {
|
||||
// 从配置中选择对应镜像源的 URL
|
||||
const feedUrl = channelConfig.feedUrls[mirror]
|
||||
this._setChannel(requestedChannel, feedUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 逻辑
|
||||
const defaultFeedUrl = mirror === 'gitcode'
|
||||
? FeedUrl.PRODUCTION
|
||||
: FeedUrl.GITHUB_LATEST
|
||||
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
|
||||
}
|
||||
|
||||
private async _fetchUpdateConfig(mirror: 'github' | 'gitcode'): Promise<UpdateConfig | null> {
|
||||
const configUrl = mirror === 'gitcode'
|
||||
? UpdateConfigUrl.GITCODE
|
||||
: UpdateConfigUrl.GITHUB
|
||||
|
||||
try {
|
||||
const response = await net.fetch(configUrl, {
|
||||
headers: {
|
||||
'User-Agent': generateUserAgent(),
|
||||
'Accept': 'application/json',
|
||||
'X-Client-Id': configManager.getClientId()
|
||||
}
|
||||
})
|
||||
return await response.json() as UpdateConfig
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch update config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 降级和容错策略
|
||||
|
||||
1. **配置文件获取失败**: 记录错误日志,返回当前版本,不提供更新
|
||||
2. **没有匹配的版本**: 提示用户当前版本不支持自动升级
|
||||
3. **网络异常**: 缓存上次成功获取的配置(可选)
|
||||
|
||||
## GitHub Release 要求
|
||||
|
||||
为支持中间版本升级,需要保留以下文件:
|
||||
|
||||
- **v1.7.0 release** 及其 latest*.yml 文件(作为 v1.7 以下用户的升级目标)
|
||||
- 未来如需强制中间版本(如 v2.8.0),需要保留对应的 release 和 latest*.yml 文件
|
||||
- 各版本的完整安装包
|
||||
|
||||
### 当前需要的 Release
|
||||
|
||||
| 版本 | 用途 | 必须保留 |
|
||||
|------|------|---------|
|
||||
| v1.7.0 | 1.7 以下用户的升级目标 | ✅ 是 |
|
||||
| v2.0.0-rc.1 | RC 测试渠道 | ❌ 可选 |
|
||||
| v2.0.0-beta.1 | Beta 测试渠道 | ❌ 可选 |
|
||||
| latest | 最新稳定版(自动) | ✅ 是 |
|
||||
|
||||
## 优势
|
||||
|
||||
1. **灵活性**: 支持任意复杂的升级路径
|
||||
2. **可扩展性**: 新增版本只需在配置文件中添加新条目
|
||||
3. **可维护性**: 配置与代码分离,无需发版即可调整升级策略
|
||||
4. **多源支持**: 自动根据地理位置选择最优配置源
|
||||
5. **版本控制**: 强制中间版本升级,确保数据迁移和兼容性
|
||||
|
||||
## 未来扩展
|
||||
|
||||
- 支持更细粒度的版本范围控制(如 `>=1.5.0 <1.8.0`)
|
||||
- 支持多步升级路径提示(如提示用户需要 1.5 → 1.8 → 2.0)
|
||||
- 支持 A/B 测试和灰度发布
|
||||
- 支持配置文件的本地缓存和过期策略
|
||||
@@ -97,7 +97,6 @@ mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
@@ -135,50 +134,42 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
What's New in v1.7.0-beta.5
|
||||
What's New in v1.7.0-beta.6
|
||||
|
||||
New Features:
|
||||
- MCPRouter Provider: Added MCPRouter provider integration with token management and server synchronization
|
||||
- MCP Marketplace: Enhanced MCP server discovery and management with multi-provider marketplace support
|
||||
- Agent Permission Mode Display: Visual permission mode cards in empty session states
|
||||
- Assistant Subscription Settings: Added subscription URL management in assistant presets
|
||||
- Enhanced Input Bar: Completely redesigned input bar with improved responsiveness and functionality
|
||||
- Better File Handling: Improved drag-and-drop and paste support for images and documents
|
||||
- Smart Tool Suggestions: Enhanced quick panel with better item selection and keyboard shortcuts
|
||||
|
||||
Improvements:
|
||||
- UI Optimization: Sidebar tooltip placement improved on macOS to avoid overlapping window controls
|
||||
- MCP Server Logos: Display server logos in Agent settings tooling section
|
||||
- Long Command Handling: Bash command tags now auto-truncate (hover to view full command for commands over 100 chars)
|
||||
- MCP OAuth Callback: Fixed callback page hanging and added multilingual support (10 languages)
|
||||
- Error Display: Improved error block display order for better readability
|
||||
- Plugin Browser: Centered tab alignment for better visual consistency
|
||||
- Smoother Input Experience: Better auto-resizing and text handling in chat input
|
||||
- Enhanced AI Performance: Improved connection stability and response speed
|
||||
- More Reliable File Uploads: Better support for various file types and upload scenarios
|
||||
- Cleaner Interface: Optimized UI elements for better visual consistency
|
||||
|
||||
Bug Fixes:
|
||||
- Fixed Agent sessions not inheriting allowed_tools configuration
|
||||
- Fixed Gemini endpoint thinking budget spelling error
|
||||
- Fixed MCP card description text overflow
|
||||
- Fixed unnecessary message timestamp updates on UI-only state changes
|
||||
- Updated dependencies: Bun to 1.3.1, uv to 0.9.5
|
||||
- Fixed image selection issue when adding custom AI providers
|
||||
- Fixed file upload problems with certain API configurations
|
||||
- Fixed input bar responsiveness issues
|
||||
- Fixed quick panel not working properly in some situations
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
v1.7.0-beta.5 新特性
|
||||
v1.7.0-beta.6 新特性
|
||||
|
||||
新功能:
|
||||
- MCPRouter 提供商:新增 MCPRouter 提供商集成,支持 token 管理和服务器同步
|
||||
- MCP 市场:增强 MCP 服务器发现和管理功能,支持多提供商市场
|
||||
- Agent 权限模式展示:空会话状态显示可视化权限模式卡片
|
||||
- 助手订阅设置:在助手预设中添加订阅 URL 管理功能
|
||||
- 增强输入栏:完全重新设计的输入栏,响应更灵敏,功能更强大
|
||||
- 更好的文件处理:改进的拖拽和粘贴功能,支持图片和文档
|
||||
- 智能工具建议:增强的快速面板,更好的项目选择和键盘快捷键
|
||||
|
||||
改进:
|
||||
- UI 优化:macOS 上侧边栏工具提示位置优化,避免与窗口控制按钮重叠
|
||||
- MCP 服务器标志:在 Agent 设置工具部分显示服务器 logo
|
||||
- 长命令处理:Bash 命令标签自动截断(超过 100 字符时悬停查看完整内容)
|
||||
- MCP OAuth 回调:修复回调页面挂起问题并添加多语言支持(10 种语言)
|
||||
- 错误信息展示:改进错误块显示顺序,提高可读性
|
||||
- 插件浏览器:标签页居中对齐,视觉效果更统一
|
||||
- 更流畅的输入体验:聊天输入框的自动调整和文本处理更佳
|
||||
- 增强 AI 性能:改进连接稳定性和响应速度
|
||||
- 更可靠的文件上传:更好地支持各种文件类型和上传场景
|
||||
- 更简洁的界面:优化 UI 元素,视觉一致性更好
|
||||
|
||||
问题修复:
|
||||
- 修复 Agent 会话未继承 allowed_tools 配置
|
||||
- 修复 Gemini 端点 thinking budget 拼写错误
|
||||
- 修复 MCP 卡片描述文本溢出问题
|
||||
- 修复仅 UI 状态变化时消息时间戳不必要的更新
|
||||
- 依赖更新:Bun 升级到 1.3.1,uv 升级到 0.9.5
|
||||
- 修复添加自定义 AI 提供商时的图片选择问题
|
||||
- 修复某些 API 配置下的文件上传问题
|
||||
- 修复输入栏响应性问题
|
||||
- 修复快速面板在某些情况下无法正常工作的问题
|
||||
<!--LANG:END-->
|
||||
|
||||
@@ -95,7 +95,8 @@ export default defineConfig({
|
||||
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
|
||||
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
|
||||
'@cherrystudio/ai-core': resolve('packages/aiCore/src'),
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.7.0-beta.3",
|
||||
"version": "1.6.5",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -58,6 +58,7 @@
|
||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||
"update:languages": "tsx scripts/update-languages.ts",
|
||||
"update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts",
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
@@ -78,7 +79,7 @@
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.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",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
@@ -107,7 +108,9 @@
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.53",
|
||||
"@ai-sdk/google-vertex": "^3.0.61",
|
||||
"@ai-sdk/cerebras": "^1.0.31",
|
||||
"@ai-sdk/gateway": "^2.0.9",
|
||||
"@ai-sdk/google-vertex": "^3.0.62",
|
||||
"@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/perplexity": "^2.0.17",
|
||||
@@ -257,12 +260,12 @@
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"electron": "38.4.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron": "38.7.0",
|
||||
"electron-builder": "26.1.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch",
|
||||
"electron-vite": "4.0.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"emittery": "^1.0.3",
|
||||
@@ -379,13 +382,11 @@
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"esbuild": "^0.25.0",
|
||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"node-abi": "4.12.0",
|
||||
"node-abi": "4.24.0",
|
||||
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.5.0",
|
||||
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.5.0",
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
@@ -394,7 +395,6 @@
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@7.1.5",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
@@ -406,9 +406,9 @@
|
||||
"@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch",
|
||||
"@ai-sdk/google@npm:2.0.30": "patch:@ai-sdk/google@npm%3A2.0.30#~/.yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch",
|
||||
"@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch",
|
||||
"@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch"
|
||||
"@ai-sdk/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"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
39
packages/ai-sdk-provider/README.md
Normal file
39
packages/ai-sdk-provider/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @cherrystudio/ai-sdk-provider
|
||||
|
||||
CherryIN provider bundle for the [Vercel AI SDK](https://ai-sdk.dev/).
|
||||
It exposes the CherryIN OpenAI-compatible entrypoints and dynamically routes Anthropic and Gemini model ids to their CherryIN upstream equivalents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
|
||||
# or
|
||||
yarn add ai @cherrystudio/ai-sdk-provider @ai-sdk/anthropic @ai-sdk/google @ai-sdk/openai
|
||||
```
|
||||
|
||||
> **Note**: This package requires peer dependencies `ai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, and `@ai-sdk/openai` to be installed.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createCherryIn, cherryIn } from '@cherrystudio/ai-sdk-provider'
|
||||
|
||||
const cherryInProvider = createCherryIn({
|
||||
apiKey: process.env.CHERRYIN_API_KEY,
|
||||
// optional overrides:
|
||||
// baseURL: 'https://open.cherryin.net/v1',
|
||||
// anthropicBaseURL: 'https://open.cherryin.net/anthropic',
|
||||
// geminiBaseURL: 'https://open.cherryin.net/gemini/v1beta',
|
||||
})
|
||||
|
||||
// Chat models will auto-route based on the model id prefix:
|
||||
const openaiModel = cherryInProvider.chat('gpt-4o-mini')
|
||||
const anthropicModel = cherryInProvider.chat('claude-3-5-sonnet-latest')
|
||||
const geminiModel = cherryInProvider.chat('gemini-2.0-pro-exp')
|
||||
|
||||
const { text } = await openaiModel.invoke('Hello CherryIN!')
|
||||
```
|
||||
|
||||
The provider also exposes `completion`, `responses`, `embedding`, `image`, `transcription`, and `speech` helpers aligned with the upstream APIs.
|
||||
|
||||
See [AI SDK docs](https://ai-sdk.dev/providers/community-providers/custom-providers) for configuring custom providers.
|
||||
64
packages/ai-sdk-provider/package.json
Normal file
64
packages/ai-sdk-provider/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@cherrystudio/ai-sdk-provider",
|
||||
"version": "0.1.0",
|
||||
"description": "Cherry Studio AI SDK provider bundle with CherryIN routing.",
|
||||
"keywords": [
|
||||
"ai-sdk",
|
||||
"provider",
|
||||
"cherryin",
|
||||
"vercel-ai-sdk",
|
||||
"cherry-studio"
|
||||
],
|
||||
"author": "Cherry Studio",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/CherryHQ/cherry-studio",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/CherryHQ/cherry-studio.git",
|
||||
"directory": "packages/ai-sdk-provider"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/CherryHQ/cherry-studio/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsc -w",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.29",
|
||||
"@ai-sdk/google": "^2.0.23",
|
||||
"@ai-sdk/openai": "^2.0.64",
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "^0.13.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
319
packages/ai-sdk-provider/src/cherryin-provider.ts
Normal file
319
packages/ai-sdk-provider/src/cherryin-provider.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { AnthropicMessagesLanguageModel } from '@ai-sdk/anthropic/internal'
|
||||
import { GoogleGenerativeAILanguageModel } from '@ai-sdk/google/internal'
|
||||
import type { OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import {
|
||||
OpenAIChatLanguageModel,
|
||||
OpenAICompletionLanguageModel,
|
||||
OpenAIEmbeddingModel,
|
||||
OpenAIImageModel,
|
||||
OpenAIResponsesLanguageModel,
|
||||
OpenAISpeechModel,
|
||||
OpenAITranscriptionModel
|
||||
} from '@ai-sdk/openai/internal'
|
||||
import {
|
||||
type EmbeddingModelV2,
|
||||
type ImageModelV2,
|
||||
type LanguageModelV2,
|
||||
type ProviderV2,
|
||||
type SpeechModelV2,
|
||||
type TranscriptionModelV2
|
||||
} from '@ai-sdk/provider'
|
||||
import type { FetchFunction } from '@ai-sdk/provider-utils'
|
||||
import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils'
|
||||
|
||||
export const CHERRYIN_PROVIDER_NAME = 'cherryin' as const
|
||||
export const DEFAULT_CHERRYIN_BASE_URL = 'https://open.cherryin.net/v1'
|
||||
export const DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL = 'https://open.cherryin.net/v1'
|
||||
export const DEFAULT_CHERRYIN_GEMINI_BASE_URL = 'https://open.cherryin.net/v1beta/models'
|
||||
|
||||
const ANTHROPIC_PREFIX = /^anthropic\//i
|
||||
const GEMINI_PREFIX = /^google\//i
|
||||
// const GEMINI_EXCLUDED_SUFFIXES = ['-nothink', '-search']
|
||||
|
||||
type HeaderValue = string | undefined
|
||||
|
||||
type HeadersInput = Record<string, HeaderValue> | (() => Record<string, HeaderValue>)
|
||||
|
||||
export interface CherryInProviderSettings {
|
||||
/**
|
||||
* CherryIN API key.
|
||||
*
|
||||
* If omitted, the provider will read the `CHERRYIN_API_KEY` environment variable.
|
||||
*/
|
||||
apiKey?: string
|
||||
/**
|
||||
* Optional custom fetch implementation.
|
||||
*/
|
||||
fetch?: FetchFunction
|
||||
/**
|
||||
* Base URL for OpenAI-compatible CherryIN endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/v1`.
|
||||
*/
|
||||
baseURL?: string
|
||||
/**
|
||||
* Base URL for Anthropic-compatible endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/anthropic`.
|
||||
*/
|
||||
anthropicBaseURL?: string
|
||||
/**
|
||||
* Base URL for Gemini-compatible endpoints.
|
||||
*
|
||||
* Defaults to `https://open.cherryin.net/gemini/v1beta`.
|
||||
*/
|
||||
geminiBaseURL?: string
|
||||
/**
|
||||
* Optional static headers applied to every request.
|
||||
*/
|
||||
headers?: HeadersInput
|
||||
}
|
||||
|
||||
export interface CherryInProvider extends ProviderV2 {
|
||||
(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
languageModel(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
chat(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
responses(modelId: string): LanguageModelV2
|
||||
completion(modelId: string, settings?: OpenAIProviderSettings): LanguageModelV2
|
||||
embedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbedding(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
textEmbeddingModel(modelId: string, settings?: OpenAIProviderSettings): EmbeddingModelV2<string>
|
||||
image(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
|
||||
imageModel(modelId: string, settings?: OpenAIProviderSettings): ImageModelV2
|
||||
transcription(modelId: string): TranscriptionModelV2
|
||||
transcriptionModel(modelId: string): TranscriptionModelV2
|
||||
speech(modelId: string): SpeechModelV2
|
||||
speechModel(modelId: string): SpeechModelV2
|
||||
}
|
||||
|
||||
const resolveApiKey = (options: CherryInProviderSettings): string =>
|
||||
loadApiKey({
|
||||
apiKey: options.apiKey,
|
||||
environmentVariableName: 'CHERRYIN_API_KEY',
|
||||
description: 'CherryIN'
|
||||
})
|
||||
|
||||
const isAnthropicModel = (modelId: string) => ANTHROPIC_PREFIX.test(modelId)
|
||||
const isGeminiModel = (modelId: string) => GEMINI_PREFIX.test(modelId)
|
||||
|
||||
const createCustomFetch = (originalFetch?: any) => {
|
||||
return async (url: string, options: any) => {
|
||||
if (options?.body) {
|
||||
try {
|
||||
const body = JSON.parse(options.body)
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length === 0 && body.tool_choice) {
|
||||
delete body.tool_choice
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch ? originalFetch(url, options) : fetch(url, options)
|
||||
}
|
||||
}
|
||||
class CherryInOpenAIChatLanguageModel extends OpenAIChatLanguageModel {
|
||||
constructor(modelId: string, settings: any) {
|
||||
super(modelId, {
|
||||
...settings,
|
||||
fetch: createCustomFetch(settings.fetch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resolveConfiguredHeaders = (headers?: HeadersInput): Record<string, HeaderValue> => {
|
||||
if (typeof headers === 'function') {
|
||||
return { ...headers() }
|
||||
}
|
||||
return headers ? { ...headers } : {}
|
||||
}
|
||||
|
||||
const toBearerToken = (authorization?: string) => (authorization ? authorization.replace(/^Bearer\s+/i, '') : undefined)
|
||||
|
||||
const createJsonHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
'Content-Type': 'application/json',
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
const createAuthHeadersGetter = (options: CherryInProviderSettings): (() => Record<string, HeaderValue>) => {
|
||||
return () => ({
|
||||
Authorization: `Bearer ${resolveApiKey(options)}`,
|
||||
...resolveConfiguredHeaders(options.headers)
|
||||
})
|
||||
}
|
||||
|
||||
export const createCherryIn = (options: CherryInProviderSettings = {}): CherryInProvider => {
|
||||
const {
|
||||
baseURL = DEFAULT_CHERRYIN_BASE_URL,
|
||||
anthropicBaseURL = DEFAULT_CHERRYIN_ANTHROPIC_BASE_URL,
|
||||
geminiBaseURL = DEFAULT_CHERRYIN_GEMINI_BASE_URL,
|
||||
fetch
|
||||
} = options
|
||||
|
||||
const getJsonHeaders = createJsonHeadersGetter(options)
|
||||
const getAuthHeaders = createAuthHeadersGetter(options)
|
||||
|
||||
const url = ({ path }: { path: string; modelId: string }) => `${withoutTrailingSlash(baseURL)}${path}`
|
||||
|
||||
const createAnthropicModel = (modelId: string) =>
|
||||
new AnthropicMessagesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.anthropic`,
|
||||
baseURL: anthropicBaseURL,
|
||||
headers: () => {
|
||||
const headers = getJsonHeaders()
|
||||
const apiKey = toBearerToken(headers.Authorization)
|
||||
return {
|
||||
...headers,
|
||||
'x-api-key': apiKey
|
||||
}
|
||||
},
|
||||
fetch,
|
||||
supportedUrls: () => ({
|
||||
'image/*': [/^https?:\/\/.*$/]
|
||||
})
|
||||
})
|
||||
|
||||
const createGeminiModel = (modelId: string) =>
|
||||
new GoogleGenerativeAILanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.google`,
|
||||
baseURL: geminiBaseURL,
|
||||
headers: () => {
|
||||
const headers = getJsonHeaders()
|
||||
const apiKey = toBearerToken(headers.Authorization)
|
||||
return {
|
||||
...headers,
|
||||
'x-goog-api-key': apiKey
|
||||
}
|
||||
},
|
||||
fetch,
|
||||
generateId: () => `${CHERRYIN_PROVIDER_NAME}-${Date.now()}`,
|
||||
supportedUrls: () => ({})
|
||||
})
|
||||
|
||||
const createOpenAIChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new CherryInOpenAIChatLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.openai-chat`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createChatModel = (modelId: string, settings: OpenAIProviderSettings = {}) => {
|
||||
if (isAnthropicModel(modelId)) {
|
||||
return createAnthropicModel(modelId)
|
||||
}
|
||||
if (isGeminiModel(modelId)) {
|
||||
return createGeminiModel(modelId)
|
||||
}
|
||||
return new OpenAIResponsesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.openai`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
}
|
||||
|
||||
const createCompletionModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAICompletionLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.completion`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createEmbeddingModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAIEmbeddingModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.embeddings`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createResponsesModel = (modelId: string) =>
|
||||
new OpenAIResponsesLanguageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.responses`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createImageModel = (modelId: string, settings: OpenAIProviderSettings = {}) =>
|
||||
new OpenAIImageModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.image`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders(),
|
||||
...settings.headers
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createTranscriptionModel = (modelId: string) =>
|
||||
new OpenAITranscriptionModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.transcription`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getAuthHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const createSpeechModel = (modelId: string) =>
|
||||
new OpenAISpeechModel(modelId, {
|
||||
provider: `${CHERRYIN_PROVIDER_NAME}.speech`,
|
||||
url,
|
||||
headers: () => ({
|
||||
...getJsonHeaders()
|
||||
}),
|
||||
fetch
|
||||
})
|
||||
|
||||
const provider: CherryInProvider = function (modelId: string, settings?: OpenAIProviderSettings) {
|
||||
if (new.target) {
|
||||
throw new Error('CherryIN provider function cannot be called with the new keyword.')
|
||||
}
|
||||
|
||||
return createChatModel(modelId, settings)
|
||||
}
|
||||
|
||||
provider.languageModel = createChatModel
|
||||
provider.chat = createOpenAIChatModel
|
||||
|
||||
provider.responses = createResponsesModel
|
||||
provider.completion = createCompletionModel
|
||||
|
||||
provider.embedding = createEmbeddingModel
|
||||
provider.textEmbedding = createEmbeddingModel
|
||||
provider.textEmbeddingModel = createEmbeddingModel
|
||||
|
||||
provider.image = createImageModel
|
||||
provider.imageModel = createImageModel
|
||||
|
||||
provider.transcription = createTranscriptionModel
|
||||
provider.transcriptionModel = createTranscriptionModel
|
||||
|
||||
provider.speech = createSpeechModel
|
||||
provider.speechModel = createSpeechModel
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
export const cherryIn = createCherryIn()
|
||||
1
packages/ai-sdk-provider/src/index.ts
Normal file
1
packages/ai-sdk-provider/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './cherryin-provider'
|
||||
19
packages/ai-sdk-provider/tsconfig.json
Normal file
19
packages/ai-sdk-provider/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmitOnError": false,
|
||||
"outDir": "./dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2020"
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
12
packages/ai-sdk-provider/tsdown.config.ts
Normal file
12
packages/ai-sdk-provider/tsdown.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts'
|
||||
},
|
||||
outDir: 'dist',
|
||||
format: ['esm', 'cjs'],
|
||||
clean: true,
|
||||
dts: true,
|
||||
tsconfig: 'tsconfig.json'
|
||||
})
|
||||
@@ -39,11 +39,13 @@
|
||||
"@ai-sdk/anthropic": "^2.0.43",
|
||||
"@ai-sdk/azure": "^2.0.66",
|
||||
"@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/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.16",
|
||||
"@ai-sdk/xai": "^2.0.31",
|
||||
"@cherrystudio/ai-sdk-provider": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { anthropic } from '@ai-sdk/anthropic'
|
||||
import type { google } from '@ai-sdk/google'
|
||||
import type { openai } from '@ai-sdk/openai'
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import type { InferToolInput, InferToolOutput } from 'ai'
|
||||
import { type Tool } from 'ai'
|
||||
|
||||
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import type { ProviderOptionsMap } from '../../../options/types'
|
||||
import type { OpenRouterSearchConfig } from './openrouter'
|
||||
|
||||
@@ -95,3 +96,56 @@ export type WebSearchToolInputSchema = {
|
||||
google: InferToolInput<GoogleWebSearchTool>
|
||||
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
|
||||
}
|
||||
|
||||
export const switchWebSearchTool = (providerId: string, config: WebSearchPluginConfig, params: any) => {
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
if (config.openai) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = openai.tools.webSearch(config.openai)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'openai-chat': {
|
||||
if (config['openai-chat']) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'anthropic': {
|
||||
if (config.anthropic) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
// case 'google-vertex':
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = google.tools.googleSearch(config.google || {})
|
||||
break
|
||||
}
|
||||
|
||||
case 'xai': {
|
||||
if (config.xai) {
|
||||
const searchOptions = createXaiOptions({
|
||||
searchParameters: { ...config.xai, mode: 'on' }
|
||||
})
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
if (config.openrouter) {
|
||||
const searchOptions = createOpenRouterOptions(config.openrouter)
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
* Web Search Plugin
|
||||
* 提供统一的网络搜索能力,支持多个 AI Provider
|
||||
*/
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
|
||||
import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options'
|
||||
import { definePlugin } from '../../'
|
||||
import type { AiRequestContext } from '../../types'
|
||||
import type { WebSearchPluginConfig } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG } from './helper'
|
||||
import { DEFAULT_WEB_SEARCH_CONFIG, switchWebSearchTool } from './helper'
|
||||
|
||||
/**
|
||||
* 网络搜索插件
|
||||
@@ -24,56 +20,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR
|
||||
|
||||
transformParams: async (params: any, context: AiRequestContext) => {
|
||||
const { providerId } = context
|
||||
switch (providerId) {
|
||||
case 'openai': {
|
||||
if (config.openai) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = openai.tools.webSearch(config.openai)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'openai-chat': {
|
||||
if (config['openai-chat']) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat'])
|
||||
}
|
||||
break
|
||||
}
|
||||
switchWebSearchTool(providerId, config, params)
|
||||
|
||||
case 'anthropic': {
|
||||
if (config.anthropic) {
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic)
|
||||
if (providerId === 'cherryin' || providerId === 'cherryin-chat') {
|
||||
// cherryin.gemini
|
||||
const _providerId = params.model.provider.split('.')[1]
|
||||
switchWebSearchTool(_providerId, config, params)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'google': {
|
||||
// case 'google-vertex':
|
||||
if (!params.tools) params.tools = {}
|
||||
params.tools.web_search = google.tools.googleSearch(config.google || {})
|
||||
break
|
||||
}
|
||||
|
||||
case 'xai': {
|
||||
if (config.xai) {
|
||||
const searchOptions = createXaiOptions({
|
||||
searchParameters: { ...config.xai, mode: 'on' }
|
||||
})
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'openrouter': {
|
||||
if (config.openrouter) {
|
||||
const searchOptions = createOpenRouterOptions(config.openrouter)
|
||||
params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import type { LanguageModelV2 } from '@ai-sdk/provider'
|
||||
import { createXai } from '@ai-sdk/xai'
|
||||
import { type CherryInProviderSettings, createCherryIn } from '@cherrystudio/ai-sdk-provider'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { Provider } from 'ai'
|
||||
import { customProvider } from 'ai'
|
||||
@@ -31,6 +32,8 @@ export const baseProviderIds = [
|
||||
'azure-responses',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'cherryin',
|
||||
'cherryin-chat',
|
||||
'huggingface'
|
||||
] as const
|
||||
|
||||
@@ -136,6 +139,26 @@ export const baseProviders = [
|
||||
creator: createOpenRouter,
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'cherryin',
|
||||
name: 'CherryIN',
|
||||
creator: createCherryIn,
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'cherryin-chat',
|
||||
name: 'CherryIN Chat',
|
||||
creator: (options: CherryInProviderSettings) => {
|
||||
const provider = createCherryIn(options)
|
||||
return customProvider({
|
||||
fallbackProvider: {
|
||||
...provider,
|
||||
languageModel: (modelId: string) => provider.chat(modelId)
|
||||
}
|
||||
})
|
||||
},
|
||||
supportsImageGeneration: true
|
||||
},
|
||||
{
|
||||
id: 'huggingface',
|
||||
name: 'HuggingFace',
|
||||
|
||||
@@ -189,6 +189,7 @@ export enum IpcChannel {
|
||||
Fs_ReadText = 'fs:readText',
|
||||
File_OpenWithRelativePath = 'file:openWithRelativePath',
|
||||
File_IsTextFile = 'file:isTextFile',
|
||||
File_ListDirectory = 'file:listDirectory',
|
||||
File_GetDirectoryStructure = 'file:getDirectoryStructure',
|
||||
File_CheckFileName = 'file:checkFileName',
|
||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||
|
||||
@@ -197,12 +197,22 @@ export enum FeedUrl {
|
||||
GITHUB_LATEST = 'https://github.com/CherryHQ/cherry-studio/releases/latest/download'
|
||||
}
|
||||
|
||||
export enum UpdateConfigUrl {
|
||||
GITHUB = 'https://raw.githubusercontent.com/CherryHQ/cherry-studio/refs/heads/x-files/app-upgrade-config/app-upgrade-config.json',
|
||||
GITCODE = 'https://raw.gitcode.com/CherryHQ/cherry-studio/raw/x-files/app-upgrade-config/app-upgrade-config.json'
|
||||
}
|
||||
|
||||
export enum UpgradeChannel {
|
||||
LATEST = 'latest', // 最新稳定版本
|
||||
RC = 'rc', // 公测版本
|
||||
BETA = 'beta' // 预览版本
|
||||
}
|
||||
|
||||
export enum UpdateMirror {
|
||||
GITHUB = 'github',
|
||||
GITCODE = 'gitcode'
|
||||
}
|
||||
|
||||
export const defaultTimeout = 10 * 1000 * 60
|
||||
|
||||
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
|
||||
|
||||
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
1
resources/database/drizzle/0002_wealthy_naoko.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `sessions` ADD `slash_commands` text;
|
||||
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
346
resources/database/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8",
|
||||
"prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session_messages": {
|
||||
"name": "session_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"executed_at": {
|
||||
"name": "executed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slash_commands": {
|
||||
"name": "slash_commands",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1758187378775,
|
||||
"tag": "0001_woozy_captain_flint",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1762526423527,
|
||||
"tag": "0002_wealthy_naoko",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
532
scripts/update-app-upgrade-config.ts
Normal file
532
scripts/update-app-upgrade-config.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import semver from 'semver'
|
||||
|
||||
type UpgradeChannel = 'latest' | 'rc' | 'beta'
|
||||
type UpdateMirror = 'github' | 'gitcode'
|
||||
|
||||
const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta']
|
||||
const MIRRORS: UpdateMirror[] = ['github', 'gitcode']
|
||||
const GITHUB_REPO = 'CherryHQ/cherry-studio'
|
||||
const GITCODE_REPO = 'CherryHQ/cherry-studio'
|
||||
const DEFAULT_FEED_TEMPLATES: Record<UpdateMirror, string> = {
|
||||
github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`,
|
||||
gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}`
|
||||
}
|
||||
const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com'
|
||||
|
||||
interface CliOptions {
|
||||
tag?: string
|
||||
configPath?: string
|
||||
segmentsPath?: string
|
||||
dryRun?: boolean
|
||||
skipReleaseChecks?: boolean
|
||||
isPrerelease?: boolean
|
||||
}
|
||||
|
||||
interface ChannelTemplateConfig {
|
||||
feedTemplates?: Partial<Record<UpdateMirror, string>>
|
||||
}
|
||||
|
||||
interface SegmentMatchRule {
|
||||
range?: string
|
||||
exact?: string[]
|
||||
excludeExact?: string[]
|
||||
}
|
||||
|
||||
interface SegmentDefinition {
|
||||
id: string
|
||||
type: 'legacy' | 'breaking' | 'latest'
|
||||
match: SegmentMatchRule
|
||||
lockedVersion?: string
|
||||
minCompatibleVersion: string
|
||||
description: string
|
||||
channelTemplates?: Partial<Record<UpgradeChannel, ChannelTemplateConfig>>
|
||||
}
|
||||
|
||||
interface SegmentMetadataFile {
|
||||
segments: SegmentDefinition[]
|
||||
}
|
||||
|
||||
interface ChannelConfig {
|
||||
version: string
|
||||
feedUrls: Record<UpdateMirror, string>
|
||||
}
|
||||
|
||||
interface VersionMetadata {
|
||||
segmentId: string
|
||||
segmentType?: string
|
||||
}
|
||||
|
||||
interface VersionEntry {
|
||||
metadata?: VersionMetadata
|
||||
minCompatibleVersion: string
|
||||
description: string
|
||||
channels: Record<UpgradeChannel, ChannelConfig | null>
|
||||
}
|
||||
|
||||
interface UpgradeConfigFile {
|
||||
lastUpdated: string
|
||||
versions: Record<string, VersionEntry>
|
||||
}
|
||||
|
||||
interface ReleaseInfo {
|
||||
tag: string
|
||||
version: string
|
||||
channel: UpgradeChannel
|
||||
}
|
||||
|
||||
interface UpdateVersionsResult {
|
||||
versions: Record<string, VersionEntry>
|
||||
updated: boolean
|
||||
}
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..')
|
||||
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json')
|
||||
const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json')
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs()
|
||||
const releaseTag = resolveTag(options)
|
||||
const normalizedVersion = normalizeVersion(releaseTag)
|
||||
const releaseChannel = detectChannel(normalizedVersion)
|
||||
if (!releaseChannel) {
|
||||
console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate version format matches prerelease status
|
||||
if (options.isPrerelease !== undefined) {
|
||||
const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc'
|
||||
|
||||
if (options.isPrerelease && !hasPrereleaseSuffix) {
|
||||
console.warn(
|
||||
`[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.isPrerelease && hasPrereleaseSuffix) {
|
||||
console.warn(
|
||||
`[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const [config, segmentFile] = await Promise.all([
|
||||
readJson<UpgradeConfigFile>(options.configPath ?? DEFAULT_CONFIG_PATH),
|
||||
readJson<SegmentMetadataFile>(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH)
|
||||
])
|
||||
|
||||
const segment = pickSegment(segmentFile.segments, normalizedVersion)
|
||||
if (!segment) {
|
||||
throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`)
|
||||
}
|
||||
|
||||
if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) {
|
||||
throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`)
|
||||
}
|
||||
|
||||
const releaseInfo: ReleaseInfo = {
|
||||
tag: formatTag(releaseTag),
|
||||
version: normalizedVersion,
|
||||
channel: releaseChannel
|
||||
}
|
||||
|
||||
const { versions: updatedVersions, updated } = await updateVersions(
|
||||
config.versions,
|
||||
segment,
|
||||
releaseInfo,
|
||||
Boolean(options.skipReleaseChecks)
|
||||
)
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.`
|
||||
)
|
||||
}
|
||||
|
||||
const updatedConfig: UpgradeConfigFile = {
|
||||
...config,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
versions: updatedVersions
|
||||
}
|
||||
|
||||
const output = JSON.stringify(updatedConfig, null, 2) + '\n'
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('Dry run enabled. Generated configuration:\n')
|
||||
console.log(output)
|
||||
return
|
||||
}
|
||||
|
||||
await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8')
|
||||
console.log(
|
||||
`✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2)
|
||||
const options: CliOptions = {}
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i]
|
||||
if (arg === '--tag') {
|
||||
options.tag = args[i + 1]
|
||||
i += 1
|
||||
} else if (arg === '--config') {
|
||||
options.configPath = args[i + 1]
|
||||
i += 1
|
||||
} else if (arg === '--segments') {
|
||||
options.segmentsPath = args[i + 1]
|
||||
i += 1
|
||||
} else if (arg === '--dry-run') {
|
||||
options.dryRun = true
|
||||
} else if (arg === '--skip-release-checks') {
|
||||
options.skipReleaseChecks = true
|
||||
} else if (arg === '--is-prerelease') {
|
||||
options.isPrerelease = args[i + 1] === 'true'
|
||||
i += 1
|
||||
} else if (arg === '--help') {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.warn(`Ignoring unknown argument "${arg}"`)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.skipReleaseChecks && !options.dryRun) {
|
||||
throw new Error('--skip-release-checks can only be used together with --dry-run')
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options]
|
||||
|
||||
Options:
|
||||
--tag <tag> Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG.
|
||||
--config <path> Path to app-upgrade-config.json.
|
||||
--segments <path> Path to app-upgrade-segments.json.
|
||||
--is-prerelease <true|false> Whether this is a prerelease (validates version format).
|
||||
--dry-run Print the result without writing to disk.
|
||||
--skip-release-checks Skip release page availability checks (only valid with --dry-run).
|
||||
--help Show this help message.`)
|
||||
}
|
||||
|
||||
function resolveTag(options: CliOptions): string {
|
||||
const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME
|
||||
const tag = options.tag ?? envTag
|
||||
|
||||
if (!tag) {
|
||||
throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.')
|
||||
}
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
function normalizeVersion(tag: string): string {
|
||||
const cleaned = semver.clean(tag, { loose: true })
|
||||
if (!cleaned) {
|
||||
throw new Error(`Tag "${tag}" is not a valid semantic version`)
|
||||
}
|
||||
|
||||
const valid = semver.valid(cleaned, { loose: true })
|
||||
if (!valid) {
|
||||
throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`)
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
function detectChannel(version: string): UpgradeChannel | null {
|
||||
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (parsed.prerelease.length === 0) {
|
||||
return 'latest'
|
||||
}
|
||||
|
||||
const label = String(parsed.prerelease[0]).toLowerCase()
|
||||
if (label === 'beta') {
|
||||
return 'beta'
|
||||
}
|
||||
if (label === 'rc') {
|
||||
return 'rc'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string): Promise<T> {
|
||||
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
|
||||
const data = await fs.readFile(absolute, 'utf-8')
|
||||
return JSON.parse(data) as T
|
||||
}
|
||||
|
||||
function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null {
|
||||
for (const segment of segments) {
|
||||
if (matchesSegment(segment.match, version)) {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean {
|
||||
if (matchRule.exact && matchRule.exact.includes(version)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (matchRule.exact) {
|
||||
return matchRule.exact.includes(version)
|
||||
}
|
||||
|
||||
return Boolean(matchRule.range)
|
||||
}
|
||||
|
||||
function formatTag(tag: string): string {
|
||||
if (tag.startsWith('refs/tags/')) {
|
||||
return tag.replace('refs/tags/', '')
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
async function updateVersions(
|
||||
versions: Record<string, VersionEntry>,
|
||||
segment: SegmentDefinition,
|
||||
releaseInfo: ReleaseInfo,
|
||||
skipReleaseValidation: boolean
|
||||
): Promise<UpdateVersionsResult> {
|
||||
const versionsCopy: Record<string, VersionEntry> = { ...versions }
|
||||
const existingKey = findVersionKeyBySegment(versionsCopy, segment.id)
|
||||
const targetKey = resolveVersionKey(existingKey, segment, releaseInfo)
|
||||
const shouldRename = existingKey && existingKey !== targetKey
|
||||
|
||||
let entry: VersionEntry
|
||||
if (existingKey) {
|
||||
entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } }
|
||||
} else {
|
||||
entry = createEmptyVersionEntry()
|
||||
}
|
||||
|
||||
entry.channels = ensureChannelSlots(entry.channels)
|
||||
|
||||
const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation)
|
||||
if (!channelUpdated) {
|
||||
return { versions, updated: false }
|
||||
}
|
||||
|
||||
if (shouldRename && existingKey) {
|
||||
delete versionsCopy[existingKey]
|
||||
}
|
||||
|
||||
entry.metadata = {
|
||||
segmentId: segment.id,
|
||||
segmentType: segment.type
|
||||
}
|
||||
entry.minCompatibleVersion = segment.minCompatibleVersion
|
||||
entry.description = segment.description
|
||||
|
||||
versionsCopy[targetKey] = entry
|
||||
return {
|
||||
versions: sortVersionMap(versionsCopy),
|
||||
updated: true
|
||||
}
|
||||
}
|
||||
|
||||
function findVersionKeyBySegment(versions: Record<string, VersionEntry>, segmentId: string): string | null {
|
||||
for (const [key, value] of Object.entries(versions)) {
|
||||
if (value.metadata?.segmentId === segmentId) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string {
|
||||
if (segment.lockedVersion) {
|
||||
return segment.lockedVersion
|
||||
}
|
||||
|
||||
if (releaseInfo.channel === 'latest') {
|
||||
return releaseInfo.version
|
||||
}
|
||||
|
||||
if (existingKey) {
|
||||
return existingKey
|
||||
}
|
||||
|
||||
const baseVersion = getBaseVersion(releaseInfo.version)
|
||||
return baseVersion ?? releaseInfo.version
|
||||
}
|
||||
|
||||
function getBaseVersion(version: string): string | null {
|
||||
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
return `${parsed.major}.${parsed.minor}.${parsed.patch}`
|
||||
}
|
||||
|
||||
function createEmptyVersionEntry(): VersionEntry {
|
||||
return {
|
||||
minCompatibleVersion: '',
|
||||
description: '',
|
||||
channels: {
|
||||
latest: null,
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureChannelSlots(
|
||||
channels: Record<UpgradeChannel, ChannelConfig | null>
|
||||
): Record<UpgradeChannel, ChannelConfig | null> {
|
||||
return CHANNELS.reduce(
|
||||
(acc, channel) => {
|
||||
acc[channel] = channels[channel] ?? null
|
||||
return acc
|
||||
},
|
||||
{} as Record<UpgradeChannel, ChannelConfig | null>
|
||||
)
|
||||
}
|
||||
|
||||
async function applyChannelUpdate(
|
||||
entry: VersionEntry,
|
||||
segment: SegmentDefinition,
|
||||
releaseInfo: ReleaseInfo,
|
||||
skipReleaseValidation: boolean
|
||||
): Promise<boolean> {
|
||||
if (!CHANNELS.includes(releaseInfo.channel)) {
|
||||
throw new Error(`Unsupported channel "${releaseInfo.channel}"`)
|
||||
}
|
||||
|
||||
const feedUrls = buildFeedUrls(segment, releaseInfo)
|
||||
|
||||
if (skipReleaseValidation) {
|
||||
console.warn(
|
||||
`[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).`
|
||||
)
|
||||
} else {
|
||||
const availability = await ensureReleaseAvailability(releaseInfo)
|
||||
if (!availability.github) {
|
||||
return false
|
||||
}
|
||||
if (releaseInfo.channel === 'latest' && !availability.gitcode) {
|
||||
console.warn(
|
||||
`[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.`
|
||||
)
|
||||
feedUrls.gitcode = GITCODE_LATEST_FALLBACK
|
||||
}
|
||||
}
|
||||
|
||||
entry.channels[releaseInfo.channel] = {
|
||||
version: releaseInfo.version,
|
||||
feedUrls
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record<UpdateMirror, string> {
|
||||
return MIRRORS.reduce(
|
||||
(acc, mirror) => {
|
||||
const template = resolveFeedTemplate(segment, releaseInfo, mirror)
|
||||
acc[mirror] = applyTemplate(template, releaseInfo)
|
||||
return acc
|
||||
},
|
||||
{} as Record<UpdateMirror, string>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string {
|
||||
if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') {
|
||||
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github
|
||||
}
|
||||
|
||||
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror]
|
||||
}
|
||||
|
||||
function applyTemplate(template: string, releaseInfo: ReleaseInfo): string {
|
||||
return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version)
|
||||
}
|
||||
|
||||
function sortVersionMap(versions: Record<string, VersionEntry>): Record<string, VersionEntry> {
|
||||
const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b))
|
||||
return sorted.reduce(
|
||||
(acc, [version, entry]) => {
|
||||
acc[version] = entry
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, VersionEntry>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReleaseAvailability {
|
||||
github: boolean
|
||||
gitcode: boolean
|
||||
}
|
||||
|
||||
async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise<ReleaseAvailability> {
|
||||
const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github']
|
||||
const availability: ReleaseAvailability = {
|
||||
github: false,
|
||||
gitcode: releaseInfo.channel === 'latest' ? false : true
|
||||
}
|
||||
|
||||
for (const mirror of mirrorsToCheck) {
|
||||
const url = getReleasePageUrl(mirror, releaseInfo.tag)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: mirror === 'github' ? 'HEAD' : 'GET',
|
||||
redirect: 'follow'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
availability[mirror] = true
|
||||
} else {
|
||||
console.warn(
|
||||
`[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).`
|
||||
)
|
||||
availability[mirror] = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`,
|
||||
error
|
||||
)
|
||||
availability[mirror] = false
|
||||
}
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
function getReleasePageUrl(mirror: UpdateMirror, tag: string): string {
|
||||
if (mirror === 'github') {
|
||||
return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`
|
||||
}
|
||||
// Use latest.yml download URL for GitCode to check if release exists
|
||||
// Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability
|
||||
return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml`
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to update app-upgrade-config:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -551,6 +551,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { FeedUrl, UpdateConfigUrl, UpdateMirror, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import type { UpdateInfo } from 'builder-util-runtime'
|
||||
import { CancellationToken } from 'builder-util-runtime'
|
||||
@@ -22,7 +22,29 @@ const LANG_MARKERS = {
|
||||
EN_START: '<!--LANG:en-->',
|
||||
ZH_CN_START: '<!--LANG:zh-CN-->',
|
||||
END: '<!--LANG:END-->'
|
||||
} as const
|
||||
}
|
||||
|
||||
interface UpdateConfig {
|
||||
lastUpdated: string
|
||||
versions: {
|
||||
[versionKey: string]: VersionConfig
|
||||
}
|
||||
}
|
||||
|
||||
interface VersionConfig {
|
||||
minCompatibleVersion: string
|
||||
description: string
|
||||
channels: {
|
||||
latest: ChannelConfig | null
|
||||
rc: ChannelConfig | null
|
||||
beta: ChannelConfig | null
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelConfig {
|
||||
version: string
|
||||
feedUrls: Record<UpdateMirror, string>
|
||||
}
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
@@ -37,7 +59,9 @@ export default class AppUpdater {
|
||||
autoUpdater.requestHeaders = {
|
||||
...autoUpdater.requestHeaders,
|
||||
'User-Agent': generateUserAgent(),
|
||||
'X-Client-Id': configManager.getClientId()
|
||||
'X-Client-Id': configManager.getClientId(),
|
||||
// no-cache
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
@@ -75,61 +99,6 @@ export default class AppUpdater {
|
||||
this.autoUpdater = autoUpdater
|
||||
}
|
||||
|
||||
private async _getReleaseVersionFromGithub(channel: UpgradeChannel) {
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
}
|
||||
try {
|
||||
logger.info(`get release version from github: ${channel}`)
|
||||
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
|
||||
headers
|
||||
})
|
||||
const data = (await responses.json()) as GithubReleaseInfo[]
|
||||
let mightHaveLatest = false
|
||||
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
|
||||
if (!item.draft && !item.prerelease) {
|
||||
mightHaveLatest = true
|
||||
}
|
||||
|
||||
return item.prerelease && item.tag_name.includes(`-${channel}.`)
|
||||
})
|
||||
|
||||
if (!release) {
|
||||
return null
|
||||
}
|
||||
|
||||
// if the release version is the same as the current version, return null
|
||||
if (release.tag_name === app.getVersion()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (mightHaveLatest) {
|
||||
logger.info(`might have latest release, get latest release`)
|
||||
const latestReleaseResponse = await net.fetch(
|
||||
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
|
||||
{
|
||||
headers
|
||||
}
|
||||
)
|
||||
const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo
|
||||
if (semver.gt(latestRelease.tag_name, release.tag_name)) {
|
||||
logger.info(
|
||||
`latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`release url is ${release.tag_name}, set channel to ${channel}`)
|
||||
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
|
||||
} catch (error) {
|
||||
logger.error('Failed to get latest not draft version from github:', error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public setAutoUpdate(isActive: boolean) {
|
||||
autoUpdater.autoDownload = isActive
|
||||
autoUpdater.autoInstallOnAppQuit = isActive
|
||||
@@ -161,6 +130,88 @@ export default class AppUpdater {
|
||||
return UpgradeChannel.LATEST
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch update configuration from GitHub or GitCode based on mirror
|
||||
* @param mirror - Mirror to fetch config from
|
||||
* @returns UpdateConfig object or null if fetch fails
|
||||
*/
|
||||
private async _fetchUpdateConfig(mirror: UpdateMirror): Promise<UpdateConfig | null> {
|
||||
const configUrl = mirror === UpdateMirror.GITCODE ? UpdateConfigUrl.GITCODE : UpdateConfigUrl.GITHUB
|
||||
|
||||
try {
|
||||
logger.info(`Fetching update config from ${configUrl} (mirror: ${mirror})`)
|
||||
const response = await net.fetch(configUrl, {
|
||||
headers: {
|
||||
'User-Agent': generateUserAgent(),
|
||||
Accept: 'application/json',
|
||||
'X-Client-Id': configManager.getClientId(),
|
||||
// no-cache
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const config = (await response.json()) as UpdateConfig
|
||||
logger.info(`Update config fetched successfully, last updated: ${config.lastUpdated}`)
|
||||
return config
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch update config:', error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find compatible channel configuration based on current version
|
||||
* @param currentVersion - Current app version
|
||||
* @param requestedChannel - Requested upgrade channel (latest/rc/beta)
|
||||
* @param config - Update configuration object
|
||||
* @returns Object containing ChannelConfig and actual channel if found, null otherwise
|
||||
*/
|
||||
private _findCompatibleChannel(
|
||||
currentVersion: string,
|
||||
requestedChannel: UpgradeChannel,
|
||||
config: UpdateConfig
|
||||
): { config: ChannelConfig; channel: UpgradeChannel } | null {
|
||||
// Get all version keys and sort descending (newest first)
|
||||
const versionKeys = Object.keys(config.versions).sort(semver.rcompare)
|
||||
|
||||
logger.info(
|
||||
`Finding compatible channel for version ${currentVersion}, requested channel: ${requestedChannel}, available versions: ${versionKeys.join(', ')}`
|
||||
)
|
||||
|
||||
for (const versionKey of versionKeys) {
|
||||
const versionConfig = config.versions[versionKey]
|
||||
const channelConfig = versionConfig.channels[requestedChannel]
|
||||
const latestChannelConfig = versionConfig.channels[UpgradeChannel.LATEST]
|
||||
|
||||
// Check version compatibility and channel availability
|
||||
if (semver.gte(currentVersion, versionConfig.minCompatibleVersion) && channelConfig !== null) {
|
||||
logger.info(
|
||||
`Found compatible version: ${versionKey} (minCompatibleVersion: ${versionConfig.minCompatibleVersion}), version: ${channelConfig.version}`
|
||||
)
|
||||
|
||||
if (
|
||||
requestedChannel !== UpgradeChannel.LATEST &&
|
||||
latestChannelConfig &&
|
||||
semver.gte(latestChannelConfig.version, channelConfig.version)
|
||||
) {
|
||||
logger.info(
|
||||
`latest channel version is greater than the requested channel version: ${latestChannelConfig.version} > ${channelConfig.version}, using latest instead`
|
||||
)
|
||||
return { config: latestChannelConfig, channel: UpgradeChannel.LATEST }
|
||||
}
|
||||
|
||||
return { config: channelConfig, channel: requestedChannel }
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`No compatible channel found for version ${currentVersion} and channel ${requestedChannel}`)
|
||||
return null
|
||||
}
|
||||
|
||||
private _setChannel(channel: UpgradeChannel, feedUrl: string) {
|
||||
this.autoUpdater.channel = channel
|
||||
this.autoUpdater.setFeedURL(feedUrl)
|
||||
@@ -172,35 +223,44 @@ export default class AppUpdater {
|
||||
}
|
||||
|
||||
private async _setFeedUrl() {
|
||||
const currentVersion = app.getVersion()
|
||||
const testPlan = configManager.getTestPlan()
|
||||
if (testPlan) {
|
||||
const channel = this._getTestChannel()
|
||||
const requestedChannel = testPlan ? this._getTestChannel() : UpgradeChannel.LATEST
|
||||
|
||||
if (channel === UpgradeChannel.LATEST) {
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
return
|
||||
}
|
||||
|
||||
const releaseUrl = await this._getReleaseVersionFromGithub(channel)
|
||||
if (releaseUrl) {
|
||||
logger.info(`release url is ${releaseUrl}, set channel to ${channel}`)
|
||||
this._setChannel(channel, releaseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// if no prerelease url, use github latest to get release
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
return
|
||||
}
|
||||
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
|
||||
// Determine mirror based on IP country
|
||||
const ipCountry = await getIpCountry()
|
||||
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
|
||||
if (ipCountry.toLowerCase() !== 'cn') {
|
||||
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
|
||||
const mirror = ipCountry.toLowerCase() === 'cn' ? UpdateMirror.GITCODE : UpdateMirror.GITHUB
|
||||
|
||||
logger.info(
|
||||
`Setting feed URL for version ${currentVersion}, testPlan: ${testPlan}, requested channel: ${requestedChannel}, mirror: ${mirror} (IP country: ${ipCountry})`
|
||||
)
|
||||
|
||||
// Try to fetch update config from remote
|
||||
const config = await this._fetchUpdateConfig(mirror)
|
||||
|
||||
if (config) {
|
||||
// Use new config-based system
|
||||
const result = this._findCompatibleChannel(currentVersion, requestedChannel, config)
|
||||
|
||||
if (result) {
|
||||
const { config: channelConfig, channel: actualChannel } = result
|
||||
const feedUrl = channelConfig.feedUrls[mirror]
|
||||
logger.info(
|
||||
`Using config-based feed URL: ${feedUrl} for channel ${actualChannel} (requested: ${requestedChannel}, mirror: ${mirror})`
|
||||
)
|
||||
this._setChannel(actualChannel, feedUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Failed to fetch update config, falling back to default feed URL')
|
||||
// Fallback: use default feed URL based on mirror
|
||||
const defaultFeedUrl = mirror === UpdateMirror.GITCODE ? FeedUrl.PRODUCTION : FeedUrl.GITHUB_LATEST
|
||||
|
||||
logger.info(`Using fallback feed URL: ${defaultFeedUrl}`)
|
||||
this._setChannel(UpgradeChannel.LATEST, defaultFeedUrl)
|
||||
}
|
||||
|
||||
public cancelDownload() {
|
||||
this.cancellationToken.cancel()
|
||||
this.cancellationToken = new CancellationToken()
|
||||
@@ -320,8 +380,3 @@ export default class AppUpdater {
|
||||
return processedInfo
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar'
|
||||
import chokidar from 'chokidar'
|
||||
import * as crypto from 'crypto'
|
||||
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
||||
import { app } from 'electron'
|
||||
import { dialog, net, shell } from 'electron'
|
||||
import * as fs from 'fs'
|
||||
import { writeFileSync } from 'fs'
|
||||
@@ -30,6 +31,73 @@ import WordExtractor from 'word-extractor'
|
||||
|
||||
const logger = loggerService.withContext('FileStorage')
|
||||
|
||||
// Get ripgrep binary path
|
||||
const getRipgrepBinaryPath = (): string | null => {
|
||||
try {
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux'
|
||||
let ripgrepBinaryPath = path.join(
|
||||
__dirname,
|
||||
'../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep',
|
||||
`${arch}-${platform}`,
|
||||
process.platform === 'win32' ? 'rg.exe' : 'rg'
|
||||
)
|
||||
|
||||
if (app.isPackaged) {
|
||||
ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
|
||||
}
|
||||
|
||||
if (fs.existsSync(ripgrepBinaryPath)) {
|
||||
return ripgrepBinaryPath
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Failed to locate ripgrep binary:', error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute ripgrep with captured output
|
||||
*/
|
||||
function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ripgrepBinaryPath = getRipgrepBinaryPath()
|
||||
|
||||
if (!ripgrepBinaryPath) {
|
||||
reject(new Error('Ripgrep binary not available'))
|
||||
return
|
||||
}
|
||||
|
||||
const { spawn } = require('child_process')
|
||||
const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
let output = ''
|
||||
let errorOutput = ''
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
output += data.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
|
||||
child.on('close', (code: number) => {
|
||||
resolve({
|
||||
exitCode: code || 0,
|
||||
output: output || errorOutput
|
||||
})
|
||||
})
|
||||
|
||||
child.on('error', (error: Error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface FileWatcherConfig {
|
||||
watchExtensions?: string[]
|
||||
ignoredPatterns?: (string | RegExp)[]
|
||||
@@ -54,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
|
||||
eventChannel: 'file-change'
|
||||
}
|
||||
|
||||
interface DirectoryListOptions {
|
||||
recursive?: boolean
|
||||
maxDepth?: number
|
||||
includeHidden?: boolean
|
||||
includeFiles?: boolean
|
||||
includeDirectories?: boolean
|
||||
maxEntries?: number
|
||||
searchPattern?: string
|
||||
}
|
||||
|
||||
const DEFAULT_DIRECTORY_LIST_OPTIONS: Required<DirectoryListOptions> = {
|
||||
recursive: true,
|
||||
maxDepth: 3,
|
||||
includeHidden: false,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntries: 10,
|
||||
searchPattern: '.'
|
||||
}
|
||||
|
||||
class FileStorage {
|
||||
private storageDir = getFilesDir()
|
||||
private notesDir = getNotesDir()
|
||||
@@ -748,6 +836,284 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public listDirectory = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
dirPath: string,
|
||||
options?: DirectoryListOptions
|
||||
): Promise<string[]> => {
|
||||
const mergedOptions: Required<DirectoryListOptions> = {
|
||||
...DEFAULT_DIRECTORY_LIST_OPTIONS,
|
||||
...options
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(dirPath)
|
||||
|
||||
const stat = await fs.promises.stat(resolvedPath).catch((error) => {
|
||||
logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolvedPath}`)
|
||||
}
|
||||
|
||||
// Use ripgrep for file listing with relevance-based sorting
|
||||
if (!getRipgrepBinaryPath()) {
|
||||
throw new Error('Ripgrep binary not available')
|
||||
}
|
||||
|
||||
return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search directories by name pattern
|
||||
*/
|
||||
private async searchDirectories(
|
||||
resolvedPath: string,
|
||||
options: Required<DirectoryListOptions>,
|
||||
currentDepth: number = 0
|
||||
): Promise<string[]> {
|
||||
if (!options.includeDirectories) return []
|
||||
if (!options.recursive && currentDepth > 0) return []
|
||||
if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return []
|
||||
|
||||
const directories: string[] = []
|
||||
const excludedDirs = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'coverage',
|
||||
'.cache'
|
||||
])
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true })
|
||||
const searchPatternLower = options.searchPattern.toLowerCase()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
// Skip hidden directories unless explicitly included
|
||||
if (!options.includeHidden && entry.name.startsWith('.')) continue
|
||||
|
||||
// Skip excluded directories
|
||||
if (excludedDirs.has(entry.name)) continue
|
||||
|
||||
const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/')
|
||||
|
||||
// Check if directory name matches search pattern
|
||||
if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) {
|
||||
directories.push(fullPath)
|
||||
}
|
||||
|
||||
// Recursively search subdirectories
|
||||
if (options.recursive && currentDepth < options.maxDepth) {
|
||||
const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1)
|
||||
directories.push(...subDirs)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error)
|
||||
}
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files by filename pattern
|
||||
*/
|
||||
private async searchByFilename(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
// Search for files using ripgrep
|
||||
if (options.includeFiles) {
|
||||
const args: string[] = ['--files']
|
||||
|
||||
// Handle hidden files
|
||||
if (!options.includeHidden) {
|
||||
args.push('--glob', '!.*')
|
||||
}
|
||||
|
||||
// Use --iglob to let ripgrep filter filenames (case-insensitive)
|
||||
if (options.searchPattern && options.searchPattern !== '.') {
|
||||
args.push('--iglob', `*${options.searchPattern}*`)
|
||||
}
|
||||
|
||||
// Exclude common hidden directories and large directories
|
||||
args.push('-g', '!**/node_modules/**')
|
||||
args.push('-g', '!**/.git/**')
|
||||
args.push('-g', '!**/.idea/**')
|
||||
args.push('-g', '!**/.vscode/**')
|
||||
args.push('-g', '!**/.DS_Store')
|
||||
args.push('-g', '!**/dist/**')
|
||||
args.push('-g', '!**/build/**')
|
||||
args.push('-g', '!**/.next/**')
|
||||
args.push('-g', '!**/.nuxt/**')
|
||||
args.push('-g', '!**/coverage/**')
|
||||
args.push('-g', '!**/.cache/**')
|
||||
|
||||
// Handle max depth
|
||||
if (!options.recursive) {
|
||||
args.push('--max-depth', '1')
|
||||
} else if (options.maxDepth > 0) {
|
||||
args.push('--max-depth', options.maxDepth.toString())
|
||||
}
|
||||
|
||||
// Add the directory path
|
||||
args.push(resolvedPath)
|
||||
|
||||
const { exitCode, output } = await executeRipgrep(args)
|
||||
|
||||
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||
if (exitCode >= 2) {
|
||||
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||
}
|
||||
|
||||
// Parse ripgrep output (no need to filter by filename - ripgrep already did it)
|
||||
files.push(
|
||||
...output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
)
|
||||
}
|
||||
|
||||
// Search for directories
|
||||
if (options.includeDirectories) {
|
||||
directories.push(...(await this.searchDirectories(resolvedPath, options)))
|
||||
}
|
||||
|
||||
// Combine and sort: directories first (alphabetically), then files (alphabetically)
|
||||
const sortedDirectories = directories.sort((a, b) => {
|
||||
const aName = path.basename(a)
|
||||
const bName = path.basename(b)
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
|
||||
const sortedFiles = files.sort((a, b) => {
|
||||
const aName = path.basename(a)
|
||||
const bName = path.basename(b)
|
||||
return aName.localeCompare(bName)
|
||||
})
|
||||
|
||||
return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files by content pattern
|
||||
*/
|
||||
private async searchByContent(resolvedPath: string, options: Required<DirectoryListOptions>): Promise<string[]> {
|
||||
const args: string[] = ['-l']
|
||||
|
||||
// Handle hidden files
|
||||
if (!options.includeHidden) {
|
||||
args.push('--glob', '!.*')
|
||||
}
|
||||
|
||||
// Exclude common hidden directories and large directories
|
||||
args.push('-g', '!**/node_modules/**')
|
||||
args.push('-g', '!**/.git/**')
|
||||
args.push('-g', '!**/.idea/**')
|
||||
args.push('-g', '!**/.vscode/**')
|
||||
args.push('-g', '!**/.DS_Store')
|
||||
args.push('-g', '!**/dist/**')
|
||||
args.push('-g', '!**/build/**')
|
||||
args.push('-g', '!**/.next/**')
|
||||
args.push('-g', '!**/.nuxt/**')
|
||||
args.push('-g', '!**/coverage/**')
|
||||
args.push('-g', '!**/.cache/**')
|
||||
|
||||
// Handle max depth
|
||||
if (!options.recursive) {
|
||||
args.push('--max-depth', '1')
|
||||
} else if (options.maxDepth > 0) {
|
||||
args.push('--max-depth', options.maxDepth.toString())
|
||||
}
|
||||
|
||||
// Handle max count
|
||||
if (options.maxEntries > 0) {
|
||||
args.push('--max-count', options.maxEntries.toString())
|
||||
}
|
||||
|
||||
// Add search pattern (search in content)
|
||||
args.push(options.searchPattern)
|
||||
|
||||
// Add the directory path
|
||||
args.push(resolvedPath)
|
||||
|
||||
const { exitCode, output } = await executeRipgrep(args)
|
||||
|
||||
// Exit code 0 means files found, 1 means no files found (still success), 2+ means error
|
||||
if (exitCode >= 2) {
|
||||
throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`)
|
||||
}
|
||||
|
||||
// Parse ripgrep output (already sorted by relevance)
|
||||
const results = output
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => line.replace(/\\/g, '/'))
|
||||
.slice(0, options.maxEntries)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async listDirectoryWithRipgrep(
|
||||
resolvedPath: string,
|
||||
options: Required<DirectoryListOptions>
|
||||
): Promise<string[]> {
|
||||
const maxEntries = options.maxEntries
|
||||
|
||||
// Step 1: Search by filename first
|
||||
logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath })
|
||||
const filenameResults = await this.searchByFilename(resolvedPath, options)
|
||||
|
||||
logger.debug('Found matches by filename', { count: filenameResults.length })
|
||||
|
||||
// If we have enough filename matches, return them
|
||||
if (filenameResults.length >= maxEntries) {
|
||||
return filenameResults.slice(0, maxEntries)
|
||||
}
|
||||
|
||||
// Step 2: If filename matches are less than maxEntries, search by content to fill up
|
||||
logger.debug('Filename matches insufficient, searching by content to fill up', {
|
||||
filenameCount: filenameResults.length,
|
||||
needed: maxEntries - filenameResults.length
|
||||
})
|
||||
|
||||
// Adjust maxEntries for content search to get enough results
|
||||
const contentOptions = {
|
||||
...options,
|
||||
maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates
|
||||
}
|
||||
|
||||
const contentResults = await this.searchByContent(resolvedPath, contentOptions)
|
||||
|
||||
logger.debug('Found matches by content', { count: contentResults.length })
|
||||
|
||||
// Combine results: filename matches first, then content matches (deduplicated)
|
||||
const combined = [...filenameResults]
|
||||
const filenameSet = new Set(filenameResults)
|
||||
|
||||
for (const filePath of contentResults) {
|
||||
if (!filenameSet.has(filePath)) {
|
||||
combined.push(filePath)
|
||||
if (combined.length >= maxEntries) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length })
|
||||
return combined.slice(0, maxEntries)
|
||||
}
|
||||
|
||||
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
|
||||
try {
|
||||
if (!dirPath || typeof dirPath !== 'string') {
|
||||
|
||||
@@ -375,13 +375,16 @@ export class WindowService {
|
||||
|
||||
mainWindow.hide()
|
||||
|
||||
// TODO: don't hide dock icon when close to tray
|
||||
// will cause the cmd+h behavior not working
|
||||
// after the electron fix the bug, we can restore this code
|
||||
// //for mac users, should hide dock icon if close to tray
|
||||
// if (isMac && isTrayOnClose) {
|
||||
// app.dock?.hide()
|
||||
// }
|
||||
//for mac users, should hide dock icon if close to tray
|
||||
if (isMac && isTrayOnClose) {
|
||||
app.dock?.hide()
|
||||
|
||||
mainWindow.once('show', () => {
|
||||
//restore the window can hide by cmd+h when the window is shown again
|
||||
// https://github.com/electron/electron/pull/47970
|
||||
app.dock?.show()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
|
||||
@@ -85,6 +85,9 @@ vi.mock('electron-updater', () => ({
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { UpdateMirror } from '@shared/config/constant'
|
||||
import { app, net } from 'electron'
|
||||
|
||||
import AppUpdater from '../AppUpdater'
|
||||
import { configManager } from '../ConfigManager'
|
||||
|
||||
@@ -274,4 +277,711 @@ describe('AppUpdater', () => {
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('_fetchUpdateConfig', () => {
|
||||
const mockConfig = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.6.7': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'Test version',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.6.7',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should fetch config from GitHub mirror', async () => {
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockConfig
|
||||
} as any)
|
||||
|
||||
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
|
||||
|
||||
expect(result).toEqual(mockConfig)
|
||||
expect(net.fetch).toHaveBeenCalledWith(expect.stringContaining('github'), expect.any(Object))
|
||||
})
|
||||
|
||||
it('should fetch config from GitCode mirror', async () => {
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockConfig
|
||||
} as any)
|
||||
|
||||
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITCODE)
|
||||
|
||||
expect(result).toEqual(mockConfig)
|
||||
// GitCode URL may vary, just check that fetch was called
|
||||
expect(net.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object))
|
||||
})
|
||||
|
||||
it('should return null on HTTP error', async () => {
|
||||
vi.mocked(net.fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404
|
||||
} as any)
|
||||
|
||||
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
vi.mocked(net.fetch).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await (appUpdater as any)._fetchUpdateConfig(UpdateMirror.GITHUB)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('_findCompatibleChannel', () => {
|
||||
const mockConfig = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.6.7': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.6.7',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.6.7',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
},
|
||||
rc: {
|
||||
version: '1.7.0-rc.1',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
},
|
||||
beta: {
|
||||
version: '1.7.0-beta.3',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-beta.3',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'2.0.0': {
|
||||
minCompatibleVersion: '1.7.0',
|
||||
description: 'v2.0.0',
|
||||
channels: {
|
||||
latest: null,
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should find compatible latest channel', () => {
|
||||
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'latest', mockConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.6.7',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
})
|
||||
expect(result?.channel).toBe('latest')
|
||||
})
|
||||
|
||||
it('should find compatible rc channel', () => {
|
||||
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', mockConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0-rc.1',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
})
|
||||
expect(result?.channel).toBe('rc')
|
||||
})
|
||||
|
||||
it('should find compatible beta channel', () => {
|
||||
vi.mocked(app.getVersion).mockReturnValue('1.5.0')
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'beta', mockConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0-beta.3',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-beta.3',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
|
||||
}
|
||||
})
|
||||
expect(result?.channel).toBe('beta')
|
||||
})
|
||||
|
||||
it('should return latest when latest version >= rc version', () => {
|
||||
const configWithNewerLatest = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.7.0': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.7.0',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.7.0',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0'
|
||||
}
|
||||
},
|
||||
rc: {
|
||||
version: '1.7.0-rc.1',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
},
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerLatest)
|
||||
|
||||
// Should return latest instead of rc because 1.7.0 >= 1.7.0-rc.1
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0',
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0',
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0'
|
||||
}
|
||||
})
|
||||
expect(result?.channel).toBe('latest') // ✅ 返回 latest 频道
|
||||
})
|
||||
|
||||
it('should return latest when latest version >= beta version', () => {
|
||||
const configWithNewerLatest = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.7.0': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.7.0',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.7.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: {
|
||||
version: '1.6.8-beta.1',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.8-beta.1',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.8-beta.1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerLatest)
|
||||
|
||||
// Should return latest instead of beta because 1.7.0 >= 1.6.8-beta.1
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should not compare latest with itself when requesting latest channel', () => {
|
||||
const config = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.7.0': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.7.0',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.7.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0'
|
||||
}
|
||||
},
|
||||
rc: {
|
||||
version: '1.7.0-rc.1',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
},
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'latest', config)
|
||||
|
||||
// Should return latest directly without comparing with itself
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should return rc when rc version > latest version', () => {
|
||||
const configWithNewerRc = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.7.0': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.7.0',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
},
|
||||
rc: {
|
||||
version: '1.7.0-rc.1',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
},
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'rc', configWithNewerRc)
|
||||
|
||||
// Should return rc because 1.7.0-rc.1 > 1.6.7
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0-rc.1',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should return beta when beta version > latest version', () => {
|
||||
const configWithNewerBeta = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.7.0': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.7.0',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: {
|
||||
version: '1.7.0-beta.5',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-beta.5',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-beta.5'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.0', 'beta', configWithNewerBeta)
|
||||
|
||||
// Should return beta because 1.7.0-beta.5 > 1.6.7
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0-beta.5',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-beta.5',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-beta.5'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should return lower version when higher version has no compatible channel', () => {
|
||||
vi.mocked(app.getVersion).mockReturnValue('1.8.0')
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'latest', mockConfig)
|
||||
|
||||
// 1.8.0 >= 1.7.0 but 2.0.0 has no latest channel, so return 1.6.7
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when current version does not meet minCompatibleVersion', () => {
|
||||
vi.mocked(app.getVersion).mockReturnValue('0.9.0')
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('0.9.0', 'latest', mockConfig)
|
||||
|
||||
// 0.9.0 < 1.0.0 (minCompatibleVersion)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return lower version rc when higher version has no rc channel', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.8.0', 'rc', mockConfig)
|
||||
|
||||
// 1.8.0 >= 1.7.0 but 2.0.0 has no rc channel, so return 1.6.7 rc
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.0-rc.1',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when no version has the requested channel', () => {
|
||||
const configWithoutRc = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.6.7': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'v1.6.7',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.5.0', 'rc', configWithoutRc)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Upgrade Path', () => {
|
||||
const fullConfig = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.6.7': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'Last v1.x',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
},
|
||||
rc: {
|
||||
version: '1.7.0-rc.1',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-rc.1',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-rc.1'
|
||||
}
|
||||
},
|
||||
beta: {
|
||||
version: '1.7.0-beta.3',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.0-beta.3',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.0-beta.3'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'2.0.0': {
|
||||
minCompatibleVersion: '1.7.0',
|
||||
description: 'First v2.x',
|
||||
channels: {
|
||||
latest: null,
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should upgrade from 1.6.3 to 1.6.7', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should block upgrade from 1.6.7 to 2.0.0 (minCompatibleVersion not met)', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.7', 'latest', fullConfig)
|
||||
|
||||
// Should return 1.6.7, not 2.0.0, because 1.6.7 < 1.7.0 (minCompatibleVersion of 2.0.0)
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.6.7',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.6.7',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.6.7'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow upgrade from 1.7.0 to 2.0.0', () => {
|
||||
const configWith2x = {
|
||||
...fullConfig,
|
||||
versions: {
|
||||
...fullConfig.versions,
|
||||
'2.0.0': {
|
||||
minCompatibleVersion: '1.7.0',
|
||||
description: 'First v2.x',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '2.0.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v2.0.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v2.0.0'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.7.0', 'latest', configWith2x)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '2.0.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v2.0.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v2.0.0'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complete Multi-Step Upgrade Path', () => {
|
||||
const fullUpgradeConfig = {
|
||||
lastUpdated: '2025-01-05T00:00:00Z',
|
||||
versions: {
|
||||
'1.7.5': {
|
||||
minCompatibleVersion: '1.0.0',
|
||||
description: 'Last v1.x stable',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '1.7.5',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.5',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.5'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
},
|
||||
'2.0.0': {
|
||||
minCompatibleVersion: '1.7.0',
|
||||
description: 'First v2.x - intermediate version',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '2.0.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v2.0.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v2.0.0'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
},
|
||||
'2.1.6': {
|
||||
minCompatibleVersion: '2.0.0',
|
||||
description: 'Current v2.x stable',
|
||||
channels: {
|
||||
latest: {
|
||||
version: '2.1.6',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/latest',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/latest'
|
||||
}
|
||||
},
|
||||
rc: null,
|
||||
beta: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('should upgrade from 1.6.3 to 1.7.5 (step 1)', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '1.7.5',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v1.7.5',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v1.7.5'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should upgrade from 1.7.5 to 2.0.0 (step 2)', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '2.0.0',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/v2.0.0',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/v2.0.0'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should upgrade from 2.0.0 to 2.1.6 (step 3)', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('2.0.0', 'latest', fullUpgradeConfig)
|
||||
|
||||
expect(result?.config).toEqual({
|
||||
version: '2.1.6',
|
||||
|
||||
feedUrls: {
|
||||
github: 'https://github.com/test/latest',
|
||||
|
||||
gitcode: 'https://gitcode.com/test/latest'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete full upgrade path: 1.6.3 -> 1.7.5 -> 2.0.0 -> 2.1.6', () => {
|
||||
// Step 1: 1.6.3 -> 1.7.5
|
||||
let currentVersion = '1.6.3'
|
||||
let result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
|
||||
expect(result?.config.version).toBe('1.7.5')
|
||||
|
||||
// Step 2: 1.7.5 -> 2.0.0
|
||||
currentVersion = result?.config.version!
|
||||
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
|
||||
expect(result?.config.version).toBe('2.0.0')
|
||||
|
||||
// Step 3: 2.0.0 -> 2.1.6
|
||||
currentVersion = result?.config.version!
|
||||
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
|
||||
expect(result?.config.version).toBe('2.1.6')
|
||||
|
||||
// Final: 2.1.6 is the latest, no more upgrades
|
||||
currentVersion = result?.config.version!
|
||||
result = (appUpdater as any)._findCompatibleChannel(currentVersion, 'latest', fullUpgradeConfig)
|
||||
expect(result?.config.version).toBe('2.1.6')
|
||||
})
|
||||
|
||||
it('should block direct upgrade from 1.6.3 to 2.0.0 (skip intermediate)', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.6.3', 'latest', fullUpgradeConfig)
|
||||
|
||||
// Should return 1.7.5, not 2.0.0, because 1.6.3 < 1.7.0 (minCompatibleVersion of 2.0.0)
|
||||
expect(result?.config.version).toBe('1.7.5')
|
||||
expect(result?.config.version).not.toBe('2.0.0')
|
||||
})
|
||||
|
||||
it('should block direct upgrade from 1.7.5 to 2.1.6 (skip intermediate)', () => {
|
||||
const result = (appUpdater as any)._findCompatibleChannel('1.7.5', 'latest', fullUpgradeConfig)
|
||||
|
||||
// Should return 2.0.0, not 2.1.6, because 1.7.5 < 2.0.0 (minCompatibleVersion of 2.1.6)
|
||||
expect(result?.config.version).toBe('2.0.0')
|
||||
expect(result?.config.version).not.toBe('2.1.6')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,14 @@ export abstract class BaseService {
|
||||
protected static db: LibSQLDatabase<typeof schema> | null = null
|
||||
protected static isInitialized = false
|
||||
protected static initializationPromise: Promise<void> | null = null
|
||||
protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
|
||||
protected jsonFields: string[] = [
|
||||
'tools',
|
||||
'mcps',
|
||||
'configuration',
|
||||
'accessible_paths',
|
||||
'allowed_tools',
|
||||
'slash_commands'
|
||||
]
|
||||
|
||||
/**
|
||||
* Initialize database with retry logic and proper error handling
|
||||
|
||||
@@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', {
|
||||
|
||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||
slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init
|
||||
|
||||
configuration: text('configuration'), // JSON, extensible settings
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { UpdateSessionResponse } from '@types'
|
||||
import { loggerService } from '@logger'
|
||||
import type { SlashCommand, UpdateSessionResponse } from '@types'
|
||||
import {
|
||||
AgentBaseSchema,
|
||||
type AgentEntity,
|
||||
@@ -13,6 +14,10 @@ import { and, count, desc, eq, type SQL } from 'drizzle-orm'
|
||||
import { BaseService } from '../BaseService'
|
||||
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
|
||||
import type { AgentModelField } from '../errors'
|
||||
import { pluginService } from '../plugins/PluginService'
|
||||
import { builtinSlashCommands } from './claudecode/commands'
|
||||
|
||||
const logger = loggerService.withContext('SessionService')
|
||||
|
||||
export class SessionService extends BaseService {
|
||||
private static instance: SessionService | null = null
|
||||
@@ -29,6 +34,52 @@ export class SessionService extends BaseService {
|
||||
await BaseService.initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Override BaseService.listSlashCommands to merge builtin and plugin commands
|
||||
*/
|
||||
async listSlashCommands(agentType: string, agentId?: string): Promise<SlashCommand[]> {
|
||||
const commands: SlashCommand[] = []
|
||||
|
||||
// Add builtin slash commands
|
||||
if (agentType === 'claude-code') {
|
||||
commands.push(...builtinSlashCommands)
|
||||
}
|
||||
|
||||
// Add local command plugins from .claude/commands/
|
||||
if (agentId) {
|
||||
try {
|
||||
const installedPlugins = await pluginService.listInstalled(agentId)
|
||||
|
||||
// Filter for command type plugins
|
||||
const commandPlugins = installedPlugins.filter((p) => p.type === 'command')
|
||||
|
||||
// Convert plugin metadata to SlashCommand format
|
||||
for (const plugin of commandPlugins) {
|
||||
const commandName = plugin.metadata.filename.replace(/\.md$/i, '')
|
||||
commands.push({
|
||||
command: `/${commandName}`,
|
||||
description: plugin.metadata.description
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Listed slash commands', {
|
||||
agentType,
|
||||
agentId,
|
||||
builtinCount: builtinSlashCommands.length,
|
||||
localCount: commandPlugins.length,
|
||||
totalCount: commands.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to list local command plugins', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
async createSession(
|
||||
agentId: string,
|
||||
req: Partial<CreateSessionRequest> = {}
|
||||
@@ -111,7 +162,13 @@ export class SessionService extends BaseService {
|
||||
|
||||
const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse
|
||||
session.tools = await this.listMcpTools(session.agent_type, session.mcps)
|
||||
session.slash_commands = await this.listSlashCommands(session.agent_type)
|
||||
|
||||
// If slash_commands is not in database yet (e.g., first invoke before init message),
|
||||
// fall back to builtin + local commands. Otherwise, use the merged commands from database.
|
||||
if (!session.slash_commands || session.slash_commands.length === 0) {
|
||||
session.slash_commands = await this.listSlashCommands(session.agent_type, agentId)
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform'
|
||||
import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform'
|
||||
|
||||
const baseStreamMetadata = {
|
||||
parent_tool_use_id: null,
|
||||
@@ -10,6 +10,19 @@ const baseStreamMetadata = {
|
||||
|
||||
const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}`
|
||||
|
||||
describe('stripLocalCommandTags', () => {
|
||||
it('removes stdout wrapper while preserving inner text', () => {
|
||||
const input = 'before <local-command-stdout>echo "hi"</local-command-stdout> after'
|
||||
expect(stripLocalCommandTags(input)).toBe('before echo "hi" after')
|
||||
})
|
||||
|
||||
it('strips multiple stdout/stderr blocks and leaves other content intact', () => {
|
||||
const input =
|
||||
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
|
||||
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude → AiSDK transform', () => {
|
||||
it('handles tool call streaming lifecycle', () => {
|
||||
const state = new ClaudeStreamState()
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import type { SlashCommand } from '@types'
|
||||
|
||||
export const builtinSlashCommands: SlashCommand[] = [
|
||||
{ command: '/add-dir', description: 'Add additional working directories' },
|
||||
{ command: '/agents', description: 'Manage custom AI subagents for specialized tasks' },
|
||||
{ command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' },
|
||||
{ command: '/clear', description: 'Clear conversation history' },
|
||||
{ command: '/compact', description: 'Compact conversation with optional focus instructions' },
|
||||
{ command: '/config', description: 'View/modify configuration' },
|
||||
{ command: '/cost', description: 'Show token usage statistics' },
|
||||
{ command: '/doctor', description: 'Checks the health of your Claude Code installation' },
|
||||
{ command: '/help', description: 'Get usage help' },
|
||||
{ command: '/init', description: 'Initialize project with CLAUDE.md guide' },
|
||||
{ command: '/login', description: 'Switch Anthropic accounts' },
|
||||
{ command: '/logout', description: 'Sign out from your Anthropic account' },
|
||||
{ command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' },
|
||||
{ command: '/memory', description: 'Edit CLAUDE.md memory files' },
|
||||
{ command: '/model', description: 'Select or change the AI model' },
|
||||
{ command: '/permissions', description: 'View or update permissions' },
|
||||
{ command: '/pr_comments', description: 'View pull request comments' },
|
||||
{ command: '/review', description: 'Request code review' },
|
||||
{ command: '/status', description: 'View account and system statuses' },
|
||||
{ command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' },
|
||||
{ command: '/vim', description: 'Enter vim mode for alternating insert and command modes' }
|
||||
{ command: '/context', description: 'Visualize current context usage as a colored grid' },
|
||||
{
|
||||
command: '/cost',
|
||||
description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)'
|
||||
},
|
||||
{ command: '/todos', description: 'List current todo items' }
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import { app } from 'electron'
|
||||
|
||||
import type { GetAgentSessionResponse } from '../..'
|
||||
import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||
import { sessionService } from '../SessionService'
|
||||
import { promptForToolApproval } from './tool-permissions'
|
||||
import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform'
|
||||
|
||||
@@ -19,6 +20,7 @@ const require_ = createRequire(import.meta.url)
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
||||
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
|
||||
const NO_RESUME_COMMANDS = ['/clear']
|
||||
|
||||
type UserInputMessage = {
|
||||
type: 'user'
|
||||
@@ -197,7 +199,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
options.strictMcpConfig = true
|
||||
}
|
||||
|
||||
if (lastAgentSessionId) {
|
||||
if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) {
|
||||
options.resume = lastAgentSessionId
|
||||
// TODO: use fork session when we support branching sessions
|
||||
// options.forkSession = true
|
||||
@@ -220,7 +222,15 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
// Start async processing on the next tick so listeners can subscribe first
|
||||
setImmediate(() => {
|
||||
this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => {
|
||||
this.processSDKQuery(
|
||||
userInputStream,
|
||||
closeUserStream,
|
||||
options,
|
||||
aiStream,
|
||||
errorChunks,
|
||||
session.agent_id,
|
||||
session.id
|
||||
).catch((error) => {
|
||||
logger.error('Unhandled Claude Code stream error', {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message } : String(error)
|
||||
})
|
||||
@@ -329,7 +339,9 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
closePromptStream: () => void,
|
||||
options: Options,
|
||||
stream: ClaudeCodeStream,
|
||||
errorChunks: string[]
|
||||
errorChunks: string[],
|
||||
agentId: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const jsonOutput: SDKMessage[] = []
|
||||
let hasCompleted = false
|
||||
@@ -342,6 +354,62 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
|
||||
jsonOutput.push(message)
|
||||
|
||||
// Handle init message - merge builtin and SDK slash_commands
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
const sdkSlashCommands = message.slash_commands || []
|
||||
logger.info('Received init message with slash commands', {
|
||||
sessionId,
|
||||
commands: sdkSlashCommands
|
||||
})
|
||||
|
||||
try {
|
||||
// Get builtin + local slash commands from BaseService
|
||||
const existingCommands = await sessionService.listSlashCommands('claude-code', agentId)
|
||||
|
||||
// Convert SDK slash_commands (string[]) to SlashCommand[] format
|
||||
// Ensure all commands start with '/'
|
||||
const sdkCommands = sdkSlashCommands.map((cmd) => {
|
||||
const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}`
|
||||
return {
|
||||
command: normalizedCmd,
|
||||
description: undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name
|
||||
const commandMap = new Map<string, { command: string; description?: string }>()
|
||||
|
||||
for (const cmd of existingCommands) {
|
||||
commandMap.set(cmd.command, cmd)
|
||||
}
|
||||
|
||||
for (const cmd of sdkCommands) {
|
||||
if (!commandMap.has(cmd.command)) {
|
||||
commandMap.set(cmd.command, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCommands = Array.from(commandMap.values())
|
||||
|
||||
// Update session in database
|
||||
await sessionService.updateSession(agentId, sessionId, {
|
||||
slash_commands: mergedCommands
|
||||
})
|
||||
|
||||
logger.info('Updated session with merged slash commands', {
|
||||
sessionId,
|
||||
existingCount: existingCommands.length,
|
||||
sdkCount: sdkCommands.length,
|
||||
totalCount: mergedCommands.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update session slash_commands', {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' || message.type === 'user') {
|
||||
logger.silly('claude response', {
|
||||
message,
|
||||
@@ -378,7 +446,6 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
hasCompleted = true
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.debug('SDK query completed successfully', {
|
||||
|
||||
@@ -73,13 +73,21 @@ const emptyUsage: LanguageModelUsage = {
|
||||
*/
|
||||
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
||||
|
||||
/**
|
||||
* Removes any local command stdout/stderr XML wrappers that should never surface to the UI.
|
||||
*/
|
||||
export const stripLocalCommandTags = (text: string): string => {
|
||||
return text.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out command-* tags from text content to prevent internal command
|
||||
* messages from appearing in the user-facing UI.
|
||||
* Removes tags like <command-message>...</command-message> and <command-name>...</command-name>
|
||||
*/
|
||||
const filterCommandTags = (text: string): string => {
|
||||
return text.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
const withoutLocalCommandTags = stripLocalCommandTags(text)
|
||||
return withoutLocalCommandTags.replace(/<command-[^>]+>.*?<\/command-[^>]+>/gs, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
|
||||
* blocks across calls so that incremental deltas can be correlated correctly.
|
||||
*/
|
||||
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
||||
logger.silly('Transforming SDKMessage', { message: sdkMessage })
|
||||
switch (sdkMessage.type) {
|
||||
case 'assistant':
|
||||
return handleAssistantMessage(sdkMessage, state)
|
||||
@@ -135,7 +144,8 @@ function handleAssistantMessage(
|
||||
const isStreamingActive = state.hasActiveStep()
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (!content) {
|
||||
const sanitizedContent = stripLocalCommandTags(content)
|
||||
if (!sanitizedContent) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
@@ -157,7 +167,7 @@ function handleAssistantMessage(
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: textId,
|
||||
text: content,
|
||||
text: sanitizedContent,
|
||||
providerMetadata
|
||||
})
|
||||
chunks.push({
|
||||
@@ -178,7 +188,10 @@ function handleAssistantMessage(
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (!isStreamingActive) {
|
||||
textBlocks.push(block.text)
|
||||
const sanitizedText = stripLocalCommandTags(block.text)
|
||||
if (sanitizedText) {
|
||||
textBlocks.push(sanitizedText)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'tool_use':
|
||||
@@ -537,6 +550,10 @@ function handleContentBlockDelta(
|
||||
logger.warn('Received text_delta for unknown block', { index })
|
||||
return
|
||||
}
|
||||
block.text = stripLocalCommandTags(block.text)
|
||||
if (!block.text) {
|
||||
break
|
||||
}
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: block.id,
|
||||
|
||||
@@ -48,6 +48,16 @@ import type {
|
||||
} from '../renderer/src/types/plugin'
|
||||
import type { ActionItem } from '../renderer/src/types/selectionTypes'
|
||||
|
||||
type DirectoryListOptions = {
|
||||
recursive?: boolean
|
||||
maxDepth?: number
|
||||
includeHidden?: boolean
|
||||
includeFiles?: boolean
|
||||
includeDirectories?: boolean
|
||||
maxEntries?: number
|
||||
searchPattern?: string
|
||||
}
|
||||
|
||||
export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) {
|
||||
if (spanContext) {
|
||||
const data = { type: 'trace', context: spanContext }
|
||||
@@ -201,6 +211,8 @@ const api = {
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
|
||||
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
|
||||
getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath),
|
||||
listDirectory: (dirPath: string, options?: DirectoryListOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_ListDirectory, dirPath, options),
|
||||
checkFileName: (dirPath: string, fileName: string, isFile: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
|
||||
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),
|
||||
|
||||
@@ -30,18 +30,22 @@ export class AiSdkToChunkAdapter {
|
||||
private onSessionUpdate?: (sessionId: string) => void
|
||||
private responseStartTimestamp: number | null = null
|
||||
private firstTokenTimestamp: number | null = null
|
||||
private hasTextContent = false
|
||||
private getSessionWasCleared?: () => boolean
|
||||
|
||||
constructor(
|
||||
private onChunk: (chunk: Chunk) => void,
|
||||
mcpTools: MCPTool[] = [],
|
||||
accumulate?: boolean,
|
||||
enableWebSearch?: boolean,
|
||||
onSessionUpdate?: (sessionId: string) => void
|
||||
onSessionUpdate?: (sessionId: string) => void,
|
||||
getSessionWasCleared?: () => boolean
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
this.onSessionUpdate = onSessionUpdate
|
||||
this.getSessionWasCleared = getSessionWasCleared
|
||||
}
|
||||
|
||||
private markFirstTokenIfNeeded() {
|
||||
@@ -84,8 +88,9 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
this.resetTimingState()
|
||||
this.responseStartTimestamp = Date.now()
|
||||
// Reset link converter state at the start of stream
|
||||
// Reset state at the start of stream
|
||||
this.isFirstChunk = true
|
||||
this.hasTextContent = false
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -129,6 +134,8 @@ export class AiSdkToChunkAdapter {
|
||||
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
|
||||
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
|
||||
this.onSessionUpdate?.(agentRawMessage.session_id)
|
||||
} else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) {
|
||||
this.onSessionUpdate?.(agentRawMessage.session_id)
|
||||
}
|
||||
this.onChunk({
|
||||
type: ChunkType.RAW,
|
||||
@@ -143,6 +150,7 @@ export class AiSdkToChunkAdapter {
|
||||
})
|
||||
break
|
||||
case 'text-delta': {
|
||||
this.hasTextContent = true
|
||||
const processedText = chunk.text || ''
|
||||
let finalText: string
|
||||
|
||||
@@ -301,6 +309,25 @@ export class AiSdkToChunkAdapter {
|
||||
}
|
||||
|
||||
case 'finish': {
|
||||
// Check if session was cleared (e.g., /clear command) and no text was output
|
||||
const sessionCleared = this.getSessionWasCleared?.() ?? false
|
||||
if (sessionCleared && !this.hasTextContent) {
|
||||
// Inject a "context cleared" message for the user
|
||||
const clearMessage = '✨ Context cleared. Starting fresh conversation.'
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: clearMessage
|
||||
})
|
||||
this.onChunk({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
text: clearMessage
|
||||
})
|
||||
final.text = clearMessage
|
||||
}
|
||||
|
||||
const usage = {
|
||||
completion_tokens: chunk.totalUsage?.outputTokens || 0,
|
||||
prompt_tokens: chunk.totalUsage?.inputTokens || 0,
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
* 2. 暂时保持接口兼容性
|
||||
*/
|
||||
|
||||
import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway'
|
||||
import { createExecutor } from '@cherrystudio/ai-core'
|
||||
import { loggerService } from '@logger'
|
||||
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
import { type Assistant, type GenerateImageParams, type Model, type Provider, SystemProviderIds } from '@renderer/types'
|
||||
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { SUPPORTED_IMAGE_ENDPOINT_LIST } from '@renderer/utils'
|
||||
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
|
||||
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
import { gateway, type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
|
||||
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||
import LegacyAiProvider from './legacy/index'
|
||||
@@ -439,6 +440,18 @@ export default class ModernAiProvider {
|
||||
|
||||
// 代理其他方法到原有实现
|
||||
public async models() {
|
||||
if (this.actualProvider.id === SystemProviderIds['ai-gateway']) {
|
||||
const formatModel = function (models: GatewayLanguageModelEntry[]): Model[] {
|
||||
return models.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: 'gateway',
|
||||
group: m.id.split('/')[0],
|
||||
description: m.description ?? undefined
|
||||
}))
|
||||
}
|
||||
return formatModel((await gateway.getAvailableModels()).models)
|
||||
}
|
||||
return this.legacyProvider.models()
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: this.formatApiHost() }
|
||||
if (this.provider.apiVersion === 'preview') {
|
||||
if (this.provider.apiVersion === 'preview' || this.provider.apiVersion === 'v1') {
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
|
||||
@@ -84,6 +84,8 @@ export async function createAiSdkProvider(config) {
|
||||
config.providerId = `${config.providerId}-chat`
|
||||
} else if (config.providerId === 'azure' && config.options?.mode === 'responses') {
|
||||
config.providerId = `${config.providerId}-responses`
|
||||
} else if (config.providerId === 'cherryin' && config.options?.mode === 'chat') {
|
||||
config.providerId = 'cherryin-chat'
|
||||
}
|
||||
localProvider = await createProviderCore(config.providerId, config.options)
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export function providerToAiSdkConfig(
|
||||
extraOptions.endpoint = endpoint
|
||||
if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
extraOptions.mode = 'responses'
|
||||
} else if (aiSdkProviderId === 'openai') {
|
||||
} else if (aiSdkProviderId === 'openai' || (aiSdkProviderId === 'cherryin' && actualProvider.type === 'openai')) {
|
||||
extraOptions.mode = 'chat'
|
||||
}
|
||||
|
||||
@@ -189,9 +189,11 @@ export function providerToAiSdkConfig(
|
||||
}
|
||||
}
|
||||
// azure
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest
|
||||
// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#responses-api
|
||||
if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') {
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview') {
|
||||
// extraOptions.apiVersion = actualProvider.apiVersion === 'preview' ? 'v1' : actualProvider.apiVersion 默认使用v1,不使用azure endpoint
|
||||
if (actualProvider.apiVersion === 'preview' || actualProvider.apiVersion === 'v1') {
|
||||
extraOptions.mode = 'responses'
|
||||
} else {
|
||||
extraOptions.mode = 'chat'
|
||||
|
||||
@@ -71,6 +71,21 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [
|
||||
creatorFunctionName: 'createHuggingFace',
|
||||
supportsImageGeneration: true,
|
||||
aliases: ['hf', 'hugging-face']
|
||||
},
|
||||
{
|
||||
id: 'ai-gateway',
|
||||
name: 'AI Gateway',
|
||||
import: () => import('@ai-sdk/gateway'),
|
||||
creatorFunctionName: 'createGateway',
|
||||
supportsImageGeneration: true,
|
||||
aliases: ['gateway']
|
||||
},
|
||||
{
|
||||
id: 'cerebras',
|
||||
name: 'Cerebras',
|
||||
import: () => import('@ai-sdk/cerebras'),
|
||||
creatorFunctionName: 'createCerebras',
|
||||
supportsImageGeneration: false
|
||||
}
|
||||
] as const
|
||||
|
||||
|
||||
@@ -113,6 +113,9 @@ export function buildProviderOptions(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'cherryin':
|
||||
providerSpecificOptions = buildCherryInProviderOptions(assistant, model, capabilities, actualProvider)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported base provider ${baseProviderId}`)
|
||||
}
|
||||
@@ -148,11 +151,12 @@ export function buildProviderOptions(
|
||||
...providerSpecificOptions,
|
||||
...getCustomParameters(assistant)
|
||||
}
|
||||
// vertex需要映射到google或anthropic
|
||||
|
||||
const rawProviderKey =
|
||||
{
|
||||
'google-vertex': 'google',
|
||||
'google-vertex-anthropic': 'anthropic'
|
||||
'google-vertex-anthropic': 'anthropic',
|
||||
'ai-gateway': 'gateway'
|
||||
}[rawProviderId] || rawProviderId
|
||||
|
||||
// 返回 AI Core SDK 要求的格式:{ 'providerId': providerOptions }
|
||||
@@ -270,6 +274,34 @@ function buildXAIProviderOptions(
|
||||
return providerOptions
|
||||
}
|
||||
|
||||
function buildCherryInProviderOptions(
|
||||
assistant: Assistant,
|
||||
model: Model,
|
||||
capabilities: {
|
||||
enableReasoning: boolean
|
||||
enableWebSearch: boolean
|
||||
enableGenerateImage: boolean
|
||||
},
|
||||
actualProvider: Provider
|
||||
): Record<string, any> {
|
||||
const serviceTierSetting = getServiceTier(model, actualProvider)
|
||||
|
||||
switch (actualProvider.type) {
|
||||
case 'openai':
|
||||
return {
|
||||
...buildOpenAIProviderOptions(assistant, model, capabilities),
|
||||
serviceTier: serviceTierSetting
|
||||
}
|
||||
|
||||
case 'anthropic':
|
||||
return buildAnthropicProviderOptions(assistant, model, capabilities)
|
||||
|
||||
case 'gemini':
|
||||
return buildGeminiProviderOptions(assistant, model, capabilities)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Bedrock providerOptions
|
||||
*/
|
||||
|
||||
@@ -109,6 +109,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
|
||||
// use thinking, doubao, zhipu, etc.
|
||||
if (isSupportedThinkingTokenDoubaoModel(model) || isSupportedThinkingTokenZhipuModel(model)) {
|
||||
if (provider.id === SystemProviderIds.cerebras) {
|
||||
return {
|
||||
disable_reasoning: true
|
||||
}
|
||||
}
|
||||
return { thinking: { type: 'disabled' } }
|
||||
}
|
||||
|
||||
@@ -306,6 +311,9 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return {}
|
||||
}
|
||||
if (isSupportedThinkingTokenZhipuModel(model)) {
|
||||
if (provider.id === SystemProviderIds.cerebras) {
|
||||
return {}
|
||||
}
|
||||
return { thinking: { type: 'enabled' } }
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,11 @@ export function buildProviderBuiltinWebSearchConfig(
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'cherryin': {
|
||||
const _providerId =
|
||||
{ 'openai-response': 'openai', openai: 'openai-chat' }[model?.endpoint_type ?? ''] ?? model?.endpoint_type
|
||||
return buildProviderBuiltinWebSearchConfig(_providerId, webSearchConfig, model)
|
||||
}
|
||||
default: {
|
||||
return {}
|
||||
}
|
||||
|
||||
BIN
src/renderer/src/assets/images/providers/cerebras.webp
Normal file
BIN
src/renderer/src/assets/images/providers/cerebras.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
1
src/renderer/src/assets/images/providers/vercel.svg
Normal file
1
src/renderer/src/assets/images/providers/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Vercel</title><path d="M12 0l12 20.785H0L12 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -1,5 +1,6 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
||||
import { scrollElementIntoView } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||
@@ -181,17 +182,14 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
// 3. 将当前项滚动到视图中
|
||||
// 获取第一个文本节点的父元素来进行滚动
|
||||
const parentElement = currentMatchRange.startContainer.parentElement
|
||||
if (shouldScroll) {
|
||||
parentElement?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
if (shouldScroll && parentElement) {
|
||||
// 优先在指定的滚动容器内滚动,避免滚动整个页面导致索引错乱/看起来"跳到第一条"
|
||||
scrollElementIntoView(parentElement, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[allRanges, currentIndex]
|
||||
[allRanges, currentIndex, target]
|
||||
)
|
||||
|
||||
const search = useCallback(
|
||||
|
||||
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
104
src/renderer/src/components/QuickPanel/defaultStrategies.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types'
|
||||
|
||||
/**
|
||||
* Default filter function
|
||||
* Implements standard filtering logic with pinyin support
|
||||
*/
|
||||
export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => {
|
||||
if (!searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Direct substring match
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match for Chinese characters
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
let pinyinText = pinyinCache.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCache.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score for sorting
|
||||
* Higher score = better match
|
||||
*/
|
||||
const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => {
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
const lowerSearchText = searchText.toLowerCase()
|
||||
|
||||
// Exact match (highest priority)
|
||||
if (lowerFilterText === lowerSearchText) {
|
||||
return 1000
|
||||
}
|
||||
|
||||
// Label exact match (very high priority)
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) {
|
||||
return 900
|
||||
}
|
||||
|
||||
// Starts with search text (high priority)
|
||||
if (lowerFilterText.startsWith(lowerSearchText)) {
|
||||
return 800
|
||||
}
|
||||
|
||||
// Label starts with search text
|
||||
if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) {
|
||||
return 700
|
||||
}
|
||||
|
||||
// Contains search text (medium priority)
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
// Earlier position = higher score
|
||||
const position = lowerFilterText.indexOf(lowerSearchText)
|
||||
return 600 - position
|
||||
}
|
||||
|
||||
// Pinyin fuzzy match (lower priority)
|
||||
return 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort function
|
||||
* Sorts items by match score in descending order
|
||||
*/
|
||||
export const defaultSortFn: QuickPanelSortFn = (items, searchText) => {
|
||||
if (!searchText) return items
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const scoreA = calculateMatchScore(a, searchText)
|
||||
const scoreB = calculateMatchScore(b, searchText)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './defaultStrategies'
|
||||
export * from './hook'
|
||||
export * from './provider'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,11 +4,12 @@ import type {
|
||||
QuickPanelCallBackOptions,
|
||||
QuickPanelCloseAction,
|
||||
QuickPanelContextType,
|
||||
QuickPanelFilterFn,
|
||||
QuickPanelListItem,
|
||||
QuickPanelOpenOptions,
|
||||
QuickPanelSortFn,
|
||||
QuickPanelTriggerInfo
|
||||
} from './types'
|
||||
|
||||
const QuickPanelContext = createContext<QuickPanelContextType | null>(null)
|
||||
|
||||
export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
@@ -17,19 +18,39 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
|
||||
const [list, setList] = useState<QuickPanelListItem[]>([])
|
||||
const [title, setTitle] = useState<string | undefined>()
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(0)
|
||||
const [defaultIndex, setDefaultIndex] = useState<number>(-1)
|
||||
const [pageSize, setPageSize] = useState<number>(7)
|
||||
const [multiple, setMultiple] = useState<boolean>(false)
|
||||
const [manageListExternally, setManageListExternally] = useState<boolean>(false)
|
||||
const [triggerInfo, setTriggerInfo] = useState<QuickPanelTriggerInfo | undefined>()
|
||||
const [filterFn, setFilterFn] = useState<QuickPanelFilterFn | undefined>()
|
||||
const [sortFn, setSortFn] = useState<QuickPanelSortFn | undefined>()
|
||||
const [onClose, setOnClose] = useState<((Options: Partial<QuickPanelCallBackOptions>) => void) | undefined>()
|
||||
const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>()
|
||||
const [onSearchChange, setOnSearchChange] = useState<((searchText: string) => void) | undefined>()
|
||||
const [lastCloseAction, setLastCloseAction] = useState<QuickPanelCloseAction | undefined>(undefined)
|
||||
|
||||
const clearTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 添加更新item选中状态的方法
|
||||
const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => {
|
||||
setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item)))
|
||||
setList((prevList) => {
|
||||
// 先尝试引用匹配(快速路径)
|
||||
const refIndex = prevList.findIndex((item) => item === targetItem)
|
||||
if (refIndex !== -1) {
|
||||
return prevList.map((item, idx) => (idx === refIndex ? { ...item, isSelected } : item))
|
||||
}
|
||||
|
||||
// 如果引用匹配失败,使用内容匹配(兜底方案)
|
||||
// 通过 label 和 filterText 来识别同一个item
|
||||
return prevList.map((item) => {
|
||||
const isSameItem =
|
||||
(item.label === targetItem.label || item.filterText === targetItem.filterText) &&
|
||||
(!targetItem.filterText || item.filterText === targetItem.filterText)
|
||||
return isSameItem ? { ...item, isSelected } : item
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 添加更新整个列表的方法
|
||||
@@ -43,17 +64,23 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
clearTimer.current = null
|
||||
}
|
||||
|
||||
setLastCloseAction(undefined)
|
||||
setTitle(options.title)
|
||||
setList(options.list)
|
||||
setDefaultIndex(options.defaultIndex ?? 0)
|
||||
const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1
|
||||
setDefaultIndex(nextDefaultIndex)
|
||||
setPageSize(options.pageSize ?? 7)
|
||||
setMultiple(options.multiple ?? false)
|
||||
setManageListExternally(options.manageListExternally ?? false)
|
||||
setSymbol(options.symbol)
|
||||
setTriggerInfo(options.triggerInfo)
|
||||
|
||||
setOnClose(() => options.onClose)
|
||||
setBeforeAction(() => options.beforeAction)
|
||||
setAfterAction(() => options.afterAction)
|
||||
setOnSearchChange(() => options.onSearchChange)
|
||||
setFilterFn(() => options.filterFn)
|
||||
setSortFn(() => options.sortFn)
|
||||
|
||||
setIsVisible(true)
|
||||
}, [])
|
||||
@@ -61,6 +88,8 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
const close = useCallback(
|
||||
(action?: QuickPanelCloseAction, searchText?: string) => {
|
||||
setIsVisible(false)
|
||||
setManageListExternally(false)
|
||||
setLastCloseAction(action)
|
||||
onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this })
|
||||
|
||||
clearTimer.current = setTimeout(() => {
|
||||
@@ -68,9 +97,13 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
setOnClose(undefined)
|
||||
setBeforeAction(undefined)
|
||||
setAfterAction(undefined)
|
||||
setOnSearchChange(undefined)
|
||||
setFilterFn(undefined)
|
||||
setSortFn(undefined)
|
||||
setTitle(undefined)
|
||||
setSymbol('')
|
||||
setTriggerInfo(undefined)
|
||||
setManageListExternally(false)
|
||||
}, 200)
|
||||
},
|
||||
[onClose]
|
||||
@@ -100,10 +133,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
afterAction,
|
||||
onSearchChange
|
||||
}),
|
||||
[
|
||||
open,
|
||||
@@ -117,10 +155,15 @@ export const QuickPanelProvider: React.FC<React.PropsWithChildren> = ({ children
|
||||
defaultIndex,
|
||||
pageSize,
|
||||
multiple,
|
||||
manageListExternally,
|
||||
triggerInfo,
|
||||
lastCloseAction,
|
||||
filterFn,
|
||||
sortFn,
|
||||
onClose,
|
||||
beforeAction,
|
||||
afterAction
|
||||
afterAction,
|
||||
onSearchChange
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export enum QuickPanelReservedSymbol {
|
||||
WebSearch = '?',
|
||||
Mcp = 'mcp',
|
||||
McpPrompt = 'mcp-prompt',
|
||||
McpResource = 'mcp-resource'
|
||||
McpResource = 'mcp-resource',
|
||||
SlashCommands = 'slash-commands'
|
||||
}
|
||||
|
||||
export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined
|
||||
@@ -27,6 +28,29 @@ export type QuickPanelCallBackOptions = {
|
||||
searchText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter function type
|
||||
* @param item - The item to check
|
||||
* @param searchText - The search text (without leading symbol)
|
||||
* @param fuzzyRegex - Fuzzy matching regex
|
||||
* @param pinyinCache - Cache for pinyin conversions
|
||||
* @returns true if item matches the search
|
||||
*/
|
||||
export type QuickPanelFilterFn = (
|
||||
item: QuickPanelListItem,
|
||||
searchText: string,
|
||||
fuzzyRegex: RegExp,
|
||||
pinyinCache: WeakMap<QuickPanelListItem, string>
|
||||
) => boolean
|
||||
|
||||
/**
|
||||
* Sort function type
|
||||
* @param items - The filtered items to sort
|
||||
* @param searchText - The search text (without leading symbol)
|
||||
* @returns sorted items
|
||||
*/
|
||||
export type QuickPanelSortFn = (items: QuickPanelListItem[], searchText: string) => QuickPanelListItem[]
|
||||
|
||||
export type QuickPanelOpenOptions = {
|
||||
/** 显示在底部左边,类似于Placeholder */
|
||||
title?: string
|
||||
@@ -48,6 +72,14 @@ export type QuickPanelOpenOptions = {
|
||||
beforeAction?: (options: QuickPanelCallBackOptions) => void
|
||||
afterAction?: (options: QuickPanelCallBackOptions) => void
|
||||
onClose?: (options: QuickPanelCallBackOptions) => void
|
||||
/** Callback when search text changes (called with debounced search text) */
|
||||
onSearchChange?: (searchText: string) => void
|
||||
/** Tool manages list + collapse behavior externally (skip filtering/auto-close) */
|
||||
manageListExternally?: boolean
|
||||
/** Custom filter function for items (follows open-closed principle) */
|
||||
filterFn?: QuickPanelFilterFn
|
||||
/** Custom sort function for filtered items (follows open-closed principle) */
|
||||
sortFn?: QuickPanelSortFn
|
||||
}
|
||||
|
||||
export type QuickPanelListItem = {
|
||||
@@ -88,10 +120,15 @@ export interface QuickPanelContextType {
|
||||
readonly pageSize: number
|
||||
readonly multiple: boolean
|
||||
readonly triggerInfo?: QuickPanelTriggerInfo
|
||||
readonly manageListExternally?: boolean
|
||||
readonly lastCloseAction?: QuickPanelCloseAction
|
||||
readonly filterFn?: QuickPanelFilterFn
|
||||
readonly sortFn?: QuickPanelSortFn
|
||||
|
||||
readonly onClose?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly afterAction?: (Options: QuickPanelCallBackOptions) => void
|
||||
readonly onSearchChange?: (searchText: string) => void
|
||||
}
|
||||
|
||||
export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none'
|
||||
|
||||
@@ -10,8 +10,8 @@ import { debounce } from 'lodash'
|
||||
import { Check } from 'lucide-react'
|
||||
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import * as tinyPinyin from 'tiny-pinyin'
|
||||
|
||||
import { defaultFilterFn, defaultSortFn } from './defaultStrategies'
|
||||
import { QuickPanelContext } from './provider'
|
||||
import type {
|
||||
QuickPanelCallBackOptions,
|
||||
@@ -62,21 +62,50 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 缓存:按 item 缓存拼音文本,避免重复转换
|
||||
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
|
||||
|
||||
// 轻量防抖:减少高频输入时的过滤调用
|
||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||
|
||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
// Use injected filter and sort functions, or fall back to defaults
|
||||
const filterFn = ctx.filterFn || defaultFilterFn
|
||||
const sortFn = ctx.sortFn || defaultSortFn
|
||||
// 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部)
|
||||
const list = useMemo(() => {
|
||||
if (!ctx.isVisible && !ctx.symbol) return []
|
||||
|
||||
const baseList = (ctx.list || []).filter((item) => !item.hidden)
|
||||
|
||||
if (ctx.manageListExternally) {
|
||||
const combinedLength = baseList.length
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
if (isSymbolChanged) {
|
||||
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
|
||||
const desiredIndex =
|
||||
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
|
||||
setIndex(desiredIndex)
|
||||
} else {
|
||||
setIndex((prevIndex) => {
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
return prevIndex
|
||||
})
|
||||
}
|
||||
|
||||
prevSearchTextRef.current = ''
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
return baseList
|
||||
}
|
||||
|
||||
const _searchText = searchText.replace(/^[/@]/, '')
|
||||
const lowerSearchText = _searchText.toLowerCase()
|
||||
const fuzzyPattern = lowerSearchText
|
||||
@@ -86,52 +115,35 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const fuzzyRegex = new RegExp(fuzzyPattern, 'ig')
|
||||
|
||||
// 拆分:固定显示项(不参与过滤)与普通项
|
||||
const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible)
|
||||
const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible)
|
||||
const pinnedItems = baseList.filter((item) => item.alwaysVisible)
|
||||
const normalItems = baseList.filter((item) => !item.alwaysVisible)
|
||||
|
||||
// Filter normal items using injected filter function
|
||||
const filteredNormalItems = normalItems.filter((item) => {
|
||||
if (!_searchText) return true
|
||||
|
||||
let filterText = item.filterText || ''
|
||||
if (typeof item.label === 'string') {
|
||||
filterText += item.label
|
||||
}
|
||||
if (typeof item.description === 'string') {
|
||||
filterText += item.description
|
||||
}
|
||||
|
||||
const lowerFilterText = filterText.toLowerCase()
|
||||
|
||||
if (lowerFilterText.includes(lowerSearchText)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
|
||||
try {
|
||||
let pinyinText = pinyinCacheRef.current.get(item)
|
||||
if (!pinyinText) {
|
||||
pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
|
||||
pinyinCacheRef.current.set(item, pinyinText)
|
||||
}
|
||||
return fuzzyRegex.test(pinyinText)
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return fuzzyRegex.test(filterText.toLowerCase())
|
||||
}
|
||||
return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current)
|
||||
})
|
||||
|
||||
// Sort filtered items using injected sort function
|
||||
const sortedNormalItems = sortFn(filteredNormalItems, _searchText)
|
||||
|
||||
// 只有在搜索文本变化或面板符号变化时才重置index
|
||||
const isSearchChanged = prevSearchTextRef.current !== searchText
|
||||
const isSymbolChanged = prevSymbolRef.current !== ctx.symbol
|
||||
|
||||
if (isSearchChanged || isSymbolChanged) {
|
||||
setIndex(-1) // 不默认高亮任何项,让用户主动选择
|
||||
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||
if (isSymbolChanged) {
|
||||
const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1
|
||||
const desiredIndex =
|
||||
typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1
|
||||
setIndex(desiredIndex)
|
||||
} else {
|
||||
setIndex(-1) // 搜索文本变化时不默认高亮
|
||||
}
|
||||
} else {
|
||||
// 如果当前index超出范围,调整到有效范围内
|
||||
setIndex((prevIndex) => {
|
||||
const combinedLength = pinnedItems.length + filteredNormalItems.length
|
||||
const combinedLength = pinnedItems.length + sortedNormalItems.length
|
||||
if (prevIndex >= combinedLength) {
|
||||
return combinedLength > 0 ? combinedLength - 1 : -1
|
||||
}
|
||||
@@ -142,10 +154,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
prevSearchTextRef.current = searchText
|
||||
prevSymbolRef.current = ctx.symbol
|
||||
|
||||
// 固定项置顶 + 过滤后的普通项
|
||||
const pinnedFiltered = [...pinnedItems, ...filteredNormalItems]
|
||||
return pinnedFiltered.filter((item) => !item.hidden)
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.list, searchText])
|
||||
// 固定项置顶 + 排序后的普通项
|
||||
return [...pinnedItems, ...sortedNormalItems]
|
||||
}, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn])
|
||||
|
||||
const canForwardAndBackward = useMemo(() => {
|
||||
return list.some((item) => item.isMenu) || historyPanel.length > 0
|
||||
@@ -179,20 +190,65 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
|
||||
if (deleteStart >= deleteEnd) return
|
||||
|
||||
// 删除文本
|
||||
const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd)
|
||||
setInputText(newText)
|
||||
const activeSearchText = searchTextRef.current ?? ''
|
||||
|
||||
setInputText((currentText) => {
|
||||
const safeText = currentText ?? ''
|
||||
const expectedSegment = includeSymbol ? symbolSegment : symbolSegment.slice(1)
|
||||
const typedSearch = activeSearchText
|
||||
const normalizedTyped = includeSymbol
|
||||
? typedSearch
|
||||
: typedSearch.startsWith(symbolSegment[0] ?? '')
|
||||
? typedSearch.slice(1)
|
||||
: typedSearch
|
||||
|
||||
if (normalizedTyped && expectedSegment !== normalizedTyped) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const segmentStart = includeSymbol ? symbolStart : symbolStart + 1
|
||||
const segmentEnd = segmentStart + expectedSegment.length
|
||||
|
||||
if (segmentStart < 0 || segmentStart > safeText.length) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
if (segmentEnd > safeText.length) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const actualSegment = safeText.slice(segmentStart, segmentEnd)
|
||||
if (actualSegment !== expectedSegment) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const clampedDeleteStart = Math.max(0, Math.min(deleteStart, safeText.length))
|
||||
const clampedDeleteEnd = Math.max(clampedDeleteStart, Math.min(deleteEnd, safeText.length))
|
||||
|
||||
if (clampedDeleteStart >= clampedDeleteEnd) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
const updatedText = safeText.slice(0, clampedDeleteStart) + safeText.slice(clampedDeleteEnd)
|
||||
|
||||
if (updatedText === safeText) {
|
||||
return safeText
|
||||
}
|
||||
|
||||
// 设置光标位置
|
||||
setTimeoutTimer(
|
||||
'quickpanel_focus',
|
||||
() => {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(deleteStart, deleteStart)
|
||||
const textareaEl = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
if (!textareaEl) return
|
||||
textareaEl.focus()
|
||||
textareaEl.setSelectionRange(clampedDeleteStart, clampedDeleteStart)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
return updatedText
|
||||
})
|
||||
|
||||
setSearchText('')
|
||||
},
|
||||
[setInputText, setTimeoutTimer]
|
||||
@@ -211,11 +267,21 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (textArea) {
|
||||
setInputText(textArea.value)
|
||||
}
|
||||
} else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) {
|
||||
} else if (
|
||||
action &&
|
||||
!['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) &&
|
||||
ctx.triggerInfo?.type === 'input'
|
||||
) {
|
||||
setTimeoutTimer(
|
||||
'quickpanel_deferred_clear',
|
||||
() => {
|
||||
clearSearchText(true)
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
},
|
||||
[ctx, clearSearchText, setInputText, searchText]
|
||||
[ctx, clearSearchText, setInputText, searchText, setTimeoutTimer]
|
||||
)
|
||||
|
||||
const handleItemAction = useCallback(
|
||||
@@ -285,12 +351,86 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
searchTextRef.current = searchText
|
||||
}, [searchText])
|
||||
|
||||
// Track onSearchChange callback and search state for debouncing
|
||||
const prevSearchCallbackTextRef = useRef('')
|
||||
const isFirstSearchRef = useRef(true)
|
||||
const searchCallbackTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const onSearchChangeRef = useRef(ctx.onSearchChange)
|
||||
|
||||
// Keep onSearchChange ref up to date
|
||||
useEffect(() => {
|
||||
onSearchChangeRef.current = ctx.onSearchChange
|
||||
}, [ctx.onSearchChange])
|
||||
|
||||
// Reset search history when panel closes
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) {
|
||||
prevSearchCallbackTextRef.current = ''
|
||||
isFirstSearchRef.current = true
|
||||
if (searchCallbackTimerRef.current) {
|
||||
clearTimeout(searchCallbackTimerRef.current)
|
||||
searchCallbackTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [ctx.isVisible])
|
||||
|
||||
// Trigger onSearchChange with debounce (called from handleInput)
|
||||
const triggerSearchChange = useCallback((searchText: string) => {
|
||||
if (!onSearchChangeRef.current) return
|
||||
|
||||
// Clean search text: remove leading symbol (/ or @) and trim
|
||||
const cleanSearchText = searchText.replace(/^[/@]/, '').trim()
|
||||
|
||||
// Don't trigger if search text hasn't changed
|
||||
if (cleanSearchText === prevSearchCallbackTextRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't trigger callback for empty search text
|
||||
if (!cleanSearchText) {
|
||||
prevSearchCallbackTextRef.current = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Clear previous timer
|
||||
if (searchCallbackTimerRef.current) {
|
||||
clearTimeout(searchCallbackTimerRef.current)
|
||||
}
|
||||
|
||||
// First search triggers immediately (0ms), subsequent searches have 300ms debounce
|
||||
const delay = isFirstSearchRef.current ? 0 : 300
|
||||
|
||||
searchCallbackTimerRef.current = setTimeout(() => {
|
||||
prevSearchCallbackTextRef.current = cleanSearchText
|
||||
isFirstSearchRef.current = false
|
||||
onSearchChangeRef.current?.(cleanSearchText)
|
||||
searchCallbackTimerRef.current = null
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchCallbackTimerRef.current) {
|
||||
clearTimeout(searchCallbackTimerRef.current)
|
||||
searchCallbackTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 获取当前输入的搜索词
|
||||
const isComposing = useRef(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSearchTextDebounced.cancel()
|
||||
}
|
||||
}, [setSearchTextDebounced])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
if (!textArea) return
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
if (isComposing.current) return
|
||||
@@ -305,6 +445,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
if (lastSymbolIndex !== -1) {
|
||||
const newSearchText = textBeforeCursor.slice(lastSymbolIndex)
|
||||
setSearchTextDebounced(newSearchText)
|
||||
// Trigger server-side search callback immediately (with its own debounce)
|
||||
triggerSearchChange(newSearchText)
|
||||
} else {
|
||||
// 使用本地 handleClose,确保在删除触发符时同步受控输入值
|
||||
handleClose('delete-symbol')
|
||||
@@ -328,16 +470,17 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
textArea.removeEventListener('input', handleInput)
|
||||
textArea.removeEventListener('compositionupdate', handleCompositionUpdate)
|
||||
textArea.removeEventListener('compositionend', handleCompositionEnd)
|
||||
setSearchTextDebounced.cancel()
|
||||
setTimeoutTimer(
|
||||
'quickpanel_clear_search',
|
||||
() => {
|
||||
setSearchText('')
|
||||
},
|
||||
200
|
||||
) // 等待面板关闭动画结束后,再清空搜索词
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (ctx.isVisible) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setSearchText('')
|
||||
}, 200)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [ctx.isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -545,19 +688,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText])
|
||||
// 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠
|
||||
const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list])
|
||||
const collapsed = hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.isVisible) return
|
||||
if (!collapsed) return
|
||||
if (ctx.triggerInfo?.type !== 'input') return
|
||||
if (ctx.multiple) return
|
||||
|
||||
const trimmedSearch = searchText.replace(/^[/@]/, '').trim()
|
||||
if (!trimmedSearch) return
|
||||
|
||||
handleClose('no_result')
|
||||
}, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText])
|
||||
const collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0
|
||||
|
||||
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
|
||||
|
||||
@@ -616,7 +747,9 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
{!collapsed && (
|
||||
{collapsed ? (
|
||||
<QuickPanelEmpty>{t('settings.quickPanel.noResult', 'No results')}</QuickPanelEmpty>
|
||||
) : (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
@@ -726,6 +859,13 @@ const QuickPanelBody = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const QuickPanelEmpty = styled.div`
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const QuickPanelFooter = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
@@ -81,6 +81,16 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
|
||||
* Hide the scrollbar automatically when scrolling is stopped
|
||||
*/
|
||||
autoHideScrollbar?: boolean
|
||||
|
||||
/**
|
||||
* Header content to display above the list
|
||||
*/
|
||||
header?: React.ReactNode
|
||||
|
||||
/**
|
||||
* Additional CSS class name for the container
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
@@ -95,6 +105,8 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
itemContainerStyle,
|
||||
scrollerStyle,
|
||||
autoHideScrollbar = false,
|
||||
header,
|
||||
className,
|
||||
...restOptions
|
||||
} = props
|
||||
|
||||
@@ -189,7 +201,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
return (
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="dynamic-virtual-list"
|
||||
className={className ? `dynamic-virtual-list ${className}` : 'dynamic-virtual-list'}
|
||||
role="region"
|
||||
aria-label="Dynamic Virtual List"
|
||||
aria-hidden={!showScrollbar}
|
||||
@@ -200,6 +212,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
...(horizontal ? { width: size ?? '100%' } : { height: size ?? '100%' }),
|
||||
...scrollerStyle
|
||||
}}>
|
||||
{header}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
|
||||
@@ -1003,6 +1003,18 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
provider: 'minimax',
|
||||
name: 'minimax-01',
|
||||
group: 'minimax-01'
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2',
|
||||
provider: 'minimax',
|
||||
name: 'MiniMax M2',
|
||||
group: 'minimax-m2'
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2-Stable',
|
||||
provider: 'minimax',
|
||||
name: 'MiniMax M2 Stable',
|
||||
group: 'minimax-m2'
|
||||
}
|
||||
],
|
||||
hyperbolic: [
|
||||
@@ -1840,5 +1852,26 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
group: 'LongCat'
|
||||
}
|
||||
],
|
||||
huggingface: []
|
||||
huggingface: [],
|
||||
'ai-gateway': [],
|
||||
cerebras: [
|
||||
{
|
||||
id: 'gpt-oss-120b',
|
||||
name: 'GPT oss 120B',
|
||||
provider: 'cerebras',
|
||||
group: 'openai'
|
||||
},
|
||||
{
|
||||
id: 'zai-glm-4.6',
|
||||
name: 'GLM 4.6',
|
||||
provider: 'cerebras',
|
||||
group: 'zai'
|
||||
},
|
||||
{
|
||||
id: 'qwen-3-235b-a22b-instruct-2507',
|
||||
name: 'Qwen 3 235B A22B Instruct',
|
||||
provider: 'cerebras',
|
||||
group: 'qwen'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-clou
|
||||
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
|
||||
import BurnCloudProviderLogo from '@renderer/assets/images/providers/burncloud.png'
|
||||
import CephalonProviderLogo from '@renderer/assets/images/providers/cephalon.jpeg'
|
||||
import CerebrasProviderLogo from '@renderer/assets/images/providers/cerebras.webp'
|
||||
import CherryInProviderLogo from '@renderer/assets/images/providers/cherryin.png'
|
||||
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
|
||||
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
|
||||
@@ -51,6 +52,7 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
|
||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
|
||||
import AIGatewayProviderLogo from '@renderer/assets/images/providers/vercel.svg'
|
||||
import VertexAIProviderLogo from '@renderer/assets/images/providers/vertexai.svg'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
|
||||
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
|
||||
@@ -470,7 +472,8 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
name: 'MiniMax',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.minimax.chat/v1/',
|
||||
apiHost: 'https://api.minimaxi.com/v1',
|
||||
anthropicApiHost: 'https://api.minimaxi.com/anthropic',
|
||||
models: SYSTEM_MODELS.minimax,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
@@ -675,6 +678,26 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
'ai-gateway': {
|
||||
id: 'ai-gateway',
|
||||
name: 'AI Gateway',
|
||||
type: 'ai-gateway',
|
||||
apiKey: '',
|
||||
apiHost: 'https://ai-gateway.vercel.sh/v1',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
cerebras: {
|
||||
id: 'cerebras',
|
||||
name: 'Cerebras AI',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.cerebras.ai/v1',
|
||||
models: SYSTEM_MODELS.cerebras,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -741,7 +764,9 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
|
||||
aionly: AiOnlyProviderLogo,
|
||||
longcat: LongCatProviderLogo,
|
||||
huggingface: HuggingfaceProviderLogo,
|
||||
sophnet: SophnetProviderLogo
|
||||
sophnet: SophnetProviderLogo,
|
||||
'ai-gateway': AIGatewayProviderLogo,
|
||||
cerebras: CerebrasProviderLogo
|
||||
} as const
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
@@ -1048,7 +1073,7 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
},
|
||||
minimax: {
|
||||
api: {
|
||||
url: 'https://api.minimax.chat/v1/'
|
||||
url: 'https://api.minimaxi.com/v1/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://platform.minimaxi.com/',
|
||||
@@ -1390,6 +1415,28 @@ export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
|
||||
docs: 'https://huggingface.co/docs',
|
||||
models: 'https://huggingface.co/models'
|
||||
}
|
||||
},
|
||||
'ai-gateway': {
|
||||
api: {
|
||||
url: 'https://ai-gateway.vercel.sh/v1/ai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://vercel.com/ai-gateway',
|
||||
apiKey: 'https://vercel.com/',
|
||||
docs: 'https://vercel.com/docs/ai-gateway',
|
||||
models: 'https://vercel.com/ai-gateway/models'
|
||||
}
|
||||
},
|
||||
cerebras: {
|
||||
api: {
|
||||
url: 'https://api.cerebras.ai/v1'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.cerebras.ai',
|
||||
apiKey: 'https://cloud.cerebras.ai',
|
||||
docs: 'https://inference-docs.cerebras.ai/introduction',
|
||||
models: 'https://inference-docs.cerebras.ai/models/overview'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,7 +1499,7 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => {
|
||||
)
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot'] as const satisfies SystemProviderId[]
|
||||
const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
* 判断提供商是否支持 service_tier 设置。 Only for OpenAI API.
|
||||
@@ -1519,6 +1566,10 @@ export function isGeminiProvider(provider: Provider): boolean {
|
||||
return provider.type === 'gemini'
|
||||
}
|
||||
|
||||
export function isAIGatewayProvider(provider: Provider): boolean {
|
||||
return provider.type === 'ai-gateway'
|
||||
}
|
||||
|
||||
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[]
|
||||
|
||||
export const isSupportAPIVersionProvider = (provider: Provider) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -6,6 +7,8 @@ import type { CreateSessionForm } from '@renderer/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('useCreateDefaultSession')
|
||||
|
||||
/**
|
||||
* Returns a stable callback that creates a default agent session and updates UI state.
|
||||
*/
|
||||
@@ -37,6 +40,9 @@ export const useCreateDefaultSession = (agentId: string | null) => {
|
||||
}
|
||||
|
||||
return created
|
||||
} catch (error) {
|
||||
logger.error('Error creating default session:', error as Error)
|
||||
return null
|
||||
} finally {
|
||||
setCreatingSession(false)
|
||||
}
|
||||
|
||||
63
src/renderer/src/hooks/useInputText.ts
Normal file
63
src/renderer/src/hooks/useInputText.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
export interface UseInputTextOptions {
|
||||
initialValue?: string
|
||||
onChange?: (text: string) => void
|
||||
}
|
||||
|
||||
export interface UseInputTextReturn {
|
||||
text: string
|
||||
setText: (text: string | ((prev: string) => string)) => void
|
||||
prevText: string
|
||||
isEmpty: boolean
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理文本输入状态的通用 Hook
|
||||
*
|
||||
* 提供文本状态管理、历史追踪和便捷方法
|
||||
*
|
||||
* @param options - 配置选项
|
||||
* @param options.initialValue - 初始文本值
|
||||
* @param options.onChange - 文本变化回调
|
||||
* @returns 文本状态和操作方法
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { text, setText, isEmpty, clear } = useInputText({
|
||||
* initialValue: '',
|
||||
* onChange: (text) => console.log('Text changed:', text)
|
||||
* })
|
||||
*
|
||||
* <input value={text} onChange={(e) => setText(e.target.value)} />
|
||||
* <button disabled={isEmpty}>Send</button>
|
||||
* <button onClick={clear}>Clear</button>
|
||||
* ```
|
||||
*/
|
||||
export function useInputText(options: UseInputTextOptions = {}): UseInputTextReturn {
|
||||
const [text, setText] = useState(options.initialValue ?? '')
|
||||
const prevTextRef = useRef(text)
|
||||
|
||||
const handleSetText = useCallback(
|
||||
(value: string | ((prev: string) => string)) => {
|
||||
const newText = typeof value === 'function' ? value(text) : value
|
||||
prevTextRef.current = text
|
||||
setText(newText)
|
||||
options.onChange?.(newText)
|
||||
},
|
||||
[text, options]
|
||||
)
|
||||
|
||||
const clear = useCallback(() => {
|
||||
handleSetText('')
|
||||
}, [handleSetText])
|
||||
|
||||
return {
|
||||
text,
|
||||
setText: handleSetText,
|
||||
prevText: prevTextRef.current,
|
||||
isEmpty: text.trim().length === 0,
|
||||
clear
|
||||
}
|
||||
}
|
||||
94
src/renderer/src/hooks/useKeyboardHandler.ts
Normal file
94
src/renderer/src/hooks/useKeyboardHandler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
export interface KeyboardHandlerCallbacks {
|
||||
onSend?: () => void
|
||||
onEscape?: () => void
|
||||
onTab?: () => void
|
||||
onCustom?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
export interface KeyboardHandlerOptions {
|
||||
sendShortcut?: 'Enter' | 'Ctrl+Enter' | 'Cmd+Enter' | 'Shift+Enter'
|
||||
enableTabNavigation?: boolean
|
||||
enableEscape?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用键盘事件处理 Hook
|
||||
*
|
||||
* 提供常见的键盘快捷键处理(发送、取消、Tab 导航等)
|
||||
*
|
||||
* @param callbacks - 键盘事件回调函数
|
||||
* @param callbacks.onSend - 发送消息回调(根据 sendShortcut 触发)
|
||||
* @param callbacks.onEscape - Escape 键回调
|
||||
* @param callbacks.onTab - Tab 键回调
|
||||
* @param callbacks.onCustom - 自定义键盘处理回调
|
||||
* @param options - 配置选项
|
||||
* @param options.sendShortcut - 发送快捷键类型(默认 'Enter')
|
||||
* @param options.enableTabNavigation - 是否启用 Tab 导航(默认 false)
|
||||
* @param options.enableEscape - 是否启用 Escape 键处理(默认 false)
|
||||
* @returns 键盘事件处理函数
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const handleKeyDown = useKeyboardHandler(
|
||||
* {
|
||||
* onSend: () => sendMessage(),
|
||||
* onEscape: () => closeModal(),
|
||||
* onTab: () => navigateToNextField()
|
||||
* },
|
||||
* {
|
||||
* sendShortcut: 'Ctrl+Enter',
|
||||
* enableTabNavigation: true,
|
||||
* enableEscape: true
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* <textarea onKeyDown={handleKeyDown} />
|
||||
* ```
|
||||
*/
|
||||
export function useKeyboardHandler(callbacks: KeyboardHandlerCallbacks, options: KeyboardHandlerOptions = {}) {
|
||||
const callbacksRef = useRef(callbacks)
|
||||
callbacksRef.current = callbacks
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const { sendShortcut = 'Enter', enableTabNavigation = false, enableEscape = false } = options
|
||||
|
||||
// Tab 导航
|
||||
if (enableTabNavigation && event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
callbacksRef.current.onTab?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Escape 键
|
||||
if (enableEscape && event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
callbacksRef.current.onEscape?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Enter 键处理
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
const isSendPressed =
|
||||
(sendShortcut === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey) ||
|
||||
(sendShortcut === 'Ctrl+Enter' && event.ctrlKey) ||
|
||||
(sendShortcut === 'Cmd+Enter' && event.metaKey) ||
|
||||
(sendShortcut === 'Shift+Enter' && event.shiftKey)
|
||||
|
||||
if (isSendPressed) {
|
||||
event.preventDefault()
|
||||
callbacksRef.current.onSend?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义处理器
|
||||
callbacksRef.current.onCustom?.(event)
|
||||
},
|
||||
[options]
|
||||
)
|
||||
|
||||
return handleKeyDown
|
||||
}
|
||||
125
src/renderer/src/hooks/useTextareaResize.ts
Normal file
125
src/renderer/src/hooks/useTextareaResize.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
export interface UseTextareaResizeOptions {
|
||||
maxHeight?: number
|
||||
minHeight?: number
|
||||
autoResize?: boolean
|
||||
}
|
||||
|
||||
export interface UseTextareaResizeReturn {
|
||||
textareaRef: React.RefObject<TextAreaRef | null>
|
||||
resize: (force?: boolean) => void
|
||||
focus: () => void
|
||||
customHeight: number | undefined
|
||||
setCustomHeight: (height: number | undefined) => void
|
||||
setExpanded: (expanded: boolean, expandedHeight?: number) => void
|
||||
isExpanded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Textarea 自动调整大小的通用 Hook
|
||||
*
|
||||
* 支持自动调整高度、手动展开/收起、自定义高度限制
|
||||
*
|
||||
* @param options - 配置选项
|
||||
* @param options.maxHeight - 最大高度限制(默认 400px)
|
||||
* @param options.minHeight - 最小高度限制(默认 30px)
|
||||
* @param options.autoResize - 是否自动调整大小(默认 true)
|
||||
* @returns Textarea ref 和调整方法
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { textareaRef, resize, setExpanded, isExpanded, customHeight } = useTextareaResize({
|
||||
* maxHeight: 400,
|
||||
* minHeight: 30
|
||||
* })
|
||||
*
|
||||
* useEffect(() => {
|
||||
* resize() // 在内容变化后调用
|
||||
* }, [text])
|
||||
*
|
||||
* <TextArea
|
||||
* ref={textareaRef}
|
||||
* style={{ height: customHeight }}
|
||||
* autoSize={customHeight ? false : { minRows: 2, maxRows: 20 }}
|
||||
* />
|
||||
* <button onClick={() => setExpanded(!isExpanded)}>Toggle Expand</button>
|
||||
* ```
|
||||
*/
|
||||
export function useTextareaResize(options: UseTextareaResizeOptions = {}): UseTextareaResizeReturn {
|
||||
const { maxHeight = 400, minHeight = 30, autoResize = true } = options
|
||||
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const [customHeight, setCustomHeight] = useState<number>()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const resize = useCallback(
|
||||
(force = false) => {
|
||||
if (!autoResize && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (!textArea) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果设置了自定义高度且不是强制调整,则跳过
|
||||
if (customHeight !== undefined && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
textArea.style.height = 'auto'
|
||||
if (textArea.scrollHeight) {
|
||||
const newHeight = Math.max(minHeight, Math.min(textArea.scrollHeight, maxHeight))
|
||||
textArea.style.height = `${newHeight}px`
|
||||
}
|
||||
},
|
||||
[autoResize, customHeight, maxHeight, minHeight]
|
||||
)
|
||||
|
||||
const focus = useCallback(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const setExpanded = useCallback(
|
||||
(expanded: boolean, expandedHeight = 0.7 * window.innerHeight) => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (!textArea) {
|
||||
setIsExpanded(expanded)
|
||||
setCustomHeight(expanded ? expandedHeight : undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
const viewportHeight = window.innerHeight || expandedHeight
|
||||
const desiredHeight = Math.max(minHeight, Math.min(expandedHeight, viewportHeight * 0.9))
|
||||
textArea.style.height = `${desiredHeight}px`
|
||||
setCustomHeight(desiredHeight)
|
||||
setIsExpanded(true)
|
||||
} else {
|
||||
textArea.style.height = 'auto'
|
||||
setCustomHeight(undefined)
|
||||
setIsExpanded(false)
|
||||
// 收起后重新计算高度
|
||||
requestAnimationFrame(() => {
|
||||
const contentHeight = textArea.scrollHeight
|
||||
const nextHeight = Math.max(minHeight, Math.min(contentHeight, maxHeight))
|
||||
textArea.style.height = `${nextHeight}px`
|
||||
})
|
||||
}
|
||||
},
|
||||
[maxHeight, minHeight]
|
||||
)
|
||||
|
||||
return {
|
||||
textareaRef,
|
||||
resize,
|
||||
focus,
|
||||
customHeight,
|
||||
setCustomHeight,
|
||||
setExpanded,
|
||||
isExpanded
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ const providerKeyMap = {
|
||||
aionly: 'provider.aionly',
|
||||
longcat: 'provider.longcat',
|
||||
huggingface: 'provider.huggingface',
|
||||
sophnet: 'provider.sophnet'
|
||||
sophnet: 'provider.sophnet',
|
||||
'ai-gateway': 'provider.ai-gateway',
|
||||
cerebras: 'provider.cerebras'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "View Full Content"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Select file from activity directory",
|
||||
"loading": "Loading Files...",
|
||||
"no_file_found": {
|
||||
"description": "No files available in accessible directories",
|
||||
"label": "No File Found"
|
||||
},
|
||||
"title": "Activity Directory"
|
||||
},
|
||||
"auto_resize": "Auto resize height",
|
||||
"clear": {
|
||||
"content": "Do you want to clear all messages of the current topic?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Clear Context {{Command}}"
|
||||
},
|
||||
"new_session": "New Session {{Command}}",
|
||||
"new_topic": "New Topic {{Command}}",
|
||||
"paste_text_file_confirm": "Paste into input bar?",
|
||||
"pause": "Pause",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Type your message here, press {{key}} to send",
|
||||
"send": "Send",
|
||||
"settings": "Settings",
|
||||
"slash_commands": {
|
||||
"description": "Agent session slash commands",
|
||||
"title": "Slash Commands"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Thinking budget exceeds the maximum token number",
|
||||
"label": "Thinking",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Code style",
|
||||
"compact": {
|
||||
"title": "Conversation Compacted"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Are you sure you want to delete this message?",
|
||||
"title": "Delete Message"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the preview version"
|
||||
"tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the v1 version"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Confirm",
|
||||
"forward": "Forward",
|
||||
"multiple": "Multiple Select",
|
||||
"noResult": "No results found",
|
||||
"page": "Page",
|
||||
"select": "Select",
|
||||
"title": "Quick Menu"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "查看完整内容"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "从活动目录中选择文件",
|
||||
"loading": "正在加载文件...",
|
||||
"no_file_found": {
|
||||
"description": "可访问目录中没有可用文件",
|
||||
"label": "未找到文件"
|
||||
},
|
||||
"title": "活动目录"
|
||||
},
|
||||
"auto_resize": "自动调整高度",
|
||||
"clear": {
|
||||
"content": "确定要清除当前会话所有消息吗?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_session": "新会话 {{Command}}",
|
||||
"new_topic": "新话题 {{Command}}",
|
||||
"paste_text_file_confirm": "粘贴到输入框?",
|
||||
"pause": "暂停",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",
|
||||
"send": "发送",
|
||||
"settings": "设置",
|
||||
"slash_commands": {
|
||||
"description": "代理会话斜杠命令",
|
||||
"title": "斜杠命令"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考预算超过最大 Token 数",
|
||||
"label": "思考",
|
||||
@@ -890,7 +904,7 @@
|
||||
"show_line_numbers": "代码显示行号",
|
||||
"temperature": {
|
||||
"label": "模型温度",
|
||||
"tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7"
|
||||
"tip": "模型生成文本的随机程度。值越大,回复内容越富有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7"
|
||||
},
|
||||
"thought_auto_collapse": {
|
||||
"label": "思考内容自动折叠",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "代码风格",
|
||||
"compact": {
|
||||
"title": "对话已压缩"
|
||||
},
|
||||
"delete": {
|
||||
"content": "确定要删除此消息吗?",
|
||||
"title": "删除消息"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "唯一AI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "百度云千帆",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "阿里云百炼",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 preview 版本"
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 v1 版本"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "确认",
|
||||
"forward": "前进",
|
||||
"multiple": "多选",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "翻页",
|
||||
"select": "选择",
|
||||
"title": "快捷菜单"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "查看完整內容"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "從活動目錄中選擇檔案",
|
||||
"loading": "載入檔案中...",
|
||||
"no_file_found": {
|
||||
"description": "可存取的目錄中沒有檔案",
|
||||
"label": "找不到檔案"
|
||||
},
|
||||
"title": "活動目錄"
|
||||
},
|
||||
"auto_resize": "自動調整高度",
|
||||
"clear": {
|
||||
"content": "您想要清除目前話題的所有訊息嗎?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "清除上下文 {{Command}}"
|
||||
},
|
||||
"new_session": "新工作階段 {{Command}}",
|
||||
"new_topic": "新話題 {{Command}}",
|
||||
"paste_text_file_confirm": "貼到輸入框?",
|
||||
"pause": "暫停",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",
|
||||
"send": "傳送",
|
||||
"settings": "設定",
|
||||
"slash_commands": {
|
||||
"description": "代理會話斜線命令",
|
||||
"title": "斜線指令"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考預算超過最大 Token 數",
|
||||
"label": "思考",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "程式碼風格",
|
||||
"compact": {
|
||||
"title": "對話已壓縮"
|
||||
},
|
||||
"delete": {
|
||||
"content": "確定要刪除此訊息嗎?",
|
||||
"title": "刪除訊息"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI 閘道器",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "唯一AI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "百度雲千帆",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "阿里雲百鍊",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 preview 版本"
|
||||
"tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 v1 版本"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "確認",
|
||||
"forward": "前進",
|
||||
"multiple": "多選",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "翻頁",
|
||||
"select": "選擇",
|
||||
"title": "快捷選單"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "Vollständigen Inhalt anzeigen"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Datei aus dem Aktivitätsverzeichnis auswählen",
|
||||
"loading": "Dateien werden geladen...",
|
||||
"no_file_found": {
|
||||
"description": "Keine Dateien in zugänglichen Verzeichnissen verfügbar",
|
||||
"label": "Keine Datei gefunden"
|
||||
},
|
||||
"title": "Aktivitätsverzeichnis"
|
||||
},
|
||||
"auto_resize": "Höhe automatisch anpassen",
|
||||
"clear": {
|
||||
"content": "Möchten Sie wirklich alle Nachrichten der aktuellen Sitzung löschen?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Kontext löschen {{Command}}"
|
||||
},
|
||||
"new_session": "Neue Sitzung {{Command}}",
|
||||
"new_topic": "Neues Thema {{Command}}",
|
||||
"paste_text_file_confirm": "In Eingabefeld einfügen?",
|
||||
"pause": "Pause",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Geben Sie hier eine Nachricht ein, drücken Sie {{key}} zum Senden",
|
||||
"send": "Senden",
|
||||
"settings": "Einstellungen",
|
||||
"slash_commands": {
|
||||
"description": "Agent-Session-Slash-Befehle",
|
||||
"title": "Schrägstrich-Befehle"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Denkbudget übersteigt maximale Token-Anzahl",
|
||||
"label": "Denken",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Code-Stil",
|
||||
"compact": {
|
||||
"title": "Gespräch komprimiert"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Möchten Sie diese Nachricht wirklich löschen?",
|
||||
"title": "Nachricht löschen"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "KI-Gateway",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "Einzige KI (AiOnly)",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Cloud Qianfan",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud Bailian",
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Bestätigen",
|
||||
"forward": "Vorwärts",
|
||||
"multiple": "Mehrfachauswahl",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Seite umblättern",
|
||||
"select": "Auswählen",
|
||||
"title": "Schnellmenü"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "Προβολή πλήρους περιεχομένου"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Επιλέξτε αρχείο από τον κατάλογο δραστηριότητας",
|
||||
"loading": "Φόρτωση Αρχείων...",
|
||||
"no_file_found": {
|
||||
"description": "Δεν υπάρχουν διαθέσιμα αρχεία σε προσβάσιμους καταλόγους",
|
||||
"label": "Δεν Βρέθηκε Αρχείο"
|
||||
},
|
||||
"title": "Κατάλογος Δραστηριοτήτων"
|
||||
},
|
||||
"auto_resize": "Αυτόματη μείωση ύψους",
|
||||
"clear": {
|
||||
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις όλα τα μηνύματα της τρέχουσας συζήτησης;",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Καθαρισμός ενδιάμεσων {{Command}}"
|
||||
},
|
||||
"new_session": "Νέα Συνεδρία {{Command}}",
|
||||
"new_topic": "Νέο θέμα {{Command}}",
|
||||
"paste_text_file_confirm": "Επικόλληση στο πεδίο εισαγωγής;",
|
||||
"pause": "Παύση",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
|
||||
"send": "Αποστολή",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"slash_commands": {
|
||||
"description": "Εντολές κάθετης γραμμής για συνεδρία πράκτορα",
|
||||
"title": "Εντολές Κάθετης Γραμμής"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Ο προϋπολογισμός σκέψης υπερβαίνει τον μέγιστο αριθμό token",
|
||||
"label": "Σκέψη",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Στυλ κώδικα",
|
||||
"compact": {
|
||||
"title": "Συνομιλία Συμπυκνωμένη"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Θέλετε να διαγράψετε αυτό το μήνυμα;",
|
||||
"title": "Διαγραφή μηνύματος"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Πύλη Τεχνητής Νοημοσύνης",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Cloud Qianfan",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "AliCloud Bailian",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια προεπισκόπηση έκδοσης"
|
||||
"tip": "Η έκδοση του API για Azure OpenAI. Αν θέλετε να χρησιμοποιήσετε το Response API, εισάγετε μια v1 έκδοσης"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Επιβεβαίωση",
|
||||
"forward": "Μπρος",
|
||||
"multiple": "Πολλαπλή επιλογή",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Σελίδα",
|
||||
"select": "Επιλογή",
|
||||
"title": "Γρήγορη Πρόσβαση"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "Ver contenido completo"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Seleccionar archivo del directorio de actividad",
|
||||
"loading": "Cargando archivos...",
|
||||
"no_file_found": {
|
||||
"description": "No hay archivos disponibles en los directorios accesibles",
|
||||
"label": "No se encontró ningún archivo"
|
||||
},
|
||||
"title": "Directorio de Actividades"
|
||||
},
|
||||
"auto_resize": "Ajuste automático de altura",
|
||||
"clear": {
|
||||
"content": "¿Estás seguro de que quieres eliminar todos los mensajes de la sesión actual?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Limpiar contexto {{Command}}"
|
||||
},
|
||||
"new_session": "Nueva Sesión {{Command}}",
|
||||
"new_topic": "Nuevo tema {{Command}}",
|
||||
"paste_text_file_confirm": "¿Pegar en el cuadro de entrada?",
|
||||
"pause": "Pausar",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
|
||||
"send": "Enviar",
|
||||
"settings": "Configuración",
|
||||
"slash_commands": {
|
||||
"description": "Comandos de sesión de agente con barra",
|
||||
"title": "Comandos de barra"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "El presupuesto de pensamiento excede el número máximo de tokens",
|
||||
"label": "Pensando",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Estilo de código",
|
||||
"compact": {
|
||||
"title": "Conversación Compactada"
|
||||
},
|
||||
"delete": {
|
||||
"content": "¿Está seguro de querer eliminar este mensaje?",
|
||||
"title": "Eliminar mensaje"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Puerta de enlace de IA",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Nube Qiánfān",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copiloto",
|
||||
"dashscope": "Álibaba Nube BaiLiàn",
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Confirmar",
|
||||
"forward": "Adelante",
|
||||
"multiple": "Selección múltiple",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Página",
|
||||
"select": "Seleccionar",
|
||||
"title": "Menú de acceso rápido"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "Voir le contenu complet"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Sélectionner le fichier dans le répertoire d'activité",
|
||||
"loading": "Chargement des fichiers...",
|
||||
"no_file_found": {
|
||||
"description": "Aucun fichier disponible dans les répertoires accessibles",
|
||||
"label": "Aucun fichier trouvé"
|
||||
},
|
||||
"title": "Répertoire d'activités"
|
||||
},
|
||||
"auto_resize": "Ajustement automatique de la hauteur",
|
||||
"clear": {
|
||||
"content": "Êtes-vous sûr de vouloir effacer tous les messages de la conversation actuelle ?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Effacer le contexte {{Command}}"
|
||||
},
|
||||
"new_session": "Nouvelle Session {{Command}}",
|
||||
"new_topic": "Nouveau sujet {{Command}}",
|
||||
"paste_text_file_confirm": "Coller dans la zone de saisie ?",
|
||||
"pause": "Pause",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
|
||||
"send": "Envoyer",
|
||||
"settings": "Paramètres",
|
||||
"slash_commands": {
|
||||
"description": "Commandes slash de session d'agent",
|
||||
"title": "Commandes Slash"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Le budget de réflexion dépasse le nombre maximum de tokens",
|
||||
"label": "Pensée",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Style de code",
|
||||
"compact": {
|
||||
"title": "Conversation Compactée"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Êtes-vous sûr de vouloir supprimer ce message?",
|
||||
"title": "Supprimer le message"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Passerelle IA",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Cloud Qianfan",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilote",
|
||||
"dashscope": "AliCloud BaiLian",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"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": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Подтвердить",
|
||||
"forward": "Вперед",
|
||||
"multiple": "Множественный выбор",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Перелистнуть страницу",
|
||||
"select": "Выбрать",
|
||||
"title": "Быстрое меню"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "完全な内容を表示"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "アクティビティディレクトリからファイルを選択",
|
||||
"loading": "ファイルを読み込んでいます...",
|
||||
"no_file_found": {
|
||||
"description": "アクセス可能なディレクトリに利用可能なファイルがありません",
|
||||
"label": "ファイルが見つかりません"
|
||||
},
|
||||
"title": "アクティビティディレクトリ"
|
||||
},
|
||||
"auto_resize": "高さを自動調整",
|
||||
"clear": {
|
||||
"content": "現在のトピックのすべてのメッセージをクリアしますか?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "コンテキストをクリア {{Command}}"
|
||||
},
|
||||
"new_session": "新しいセッション {{Command}}",
|
||||
"new_topic": "新しいトピック {{Command}}",
|
||||
"paste_text_file_confirm": "入力欄に貼り付けますか?",
|
||||
"pause": "一時停止",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"send": "送信",
|
||||
"settings": "設定",
|
||||
"slash_commands": {
|
||||
"description": "エージェントセッションスラッシュコマンド",
|
||||
"title": "スラッシュコマンド"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "思考予算が最大トークン数を超えました",
|
||||
"label": "思考",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "コードスタイル",
|
||||
"compact": {
|
||||
"title": "会話圧縮"
|
||||
},
|
||||
"delete": {
|
||||
"content": "このメッセージを削除してもよろしいですか?",
|
||||
"title": "メッセージを削除"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AIゲートウェイ",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、previewバージョンを入力してください"
|
||||
"tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、v1バージョンを入力してください"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "確認",
|
||||
"forward": "進む",
|
||||
"multiple": "複数選択",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "ページ",
|
||||
"select": "選択",
|
||||
"title": "クイックメニュー"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "Ver conteúdo completo"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Selecionar arquivo do diretório de atividades",
|
||||
"loading": "Carregando Arquivos...",
|
||||
"no_file_found": {
|
||||
"description": "Nenhum arquivo disponível em diretórios acessíveis",
|
||||
"label": "Nenhum Arquivo Encontrado"
|
||||
},
|
||||
"title": "Diretório de Atividades"
|
||||
},
|
||||
"auto_resize": "Ajuste automático de altura",
|
||||
"clear": {
|
||||
"content": "Tem certeza de que deseja limpar todas as mensagens da sessão atual?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Limpar contexto {{Command}}"
|
||||
},
|
||||
"new_session": "Nova Sessão {{Command}}",
|
||||
"new_topic": "Novo tópico {{Command}}",
|
||||
"paste_text_file_confirm": "Colar na caixa de entrada?",
|
||||
"pause": "Pausar",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
|
||||
"send": "Enviar",
|
||||
"settings": "Configurações",
|
||||
"slash_commands": {
|
||||
"description": "Comandos de barra da sessão do agente",
|
||||
"title": "Comandos de Barra"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Orçamento de pensamento excede o número máximo de tokens",
|
||||
"label": "Pensando",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Estilo de código",
|
||||
"compact": {
|
||||
"title": "Conversa Compactada"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Tem certeza de que deseja excluir esta mensagem?",
|
||||
"title": "Excluir mensagem"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "Gateway de IA",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Nuvem Baidu",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copiloto",
|
||||
"dashscope": "Área de Atuação AliCloud",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"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": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Confirmar",
|
||||
"forward": "Avançar",
|
||||
"multiple": "Múltipla Seleção",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Página",
|
||||
"select": "Selecionar",
|
||||
"title": "Menu de Atalho"
|
||||
|
||||
@@ -631,6 +631,15 @@
|
||||
"view_full_content": "Показать полное содержимое"
|
||||
},
|
||||
"input": {
|
||||
"activity_directory": {
|
||||
"description": "Выбрать файл из каталога активности",
|
||||
"loading": "Загрузка файлов...",
|
||||
"no_file_found": {
|
||||
"description": "Нет доступных файлов в доступных каталогах",
|
||||
"label": "Файл не найден"
|
||||
},
|
||||
"title": "Каталог активностей"
|
||||
},
|
||||
"auto_resize": "Автоматическая высота",
|
||||
"clear": {
|
||||
"content": "Хотите очистить все сообщения текущего топика?",
|
||||
@@ -654,6 +663,7 @@
|
||||
"new": {
|
||||
"context": "Очистить контекст {{Command}}"
|
||||
},
|
||||
"new_session": "Новая сессия {{Команда}}",
|
||||
"new_topic": "Новый топик {{Command}}",
|
||||
"paste_text_file_confirm": "Вставить в поле ввода?",
|
||||
"pause": "Остановить",
|
||||
@@ -661,6 +671,10 @@
|
||||
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
|
||||
"send": "Отправить",
|
||||
"settings": "Настройки",
|
||||
"slash_commands": {
|
||||
"description": "Слэш-команды сеанса агента",
|
||||
"title": "Слэш-команды"
|
||||
},
|
||||
"thinking": {
|
||||
"budget_exceeds_max": "Бюджет размышления превышает максимальное количество токенов",
|
||||
"label": "Мыслим",
|
||||
@@ -1771,6 +1785,9 @@
|
||||
},
|
||||
"message": {
|
||||
"code_style": "Стиль кода",
|
||||
"compact": {
|
||||
"title": "Сжатый разговор"
|
||||
},
|
||||
"delete": {
|
||||
"content": "Вы уверены, что хотите удалить это сообщение?",
|
||||
"title": "Удалить сообщение"
|
||||
@@ -2467,6 +2484,7 @@
|
||||
},
|
||||
"provider": {
|
||||
"302ai": "302.AI",
|
||||
"ai-gateway": "AI-шлюз",
|
||||
"aihubmix": "AiHubMix",
|
||||
"aionly": "AiOnly",
|
||||
"alayanew": "Alaya NeW",
|
||||
@@ -2477,6 +2495,7 @@
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"burncloud": "BurnCloud",
|
||||
"cephalon": "Cephalon",
|
||||
"cerebras": "Cerebras AI",
|
||||
"cherryin": "CherryIN",
|
||||
"copilot": "GitHub Copilot",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
@@ -4324,7 +4343,7 @@
|
||||
},
|
||||
"azure": {
|
||||
"apiversion": {
|
||||
"tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию preview"
|
||||
"tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию v1"
|
||||
}
|
||||
},
|
||||
"basic_auth": {
|
||||
@@ -4459,6 +4478,7 @@
|
||||
"confirm": "Подтвердить",
|
||||
"forward": "Вперед",
|
||||
"multiple": "Множественный выбор",
|
||||
"noResult": "[to be translated]:No results found",
|
||||
"page": "Страница",
|
||||
"select": "Выбрать",
|
||||
"title": "Быстрое меню"
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Alert, Flex } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -161,29 +161,6 @@ const Chat: FC<Props> = (props) => {
|
||||
|
||||
const mainHeight = isTopNavbar ? 'calc(100vh - var(--navbar-height) - 6px)' : 'calc(100vh - var(--navbar-height))'
|
||||
|
||||
const SessionMessages = useMemo(() => {
|
||||
if (activeAgentId === null) {
|
||||
return () => <div> Active Agent ID is invalid.</div>
|
||||
}
|
||||
if (!activeSessionId) {
|
||||
return () => <div> Active Session ID is invalid.</div>
|
||||
}
|
||||
if (!apiServer.enabled) {
|
||||
return () => <Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: '5px 16px' }} />
|
||||
}
|
||||
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
|
||||
}, [activeAgentId, activeSessionId, apiServer.enabled, t])
|
||||
|
||||
const SessionInputBar = useMemo(() => {
|
||||
if (activeAgentId === null) {
|
||||
return () => <div> Active Agent ID is invalid.</div>
|
||||
}
|
||||
if (!activeSessionId) {
|
||||
return () => <div> Active Session ID is invalid.</div>
|
||||
}
|
||||
return () => <AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} />
|
||||
}, [activeAgentId, activeSessionId])
|
||||
|
||||
// TODO: more info
|
||||
const AgentInvalid = useCallback(() => {
|
||||
return <Alert type="warning" message="Select an agent" style={{ margin: '5px 16px' }} />
|
||||
@@ -250,8 +227,12 @@ const Chat: FC<Props> = (props) => {
|
||||
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId && <SessionInvalid />}
|
||||
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId && (
|
||||
<>
|
||||
<SessionMessages />
|
||||
<SessionInputBar />
|
||||
{!apiServer.enabled ? (
|
||||
<Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: '5px 16px' }} />
|
||||
) : (
|
||||
<AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
|
||||
)}
|
||||
<AgentSessionInputbar agentId={activeAgentId} sessionId={activeSessionId} />
|
||||
</>
|
||||
)}
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
|
||||
@@ -1,63 +1,201 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelView } from '@renderer/components/QuickPanel'
|
||||
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
|
||||
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||
import { useSession } from '@renderer/hooks/agents/useSession'
|
||||
import { useInputText } from '@renderer/hooks/useInputText'
|
||||
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { getModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useTextareaResize } from '@renderer/hooks/useTextareaResize'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { pauseTrace } from '@renderer/services/SpanManagerService'
|
||||
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import type { Assistant, Message, Model, Topic } from '@renderer/types'
|
||||
import type { FileType } from '@renderer/types'
|
||||
import type { MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
|
||||
import { Tooltip } from 'antd'
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { CirclePause, MessageSquareDiff } from 'lucide-react'
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import NarrowLayout from '../Messages/NarrowLayout'
|
||||
import SendMessageButton from './SendMessageButton'
|
||||
import { InputbarCore } from './components/InputbarCore'
|
||||
import {
|
||||
InputbarToolsProvider,
|
||||
useInputbarToolsDispatch,
|
||||
useInputbarToolsInternalDispatch,
|
||||
useInputbarToolsState
|
||||
} from './context/InputbarToolsProvider'
|
||||
import InputbarTools from './InputbarTools'
|
||||
import { getInputbarConfig } from './registry'
|
||||
import { TopicType } from './types'
|
||||
|
||||
const logger = loggerService.withContext('Inputbar')
|
||||
const logger = loggerService.withContext('AgentSessionInputbar')
|
||||
const agentSessionDraftCache = new Map<string, string>()
|
||||
|
||||
const readDraftFromCache = (key: string): string => {
|
||||
return agentSessionDraftCache.get(key) ?? ''
|
||||
}
|
||||
|
||||
const writeDraftToCache = (key: string, value: string) => {
|
||||
if (!value) {
|
||||
agentSessionDraftCache.delete(key)
|
||||
} else {
|
||||
agentSessionDraftCache.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
agentId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const _text = ''
|
||||
|
||||
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
const [inputFocus, setInputFocus] = useState(false)
|
||||
const { session } = useSession(agentId, sessionId)
|
||||
const { apiServer } = useSettings()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
// FIXME: 不应该使用ref将action传到context提供给tool,权宜之计
|
||||
const actionsRef = useRef({
|
||||
resizeTextArea: () => {},
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
onTextChange: (_updater: React.SetStateAction<string> | ((prev: string) => string)) => {},
|
||||
toggleExpanded: () => {}
|
||||
})
|
||||
|
||||
// Create assistant stub with session data
|
||||
const assistantStub = useMemo<Assistant | null>(() => {
|
||||
if (!session) return null
|
||||
|
||||
// Extract model info
|
||||
const [providerId, actualModelId] = session.model?.split(':') ?? [undefined, undefined]
|
||||
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
|
||||
|
||||
const model: Model | undefined = actualModel
|
||||
? {
|
||||
id: actualModel.id,
|
||||
name: actualModel.name,
|
||||
provider: actualModel.provider,
|
||||
group: actualModel.group
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: session.agent_id ?? agentId,
|
||||
name: session.name ?? 'Agent Session',
|
||||
prompt: session.instructions ?? '',
|
||||
topics: [] as Topic[],
|
||||
type: 'agent-session',
|
||||
model,
|
||||
defaultModel: model,
|
||||
tags: [],
|
||||
enableWebSearch: false
|
||||
} as Assistant
|
||||
}, [session, agentId])
|
||||
|
||||
// Prepare session data for tools
|
||||
const sessionData = useMemo(() => {
|
||||
if (!session) return undefined
|
||||
return {
|
||||
agentId,
|
||||
sessionId,
|
||||
slashCommands: session.slash_commands,
|
||||
tools: session.tools,
|
||||
accessiblePaths: session.accessible_paths ?? []
|
||||
}
|
||||
}, [session, agentId, sessionId])
|
||||
|
||||
const initialState = useMemo(
|
||||
() => ({
|
||||
mentionedModels: [],
|
||||
selectedKnowledgeBases: [],
|
||||
files: [] as FileType[],
|
||||
isExpanded: false
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
if (!assistantStub) {
|
||||
return null // Wait for session to load
|
||||
}
|
||||
|
||||
return (
|
||||
<InputbarToolsProvider
|
||||
initialState={initialState}
|
||||
actions={{
|
||||
resizeTextArea: () => actionsRef.current.resizeTextArea(),
|
||||
onTextChange: (updater) => actionsRef.current.onTextChange(updater),
|
||||
// Agent Session specific actions
|
||||
addNewTopic: () => {},
|
||||
clearTopic: () => {},
|
||||
onNewContext: () => {},
|
||||
toggleExpanded: () => actionsRef.current.toggleExpanded()
|
||||
}}>
|
||||
<AgentSessionInputbarInner
|
||||
assistant={assistantStub}
|
||||
agentId={agentId}
|
||||
sessionId={sessionId}
|
||||
sessionData={sessionData}
|
||||
actionsRef={actionsRef}
|
||||
/>
|
||||
</InputbarToolsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
assistant: Assistant
|
||||
agentId: string
|
||||
sessionId: string
|
||||
sessionData?: {
|
||||
agentId?: string
|
||||
sessionId?: string
|
||||
slashCommands?: Array<{ command: string; description?: string }>
|
||||
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||
}
|
||||
actionsRef: React.MutableRefObject<{
|
||||
resizeTextArea: () => void
|
||||
onTextChange: (updater: React.SetStateAction<string> | ((prev: string) => string)) => void
|
||||
toggleExpanded: (nextState?: boolean) => void
|
||||
}>
|
||||
}
|
||||
|
||||
const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, sessionId, sessionData, actionsRef }) => {
|
||||
const scope = TopicType.Session
|
||||
const config = getInputbarConfig(scope)
|
||||
|
||||
// Use shared hooks for text and textarea management
|
||||
const initialDraft = useMemo(() => readDraftFromCache(agentId), [agentId])
|
||||
const persistDraft = useCallback((next: string) => writeDraftToCache(agentId, next), [agentId])
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
isEmpty: inputEmpty
|
||||
} = useInputText({
|
||||
initialValue: initialDraft,
|
||||
onChange: persistDraft
|
||||
})
|
||||
const {
|
||||
textareaRef,
|
||||
resize: resizeTextArea,
|
||||
focus: focusTextarea,
|
||||
setExpanded,
|
||||
isExpanded: textareaIsExpanded
|
||||
} = useTextareaResize({ maxHeight: 400, minHeight: 30 })
|
||||
const { sendMessageShortcut, apiServer } = useSettings()
|
||||
|
||||
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const { files } = useInputbarToolsState()
|
||||
const { toolsRegistry, setIsExpanded } = useInputbarToolsDispatch()
|
||||
const { setCouldAddImageFile } = useInputbarToolsInternalDispatch()
|
||||
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -65,12 +203,152 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
|
||||
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
|
||||
|
||||
const focusTextarea = useCallback(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
// Calculate vision and image generation support
|
||||
const isVisionAssistant = useMemo(() => (assistant.model ? isVisionModel(assistant.model) : false), [assistant.model])
|
||||
const isGenerateImageAssistant = useMemo(
|
||||
() => (assistant.model ? isGenerateImageModel(assistant.model) : false),
|
||||
[assistant.model]
|
||||
)
|
||||
|
||||
const inputEmpty = isEmpty(text)
|
||||
const sendDisabled = inputEmpty || !apiServer.enabled
|
||||
// Agent sessions don't support model mentions yet, so we only check the assistant's model
|
||||
const canAddImageFile = useMemo(() => {
|
||||
return isVisionAssistant || isGenerateImageAssistant
|
||||
}, [isVisionAssistant, isGenerateImageAssistant])
|
||||
|
||||
const canAddTextFile = useMemo(() => {
|
||||
return isVisionAssistant || (!isVisionAssistant && !isGenerateImageAssistant)
|
||||
}, [isVisionAssistant, isGenerateImageAssistant])
|
||||
|
||||
// Update the couldAddImageFile state when the model changes
|
||||
useEffect(() => {
|
||||
setCouldAddImageFile(canAddImageFile)
|
||||
}, [canAddImageFile, setCouldAddImageFile])
|
||||
|
||||
const syncExpandedState = useCallback(
|
||||
(expanded: boolean) => {
|
||||
setExpanded(expanded)
|
||||
setIsExpanded(expanded)
|
||||
},
|
||||
[setExpanded, setIsExpanded]
|
||||
)
|
||||
const handleToggleExpanded = useCallback(
|
||||
(nextState?: boolean) => {
|
||||
const target = typeof nextState === 'boolean' ? nextState : !textareaIsExpanded
|
||||
syncExpandedState(target)
|
||||
focusTextarea()
|
||||
},
|
||||
[focusTextarea, syncExpandedState, textareaIsExpanded]
|
||||
)
|
||||
|
||||
// Update actionsRef for InputbarTools
|
||||
useEffect(() => {
|
||||
actionsRef.current = {
|
||||
resizeTextArea,
|
||||
onTextChange: setText,
|
||||
toggleExpanded: handleToggleExpanded
|
||||
}
|
||||
}, [resizeTextArea, setText, actionsRef, handleToggleExpanded])
|
||||
|
||||
const rootTriggerHandlerRef = useRef<((payload?: unknown) => void) | undefined>(undefined)
|
||||
|
||||
// Update handler logic when dependencies change
|
||||
// For Agent Session, we directly trigger SlashCommands panel instead of Root menu
|
||||
useEffect(() => {
|
||||
rootTriggerHandlerRef.current = (payload) => {
|
||||
const slashCommands = sessionData?.slashCommands || []
|
||||
const triggerInfo = (payload ?? {}) as QuickPanelTriggerInfo
|
||||
|
||||
if (slashCommands.length === 0) {
|
||||
quickPanel.open({
|
||||
title: t('chat.input.slash_commands.title'),
|
||||
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||
triggerInfo,
|
||||
list: [
|
||||
{
|
||||
label: t('chat.input.slash_commands.empty', 'No slash commands available'),
|
||||
description: '',
|
||||
icon: null,
|
||||
disabled: true,
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
quickPanel.open({
|
||||
title: t('chat.input.slash_commands.title'),
|
||||
symbol: QuickPanelReservedSymbol.SlashCommands,
|
||||
triggerInfo,
|
||||
list: slashCommands.map((cmd) => ({
|
||||
label: cmd.command,
|
||||
description: cmd.description || '',
|
||||
icon: null,
|
||||
filterText: `${cmd.command} ${cmd.description || ''}`,
|
||||
action: () => {
|
||||
// Insert command into textarea
|
||||
setText((prev: string) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
if (!textArea) {
|
||||
return prev + ' ' + cmd.command
|
||||
}
|
||||
|
||||
const cursorPosition = textArea.selectionStart || 0
|
||||
const textBeforeCursor = prev.slice(0, cursorPosition)
|
||||
const lastSlashIndex = textBeforeCursor.lastIndexOf('/')
|
||||
|
||||
if (lastSlashIndex !== -1 && cursorPosition > lastSlashIndex) {
|
||||
// Replace from '/' to cursor with command
|
||||
const newText = prev.slice(0, lastSlashIndex) + cmd.command + ' ' + prev.slice(cursorPosition)
|
||||
const newCursorPos = lastSlashIndex + cmd.command.length + 1
|
||||
|
||||
setTimeout(() => {
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return newText
|
||||
}
|
||||
|
||||
// No '/' found, just insert at cursor
|
||||
const newText = prev.slice(0, cursorPosition) + cmd.command + ' ' + prev.slice(cursorPosition)
|
||||
const newCursorPos = cursorPosition + cmd.command.length + 1
|
||||
|
||||
setTimeout(() => {
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return newText
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
}, [sessionData, quickPanel, t, setText])
|
||||
|
||||
// Register the trigger handler (only once)
|
||||
useEffect(() => {
|
||||
if (!config.enableQuickPanel) {
|
||||
return
|
||||
}
|
||||
|
||||
const disposeRootTrigger = toolsRegistry.registerTrigger(
|
||||
'agent-session-root',
|
||||
QuickPanelReservedSymbol.Root,
|
||||
(payload) => rootTriggerHandlerRef.current?.(payload)
|
||||
)
|
||||
|
||||
return () => {
|
||||
disposeRootTrigger()
|
||||
}
|
||||
}, [config.enableQuickPanel, toolsRegistry])
|
||||
|
||||
const sendDisabled = (inputEmpty && files.length === 0) || !apiServer.enabled
|
||||
|
||||
const streamingAskIds = useMemo(() => {
|
||||
if (!topicMessages) {
|
||||
@@ -93,64 +371,6 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
}, [topicMessages])
|
||||
|
||||
const canAbort = loading && streamingAskIds.length > 0
|
||||
const createSessionDisabled = creatingSession || !apiServer.enabled
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
if (createSessionDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createDefaultSession()
|
||||
if (created) {
|
||||
focusTextarea()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create agent session via toolbar:', error as Error)
|
||||
}
|
||||
}, [createDefaultSession, createSessionDisabled, focusTextarea])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
//to check if the SendMessage key is pressed
|
||||
//other keys should be ignored
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
// 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
// 2) 不再基于 quickPanel.isVisible 主动拦截。
|
||||
// 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
|
||||
// 其它带修饰键的 Enter 则由输入框处理为换行。
|
||||
|
||||
if (event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
|
||||
// update text by setState, not directly modify textarea.value
|
||||
setText(newText)
|
||||
|
||||
// set cursor position in the next render cycle
|
||||
setTimeoutTimer(
|
||||
'handleKeyDown',
|
||||
() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const abortAgentSession = useCallback(async () => {
|
||||
if (!streamingAskIds.length) {
|
||||
@@ -180,79 +400,43 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
|
||||
try {
|
||||
const userMessageId = uuid()
|
||||
const mainBlock = createMainTextBlock(userMessageId, text, {
|
||||
|
||||
// For agent sessions, append file paths to the text content instead of uploading files
|
||||
let messageText = text
|
||||
if (files.length > 0) {
|
||||
const filePaths = files.map((file) => file.path).join('\n')
|
||||
messageText = text ? `${text}\n\nAttached files:\n${filePaths}` : `Attached files:\n${filePaths}`
|
||||
}
|
||||
|
||||
const mainBlock = createMainTextBlock(userMessageId, messageText, {
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const userMessageBlocks: MessageBlock[] = [mainBlock]
|
||||
|
||||
// Extract the actual model ID from session.model (format: "provider:modelId")
|
||||
const [providerId, actualModelId] = session?.model?.split(':') ?? [undefined, undefined]
|
||||
|
||||
// Try to find the actual model from providers
|
||||
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
|
||||
|
||||
const model: Model | undefined = actualModel
|
||||
? {
|
||||
id: actualModel.id,
|
||||
name: actualModel.name, // Use actual model name if found
|
||||
provider: actualModel.provider,
|
||||
group: actualModel.group
|
||||
}
|
||||
: undefined
|
||||
|
||||
// Calculate token usage for the user message
|
||||
const usage = await estimateUserPromptUsage({ content: text })
|
||||
|
||||
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
|
||||
id: userMessageId,
|
||||
blocks: userMessageBlocks.map((block) => block?.id),
|
||||
model,
|
||||
modelId: model?.id,
|
||||
model: assistant.model,
|
||||
modelId: assistant.model?.id,
|
||||
usage
|
||||
})
|
||||
|
||||
const assistantStub: Assistant = {
|
||||
id: session?.agent_id ?? agentId,
|
||||
name: session?.name ?? 'Agent Session',
|
||||
prompt: session?.instructions ?? '',
|
||||
topics: [] as Topic[],
|
||||
type: 'agent-session',
|
||||
model,
|
||||
defaultModel: model,
|
||||
tags: [],
|
||||
enableWebSearch: false
|
||||
}
|
||||
|
||||
dispatch(
|
||||
dispatchSendMessage(userMessage, userMessageBlocks, assistantStub, sessionTopicId, {
|
||||
dispatchSendMessage(userMessage, userMessageBlocks, assistant, sessionTopicId, {
|
||||
agentId,
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
|
||||
setText('')
|
||||
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
|
||||
setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to send message:', error as Error)
|
||||
}
|
||||
}, [
|
||||
session?.model,
|
||||
agentId,
|
||||
dispatch,
|
||||
sendDisabled,
|
||||
session?.agent_id,
|
||||
session?.instructions,
|
||||
session?.name,
|
||||
sessionId,
|
||||
sessionTopicId,
|
||||
setTimeoutTimer,
|
||||
text
|
||||
])
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newText = e.target.value
|
||||
setText(newText)
|
||||
}, [])
|
||||
}, [sendDisabled, agentId, dispatch, assistant, sessionId, sessionTopicId, setText, setTimeoutTimer, text, files])
|
||||
|
||||
useEffect(() => {
|
||||
if (!document.querySelector('.topview-fullscreen-container')) {
|
||||
@@ -260,137 +444,57 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
}
|
||||
}, [focusTextarea])
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
if (document.activeElement?.closest('.ant-modal')) {
|
||||
return
|
||||
const supportedExts = useMemo(() => {
|
||||
if (canAddImageFile && canAddTextFile) {
|
||||
return [...imageExts, ...documentExts, ...textExts]
|
||||
}
|
||||
|
||||
const lastFocusedComponent = PasteService.getLastFocusedComponent()
|
||||
if (canAddImageFile) {
|
||||
return [...imageExts]
|
||||
}
|
||||
|
||||
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
|
||||
focusTextarea()
|
||||
if (canAddTextFile) {
|
||||
return [...documentExts, ...textExts]
|
||||
}
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [focusTextarea])
|
||||
|
||||
return []
|
||||
}, [canAddImageFile, canAddTextFile])
|
||||
|
||||
const leftToolbar = useMemo(
|
||||
() => (
|
||||
<ToolbarGroup>
|
||||
{config.showTools && <InputbarTools scope={scope} assistantId={assistant.id} session={sessionData} />}
|
||||
</ToolbarGroup>
|
||||
),
|
||||
[config.showTools, scope, assistant.id, sessionData]
|
||||
)
|
||||
const placeholderText = useMemo(
|
||||
() =>
|
||||
t('chat.input.placeholder', {
|
||||
key: getSendMessageShortcutLabel(sendMessageShortcut)
|
||||
}),
|
||||
[sendMessageShortcut, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<Container className="inputbar">
|
||||
<QuickPanelView setInputText={setText} />
|
||||
<InputBarContainer
|
||||
id="inputbar"
|
||||
className={classNames('inputbar-container', inputFocus && 'focus')}
|
||||
ref={containerRef}>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('chat.input.placeholder_without_triggers', {
|
||||
key: getSendMessageShortcutLabel(sendMessageShortcut)
|
||||
})}
|
||||
autoFocus
|
||||
variant="borderless"
|
||||
spellCheck={enableSpellCheck}
|
||||
rows={2}
|
||||
autoSize={{ minRows: 2, maxRows: 20 }}
|
||||
ref={textareaRef}
|
||||
style={{
|
||||
fontSize,
|
||||
minHeight: '30px'
|
||||
}}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
setInputFocus(true)
|
||||
// 记录当前聚焦的组件
|
||||
PasteService.setLastFocusedComponent('inputbar')
|
||||
if (e.target.value.length === 0) {
|
||||
e.target.setSelectionRange(0, 0)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
<InputbarCore
|
||||
scope={TopicType.Session}
|
||||
text={text}
|
||||
onTextChange={setText}
|
||||
textareaRef={textareaRef}
|
||||
resizeTextArea={resizeTextArea}
|
||||
focusTextarea={focusTextarea}
|
||||
placeholder={placeholderText}
|
||||
supportedExts={supportedExts}
|
||||
onPause={abortAgentSession}
|
||||
isLoading={canAbort}
|
||||
handleSendMessage={sendMessage}
|
||||
leftToolbar={leftToolbar}
|
||||
forceEnableQuickPanelTriggers
|
||||
/>
|
||||
<Toolbar>
|
||||
<ToolbarGroup>
|
||||
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })}>
|
||||
<ActionIconButton
|
||||
onClick={handleCreateSession}
|
||||
disabled={createSessionDisabled}
|
||||
loading={creatingSession}>
|
||||
<MessageSquareDiff size={19} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup>
|
||||
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
|
||||
{canAbort && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')}>
|
||||
<ActionIconButton onClick={abortAgentSession} style={{ marginRight: -2 }}>
|
||||
<CirclePause size={20} color="var(--color-error)" />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
</InputBarContainer>
|
||||
</Container>
|
||||
</NarrowLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Add these styled components at the bottom
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0 18px 18px 18px;
|
||||
[navbar-position='top'] & {
|
||||
padding: 0 18px 10px 18px;
|
||||
}
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border-radius: 17px;
|
||||
padding-top: 8px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
&.file-dragging {
|
||||
border: 2px dashed #2ecc71;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(46, 204, 113, 0.03);
|
||||
border-radius: 14px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Toolbar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 8px;
|
||||
height: 40px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const ToolbarGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -398,26 +502,4 @@ const ToolbarGroup = styled.div`
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '6px 15px 0px' // 减小顶部padding
|
||||
}
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: none !important;
|
||||
&.ant-input {
|
||||
line-height: 1.4;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
export default AgentSessionInputbar
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +1,241 @@
|
||||
import '@renderer/pages/home/Inputbar/tools'
|
||||
|
||||
import type { DropResult } from '@hello-pangea/dnd'
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { MdiLightbulbOn } from '@renderer/components/Icons'
|
||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import {
|
||||
isAnthropicModel,
|
||||
isGeminiModel,
|
||||
isGenerateImageModel,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { isSupportUrlContextProvider } from '@renderer/config/providers'
|
||||
import type { QuickPanelListItem, QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useInputbarTools } from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider'
|
||||
import type {
|
||||
InputbarScope,
|
||||
ToolActionKey,
|
||||
ToolActionMap,
|
||||
ToolDefinition,
|
||||
ToolOrderConfig,
|
||||
ToolQuickPanelApi,
|
||||
ToolRenderContext,
|
||||
ToolStateKey,
|
||||
ToolStateMap
|
||||
} from '@renderer/pages/home/Inputbar/types'
|
||||
import { getToolsForScope } from '@renderer/pages/home/Inputbar/types'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import type { FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import { selectToolOrderForScope, setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
|
||||
import type { InputBarToolType } from '@renderer/types/chat'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { Divider, Dropdown, Tooltip } from 'antd'
|
||||
import { Divider, Dropdown } from 'antd'
|
||||
import type { ItemType } from 'antd/es/menu/interface'
|
||||
import {
|
||||
AtSign,
|
||||
Check,
|
||||
CircleChevronRight,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Hammer,
|
||||
Languages,
|
||||
Link,
|
||||
Maximize,
|
||||
MessageSquareDiff,
|
||||
Minimize,
|
||||
PaintbrushVertical,
|
||||
Paperclip,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import type { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { Check, CircleChevronRight } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import type { AttachmentButtonRef } from './AttachmentButton'
|
||||
import AttachmentButton from './AttachmentButton'
|
||||
import GenerateImageButton from './GenerateImageButton'
|
||||
import type { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
|
||||
import KnowledgeBaseButton from './KnowledgeBaseButton'
|
||||
import type { MCPToolsButtonRef } from './MCPToolsButton'
|
||||
import MCPToolsButton from './MCPToolsButton'
|
||||
import type { MentionModelsButtonRef } from './MentionModelsButton'
|
||||
import MentionModelsButton from './MentionModelsButton'
|
||||
import NewContextButton from './NewContextButton'
|
||||
import type { QuickPhrasesButtonRef } from './QuickPhrasesButton'
|
||||
import QuickPhrasesButton from './QuickPhrasesButton'
|
||||
import type { ThinkingButtonRef } from './ThinkingButton'
|
||||
import ThinkingButton from './ThinkingButton'
|
||||
import type { UrlContextButtonRef } from './UrlContextbutton'
|
||||
import UrlContextButton from './UrlContextbutton'
|
||||
import type { WebSearchButtonRef } from './WebSearchButton'
|
||||
import WebSearchButton from './WebSearchButton'
|
||||
|
||||
const logger = loggerService.withContext('InputbarTools')
|
||||
|
||||
export interface InputbarToolsRef {
|
||||
getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[]
|
||||
openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
||||
openAttachmentQuickPanel: () => void
|
||||
}
|
||||
|
||||
export interface InputbarToolsProps {
|
||||
export interface InputbarToolsNewProps {
|
||||
scope: InputbarScope
|
||||
assistantId: string
|
||||
model: Model
|
||||
files: FileType[]
|
||||
setFiles: Dispatch<SetStateAction<FileType[]>>
|
||||
extensions: string[]
|
||||
setText: Dispatch<SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
setSelectedKnowledgeBases: Dispatch<SetStateAction<KnowledgeBase[]>>
|
||||
mentionedModels: Model[]
|
||||
setMentionedModels: Dispatch<SetStateAction<Model[]>>
|
||||
couldAddImageFile: boolean
|
||||
isExpanded: boolean
|
||||
onToggleExpanded: () => void
|
||||
|
||||
addNewTopic: () => void
|
||||
clearTopic: () => void
|
||||
onNewContext: () => void
|
||||
// Session data for Agent Session scope (optional)
|
||||
session?: {
|
||||
agentId?: string
|
||||
sessionId?: string
|
||||
slashCommands?: Array<{ command: string; description?: string }>
|
||||
tools?: Array<{ id: string; name: string; type: string; description?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolButtonConfig {
|
||||
interface ToolConfig {
|
||||
key: InputBarToolType
|
||||
component: ReactNode
|
||||
condition?: boolean
|
||||
visible?: boolean
|
||||
label?: string
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
tool: ToolDefinition
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const DraggablePortal = ({ children, isDragging }) => {
|
||||
const DraggablePortal = ({ children, isDragging }: { children: React.ReactNode; isDragging: boolean }) => {
|
||||
return isDragging ? createPortal(children, document.body) : children
|
||||
}
|
||||
|
||||
const InputbarTools = ({
|
||||
ref,
|
||||
assistantId,
|
||||
model,
|
||||
files,
|
||||
setFiles,
|
||||
setText,
|
||||
resizeTextArea,
|
||||
selectedKnowledgeBases,
|
||||
setSelectedKnowledgeBases,
|
||||
mentionedModels,
|
||||
setMentionedModels,
|
||||
couldAddImageFile,
|
||||
isExpanded: isExpended,
|
||||
onToggleExpanded: onToggleExpended,
|
||||
addNewTopic,
|
||||
clearTopic,
|
||||
onNewContext,
|
||||
extensions
|
||||
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
|
||||
const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { assistant, model } = useAssistant(assistantId)
|
||||
const toolsContext = useInputbarTools()
|
||||
const quickPanelContext = useQuickPanel()
|
||||
const quickPanelApiCacheRef = useRef(new Map<string, ToolQuickPanelApi>())
|
||||
|
||||
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
|
||||
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
|
||||
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
|
||||
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
|
||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
||||
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
|
||||
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
|
||||
const urlContextButtonRef = useRef<UrlContextButtonRef | null>(null)
|
||||
const getQuickPanelApiForTool = useCallback(
|
||||
(toolKey: string): ToolQuickPanelApi => {
|
||||
const cache = quickPanelApiCacheRef.current
|
||||
|
||||
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
|
||||
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
|
||||
|
||||
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
|
||||
|
||||
const showThinkingButton = useMemo(
|
||||
() => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model),
|
||||
[model]
|
||||
)
|
||||
|
||||
const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant])
|
||||
|
||||
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
||||
const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton
|
||||
|
||||
const handleKnowledgeBaseSelect = useCallback(
|
||||
(bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
},
|
||||
[setSelectedKnowledgeBases, updateAssistant]
|
||||
)
|
||||
|
||||
// 仅允许在不含图片文件时mention非视觉模型
|
||||
const couldMentionNotVisionModel = useMemo(() => {
|
||||
return !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
}, [files])
|
||||
|
||||
const onMentionModel = useCallback(
|
||||
(model: Model) => {
|
||||
// 我想应该没有模型是只支持视觉而不支持文本的?
|
||||
if (isVisionModel(model) || couldMentionNotVisionModel) {
|
||||
setMentionedModels((prev) => {
|
||||
const modelId = getModelUniqId(model)
|
||||
const exists = prev.some((m) => getModelUniqId(m) === modelId)
|
||||
return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model]
|
||||
if (!cache.has(toolKey)) {
|
||||
cache.set(toolKey, {
|
||||
registerRootMenu: (entries: QuickPanelListItem[]) =>
|
||||
toolsContext.toolsRegistry.registerRootMenu(toolKey, entries),
|
||||
registerTrigger: (symbol: QuickPanelReservedSymbol, handler: (payload?: unknown) => void) =>
|
||||
toolsContext.toolsRegistry.registerTrigger(toolKey, symbol, handler)
|
||||
})
|
||||
} else {
|
||||
logger.error('Cannot add non-vision model when images are uploaded')
|
||||
}
|
||||
|
||||
return cache.get(toolKey)!
|
||||
},
|
||||
[couldMentionNotVisionModel, setMentionedModels]
|
||||
[toolsContext.toolsRegistry]
|
||||
)
|
||||
|
||||
const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels])
|
||||
const reduxToolOrder = useAppSelector((state) => selectToolOrderForScope(state, scope))
|
||||
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
|
||||
const [targetTool, setTargetTool] = useState<ToolConfig | null>(null)
|
||||
|
||||
const onEnableGenerateImage = useCallback(() => {
|
||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}, [assistant.enableGenerateImage, updateAssistant])
|
||||
// Get tools for current scope
|
||||
const availableTools = useMemo(() => {
|
||||
return getToolsForScope(scope, { assistant, model, session })
|
||||
}, [scope, assistant, model, session])
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
// Get tool order for current scope
|
||||
const toolOrder = useMemo(() => {
|
||||
return reduxToolOrder
|
||||
}, [reduxToolOrder])
|
||||
|
||||
// Build render context for tools
|
||||
const buildRenderContext = useCallback(
|
||||
<S extends readonly ToolStateKey[], A extends readonly ToolActionKey[]>(
|
||||
tool: ToolDefinition<S, A>
|
||||
): ToolRenderContext<S, A> => {
|
||||
const deps = tool.dependencies
|
||||
// 为工具提供完整的 QuickPanel API(注册 + 控制面板)
|
||||
const quickPanel = getQuickPanelApiForTool(tool.key)
|
||||
|
||||
const state = (deps?.state || ([] as unknown as S)).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = toolsContext[key]
|
||||
return acc
|
||||
},
|
||||
{} as Pick<ToolStateMap, S[number]>
|
||||
)
|
||||
|
||||
const actions = (deps?.actions || ([] as unknown as A)).reduce(
|
||||
(acc, key) => {
|
||||
const actionValue = toolsContext[key]
|
||||
if (actionValue) {
|
||||
acc[key] = actionValue
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Pick<ToolActionMap, A[number]>
|
||||
)
|
||||
|
||||
return {
|
||||
scope,
|
||||
assistant,
|
||||
model,
|
||||
session,
|
||||
state,
|
||||
actions,
|
||||
quickPanel,
|
||||
quickPanelController: quickPanelContext,
|
||||
t
|
||||
} as ToolRenderContext<S, A>
|
||||
},
|
||||
[assistant, model, quickPanelContext, scope, session, t, toolsContext, getQuickPanelApiForTool]
|
||||
)
|
||||
|
||||
// Build tool metadata (without rendering)
|
||||
// Tools with render: null are pure menu contributors and won't appear in UI
|
||||
const toolMetadata = useMemo(() => {
|
||||
return availableTools.map((tool) => ({
|
||||
key: tool.key as InputBarToolType,
|
||||
label: typeof tool.label === 'function' ? tool.label(t) : tool.label,
|
||||
tool
|
||||
}))
|
||||
}, [availableTools, t])
|
||||
|
||||
// Declarative tools registration (for tools with quickPanel config)
|
||||
// This handles pure menu contributors and trigger handlers
|
||||
useEffect(() => {
|
||||
const disposeCallbacks: Array<() => void> = []
|
||||
|
||||
for (const tool of availableTools) {
|
||||
if (!tool.quickPanel) continue
|
||||
|
||||
const context = buildRenderContext(tool)
|
||||
|
||||
// Register root menu items (declarative)
|
||||
if (tool.quickPanel.rootMenu) {
|
||||
const menuItems = tool.quickPanel.rootMenu.createMenuItems(context)
|
||||
const dispose = toolsContext.toolsRegistry.registerRootMenu(tool.key, menuItems)
|
||||
disposeCallbacks.push(dispose)
|
||||
}
|
||||
|
||||
// Register triggers (declarative)
|
||||
if (tool.quickPanel.triggers) {
|
||||
for (const triggerConfig of tool.quickPanel.triggers) {
|
||||
const handler = triggerConfig.createHandler(context)
|
||||
const dispose = toolsContext.toolsRegistry.registerTrigger(tool.key, triggerConfig.symbol, handler)
|
||||
disposeCallbacks.push(dispose)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposeCallbacks.forEach((dispose) => dispose())
|
||||
}
|
||||
}, [availableTools, buildRenderContext, toolsContext.toolsRegistry])
|
||||
|
||||
// Filter visible tools (only those with render functions, not pure menu contributors)
|
||||
const visibleTools = useMemo(() => {
|
||||
// 1. Get explicitly visible tools from toolOrder
|
||||
const explicitlyVisible = toolOrder.visible
|
||||
.map((key) => {
|
||||
const meta = toolMetadata.find((item) => item.key === key)
|
||||
if (!meta || meta.tool.render === null) return null
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
tool: meta.tool,
|
||||
visible: true
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ToolConfig[]
|
||||
|
||||
// 2. Find new tools not in toolOrder (auto-show new tools)
|
||||
const knownToolKeys = new Set([...toolOrder.visible, ...toolOrder.hidden])
|
||||
const newTools = toolMetadata
|
||||
.filter((meta) => !knownToolKeys.has(meta.key) && meta.tool.render !== null)
|
||||
.map((meta) => ({
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
tool: meta.tool,
|
||||
visible: true
|
||||
}))
|
||||
|
||||
// 3. Merge: explicit order + new tools at end
|
||||
return [...explicitlyVisible, ...newTools]
|
||||
}, [toolMetadata, toolOrder.visible, toolOrder.hidden])
|
||||
|
||||
const hiddenTools = useMemo(() => {
|
||||
return toolOrder.hidden
|
||||
.map((key) => {
|
||||
const meta = toolMetadata.find((item) => item.key === key)
|
||||
if (!meta || meta.tool.render === null) return null // Filter out pure menu contributors
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
tool: meta.tool,
|
||||
visible: false
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ToolConfig[]
|
||||
}, [toolMetadata, toolOrder.hidden])
|
||||
|
||||
const showDivider = useMemo(() => {
|
||||
return hiddenTools.length > 0 && visibleTools.length > 0
|
||||
}, [hiddenTools, visibleTools])
|
||||
|
||||
const showCollapseButton = useMemo(() => {
|
||||
return hiddenTools.length > 0
|
||||
}, [hiddenTools])
|
||||
|
||||
const toggleToolVisibility = useCallback(
|
||||
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
|
||||
const newToolOrder = {
|
||||
const newToolOrder: ToolOrderConfig = {
|
||||
visible: [...toolOrder.visible],
|
||||
hidden: [...toolOrder.hidden]
|
||||
}
|
||||
@@ -212,129 +248,20 @@ const InputbarTools = ({
|
||||
newToolOrder.visible.push(toolKey)
|
||||
}
|
||||
|
||||
dispatch(setToolOrder(newToolOrder))
|
||||
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
|
||||
setTargetTool(null)
|
||||
},
|
||||
[dispatch, toolOrder.hidden, toolOrder.visible]
|
||||
[dispatch, scope, toolOrder]
|
||||
)
|
||||
|
||||
const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => {
|
||||
const { text, translate } = params
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('settings.quickPhrase.title'),
|
||||
description: '',
|
||||
icon: <Zap />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
quickPhrasesButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.settings.reasoning_effort.label'),
|
||||
description: '',
|
||||
icon: <MdiLightbulbOn />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
thinkingButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('assistants.presets.edit.model.select.title'),
|
||||
description: '',
|
||||
icon: <AtSign />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
mentionModelsButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.input.knowledge_base'),
|
||||
description: '',
|
||||
icon: <FileSearch />,
|
||||
isMenu: true,
|
||||
disabled: files.length > 0,
|
||||
hidden: !showKnowledgeBaseButton,
|
||||
action: () => {
|
||||
knowledgeBaseButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('settings.mcp.title'),
|
||||
description: t('settings.mcp.not_support'),
|
||||
icon: <Hammer />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
mcpToolsButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
|
||||
description: '',
|
||||
icon: <Hammer />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
mcpToolsButtonRef.current?.openPromptList()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: `MCP ${t('settings.mcp.tabs.resources')}`,
|
||||
description: '',
|
||||
icon: <Hammer />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
mcpToolsButtonRef.current?.openResourcesList()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.input.web_search.label'),
|
||||
description: '',
|
||||
icon: <Globe />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
webSearchButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.input.url_context'),
|
||||
description: '',
|
||||
icon: <Link />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
urlContextButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
|
||||
description: '',
|
||||
icon: <Paperclip />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
attachmentButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('translate.title'),
|
||||
description: t('translate.menu.description'),
|
||||
icon: <Languages />,
|
||||
action: () => {
|
||||
if (!text) return
|
||||
translate()
|
||||
}
|
||||
}
|
||||
] satisfies QuickPanelListItem[]
|
||||
}
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
const { source, destination } = result
|
||||
|
||||
if (!destination) return
|
||||
|
||||
const sourceId = source.droppableId
|
||||
const destinationId = destination.droppableId
|
||||
|
||||
const newToolOrder = {
|
||||
const newToolOrder: ToolOrderConfig = {
|
||||
visible: [...toolOrder.visible],
|
||||
hidden: [...toolOrder.hidden]
|
||||
}
|
||||
@@ -352,216 +279,9 @@ const InputbarTools = ({
|
||||
newToolOrder[destArray].splice(destination.index, 0, removed)
|
||||
}
|
||||
|
||||
dispatch(setToolOrder(newToolOrder))
|
||||
dispatch(setToolOrder({ scope, toolOrder: newToolOrder }))
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getQuickPanelMenu: getQuickPanelMenuImpl,
|
||||
openMentionModelsPanel: (triggerInfo) => mentionModelsButtonRef.current?.openQuickPanel(triggerInfo),
|
||||
openAttachmentQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
|
||||
}))
|
||||
|
||||
const toolButtons = useMemo<ToolButtonConfig[]>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'new_topic',
|
||||
label: t('chat.input.new_topic', { Command: '' }),
|
||||
component: (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.new_topic', { Command: newTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ActionIconButton onClick={addNewTopic}>
|
||||
<MessageSquareDiff size={19} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'attachment',
|
||||
label: t('chat.input.upload.image_or_document'),
|
||||
component: (
|
||||
<AttachmentButton
|
||||
ref={attachmentButtonRef}
|
||||
couldAddImageFile={couldAddImageFile}
|
||||
extensions={extensions}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'thinking',
|
||||
label: t('chat.input.thinking.label'),
|
||||
component: <ThinkingButton ref={thinkingButtonRef} model={model} assistantId={assistant.id} />,
|
||||
condition: showThinkingButton
|
||||
},
|
||||
{
|
||||
key: 'web_search',
|
||||
label: t('chat.input.web_search.label'),
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistantId={assistant.id} />,
|
||||
condition: !isMandatoryWebSearchModel(model)
|
||||
},
|
||||
{
|
||||
key: 'url_context',
|
||||
label: t('chat.input.url_context'),
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistantId={assistant.id} />,
|
||||
condition:
|
||||
(isGeminiModel(model) || isAnthropicModel(model)) &&
|
||||
(isSupportUrlContextProvider(getProviderByModel(model)) || model.endpoint_type === 'gemini')
|
||||
},
|
||||
{
|
||||
key: 'knowledge_base',
|
||||
label: t('chat.input.knowledge_base'),
|
||||
component: (
|
||||
<KnowledgeBaseButton
|
||||
ref={knowledgeBaseButtonRef}
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onSelect={handleKnowledgeBaseSelect}
|
||||
disabled={files.length > 0}
|
||||
/>
|
||||
),
|
||||
condition: showKnowledgeBaseButton
|
||||
},
|
||||
{
|
||||
key: 'mcp_tools',
|
||||
label: t('settings.mcp.title'),
|
||||
component: (
|
||||
<MCPToolsButton
|
||||
assistantId={assistant.id}
|
||||
ref={mcpToolsButtonRef}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
/>
|
||||
),
|
||||
condition: showMcpServerButton
|
||||
},
|
||||
{
|
||||
key: 'generate_image',
|
||||
label: t('chat.input.generate_image'),
|
||||
component: (
|
||||
<GenerateImageButton model={model} assistant={assistant} onEnableGenerateImage={onEnableGenerateImage} />
|
||||
),
|
||||
condition: isGenerateImageModel(model)
|
||||
},
|
||||
{
|
||||
key: 'mention_models',
|
||||
label: t('assistants.presets.edit.model.select.title'),
|
||||
component: (
|
||||
<MentionModelsButton
|
||||
ref={mentionModelsButtonRef}
|
||||
mentionedModels={mentionedModels}
|
||||
onMentionModel={onMentionModel}
|
||||
onClearMentionModels={onClearMentionModels}
|
||||
couldMentionNotVisionModel={couldMentionNotVisionModel}
|
||||
files={files}
|
||||
setText={setText}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'quick_phrases',
|
||||
label: t('settings.quickPhrase.title'),
|
||||
component: (
|
||||
<QuickPhrasesButton
|
||||
ref={quickPhrasesButtonRef}
|
||||
setInputValue={setText}
|
||||
resizeTextArea={resizeTextArea}
|
||||
assistantId={assistant.id}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'clear_topic',
|
||||
label: t('chat.input.clear.label', { Command: '' }),
|
||||
component: (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ActionIconButton onClick={clearTopic}>
|
||||
<PaintbrushVertical size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'toggle_expand',
|
||||
label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'),
|
||||
component: (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ActionIconButton onClick={onToggleExpended}>
|
||||
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'new_context',
|
||||
label: t('chat.input.new.context', { Command: '' }),
|
||||
component: <NewContextButton onNewContext={onNewContext} />
|
||||
}
|
||||
]
|
||||
}, [
|
||||
addNewTopic,
|
||||
assistant,
|
||||
clearTopicShortcut,
|
||||
clearTopic,
|
||||
couldAddImageFile,
|
||||
couldMentionNotVisionModel,
|
||||
extensions,
|
||||
files,
|
||||
handleKnowledgeBaseSelect,
|
||||
isExpended,
|
||||
mentionedModels,
|
||||
model,
|
||||
newTopicShortcut,
|
||||
onClearMentionModels,
|
||||
onEnableGenerateImage,
|
||||
onMentionModel,
|
||||
onNewContext,
|
||||
onToggleExpended,
|
||||
resizeTextArea,
|
||||
selectedKnowledgeBases,
|
||||
setFiles,
|
||||
setText,
|
||||
showKnowledgeBaseButton,
|
||||
showMcpServerButton,
|
||||
showThinkingButton,
|
||||
t
|
||||
])
|
||||
|
||||
const visibleTools = useMemo(() => {
|
||||
return toolOrder.visible.map((v) => ({
|
||||
...toolButtons.find((tool) => tool.key === v),
|
||||
visible: true
|
||||
})) as ToolButtonConfig[]
|
||||
}, [toolButtons, toolOrder])
|
||||
|
||||
const hiddenTools = useMemo(() => {
|
||||
return toolOrder.hidden.map((v) => ({
|
||||
...toolButtons.find((tool) => tool.key === v),
|
||||
visible: false
|
||||
})) as ToolButtonConfig[]
|
||||
}, [toolButtons, toolOrder])
|
||||
|
||||
const showDivider = useMemo(() => {
|
||||
return (
|
||||
hiddenTools.filter((tool) => tool.condition ?? true).length > 0 &&
|
||||
visibleTools.filter((tool) => tool.condition ?? true).length !== 0
|
||||
)
|
||||
}, [hiddenTools, visibleTools])
|
||||
|
||||
const showCollapseButton = useMemo(() => {
|
||||
return hiddenTools.filter((tool) => tool.condition ?? true).length > 0
|
||||
}, [hiddenTools])
|
||||
|
||||
const getMenuItems = useMemo(() => {
|
||||
const baseItems: ItemType[] = [...visibleTools, ...hiddenTools].map((tool) => ({
|
||||
label: tool.label,
|
||||
@@ -571,29 +291,35 @@ const InputbarTools = ({
|
||||
{tool.visible ? <Check size={16} /> : undefined}
|
||||
</div>
|
||||
),
|
||||
onClick: () => {
|
||||
toggleToolVisibility(tool.key, tool.visible)
|
||||
}
|
||||
onClick: () => toggleToolVisibility(tool.key, tool.visible)
|
||||
}))
|
||||
|
||||
if (targetTool) {
|
||||
baseItems.push({
|
||||
type: 'divider'
|
||||
})
|
||||
baseItems.push({ type: 'divider' })
|
||||
baseItems.push({
|
||||
label: `${targetTool.visible ? t('chat.input.tools.collapse_in') : t('chat.input.tools.collapse_out')} "${targetTool.label}"`,
|
||||
key: 'selected_' + targetTool.key,
|
||||
icon: <div style={{ width: 20, height: 20 }}></div>,
|
||||
onClick: () => {
|
||||
toggleToolVisibility(targetTool.key, targetTool.visible)
|
||||
}
|
||||
onClick: () => toggleToolVisibility(targetTool.key, targetTool.visible)
|
||||
})
|
||||
}
|
||||
|
||||
return baseItems
|
||||
}, [hiddenTools, t, targetTool, toggleToolVisibility, visibleTools])
|
||||
|
||||
const managerElements = useMemo(() => {
|
||||
return availableTools
|
||||
.map((tool) => {
|
||||
if (!tool.quickPanelManager) return null
|
||||
const Manager = tool.quickPanelManager
|
||||
const context = buildRenderContext(tool)
|
||||
return <Manager key={`${tool.key}-quick-panel-manager`} context={context} />
|
||||
})
|
||||
.filter((element): element is React.ReactElement => element !== null)
|
||||
}, [availableTools, buildRenderContext])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
|
||||
<ToolsContainer
|
||||
onContextMenu={(e) => {
|
||||
@@ -607,29 +333,26 @@ const InputbarTools = ({
|
||||
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
|
||||
{(provided) => (
|
||||
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{visibleTools.map(
|
||||
(tool, index) =>
|
||||
(tool.condition ?? true) && (
|
||||
<Draggable key={tool.key} draggableId={tool.key} index={index}>
|
||||
{visibleTools.map((toolConfig, index) => {
|
||||
const context = buildRenderContext(toolConfig.tool)
|
||||
return (
|
||||
<Draggable key={toolConfig.key} draggableId={toolConfig.key} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<DraggablePortal isDragging={snapshot.isDragging}>
|
||||
<ToolWrapper
|
||||
data-key={tool.key}
|
||||
onContextMenu={() => setTargetTool(tool)}
|
||||
data-key={toolConfig.key}
|
||||
onContextMenu={() => setTargetTool(toolConfig)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
...provided.draggableProps.style
|
||||
}}>
|
||||
{tool.component}
|
||||
style={provided.draggableProps.style}>
|
||||
{toolConfig.tool.render?.(context)}
|
||||
</ToolWrapper>
|
||||
</DraggablePortal>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
)}
|
||||
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</VisibleTools>
|
||||
)}
|
||||
@@ -640,18 +363,16 @@ const InputbarTools = ({
|
||||
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
|
||||
{(provided) => (
|
||||
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{hiddenTools.map(
|
||||
(tool, index) =>
|
||||
(tool.condition ?? true) && (
|
||||
<Draggable key={tool.key} draggableId={tool.key} index={index}>
|
||||
{hiddenTools.map((toolConfig, index) => {
|
||||
const context = buildRenderContext(toolConfig.tool)
|
||||
return (
|
||||
<Draggable key={toolConfig.key} draggableId={toolConfig.key} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<DraggablePortal isDragging={snapshot.isDragging}>
|
||||
<ToolWrapper
|
||||
data-key={tool.key}
|
||||
className={classNames({
|
||||
'is-collapsed': isCollapse
|
||||
})}
|
||||
onContextMenu={() => setTargetTool(tool)}
|
||||
data-key={toolConfig.key}
|
||||
className={classNames({ 'is-collapsed': isCollapse })}
|
||||
onContextMenu={() => setTargetTool(toolConfig)}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
@@ -659,13 +380,13 @@ const InputbarTools = ({
|
||||
...provided.draggableProps.style,
|
||||
transitionDelay: `${index * 0.02}s`
|
||||
}}>
|
||||
{tool.component}
|
||||
{toolConfig.tool.render?.(context)}
|
||||
</ToolWrapper>
|
||||
</DraggablePortal>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
)}
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</HiddenTools>
|
||||
)}
|
||||
@@ -673,25 +394,21 @@ const InputbarTools = ({
|
||||
</DragDropContext>
|
||||
|
||||
{showCollapseButton && (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
|
||||
arrow>
|
||||
<ActionIconButton onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
|
||||
<CircleChevronRight
|
||||
size={18}
|
||||
style={{
|
||||
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
|
||||
}}
|
||||
/>
|
||||
<ActionIconButton
|
||||
onClick={() => dispatch(setIsCollapsed(!isCollapse))}
|
||||
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}>
|
||||
<CircleChevronRight size={18} style={{ transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)' }} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ToolsContainer>
|
||||
</Dropdown>
|
||||
{managerElements}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
InputbarTools.displayName = 'InputbarTools'
|
||||
|
||||
const ToolsContainer = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import { type QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import type { FileType, Model } from '@renderer/types'
|
||||
import { getFancyProviderName } from '@renderer/utils'
|
||||
import { Avatar, Tooltip } from 'antd'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { AtSign, CircleX, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface MentionModelsButtonRef {
|
||||
openQuickPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<MentionModelsButtonRef | null>
|
||||
mentionedModels: Model[]
|
||||
onMentionModel: (model: Model) => void
|
||||
onClearMentionModels: () => void
|
||||
couldMentionNotVisionModel: boolean
|
||||
files: FileType[]
|
||||
setText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const MentionModelsButton: FC<Props> = ({
|
||||
ref,
|
||||
mentionedModels,
|
||||
onMentionModel,
|
||||
onClearMentionModels,
|
||||
couldMentionNotVisionModel,
|
||||
files,
|
||||
setText
|
||||
}) => {
|
||||
const { providers } = useProviders()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
|
||||
// 记录是否有模型被选择的动作发生
|
||||
const hasModelActionRef = useRef<boolean>(false)
|
||||
// 记录触发信息,用于清除操作
|
||||
const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
// 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本
|
||||
const removeAtSymbolAndText = useCallback(
|
||||
(currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => {
|
||||
const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length))
|
||||
|
||||
// ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配
|
||||
if (searchText !== undefined) {
|
||||
const pattern = '@' + searchText
|
||||
const fromIndex = Math.max(0, safeCaret - 1)
|
||||
const start = currentText.lastIndexOf(pattern, fromIndex)
|
||||
if (start !== -1) {
|
||||
const end = start + pattern.length
|
||||
return currentText.slice(0, start) + currentText.slice(end)
|
||||
}
|
||||
|
||||
// 兜底:使用打开时的 position 做校验后再删
|
||||
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
||||
const expected = pattern
|
||||
const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length)
|
||||
if (actual === expected) {
|
||||
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length)
|
||||
}
|
||||
// 如果不完全匹配,安全起见仅删除单个 '@'
|
||||
return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1)
|
||||
}
|
||||
|
||||
// 未找到匹配则不改动
|
||||
return currentText
|
||||
}
|
||||
|
||||
// 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾)
|
||||
{
|
||||
const fromIndex = Math.max(0, safeCaret - 1)
|
||||
const start = currentText.lastIndexOf('@', fromIndex)
|
||||
if (start === -1) {
|
||||
// 兜底:使用打开时的 position(若存在),按空白边界删除
|
||||
if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') {
|
||||
let endPos = fallbackPosition + 1
|
||||
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||
endPos++
|
||||
}
|
||||
return currentText.slice(0, fallbackPosition) + currentText.slice(endPos)
|
||||
}
|
||||
return currentText
|
||||
}
|
||||
|
||||
let endPos = start + 1
|
||||
while (endPos < currentText.length && !/\s/.test(currentText[endPos])) {
|
||||
endPos++
|
||||
}
|
||||
return currentText.slice(0, start) + currentText.slice(endPos)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const pinnedModels = useLiveQuery(
|
||||
async () => {
|
||||
const setting = await db.settings.get('pinned:models')
|
||||
return setting?.value || []
|
||||
},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
|
||||
const modelItems = useMemo(() => {
|
||||
const items: QuickPanelListItem[] = []
|
||||
|
||||
if (pinnedModels.length > 0) {
|
||||
const pinnedItems = providers.flatMap((p) =>
|
||||
p.models
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
|
||||
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m)))
|
||||
.map((m) => ({
|
||||
label: (
|
||||
<>
|
||||
<ProviderName>{getFancyProviderName(p)}</ProviderName>
|
||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
||||
</>
|
||||
),
|
||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m)} size={20}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: getFancyProviderName(p) + m.name,
|
||||
action: () => {
|
||||
hasModelActionRef.current = true // 标记有模型动作发生
|
||||
onMentionModel(m)
|
||||
},
|
||||
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
)
|
||||
|
||||
if (pinnedItems.length > 0) {
|
||||
items.push(...sortBy(pinnedItems, ['label']))
|
||||
}
|
||||
}
|
||||
|
||||
providers.forEach((p) => {
|
||||
const providerModels = sortBy(
|
||||
p.models
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.filter((m) => !pinnedModels.includes(getModelUniqId(m)))
|
||||
.filter((m) => couldMentionNotVisionModel || (!couldMentionNotVisionModel && isVisionModel(m))),
|
||||
['group', 'name']
|
||||
)
|
||||
|
||||
const providerModelItems = providerModels.map((m) => ({
|
||||
label: (
|
||||
<>
|
||||
<ProviderName>{getFancyProviderName(p)}</ProviderName>
|
||||
<span style={{ opacity: 0.8 }}> | {m.name}</span>
|
||||
</>
|
||||
),
|
||||
description: <ModelTagsWithLabel model={m} showLabel={false} size={10} style={{ opacity: 0.8 }} />,
|
||||
icon: (
|
||||
<Avatar src={getModelLogo(m)} size={20}>
|
||||
{first(m.name)}
|
||||
</Avatar>
|
||||
),
|
||||
filterText: getFancyProviderName(p) + m.name,
|
||||
action: () => {
|
||||
hasModelActionRef.current = true // 标记有模型动作发生
|
||||
onMentionModel(m)
|
||||
},
|
||||
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
|
||||
}))
|
||||
|
||||
if (providerModelItems.length > 0) {
|
||||
items.push(...providerModelItems)
|
||||
}
|
||||
})
|
||||
|
||||
items.push({
|
||||
label: t('settings.models.add.add_model') + '...',
|
||||
icon: <Plus />,
|
||||
action: () => navigate('/settings/provider'),
|
||||
isSelected: false
|
||||
})
|
||||
|
||||
items.unshift({
|
||||
label: t('settings.input.clear.all'),
|
||||
description: t('settings.input.clear.models'),
|
||||
icon: <CircleX />,
|
||||
alwaysVisible: true,
|
||||
isSelected: false,
|
||||
action: ({ context: ctx }) => {
|
||||
onClearMentionModels()
|
||||
|
||||
// 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除)
|
||||
if (triggerInfoRef.current?.type === 'input') {
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position)
|
||||
})
|
||||
}
|
||||
|
||||
ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}, [
|
||||
pinnedModels,
|
||||
providers,
|
||||
t,
|
||||
couldMentionNotVisionModel,
|
||||
mentionedModels,
|
||||
onMentionModel,
|
||||
navigate,
|
||||
onClearMentionModels,
|
||||
setText,
|
||||
removeAtSymbolAndText
|
||||
])
|
||||
|
||||
const openQuickPanel = useCallback(
|
||||
(triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => {
|
||||
// 重置模型动作标记
|
||||
hasModelActionRef.current = false
|
||||
// 保存触发信息
|
||||
triggerInfoRef.current = triggerInfo
|
||||
|
||||
quickPanel.open({
|
||||
title: t('assistants.presets.edit.model.select.title'),
|
||||
list: modelItems,
|
||||
symbol: QuickPanelReservedSymbol.MentionModels,
|
||||
multiple: true,
|
||||
triggerInfo: triggerInfo || { type: 'button' },
|
||||
afterAction({ item }) {
|
||||
item.isSelected = !item.isSelected
|
||||
},
|
||||
onClose({ action, searchText, context: ctx }) {
|
||||
// ESC关闭时的处理:删除 @ 和搜索文本
|
||||
if (action === 'esc') {
|
||||
// 只有在输入触发且有模型选择动作时才删除@字符和搜索文本
|
||||
const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current
|
||||
if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) {
|
||||
// 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底
|
||||
setText((currentText) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length
|
||||
return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Backspace删除@的情况(delete-symbol):
|
||||
// @ 已经被Backspace自然删除,面板关闭,不需要额外操作
|
||||
triggerInfoRef.current = undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
[modelItems, quickPanel, t, setText, removeAtSymbolAndText]
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close()
|
||||
} else {
|
||||
openQuickPanel({ type: 'button' })
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
|
||||
const filesRef = useRef(files)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查files是否变化
|
||||
if (filesRef.current !== files) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close()
|
||||
}
|
||||
filesRef.current = files
|
||||
}
|
||||
}, [files, quickPanel])
|
||||
|
||||
// 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) {
|
||||
// 直接使用重新计算的 modelItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(modelItems)
|
||||
}
|
||||
}, [mentionedModels, quickPanel, modelItems])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('assistants.presets.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
|
||||
<AtSign size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderName = styled.span`
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
export default memo(MentionModelsButton)
|
||||
803
src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx
Normal file
803
src/renderer/src/pages/home/Inputbar/components/InputbarCore.tsx
Normal file
@@ -0,0 +1,803 @@
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import type { QuickPanelTriggerInfo } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import { translateText } from '@renderer/services/TranslateService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import type { FileType } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import { isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Tooltip } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { CirclePause, Languages } from 'lucide-react'
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import NarrowLayout from '../../Messages/NarrowLayout'
|
||||
import AttachmentPreview from '../AttachmentPreview'
|
||||
import {
|
||||
useInputbarToolsDispatch,
|
||||
useInputbarToolsInternalDispatch,
|
||||
useInputbarToolsState
|
||||
} from '../context/InputbarToolsProvider'
|
||||
import { useFileDragDrop } from '../hooks/useFileDragDrop'
|
||||
import { usePasteHandler } from '../hooks/usePasteHandler'
|
||||
import { getInputbarConfig } from '../registry'
|
||||
import SendMessageButton from '../SendMessageButton'
|
||||
import type { InputbarScope } from '../types'
|
||||
|
||||
const logger = loggerService.withContext('InputbarCore')
|
||||
|
||||
export interface InputbarCoreProps {
|
||||
scope: InputbarScope
|
||||
placeholder?: string
|
||||
|
||||
text: string
|
||||
onTextChange: (text: string) => void
|
||||
textareaRef: React.RefObject<any>
|
||||
resizeTextArea: (force?: boolean) => void
|
||||
focusTextarea: () => void
|
||||
|
||||
supportedExts: string[]
|
||||
isLoading: boolean
|
||||
|
||||
onPause?: () => void
|
||||
handleSendMessage: () => void
|
||||
|
||||
// Toolbar sections
|
||||
leftToolbar?: React.ReactNode
|
||||
rightToolbar?: React.ReactNode
|
||||
|
||||
// Preview sections (attachments, mentions, etc.)
|
||||
topContent?: React.ReactNode
|
||||
|
||||
// Override the user preference for quick panel triggers
|
||||
forceEnableQuickPanelTriggers?: boolean
|
||||
}
|
||||
|
||||
const TextareaStyle: CSSProperties = {
|
||||
paddingLeft: 0,
|
||||
padding: '6px 15px 0px'
|
||||
}
|
||||
|
||||
/**
|
||||
* InputbarCore - 核心输入栏组件
|
||||
*
|
||||
* 提供基础的文本输入、工具栏、拖拽等功能的 UI 框架
|
||||
* 业务逻辑通过 props 注入,保持组件纯粹
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <InputbarCore
|
||||
* text={text}
|
||||
* onTextChange={(e) => setText(e.target.value)}
|
||||
* textareaRef={textareaRef}
|
||||
* textareaHeight={customHeight}
|
||||
* onKeyDown={handleKeyDown}
|
||||
* onPaste={handlePaste}
|
||||
* topContent={<AttachmentPreview files={files} />}
|
||||
* leftToolbar={<InputbarTools />}
|
||||
* rightToolbar={<SendMessageButton />}
|
||||
* quickPanel={<QuickPanelView />}
|
||||
* fontSize={14}
|
||||
* enableSpellCheck={true}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const InputbarCore: FC<InputbarCoreProps> = ({
|
||||
scope,
|
||||
placeholder,
|
||||
text,
|
||||
onTextChange,
|
||||
textareaRef,
|
||||
resizeTextArea,
|
||||
focusTextarea,
|
||||
supportedExts,
|
||||
isLoading,
|
||||
onPause,
|
||||
handleSendMessage,
|
||||
leftToolbar,
|
||||
rightToolbar,
|
||||
topContent,
|
||||
forceEnableQuickPanelTriggers
|
||||
}) => {
|
||||
const config = useMemo(() => getInputbarConfig(scope), [scope])
|
||||
const { files, isExpanded } = useInputbarToolsState()
|
||||
const { setFiles, setIsExpanded, toolsRegistry, triggers } = useInputbarToolsDispatch()
|
||||
const { setExtensions } = useInputbarToolsInternalDispatch()
|
||||
const isEmpty = text.trim().length === 0
|
||||
const [inputFocus, setInputFocus] = useState(false)
|
||||
const {
|
||||
targetLanguage,
|
||||
sendMessageShortcut,
|
||||
fontSize,
|
||||
pasteLongTextAsFile,
|
||||
pasteLongTextThreshold,
|
||||
autoTranslateWithSpace,
|
||||
enableQuickPanelTriggers,
|
||||
enableSpellCheck
|
||||
} = useSettings()
|
||||
const quickPanelTriggersEnabled = forceEnableQuickPanelTriggers ?? enableQuickPanelTriggers
|
||||
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const { getLanguageByLangcode } = useTranslate()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||
const spaceClickTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const { searching } = useRuntime()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
// 全局 QuickPanel Hook (用于控制面板显示状态)
|
||||
const quickPanel = useQuickPanel()
|
||||
const quickPanelOpen = quickPanel.open
|
||||
|
||||
const textRef = useRef(text)
|
||||
useEffect(() => {
|
||||
textRef.current = text
|
||||
}, [text])
|
||||
|
||||
const setText = useCallback<React.Dispatch<React.SetStateAction<string>>>(
|
||||
(value) => {
|
||||
if (typeof value === 'function') {
|
||||
onTextChange(value(textRef.current))
|
||||
} else {
|
||||
onTextChange(value)
|
||||
}
|
||||
},
|
||||
[onTextChange]
|
||||
)
|
||||
|
||||
const { handlePaste } = usePasteHandler(text, setText, {
|
||||
supportedExts,
|
||||
setFiles,
|
||||
pasteLongTextAsFile,
|
||||
pasteLongTextThreshold,
|
||||
onResize: resizeTextArea,
|
||||
t
|
||||
})
|
||||
|
||||
const { handleDragEnter, handleDragLeave, handleDragOver, handleDrop, isDragging } = useFileDragDrop({
|
||||
supportedExts,
|
||||
setFiles,
|
||||
onTextDropped: (droppedText) => setText((prev) => prev + droppedText),
|
||||
enabled: config.enableDragDrop,
|
||||
t
|
||||
})
|
||||
// 判断是否可以发送:文本不为空或有文件
|
||||
const cannotSend = isEmpty && files.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
setExtensions(supportedExts)
|
||||
}, [setExtensions, supportedExts])
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
(nextState?: boolean) => {
|
||||
const target = typeof nextState === 'boolean' ? nextState : !isExpanded
|
||||
setIsExpanded(target)
|
||||
focusTextarea()
|
||||
},
|
||||
[focusTextarea, setIsExpanded, isExpanded]
|
||||
)
|
||||
|
||||
const translate = useCallback(async () => {
|
||||
if (isTranslating) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage))
|
||||
translatedText && setText(translatedText)
|
||||
setTimeoutTimer('translate', () => resizeTextArea(), 0)
|
||||
} catch (error) {
|
||||
logger.warn('Translation failed:', error as Error)
|
||||
} finally {
|
||||
setIsTranslating(false)
|
||||
}
|
||||
}, [getLanguageByLangcode, isTranslating, resizeTextArea, setText, setTimeoutTimer, targetLanguage, text])
|
||||
|
||||
const rootTriggerHandlerRef = useRef<((payload?: unknown) => void) | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
rootTriggerHandlerRef.current = (payload) => {
|
||||
const menuItems = triggers.getRootMenu()
|
||||
|
||||
if (text.trim()) {
|
||||
menuItems.push({
|
||||
label: t('translate.title'),
|
||||
description: t('translate.menu.description'),
|
||||
icon: <Languages size={16} />,
|
||||
action: () => translate()
|
||||
})
|
||||
}
|
||||
|
||||
if (!menuItems.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const triggerInfo = (payload ?? {}) as QuickPanelTriggerInfo
|
||||
quickPanelOpen({
|
||||
title: t('settings.quickPanel.title'),
|
||||
list: menuItems,
|
||||
symbol: QuickPanelReservedSymbol.Root,
|
||||
triggerInfo
|
||||
})
|
||||
}
|
||||
}, [triggers, quickPanelOpen, t, text, translate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.enableQuickPanel) {
|
||||
return
|
||||
}
|
||||
|
||||
const disposeRootTrigger = toolsRegistry.registerTrigger(
|
||||
'inputbar-root',
|
||||
QuickPanelReservedSymbol.Root,
|
||||
(payload) => rootTriggerHandlerRef.current?.(payload)
|
||||
)
|
||||
|
||||
return () => {
|
||||
disposeRootTrigger()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.enableQuickPanel])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
event.preventDefault()
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (!textArea) {
|
||||
return
|
||||
}
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const selectionLength = textArea.selectionEnd - textArea.selectionStart
|
||||
const text = textArea.value
|
||||
|
||||
let match = text.slice(cursorPosition + selectionLength).match(/\$\{[^}]+\}/)
|
||||
let startIndex: number
|
||||
|
||||
if (!match) {
|
||||
match = text.match(/\$\{[^}]+\}/)
|
||||
startIndex = match?.index ?? -1
|
||||
} else {
|
||||
startIndex = cursorPosition + selectionLength + match.index!
|
||||
}
|
||||
|
||||
if (startIndex !== -1) {
|
||||
const endIndex = startIndex + match![0].length
|
||||
textArea.setSelectionRange(startIndex, endIndex)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (autoTranslateWithSpace && event.key === ' ') {
|
||||
setSpaceClickCount((prev) => prev + 1)
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
spaceClickTimer.current = setTimeout(() => {
|
||||
setSpaceClickCount(0)
|
||||
}, 200)
|
||||
|
||||
if (spaceClickCount === 2) {
|
||||
logger.info('Triple space detected - trigger translation')
|
||||
setSpaceClickCount(0)
|
||||
translate()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isExpanded && event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
handleToggleExpanded()
|
||||
return
|
||||
}
|
||||
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
handleSendMessage()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const currentText = textArea.value
|
||||
const newText = currentText.substring(0, start) + '\n' + currentText.substring(end)
|
||||
|
||||
setText(newText)
|
||||
|
||||
setTimeoutTimer(
|
||||
'handleKeyDown',
|
||||
() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && text.length === 0 && files.length > 0) {
|
||||
setFiles((prev) => prev.slice(0, -1))
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
[
|
||||
inputFocus,
|
||||
autoTranslateWithSpace,
|
||||
isExpanded,
|
||||
text.length,
|
||||
files.length,
|
||||
textareaRef,
|
||||
spaceClickCount,
|
||||
translate,
|
||||
handleToggleExpanded,
|
||||
sendMessageShortcut,
|
||||
handleSendMessage,
|
||||
setText,
|
||||
setTimeoutTimer,
|
||||
setFiles
|
||||
]
|
||||
)
|
||||
|
||||
const handleTextareaChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newText = e.target.value
|
||||
setText(newText)
|
||||
|
||||
const isDeletion = newText.length < textRef.current.length
|
||||
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
const cursorPosition = textArea?.selectionStart ?? newText.length
|
||||
const lastSymbol = newText[cursorPosition - 1]
|
||||
const previousChar = newText[cursorPosition - 2]
|
||||
const isCursorAtTextStart = cursorPosition <= 1
|
||||
const hasValidTriggerBoundary = previousChar === ' ' || isCursorAtTextStart
|
||||
|
||||
const openRootPanelAt = (position: number) => {
|
||||
triggers.emit(QuickPanelReservedSymbol.Root, {
|
||||
type: 'input',
|
||||
position,
|
||||
originalText: newText
|
||||
})
|
||||
}
|
||||
|
||||
const openMentionPanelAt = (position: number) => {
|
||||
triggers.emit(QuickPanelReservedSymbol.MentionModels, {
|
||||
type: 'input',
|
||||
position,
|
||||
originalText: newText
|
||||
})
|
||||
}
|
||||
|
||||
if (quickPanelTriggersEnabled && config.enableQuickPanel) {
|
||||
const hasRootMenuItems = triggers.getRootMenu().length > 0
|
||||
const textBeforeCursor = newText.slice(0, cursorPosition)
|
||||
const lastRootIndex = textBeforeCursor.lastIndexOf(QuickPanelReservedSymbol.Root)
|
||||
const lastMentionIndex = textBeforeCursor.lastIndexOf(QuickPanelReservedSymbol.MentionModels)
|
||||
const lastTriggerIndex = Math.max(lastRootIndex, lastMentionIndex)
|
||||
|
||||
const allowResumeSearch =
|
||||
!quickPanel.isVisible &&
|
||||
(quickPanel.lastCloseAction === undefined || quickPanel.lastCloseAction === 'outsideclick')
|
||||
|
||||
if (!quickPanel.isVisible && lastTriggerIndex !== -1 && cursorPosition > lastTriggerIndex) {
|
||||
const triggerChar = newText[lastTriggerIndex]
|
||||
const boundaryChar = newText[lastTriggerIndex - 1] ?? ''
|
||||
const hasBoundary = lastTriggerIndex === 0 || /\s/.test(boundaryChar)
|
||||
const searchSegment = newText.slice(lastTriggerIndex + 1, cursorPosition)
|
||||
const hasSearchContent = searchSegment.trim().length > 0
|
||||
|
||||
if (hasBoundary && (!hasSearchContent || isDeletion || allowResumeSearch)) {
|
||||
if (triggerChar === QuickPanelReservedSymbol.Root && hasRootMenuItems) {
|
||||
openRootPanelAt(lastTriggerIndex)
|
||||
} else if (triggerChar === QuickPanelReservedSymbol.MentionModels) {
|
||||
openMentionPanelAt(lastTriggerIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSymbol === QuickPanelReservedSymbol.Root && hasValidTriggerBoundary && hasRootMenuItems) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) {
|
||||
openRootPanelAt(cursorPosition - 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSymbol === QuickPanelReservedSymbol.MentionModels && hasValidTriggerBoundary) {
|
||||
if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||
quickPanel.close('switch-symbol')
|
||||
}
|
||||
if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) {
|
||||
openMentionPanelAt(cursorPosition - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quickPanel.isVisible && quickPanel.triggerInfo?.type === 'input') {
|
||||
const activeSymbol = quickPanel.symbol as QuickPanelReservedSymbol
|
||||
const triggerPosition = quickPanel.triggerInfo.position ?? -1
|
||||
const isTrackedSymbol =
|
||||
activeSymbol === QuickPanelReservedSymbol.Root || activeSymbol === QuickPanelReservedSymbol.MentionModels
|
||||
|
||||
if (isTrackedSymbol && triggerPosition >= 0) {
|
||||
// Check if cursor is before the trigger position (user deleted the symbol)
|
||||
if (cursorPosition <= triggerPosition) {
|
||||
quickPanel.close('delete-symbol')
|
||||
} else {
|
||||
// Check if the trigger symbol still exists at the expected position
|
||||
const triggerChar = newText[triggerPosition]
|
||||
if (triggerChar !== activeSymbol) {
|
||||
quickPanel.close('delete-symbol')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setText, textareaRef, quickPanelTriggersEnabled, config.enableQuickPanel, quickPanel, triggers]
|
||||
)
|
||||
|
||||
const onTranslated = useCallback(
|
||||
(translatedText: string) => {
|
||||
setText(translatedText)
|
||||
setTimeoutTimer('onTranslated', () => resizeTextArea(), 0)
|
||||
},
|
||||
[resizeTextArea, setText, setTimeoutTimer]
|
||||
)
|
||||
|
||||
const appendTxtContentToInput = useCallback(
|
||||
async (file: FileType, event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const targetPath = file.path
|
||||
const content = await window.api.file.readExternal(targetPath, true)
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
} catch (clipboardError) {
|
||||
logger.warn('Failed to copy txt attachment content to clipboard:', clipboardError as Error)
|
||||
}
|
||||
|
||||
setText((prev) => {
|
||||
if (!prev) {
|
||||
return content
|
||||
}
|
||||
|
||||
const needsSeparator = !prev.endsWith('\n')
|
||||
return needsSeparator ? `${prev}\n${content}` : prev + content
|
||||
})
|
||||
|
||||
setFiles((prev) => prev.filter((currentFile) => currentFile.id !== file.id))
|
||||
|
||||
setTimeoutTimer(
|
||||
'appendTxtAttachment',
|
||||
() => {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const end = textArea.value.length
|
||||
focusTextarea()
|
||||
textArea.setSelectionRange(end, end)
|
||||
}
|
||||
|
||||
resizeTextArea(true)
|
||||
},
|
||||
0
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to append txt attachment content:', error as Error)
|
||||
window.toast.error(t('chat.input.file_error'))
|
||||
}
|
||||
},
|
||||
[focusTextarea, resizeTextArea, setFiles, setText, setTimeoutTimer, t, textareaRef]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setInputFocus(true)
|
||||
dispatch(setSearching(false))
|
||||
if (quickPanel.isVisible && quickPanel.triggerInfo?.type !== 'input') {
|
||||
quickPanel.close()
|
||||
}
|
||||
PasteService.setLastFocusedComponent('inputbar')
|
||||
}, [dispatch, quickPanel])
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (!config.enableDragDrop) {
|
||||
return
|
||||
}
|
||||
|
||||
startDragY.current = event.clientY
|
||||
startHeight.current = textareaRef.current?.resizableTextArea?.textArea?.offsetHeight || 0
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = startDragY.current - e.clientY
|
||||
const newHeight = Math.max(40, Math.min(400, startHeight.current + deltaY))
|
||||
setTextareaHeight(newHeight)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
},
|
||||
[config.enableDragDrop, setTextareaHeight, textareaRef]
|
||||
)
|
||||
|
||||
const onQuote = useCallback(
|
||||
(quoted: string) => {
|
||||
const formatted = formatQuotedText(quoted)
|
||||
setText((prevText) => {
|
||||
const next = prevText ? `${prevText}\n${formatted}\n` : `${formatted}\n`
|
||||
setTimeoutTimer('onQuote', () => resizeTextArea(), 0)
|
||||
return next
|
||||
})
|
||||
focusTextarea()
|
||||
},
|
||||
[focusTextarea, resizeTextArea, setText, setTimeoutTimer]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const quoteListener = window.electron?.ipcRenderer.on(IpcChannel.App_QuoteToMain, (_, selectedText: string) =>
|
||||
onQuote(selectedText)
|
||||
)
|
||||
return () => {
|
||||
quoteListener?.()
|
||||
}
|
||||
}, [onQuote])
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = requestAnimationFrame(() => resizeTextArea())
|
||||
return () => cancelAnimationFrame(timerId)
|
||||
}, [resizeTextArea])
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
if (document.activeElement?.closest('.ant-modal')) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastFocusedComponent = PasteService.getLastFocusedComponent()
|
||||
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
|
||||
focusTextarea()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [focusTextarea])
|
||||
|
||||
useEffect(() => {
|
||||
PasteService.init()
|
||||
|
||||
PasteService.registerHandler('inputbar', handlePaste)
|
||||
|
||||
return () => {
|
||||
PasteService.unregisterHandler('inputbar')
|
||||
}
|
||||
}, [handlePaste])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (spaceClickTimer.current) {
|
||||
clearTimeout(spaceClickTimer.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rightSectionExtras = useMemo(() => {
|
||||
const extras: React.ReactNode[] = []
|
||||
extras.push(<TranslateButton key="translate" text={text} onTranslated={onTranslated} isLoading={isTranslating} />)
|
||||
extras.push(<SendMessageButton sendMessage={handleSendMessage} disabled={cannotSend || isLoading || searching} />)
|
||||
|
||||
if (isLoading) {
|
||||
extras.push(
|
||||
<Tooltip key="pause" placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||
<ActionIconButton onClick={onPause} style={{ marginRight: -2 }}>
|
||||
<CirclePause size={20} color="var(--color-error)" />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{extras}</>
|
||||
}, [text, onTranslated, isTranslating, handleSendMessage, cannotSend, isLoading, searching, t, onPause])
|
||||
|
||||
const quickPanelElement = config.enableQuickPanel ? <QuickPanelView setInputText={setText} /> : null
|
||||
|
||||
return (
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<Container
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={classNames('inputbar')}>
|
||||
{quickPanelElement}
|
||||
<InputBarContainer
|
||||
id="inputbar"
|
||||
className={classNames('inputbar-container', isDragging && 'file-dragging', isExpanded && 'expanded')}>
|
||||
<DragHandle onMouseDown={handleDragStart}>
|
||||
<HolderOutlined style={{ fontSize: 12 }} />
|
||||
</DragHandle>
|
||||
{files.length > 0 && (
|
||||
<AttachmentPreview files={files} setFiles={setFiles} onAttachmentContextMenu={appendTxtContentToInput} />
|
||||
)}
|
||||
{topContent}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => handlePaste(e.nativeEvent)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
placeholder={isTranslating ? t('chat.input.translating') : placeholder}
|
||||
autoFocus
|
||||
variant="borderless"
|
||||
spellCheck={enableSpellCheck}
|
||||
rows={2}
|
||||
autoSize={textareaHeight ? false : { minRows: 2, maxRows: 20 }}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
style={{
|
||||
fontSize,
|
||||
height: textareaHeight,
|
||||
minHeight: '30px'
|
||||
}}
|
||||
disabled={isTranslating || searching}
|
||||
onClick={() => {
|
||||
searching && dispatch(setSearching(false))
|
||||
quickPanel.close()
|
||||
}}
|
||||
/>
|
||||
|
||||
<BottomBar>
|
||||
<LeftSection>{leftToolbar}</LeftSection>
|
||||
<RightSection>
|
||||
{rightToolbar}
|
||||
{rightSectionExtras}
|
||||
</RightSection>
|
||||
</BottomBar>
|
||||
</InputBarContainer>
|
||||
</Container>
|
||||
</NarrowLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Styled Components
|
||||
const DragHandle = styled.div`
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: row-resize;
|
||||
color: var(--color-icon);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
transform: rotate(90deg);
|
||||
font-size: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0 18px 18px 18px;
|
||||
[navbar-position='top'] & {
|
||||
padding: 0 18px 10px 18px;
|
||||
}
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border-radius: 17px;
|
||||
padding-top: 8px;
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
&.file-dragging {
|
||||
border: 2px dashed #2ecc71;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(46, 204, 113, 0.03);
|
||||
border-radius: 14px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Textarea = styled(TextArea)`
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
resize: none !important;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: none !important;
|
||||
&.ant-input {
|
||||
line-height: 1.4;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
`
|
||||
|
||||
const BottomBar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 8px;
|
||||
height: 40px;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const LeftSection = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
const RightSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
@@ -0,0 +1,347 @@
|
||||
import type { QuickPanelListItem, QuickPanelReservedSymbol } from '@renderer/components/QuickPanel'
|
||||
import type { FileType, KnowledgeBase, Model } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
type QuickPanelTriggerHandler = (payload?: unknown) => void
|
||||
|
||||
/**
|
||||
* Read-only state interface for Inputbar tools.
|
||||
* Components subscribing to this state will re-render on changes.
|
||||
*/
|
||||
export interface InputbarToolsState {
|
||||
/** Attached files */
|
||||
files: FileType[]
|
||||
/** Models mentioned in the input */
|
||||
mentionedModels: Model[]
|
||||
/** Selected knowledge base items */
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
/** Whether the inputbar is expanded */
|
||||
isExpanded: boolean
|
||||
|
||||
/** Whether image files can be added (derived state) */
|
||||
couldAddImageFile: boolean
|
||||
/** Whether non-vision models can be mentioned (derived state) */
|
||||
couldMentionNotVisionModel: boolean
|
||||
/** Supported file extensions (derived state) */
|
||||
extensions: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools registry API for tool buttons.
|
||||
* Used to register menu items and triggers.
|
||||
*/
|
||||
export interface ToolsRegistryAPI {
|
||||
/**
|
||||
* Register a tool to the root menu (triggered by `/`).
|
||||
* @param toolKey - Unique tool identifier
|
||||
* @param entries - Menu items to register
|
||||
* @returns Cleanup function to unregister
|
||||
*/
|
||||
registerRootMenu: (toolKey: string, entries: QuickPanelListItem[]) => () => void
|
||||
|
||||
/**
|
||||
* Register a trigger handler function.
|
||||
* @param toolKey - Unique tool identifier
|
||||
* @param symbol - Trigger symbol (e.g., @, #, /)
|
||||
* @param handler - Handler function to execute on trigger
|
||||
* @returns Cleanup function to unregister
|
||||
*/
|
||||
registerTrigger: (toolKey: string, symbol: QuickPanelReservedSymbol, handler: QuickPanelTriggerHandler) => () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers API for Inputbar component.
|
||||
* Used to trigger panels and retrieve menu items.
|
||||
*/
|
||||
export interface TriggersAPI {
|
||||
/**
|
||||
* Emit a trigger for the specified symbol.
|
||||
* @param symbol - Trigger symbol
|
||||
* @param payload - Data to pass to trigger handlers
|
||||
*/
|
||||
emit: (symbol: QuickPanelReservedSymbol, payload?: unknown) => void
|
||||
|
||||
/**
|
||||
* Get all root menu items (merged from all registered tools).
|
||||
* @returns Merged menu items list
|
||||
*/
|
||||
getRootMenu: () => QuickPanelListItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch interface containing all action functions.
|
||||
* These functions have stable references and won't cause re-renders.
|
||||
*/
|
||||
export interface InputbarToolsDispatch {
|
||||
/** State setters */
|
||||
setFiles: React.Dispatch<React.SetStateAction<FileType[]>>
|
||||
setMentionedModels: React.Dispatch<React.SetStateAction<Model[]>>
|
||||
setSelectedKnowledgeBases: React.Dispatch<React.SetStateAction<KnowledgeBase[]>>
|
||||
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>
|
||||
|
||||
/** Parent component actions */
|
||||
resizeTextArea: () => void
|
||||
addNewTopic: () => void
|
||||
clearTopic: () => void
|
||||
onNewContext: () => void
|
||||
toggleExpanded: (nextState?: boolean) => void
|
||||
|
||||
/** Text manipulation (avoids putting text state in Context) */
|
||||
onTextChange: (updater: string | ((prev: string) => string)) => void
|
||||
|
||||
/** Tools registry API (for tool buttons) */
|
||||
toolsRegistry: ToolsRegistryAPI
|
||||
|
||||
/** Triggers API (for Inputbar component) */
|
||||
triggers: TriggersAPI
|
||||
}
|
||||
|
||||
const InputbarToolsStateContext = createContext<InputbarToolsState | undefined>(undefined)
|
||||
const InputbarToolsDispatchContext = createContext<InputbarToolsDispatch | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Get Inputbar Tools state (read-only).
|
||||
* Components using this hook will re-render when state changes.
|
||||
*/
|
||||
export const useInputbarToolsState = (): InputbarToolsState => {
|
||||
const context = use(InputbarToolsStateContext)
|
||||
if (!context) {
|
||||
throw new Error('useInputbarToolsState must be used within InputbarToolsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Inputbar Tools dispatch functions (stable references).
|
||||
* Components using this hook won't re-render when state changes.
|
||||
*/
|
||||
export const useInputbarToolsDispatch = (): InputbarToolsDispatch => {
|
||||
const context = use(InputbarToolsDispatchContext)
|
||||
if (!context) {
|
||||
throw new Error('useInputbarToolsDispatch must be used within InputbarToolsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined type containing both state and dispatch.
|
||||
* Used for type inference in tool buttons.
|
||||
*/
|
||||
export type InputbarToolsContextValue = InputbarToolsState & InputbarToolsDispatch
|
||||
|
||||
/**
|
||||
* Get both state and dispatch (convenience hook).
|
||||
* Components using this hook will re-render when state changes.
|
||||
*/
|
||||
export const useInputbarTools = (): InputbarToolsContextValue => {
|
||||
const state = useInputbarToolsState()
|
||||
const dispatch = useInputbarToolsDispatch()
|
||||
return { ...state, ...dispatch }
|
||||
}
|
||||
|
||||
interface InputbarToolsProviderProps {
|
||||
children: React.ReactNode
|
||||
initialState?: Partial<{
|
||||
files: FileType[]
|
||||
mentionedModels: Model[]
|
||||
selectedKnowledgeBases: KnowledgeBase[]
|
||||
isExpanded: boolean
|
||||
couldAddImageFile: boolean
|
||||
extensions: string[]
|
||||
}>
|
||||
actions: {
|
||||
resizeTextArea: () => void
|
||||
addNewTopic: () => void
|
||||
clearTopic: () => void
|
||||
onNewContext: () => void
|
||||
onTextChange: (updater: string | ((prev: string) => string)) => void
|
||||
toggleExpanded: (nextState?: boolean) => void
|
||||
}
|
||||
}
|
||||
|
||||
export const InputbarToolsProvider: React.FC<InputbarToolsProviderProps> = ({ children, initialState, actions }) => {
|
||||
// Core state
|
||||
const [files, setFiles] = useState<FileType[]>(initialState?.files || [])
|
||||
const [mentionedModels, setMentionedModels] = useState<Model[]>(initialState?.mentionedModels || [])
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>(
|
||||
initialState?.selectedKnowledgeBases || []
|
||||
)
|
||||
const [isExpanded, setIsExpanded] = useState(initialState?.isExpanded || false)
|
||||
|
||||
// Derived state (internal management)
|
||||
const [couldAddImageFile, setCouldAddImageFile] = useState(initialState?.couldAddImageFile || false)
|
||||
const [extensions, setExtensions] = useState<string[]>(initialState?.extensions || [])
|
||||
|
||||
const couldMentionNotVisionModel = !files.some((file) => file.type === FileTypes.IMAGE)
|
||||
|
||||
// Quick Panel Registry (stored in refs to avoid re-renders)
|
||||
const rootMenuRegistryRef = useRef(new Map<string, QuickPanelListItem[]>())
|
||||
const triggerRegistryRef = useRef(new Map<QuickPanelReservedSymbol, Map<string, QuickPanelTriggerHandler>>())
|
||||
|
||||
// Quick Panel API (stable references)
|
||||
const getQuickPanelRootMenu = useCallback(() => {
|
||||
const allEntries: QuickPanelListItem[] = []
|
||||
rootMenuRegistryRef.current.forEach((entries) => {
|
||||
allEntries.push(...entries)
|
||||
})
|
||||
return allEntries
|
||||
}, [])
|
||||
|
||||
const registerRootMenu = useCallback((toolKey: string, entries: QuickPanelListItem[]) => {
|
||||
rootMenuRegistryRef.current.set(toolKey, entries)
|
||||
return () => {
|
||||
rootMenuRegistryRef.current.delete(toolKey)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const registerTrigger = useCallback(
|
||||
(toolKey: string, symbol: QuickPanelReservedSymbol, handler: QuickPanelTriggerHandler) => {
|
||||
if (!triggerRegistryRef.current.has(symbol)) {
|
||||
triggerRegistryRef.current.set(symbol, new Map())
|
||||
}
|
||||
|
||||
const handlers = triggerRegistryRef.current.get(symbol)!
|
||||
handlers.set(toolKey, handler)
|
||||
|
||||
return () => {
|
||||
const currentHandlers = triggerRegistryRef.current.get(symbol)
|
||||
if (!currentHandlers) return
|
||||
|
||||
currentHandlers.delete(toolKey)
|
||||
if (currentHandlers.size === 0) {
|
||||
triggerRegistryRef.current.delete(symbol)
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const emitTrigger = useCallback((symbol: QuickPanelReservedSymbol, payload?: unknown) => {
|
||||
const handlers = triggerRegistryRef.current.get(symbol)
|
||||
handlers?.forEach((handler) => {
|
||||
handler?.(payload)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Stabilize parent actions (prevent dispatch context updates from parent action reference changes)
|
||||
const actionsRef = useRef(actions)
|
||||
useEffect(() => {
|
||||
actionsRef.current = actions
|
||||
}, [actions])
|
||||
|
||||
const stableActions = useMemo(
|
||||
() => ({
|
||||
resizeTextArea: () => actionsRef.current.resizeTextArea(),
|
||||
addNewTopic: () => actionsRef.current.addNewTopic(),
|
||||
clearTopic: () => actionsRef.current.clearTopic(),
|
||||
onNewContext: () => actionsRef.current.onNewContext(),
|
||||
onTextChange: (updater: string | ((prev: string) => string)) => actionsRef.current.onTextChange(updater),
|
||||
toggleExpanded: (nextState?: boolean) => actionsRef.current.toggleExpanded(nextState)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
// State Context Value (updates when state changes)
|
||||
const stateValue = useMemo<InputbarToolsState>(
|
||||
() => ({
|
||||
files,
|
||||
mentionedModels,
|
||||
selectedKnowledgeBases,
|
||||
isExpanded,
|
||||
couldAddImageFile,
|
||||
couldMentionNotVisionModel,
|
||||
extensions
|
||||
}),
|
||||
[
|
||||
files,
|
||||
mentionedModels,
|
||||
selectedKnowledgeBases,
|
||||
isExpanded,
|
||||
couldAddImageFile,
|
||||
couldMentionNotVisionModel,
|
||||
extensions
|
||||
]
|
||||
)
|
||||
|
||||
// Tools Registry API (stable references for tool buttons)
|
||||
const toolsRegistryAPI = useMemo<ToolsRegistryAPI>(
|
||||
() => ({
|
||||
registerRootMenu,
|
||||
registerTrigger
|
||||
}),
|
||||
[registerRootMenu, registerTrigger]
|
||||
)
|
||||
|
||||
// Triggers API (stable references for Inputbar component)
|
||||
const triggersAPI = useMemo<TriggersAPI>(
|
||||
() => ({
|
||||
emit: emitTrigger,
|
||||
getRootMenu: getQuickPanelRootMenu
|
||||
}),
|
||||
[emitTrigger, getQuickPanelRootMenu]
|
||||
)
|
||||
|
||||
// Dispatch Context Value (stable references)
|
||||
const dispatchValue = useMemo<InputbarToolsDispatch>(
|
||||
() => ({
|
||||
// State setters (React guarantees stable references)
|
||||
setFiles,
|
||||
setMentionedModels,
|
||||
setSelectedKnowledgeBases,
|
||||
setIsExpanded,
|
||||
|
||||
// Stable actions
|
||||
...stableActions,
|
||||
|
||||
// API objects
|
||||
toolsRegistry: toolsRegistryAPI,
|
||||
triggers: triggersAPI
|
||||
}),
|
||||
[stableActions, toolsRegistryAPI, triggersAPI]
|
||||
)
|
||||
|
||||
// Internal Dispatch (contains setCouldAddImageFile and setExtensions)
|
||||
// These setters are exposed to Inputbar but not to tool buttons
|
||||
// Using a separate internal context to avoid polluting the main dispatch context
|
||||
const internalDispatchValue = useMemo(
|
||||
() => ({
|
||||
setCouldAddImageFile,
|
||||
setExtensions
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<InputbarToolsStateContext value={stateValue}>
|
||||
<InputbarToolsDispatchContext value={dispatchValue}>
|
||||
<InputbarToolsInternalDispatchContext value={internalDispatchValue}>
|
||||
{children}
|
||||
</InputbarToolsInternalDispatchContext>
|
||||
</InputbarToolsDispatchContext>
|
||||
</InputbarToolsStateContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal dispatch interface for Inputbar component only.
|
||||
* Used to set derived state (couldAddImageFile, extensions).
|
||||
*/
|
||||
interface InputbarToolsInternalDispatch {
|
||||
setCouldAddImageFile: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setExtensions: React.Dispatch<React.SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
const InputbarToolsInternalDispatchContext = createContext<InputbarToolsInternalDispatch | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* Internal hook for Inputbar component only.
|
||||
* Used to set derived state (couldAddImageFile, extensions).
|
||||
*/
|
||||
export const useInputbarToolsInternalDispatch = (): InputbarToolsInternalDispatch => {
|
||||
const context = use(InputbarToolsInternalDispatchContext)
|
||||
if (!context) {
|
||||
throw new Error('useInputbarToolsInternalDispatch must be used within InputbarToolsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useDrag } from '@renderer/hooks/useDrag'
|
||||
import type { FileType } from '@renderer/types'
|
||||
import { filterSupportedFiles } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const logger = loggerService.withContext('useFileDragDrop')
|
||||
|
||||
export interface UseFileDragDropOptions {
|
||||
supportedExts: string[]
|
||||
setFiles: (updater: (prevFiles: FileType[]) => FileType[]) => void
|
||||
onTextDropped?: (text: string) => void
|
||||
enabled?: boolean
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputbar 文件拖拽上传 Hook
|
||||
*
|
||||
* 处理文件拖拽、文本拖拽,支持文件类型过滤和错误提示
|
||||
*
|
||||
* @param options - 拖拽配置选项
|
||||
* @returns 拖拽状态和事件处理函数
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const dragDrop = useFileDragDrop({
|
||||
* supportedExts: ['.png', '.jpg', '.pdf'],
|
||||
* setFiles: (updater) => setFiles(updater),
|
||||
* onTextDropped: (text) => setText(text),
|
||||
* enabled: true,
|
||||
* t: useTranslation().t
|
||||
* })
|
||||
*
|
||||
* <div
|
||||
* onDragEnter={dragDrop.handleDragEnter}
|
||||
* onDragLeave={dragDrop.handleDragLeave}
|
||||
* onDragOver={dragDrop.handleDragOver}
|
||||
* onDrop={dragDrop.handleDrop}
|
||||
* className={dragDrop.isDragging ? 'dragging' : ''}
|
||||
* >
|
||||
* Drop files here
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function useFileDragDrop(options: UseFileDragDropOptions) {
|
||||
const handleDrop = useCallback(
|
||||
async (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!options.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理文本拖拽
|
||||
const droppedText = await getTextFromDropEvent(event)
|
||||
if (droppedText) {
|
||||
options.onTextDropped?.(droppedText)
|
||||
}
|
||||
|
||||
// 处理文件拖拽
|
||||
const droppedFiles = await getFilesFromDropEvent(event).catch((err) => {
|
||||
logger.error('handleDrop:', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (droppedFiles) {
|
||||
const supportedFiles = await filterSupportedFiles(droppedFiles, options.supportedExts)
|
||||
if (supportedFiles.length > 0) {
|
||||
options.setFiles((prevFiles) => [...prevFiles, ...supportedFiles])
|
||||
}
|
||||
|
||||
// 如果有不支持的文件,显示提示
|
||||
if (droppedFiles.length > 0 && supportedFiles.length !== droppedFiles.length) {
|
||||
window.toast.info(
|
||||
options.t('chat.input.file_not_supported_count', {
|
||||
count: droppedFiles.length - supportedFiles.length
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[options]
|
||||
)
|
||||
|
||||
const dragState = useDrag(handleDrop)
|
||||
|
||||
return {
|
||||
isDragging: options.enabled ? dragState.isDragging : false,
|
||||
setIsDragging: dragState.setIsDragging,
|
||||
handleDragOver: options.enabled ? dragState.handleDragOver : undefined,
|
||||
handleDragEnter: options.enabled ? dragState.handleDragEnter : undefined,
|
||||
handleDragLeave: options.enabled ? dragState.handleDragLeave : undefined,
|
||||
handleDrop: options.enabled ? dragState.handleDrop : undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import PasteService from '@renderer/services/PasteService'
|
||||
import type { FileMetadata } from '@renderer/types'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export interface UsePasteHandlerOptions {
|
||||
supportedExts: string[]
|
||||
pasteLongTextAsFile?: boolean
|
||||
pasteLongTextThreshold?: number
|
||||
setFiles: (updater: (prevFiles: FileMetadata[]) => FileMetadata[]) => void
|
||||
onResize?: () => void
|
||||
t: TFunction
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputbar 专用粘贴处理 Hook
|
||||
*
|
||||
* 处理文件、长文本、图片等粘贴场景,集成 PasteService
|
||||
*
|
||||
* @param text - 当前文本内容
|
||||
* @param setText - 设置文本的函数
|
||||
* @param options - 粘贴处理配置
|
||||
* @returns 粘贴事件处理函数
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { handlePaste } = usePasteHandler(text, setText, {
|
||||
* supportedExts: ['.png', '.jpg', '.pdf'],
|
||||
* pasteLongTextAsFile: true,
|
||||
* pasteLongTextThreshold: 5000,
|
||||
* setFiles: (updater) => setFiles(updater),
|
||||
* onResize: () => resize(),
|
||||
* t: useTranslation().t
|
||||
* })
|
||||
*
|
||||
* <textarea onPaste={handlePaste} />
|
||||
* ```
|
||||
*/
|
||||
export function usePasteHandler(
|
||||
text: string,
|
||||
setText: (text: string | ((prev: string) => string)) => void,
|
||||
options: UsePasteHandlerOptions
|
||||
) {
|
||||
const handlePaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
return await PasteService.handlePaste(
|
||||
event,
|
||||
options.supportedExts,
|
||||
options.setFiles,
|
||||
setText,
|
||||
options.pasteLongTextAsFile ?? false,
|
||||
options.pasteLongTextThreshold ?? 5000,
|
||||
text,
|
||||
options.onResize ?? (() => {}),
|
||||
options.t
|
||||
)
|
||||
},
|
||||
[text, setText, options]
|
||||
)
|
||||
|
||||
return { handlePaste }
|
||||
}
|
||||
53
src/renderer/src/pages/home/Inputbar/registry.ts
Normal file
53
src/renderer/src/pages/home/Inputbar/registry.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { TopicType } from '@renderer/types'
|
||||
|
||||
import type { InputbarScope, InputbarScopeConfig } from './types'
|
||||
|
||||
const DEFAULT_INPUTBAR_SCOPE: InputbarScope = TopicType.Chat
|
||||
|
||||
const inputbarRegistry = new Map<InputbarScope, InputbarScopeConfig>([
|
||||
[
|
||||
TopicType.Chat,
|
||||
{
|
||||
minRows: 1,
|
||||
maxRows: 8,
|
||||
showTokenCount: true,
|
||||
showTools: true,
|
||||
toolsCollapsible: true,
|
||||
enableQuickPanel: true,
|
||||
enableDragDrop: true
|
||||
}
|
||||
],
|
||||
[
|
||||
TopicType.Session,
|
||||
{
|
||||
placeholder: 'Type a message...',
|
||||
minRows: 2,
|
||||
maxRows: 20,
|
||||
showTokenCount: false,
|
||||
showTools: true,
|
||||
toolsCollapsible: false,
|
||||
enableQuickPanel: true,
|
||||
enableDragDrop: true
|
||||
}
|
||||
],
|
||||
[
|
||||
'mini-window',
|
||||
{
|
||||
minRows: 1,
|
||||
maxRows: 3,
|
||||
showTokenCount: false,
|
||||
showTools: true,
|
||||
toolsCollapsible: false,
|
||||
enableQuickPanel: true,
|
||||
enableDragDrop: false
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
export const registerInputbarConfig = (scope: InputbarScope, config: InputbarScopeConfig): void => {
|
||||
inputbarRegistry.set(scope, config)
|
||||
}
|
||||
|
||||
export const getInputbarConfig = (scope: InputbarScope): InputbarScopeConfig => {
|
||||
return inputbarRegistry.get(scope) || inputbarRegistry.get(DEFAULT_INPUTBAR_SCOPE)!
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||
import type React from 'react'
|
||||
|
||||
import ActivityDirectoryButton from './components/ActivityDirectoryButton'
|
||||
import ActivityDirectoryQuickPanelManager from './components/ActivityDirectoryQuickPanelManager'
|
||||
|
||||
/**
|
||||
* Activity Directory Tool
|
||||
*
|
||||
* Allows users to search and select files from the agent's accessible directories.
|
||||
* Uses @ trigger (same symbol as MentionModels, but different scope).
|
||||
* Only visible in Agent Session (TopicType.Session).
|
||||
*/
|
||||
const activityDirectoryTool = defineTool({
|
||||
key: 'activity_directory',
|
||||
label: (t) => t('chat.input.activity_directory.title'),
|
||||
visibleInScopes: [TopicType.Session],
|
||||
|
||||
dependencies: {
|
||||
state: [] as const,
|
||||
actions: ['onTextChange'] as const
|
||||
},
|
||||
|
||||
render: function ActivityDirectoryToolRender(context) {
|
||||
const { quickPanel, quickPanelController, actions, session } = context
|
||||
const { onTextChange } = actions
|
||||
|
||||
// Get accessible paths from session data
|
||||
const accessiblePaths = session?.accessiblePaths ?? []
|
||||
|
||||
// Only render if we have accessible paths
|
||||
if (accessiblePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityDirectoryButton
|
||||
quickPanel={quickPanel}
|
||||
quickPanelController={quickPanelController}
|
||||
accessiblePaths={accessiblePaths}
|
||||
setText={onTextChange as React.Dispatch<React.SetStateAction<string>>}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
quickPanelManager: ActivityDirectoryQuickPanelManager
|
||||
})
|
||||
|
||||
registerTool(activityDirectoryTool)
|
||||
|
||||
export default activityDirectoryTool
|
||||
@@ -0,0 +1,33 @@
|
||||
import AttachmentButton from '@renderer/pages/home/Inputbar/tools/components/AttachmentButton'
|
||||
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||
|
||||
const attachmentTool = defineTool({
|
||||
key: 'attachment',
|
||||
label: (t) => t('chat.input.upload.image_or_document'),
|
||||
|
||||
visibleInScopes: [TopicType.Chat, TopicType.Session, 'mini-window'],
|
||||
|
||||
dependencies: {
|
||||
state: ['files', 'couldAddImageFile', 'extensions'] as const,
|
||||
actions: ['setFiles'] as const
|
||||
},
|
||||
|
||||
render: (context) => {
|
||||
const { state, actions, quickPanel } = context
|
||||
|
||||
return (
|
||||
<AttachmentButton
|
||||
quickPanel={quickPanel}
|
||||
couldAddImageFile={state.couldAddImageFile}
|
||||
extensions={state.extensions}
|
||||
files={state.files}
|
||||
setFiles={actions.setFiles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Register the tool
|
||||
registerTool(attachmentTool)
|
||||
|
||||
export default attachmentTool
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { PaintbrushVertical } from 'lucide-react'
|
||||
|
||||
const clearTopicTool = defineTool({
|
||||
key: 'clear_topic',
|
||||
label: (t) => t('chat.input.clear.label', { Command: '' }),
|
||||
visibleInScopes: [TopicType.Chat],
|
||||
dependencies: {
|
||||
actions: ['clearTopic'] as const
|
||||
},
|
||||
render: function ClearTopicRender(context) {
|
||||
const { actions, t } = context
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.clear.label', { Command: clearTopicShortcut })}
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ActionIconButton onClick={actions.clearTopic}>
|
||||
<PaintbrushVertical size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
registerTool(clearTopicTool)
|
||||
|
||||
export default clearTopicTool
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { FolderOpen } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useActivityDirectoryPanel } from './useActivityDirectoryPanel'
|
||||
|
||||
interface Props {
|
||||
quickPanel: ToolQuickPanelApi
|
||||
quickPanelController: ToolQuickPanelController
|
||||
accessiblePaths: string[]
|
||||
setText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const ActivityDirectoryButton: FC<Props> = ({ quickPanel, quickPanelController, accessiblePaths, setText }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { handleOpenQuickPanel } = useActivityDirectoryPanel(
|
||||
{
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
accessiblePaths,
|
||||
setText
|
||||
},
|
||||
'button'
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.activity_directory.title')} mouseLeaveDelay={0} arrow>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel}>
|
||||
<FolderOpen size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ActivityDirectoryButton)
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ToolActionKey, ToolRenderContext, ToolStateKey } from '@renderer/pages/home/Inputbar/types'
|
||||
import type React from 'react'
|
||||
|
||||
import { useActivityDirectoryPanel } from './useActivityDirectoryPanel'
|
||||
|
||||
interface ManagerProps {
|
||||
context: ToolRenderContext<readonly ToolStateKey[], readonly ToolActionKey[]>
|
||||
}
|
||||
|
||||
const ActivityDirectoryQuickPanelManager = ({ context }: ManagerProps) => {
|
||||
const {
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
actions: { onTextChange },
|
||||
session
|
||||
} = context
|
||||
|
||||
// Get accessible paths from session data
|
||||
const accessiblePaths = session?.accessiblePaths ?? []
|
||||
|
||||
// Always call hooks unconditionally (React rules)
|
||||
useActivityDirectoryPanel(
|
||||
{
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
accessiblePaths,
|
||||
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||
},
|
||||
'manager'
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default ActivityDirectoryQuickPanelManager
|
||||
@@ -1,22 +1,18 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||
import type { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file'
|
||||
import { Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react'
|
||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||
import { useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openFileSelectDialog: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<AttachmentButtonRef | null>
|
||||
quickPanel: ToolQuickPanelApi
|
||||
couldAddImageFile: boolean
|
||||
extensions: string[]
|
||||
files: FileType[]
|
||||
@@ -24,9 +20,9 @@ interface Props {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||
const AttachmentButton: FC<Props> = ({ quickPanel, couldAddImageFile, extensions, files, setFiles, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const [selecting, setSelecting] = useState<boolean>(false)
|
||||
|
||||
@@ -71,7 +67,7 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
|
||||
|
||||
const openKnowledgeFileList = useCallback(
|
||||
(base: KnowledgeBase) => {
|
||||
quickPanel.open({
|
||||
quickPanelHook.open({
|
||||
title: base.name,
|
||||
list: base.items
|
||||
.filter((file): file is KnowledgeItem => ['file'].includes(file.type))
|
||||
@@ -102,7 +98,7 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
|
||||
multiple: true
|
||||
})
|
||||
},
|
||||
[files, quickPanel, setFiles]
|
||||
[files, quickPanelHook, setFiles]
|
||||
)
|
||||
|
||||
const items = useMemo(() => {
|
||||
@@ -130,17 +126,31 @@ const AttachmentButton: FC<Props> = ({ ref, couldAddImageFile, extensions, files
|
||||
}, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
quickPanelHook.open({
|
||||
title: t('chat.input.upload.attachment'),
|
||||
list: items,
|
||||
symbol: QuickPanelReservedSymbol.File
|
||||
})
|
||||
}, [items, quickPanel, t])
|
||||
}, [items, quickPanelHook, t])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel,
|
||||
openFileSelectDialog
|
||||
}))
|
||||
useEffect(() => {
|
||||
const disposeRootMenu = quickPanel.registerRootMenu([
|
||||
{
|
||||
label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'),
|
||||
description: '',
|
||||
icon: <Paperclip />,
|
||||
isMenu: true,
|
||||
action: () => openQuickPanel()
|
||||
}
|
||||
])
|
||||
|
||||
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.File, () => openQuickPanel())
|
||||
|
||||
return () => {
|
||||
disposeRootMenu()
|
||||
disposeTrigger()
|
||||
}
|
||||
}, [couldAddImageFile, openQuickPanel, quickPanel, t])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -1,30 +1,27 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import type { QuickPanelListItem } from '@renderer/components/QuickPanel'
|
||||
import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import type { KnowledgeBase } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { CircleX, FileSearch, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface KnowledgeBaseButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<KnowledgeBaseButtonRef | null>
|
||||
quickPanel: ToolQuickPanelApi
|
||||
selectedBases?: KnowledgeBase[]
|
||||
onSelect: (bases: KnowledgeBase[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled }) => {
|
||||
const KnowledgeBaseButton: FC<Props> = ({ quickPanel, selectedBases, onSelect, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const quickPanel = useQuickPanel()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const selectedBasesRef = useRef(selectedBases)
|
||||
|
||||
@@ -76,7 +73,7 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
quickPanelHook.open({
|
||||
title: t('chat.input.knowledge_base'),
|
||||
list: baseItems,
|
||||
symbol: QuickPanelReservedSymbol.KnowledgeBase,
|
||||
@@ -85,27 +82,42 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [baseItems, quickPanel, t])
|
||||
}, [baseItems, quickPanelHook, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
quickPanel.close()
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
quickPanelHook.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
}, [openQuickPanel, quickPanelHook])
|
||||
|
||||
// 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态
|
||||
useEffect(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.KnowledgeBase) {
|
||||
// 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态
|
||||
quickPanel.updateList(baseItems)
|
||||
quickPanelHook.updateList(baseItems)
|
||||
}
|
||||
}, [selectedBases, quickPanel, baseItems])
|
||||
}, [selectedBases, quickPanelHook, baseItems])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
useEffect(() => {
|
||||
const disposeRootMenu = quickPanel.registerRootMenu([
|
||||
{
|
||||
label: t('chat.input.knowledge_base'),
|
||||
description: '',
|
||||
icon: <FileSearch />,
|
||||
isMenu: true,
|
||||
action: () => openQuickPanel()
|
||||
}
|
||||
])
|
||||
|
||||
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.KnowledgeBase, () => openQuickPanel())
|
||||
|
||||
return () => {
|
||||
disposeRootMenu()
|
||||
disposeTrigger()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel, t])
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} mouseLeaveDelay={0} arrow>
|
||||
@@ -6,6 +6,7 @@ import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@rendere
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { EventEmitter } from '@renderer/services/EventService'
|
||||
import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
|
||||
@@ -13,19 +14,13 @@ import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import { Form, Input, Tooltip } from 'antd'
|
||||
import { CircleX, Hammer, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
export interface MCPToolsButtonRef {
|
||||
openQuickPanel: () => void
|
||||
openPromptList: () => void
|
||||
openResourcesList: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
assistantId: string
|
||||
ref?: React.RefObject<MCPToolsButtonRef | null>
|
||||
quickPanel: ToolQuickPanelApi
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
}
|
||||
@@ -115,10 +110,10 @@ const extractPromptContent = (response: any): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assistantId }) => {
|
||||
const MCPToolsButton: FC<Props> = ({ quickPanel, setInputValue, resizeTextArea, assistantId }) => {
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
@@ -219,15 +214,15 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
||||
isSelected: false,
|
||||
action: () => {
|
||||
updateMcpEnabled(false)
|
||||
quickPanel.close()
|
||||
quickPanelHook.close()
|
||||
}
|
||||
})
|
||||
|
||||
return newList
|
||||
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
|
||||
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open({
|
||||
quickPanelHook.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: menuItems,
|
||||
symbol: QuickPanelReservedSymbol.Mcp,
|
||||
@@ -236,7 +231,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
||||
item.isSelected = !item.isSelected
|
||||
}
|
||||
})
|
||||
}, [menuItems, quickPanel, t])
|
||||
}, [menuItems, quickPanelHook, t])
|
||||
|
||||
// 使用 useCallback 优化 insertPromptIntoTextArea
|
||||
const insertPromptIntoTextArea = useCallback(
|
||||
@@ -376,13 +371,13 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
||||
|
||||
const openPromptList = useCallback(async () => {
|
||||
const prompts = await promptList
|
||||
quickPanel.open({
|
||||
quickPanelHook.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: prompts,
|
||||
symbol: QuickPanelReservedSymbol.McpPrompt,
|
||||
multiple: true
|
||||
})
|
||||
}, [promptList, quickPanel, t])
|
||||
}, [promptList, quickPanelHook, t])
|
||||
|
||||
const handleResourceSelect = useCallback(
|
||||
(resource: MCPResource) => {
|
||||
@@ -464,27 +459,60 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, assista
|
||||
}, [activedMcpServers])
|
||||
|
||||
const openResourcesList = useCallback(async () => {
|
||||
quickPanel.open({
|
||||
quickPanelHook.open({
|
||||
title: t('settings.mcp.title'),
|
||||
list: resourcesList,
|
||||
symbol: QuickPanelReservedSymbol.McpResource,
|
||||
multiple: true
|
||||
})
|
||||
}, [resourcesList, quickPanel, t])
|
||||
}, [resourcesList, quickPanelHook, t])
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||
quickPanel.close()
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.Mcp) {
|
||||
quickPanelHook.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
}, [openQuickPanel, quickPanelHook])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel,
|
||||
openPromptList,
|
||||
openResourcesList
|
||||
}))
|
||||
useEffect(() => {
|
||||
const disposeMain = quickPanel.registerRootMenu([
|
||||
{
|
||||
label: t('settings.mcp.title'),
|
||||
description: '',
|
||||
icon: <Hammer />,
|
||||
isMenu: true,
|
||||
action: () => openQuickPanel()
|
||||
},
|
||||
{
|
||||
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
|
||||
description: '',
|
||||
icon: <Hammer />,
|
||||
isMenu: true,
|
||||
action: () => openPromptList()
|
||||
},
|
||||
{
|
||||
label: `MCP ${t('settings.mcp.tabs.resources')}`,
|
||||
description: '',
|
||||
icon: <Hammer />,
|
||||
isMenu: true,
|
||||
action: () => openResourcesList()
|
||||
}
|
||||
])
|
||||
|
||||
const disposeMainTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.Mcp, () => openQuickPanel())
|
||||
const disposePromptTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.McpPrompt, () => openPromptList())
|
||||
const disposeResourceTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.McpResource, () =>
|
||||
openResourcesList()
|
||||
)
|
||||
|
||||
return () => {
|
||||
disposeMain()
|
||||
disposeMainTrigger()
|
||||
disposePromptTrigger()
|
||||
disposeResourceTrigger()
|
||||
}
|
||||
}, [openPromptList, openQuickPanel, openResourcesList, quickPanel, t])
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} mouseLeaveDelay={0} arrow>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import type { ToolQuickPanelApi, ToolQuickPanelController } from '@renderer/pages/home/Inputbar/types'
|
||||
import type { FileType, Model } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { AtSign } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useMentionModelsPanel } from './useMentionModelsPanel'
|
||||
|
||||
interface Props {
|
||||
quickPanel: ToolQuickPanelApi
|
||||
quickPanelController: ToolQuickPanelController
|
||||
mentionedModels: Model[]
|
||||
setMentionedModels: React.Dispatch<React.SetStateAction<Model[]>>
|
||||
couldMentionNotVisionModel: boolean
|
||||
files: FileType[]
|
||||
setText: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
|
||||
const MentionModelsButton: FC<Props> = ({
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
mentionedModels,
|
||||
setMentionedModels,
|
||||
couldMentionNotVisionModel,
|
||||
files,
|
||||
setText
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { handleOpenQuickPanel } = useMentionModelsPanel(
|
||||
{
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
mentionedModels,
|
||||
setMentionedModels,
|
||||
couldMentionNotVisionModel,
|
||||
files,
|
||||
setText
|
||||
},
|
||||
'button'
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('assistants.presets.edit.model.select.title')} mouseLeaveDelay={0} arrow>
|
||||
<ActionIconButton onClick={handleOpenQuickPanel} active={mentionedModels.length > 0}>
|
||||
<AtSign size={18} />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MentionModelsButton)
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ToolActionKey, ToolRenderContext, ToolStateKey } from '@renderer/pages/home/Inputbar/types'
|
||||
import type { FileType, Model } from '@renderer/types'
|
||||
import type React from 'react'
|
||||
|
||||
import { useMentionModelsPanel } from './useMentionModelsPanel'
|
||||
|
||||
interface ManagerProps {
|
||||
context: ToolRenderContext<readonly ToolStateKey[], readonly ToolActionKey[]>
|
||||
}
|
||||
|
||||
const MentionModelsQuickPanelManager = ({ context }: ManagerProps) => {
|
||||
const {
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
state: { mentionedModels, files, couldMentionNotVisionModel },
|
||||
actions: { setMentionedModels, onTextChange }
|
||||
} = context
|
||||
|
||||
useMentionModelsPanel(
|
||||
{
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
mentionedModels: mentionedModels as Model[],
|
||||
setMentionedModels: setMentionedModels as React.Dispatch<React.SetStateAction<Model[]>>,
|
||||
couldMentionNotVisionModel,
|
||||
files: files as FileType[],
|
||||
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||
},
|
||||
'manager'
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default MentionModelsQuickPanelManager
|
||||
@@ -2,38 +2,39 @@ import { ActionIconButton } from '@renderer/components/Buttons'
|
||||
import {
|
||||
type QuickPanelListItem,
|
||||
type QuickPanelOpenOptions,
|
||||
QuickPanelReservedSymbol
|
||||
QuickPanelReservedSymbol,
|
||||
type QuickPanelTriggerInfo
|
||||
} from '@renderer/components/QuickPanel'
|
||||
import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types'
|
||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||
import type { QuickPhrase } from '@renderer/types'
|
||||
import { Input, Modal, Radio, Space, Tooltip } from 'antd'
|
||||
import { BotMessageSquare, Plus, Zap } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface QuickPhrasesButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<QuickPhrasesButtonRef | null>
|
||||
quickPanel: ToolQuickPanelApi
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
resizeTextArea: () => void
|
||||
assistantId: string
|
||||
}
|
||||
|
||||
const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => {
|
||||
const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assistantId }: Props) => {
|
||||
const [quickPhrasesList, setQuickPhrasesList] = useState<QuickPhrase[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({ title: '', content: '', location: 'global' })
|
||||
const { t } = useTranslation()
|
||||
const quickPanel = useQuickPanel()
|
||||
const quickPanelHook = useQuickPanel()
|
||||
const { assistant, updateAssistant } = useAssistant(assistantId)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const triggerInfoRef = useRef<
|
||||
(QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string }) | undefined
|
||||
>(undefined)
|
||||
|
||||
const loadQuickListPhrases = useCallback(
|
||||
async (regularPhrases: QuickPhrase[] = []) => {
|
||||
@@ -58,21 +59,60 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }:
|
||||
'handlePhraseSelect_1',
|
||||
() => {
|
||||
setInputValue((prev) => {
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement
|
||||
const cursorPosition = textArea.selectionStart
|
||||
const selectionStart = cursorPosition
|
||||
const selectionEndPosition = cursorPosition + phrase.content.length
|
||||
const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition)
|
||||
const triggerInfo = triggerInfoRef.current
|
||||
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null
|
||||
|
||||
const focusAndSelect = (start: number) => {
|
||||
setTimeoutTimer(
|
||||
'handlePhraseSelect_2',
|
||||
() => {
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
textArea.setSelectionRange(selectionStart, selectionEndPosition)
|
||||
textArea.setSelectionRange(start, start + phrase.content.length)
|
||||
}
|
||||
resizeTextArea()
|
||||
},
|
||||
10
|
||||
)
|
||||
}
|
||||
|
||||
if (triggerInfo?.type === 'input' && triggerInfo.position !== undefined) {
|
||||
const symbol = triggerInfo.symbol ?? QuickPanelReservedSymbol.Root
|
||||
const searchText = triggerInfo.searchText ?? ''
|
||||
const startIndex = triggerInfo.position
|
||||
|
||||
let endIndex = startIndex + 1
|
||||
if (searchText) {
|
||||
const expected = symbol + searchText
|
||||
const actual = prev.slice(startIndex, startIndex + expected.length)
|
||||
if (actual === expected) {
|
||||
endIndex = startIndex + expected.length
|
||||
} else {
|
||||
while (endIndex < prev.length && !/\s/.test(prev[endIndex])) {
|
||||
endIndex++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while (endIndex < prev.length && !/\s/.test(prev[endIndex])) {
|
||||
endIndex++
|
||||
}
|
||||
}
|
||||
|
||||
const newText = prev.slice(0, startIndex) + phrase.content + prev.slice(endIndex)
|
||||
triggerInfoRef.current = undefined
|
||||
focusAndSelect(startIndex)
|
||||
return newText
|
||||
}
|
||||
|
||||
if (!textArea) {
|
||||
triggerInfoRef.current = undefined
|
||||
return prev + phrase.content
|
||||
}
|
||||
|
||||
const cursorPosition = textArea.selectionStart ?? prev.length
|
||||
const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition)
|
||||
triggerInfoRef.current = undefined
|
||||
focusAndSelect(cursorPosition)
|
||||
return newText
|
||||
})
|
||||
},
|
||||
@@ -138,21 +178,74 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }:
|
||||
[phraseItems, t]
|
||||
)
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
quickPanel.open(quickPanelOpenOptions)
|
||||
}, [quickPanel, quickPanelOpenOptions])
|
||||
type QuickPhraseTrigger =
|
||||
| (QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string })
|
||||
| undefined
|
||||
|
||||
const openQuickPanel = useCallback(
|
||||
(triggerInfo?: QuickPhraseTrigger) => {
|
||||
triggerInfoRef.current = triggerInfo
|
||||
quickPanelHook.open({
|
||||
...quickPanelOpenOptions,
|
||||
triggerInfo:
|
||||
triggerInfo && triggerInfo.type === 'input'
|
||||
? {
|
||||
type: triggerInfo.type,
|
||||
position: triggerInfo.position,
|
||||
originalText: triggerInfo.originalText
|
||||
}
|
||||
: triggerInfo,
|
||||
onClose: () => {
|
||||
triggerInfoRef.current = undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
[quickPanelHook, quickPanelOpenOptions]
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||
quickPanel.close()
|
||||
if (quickPanelHook.isVisible && quickPanelHook.symbol === QuickPanelReservedSymbol.QuickPhrases) {
|
||||
quickPanelHook.close()
|
||||
} else {
|
||||
openQuickPanel()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel])
|
||||
}, [openQuickPanel, quickPanelHook])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
}))
|
||||
useEffect(() => {
|
||||
const disposeRootMenu = quickPanel.registerRootMenu([
|
||||
{
|
||||
label: t('settings.quickPhrase.title'),
|
||||
description: '',
|
||||
icon: <Zap />,
|
||||
isMenu: true,
|
||||
action: ({ context, searchText }) => {
|
||||
const rootTrigger =
|
||||
context.triggerInfo && context.triggerInfo.type === 'input'
|
||||
? {
|
||||
...context.triggerInfo,
|
||||
symbol: QuickPanelReservedSymbol.Root,
|
||||
searchText: searchText ?? ''
|
||||
}
|
||||
: undefined
|
||||
|
||||
context.close('select')
|
||||
setTimeout(() => {
|
||||
openQuickPanel(rootTrigger)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const disposeTrigger = quickPanel.registerTrigger(QuickPanelReservedSymbol.QuickPhrases, (payload) => {
|
||||
const trigger = (payload || undefined) as QuickPhraseTrigger
|
||||
openQuickPanel(trigger)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposeRootMenu()
|
||||
disposeTrigger()
|
||||
}
|
||||
}, [openQuickPanel, quickPanel, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user