mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-03 11:19:10 +08:00
Merge branch 'main' of github.com:CherryHQ/cherry-studio into wip/data-refactor
This commit is contained in:
commit
5cc7390bb6
7
.github/workflows/nightly-build.yml
vendored
7
.github/workflows/nightly-build.yml
vendored
@ -51,7 +51,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
@ -94,7 +94,6 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get install -y rpm
|
sudo apt-get install -y rpm
|
||||||
yarn build:npm linux
|
|
||||||
yarn build:linux
|
yarn build:linux
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@ -107,7 +106,6 @@ jobs:
|
|||||||
- name: Build Mac
|
- name: Build Mac
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
run: |
|
run: |
|
||||||
yarn build:npm mac
|
|
||||||
yarn build:mac
|
yarn build:mac
|
||||||
env:
|
env:
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
@ -125,7 +123,6 @@ jobs:
|
|||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
run: |
|
run: |
|
||||||
yarn build:npm windows
|
|
||||||
yarn build:win
|
yarn build:win
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@ -229,7 +226,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: all-artifacts
|
path: all-artifacts
|
||||||
merge-multiple: false
|
merge-multiple: false
|
||||||
|
|||||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@ -80,7 +80,6 @@ jobs:
|
|||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get install -y rpm
|
sudo apt-get install -y rpm
|
||||||
yarn build:npm linux
|
|
||||||
yarn build:linux
|
yarn build:linux
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@ -95,7 +94,6 @@ jobs:
|
|||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
run: |
|
run: |
|
||||||
sudo -H pip install setuptools
|
sudo -H pip install setuptools
|
||||||
yarn build:npm mac
|
|
||||||
yarn build:mac
|
yarn build:mac
|
||||||
env:
|
env:
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
@ -113,7 +111,6 @@ jobs:
|
|||||||
- name: Build Windows
|
- name: Build Windows
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
run: |
|
run: |
|
||||||
yarn build:npm windows
|
|
||||||
yarn build:win
|
yarn build:win
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -62,6 +62,7 @@ files:
|
|||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
- '**/*.{metal,exp,lib}'
|
- '**/*.{metal,exp,lib}'
|
||||||
|
- 'node_modules/@img/sharp-libvips-*/**'
|
||||||
extraResources:
|
extraResources:
|
||||||
- from: 'migrations/sqlite-drizzle'
|
- from: 'migrations/sqlite-drizzle'
|
||||||
to: 'migrations/sqlite-drizzle'
|
to: 'migrations/sqlite-drizzle'
|
||||||
@ -117,6 +118,7 @@ publish:
|
|||||||
url: https://releases.cherry-ai.com
|
url: https://releases.cherry-ai.com
|
||||||
electronDownload:
|
electronDownload:
|
||||||
mirror: https://npmmirror.com/mirrors/electron/
|
mirror: https://npmmirror.com/mirrors/electron/
|
||||||
|
beforePack: scripts/before-pack.js
|
||||||
afterPack: scripts/after-pack.js
|
afterPack: scripts/after-pack.js
|
||||||
afterSign: scripts/notarize.js
|
afterSign: scripts/notarize.js
|
||||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.5.8-rc.2",
|
"version": "1.5.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@ -40,7 +40,6 @@
|
|||||||
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
|
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
|
||||||
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
||||||
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
||||||
"build:npm": "node scripts/build-npm.js",
|
|
||||||
"release": "node scripts/version.js",
|
"release": "node scripts/version.js",
|
||||||
"publish": "yarn build:check && yarn release patch push",
|
"publish": "yarn build:check && yarn release patch push",
|
||||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||||
@ -232,7 +231,9 @@
|
|||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"google-auth-library": "^9.15.1",
|
"google-auth-library": "^9.15.1",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
|
"html-tags": "^5.1.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"htmlparser2": "^10.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
|
|||||||
@ -1,92 +1,10 @@
|
|||||||
const { Arch } = require('electron-builder')
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
exports.default = async function (context) {
|
exports.default = async function (context) {
|
||||||
const platform = context.packager.platform.name
|
const platform = context.packager.platform.name
|
||||||
const arch = context.arch
|
|
||||||
|
|
||||||
if (platform === 'mac') {
|
|
||||||
const node_modules_path = path.join(
|
|
||||||
context.appOutDir,
|
|
||||||
'Cherry Studio.app',
|
|
||||||
'Contents',
|
|
||||||
'Resources',
|
|
||||||
'app.asar.unpacked',
|
|
||||||
'node_modules'
|
|
||||||
)
|
|
||||||
|
|
||||||
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
|
|
||||||
|
|
||||||
keepPackageNodeFiles(
|
|
||||||
node_modules_path,
|
|
||||||
'@img',
|
|
||||||
arch === Arch.arm64
|
|
||||||
? ['sharp-darwin-arm64', 'sharp-libvips-darwin-arm64']
|
|
||||||
: ['sharp-darwin-x64', 'sharp-libvips-darwin-x64']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'linux') {
|
|
||||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
|
||||||
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
|
|
||||||
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
|
|
||||||
|
|
||||||
keepPackageNodeFiles(
|
|
||||||
node_modules_path,
|
|
||||||
'@img',
|
|
||||||
arch === Arch.arm64
|
|
||||||
? ['sharp-libvips-linux-arm64', 'sharp-linux-arm64']
|
|
||||||
: ['sharp-libvips-linux-x64', 'sharp-linux-x64']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'windows') {
|
|
||||||
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
|
|
||||||
if (arch === Arch.arm64) {
|
|
||||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
|
|
||||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
|
|
||||||
}
|
|
||||||
if (arch === Arch.x64) {
|
|
||||||
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
|
|
||||||
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
|
|
||||||
}
|
|
||||||
|
|
||||||
keepPackageNodeFiles(
|
|
||||||
node_modules_path,
|
|
||||||
'@img',
|
|
||||||
arch === Arch.arm64
|
|
||||||
? ['sharp-win32-arm64', 'sharp-libvips-win32-arm64']
|
|
||||||
: ['sharp-win32-x64', 'sharp-libvips-win32-x64']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'windows') {
|
if (platform === 'windows') {
|
||||||
fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true })
|
fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true })
|
||||||
fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true })
|
fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 使用指定架构的 node_modules 文件
|
|
||||||
* @param {*} nodeModulesPath
|
|
||||||
* @param {*} packageName
|
|
||||||
* @param {*} arch
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
|
|
||||||
const modulePath = path.join(nodeModulesPath, packageName)
|
|
||||||
|
|
||||||
if (!fs.existsSync(modulePath)) {
|
|
||||||
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirs = fs.readdirSync(modulePath)
|
|
||||||
dirs
|
|
||||||
.filter((dir) => !arch.includes(dir))
|
|
||||||
.forEach((dir) => {
|
|
||||||
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
|
|
||||||
console.log(`[After Pack] Removed dir: ${dir}`, arch)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
97
scripts/before-pack.js
Normal file
97
scripts/before-pack.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
const { Arch } = require('electron-builder')
|
||||||
|
const { downloadNpmPackage } = require('./utils')
|
||||||
|
|
||||||
|
// if you want to add new prebuild binaries packages with different architectures, you can add them here
|
||||||
|
// please add to allX64 and allArm64 from yarn.lock
|
||||||
|
const allArm64 = {
|
||||||
|
'@img/sharp-darwin-arm64': '0.34.3',
|
||||||
|
'@img/sharp-win32-arm64': '0.34.3',
|
||||||
|
'@img/sharp-linux-arm64': '0.34.3',
|
||||||
|
'@img/sharp-linuxmusl-arm64': '0.34.3',
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64': '1.2.0',
|
||||||
|
'@img/sharp-libvips-linux-arm64': '1.2.0',
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': '1.2.0',
|
||||||
|
|
||||||
|
'@libsql/darwin-arm64': '0.4.7',
|
||||||
|
'@libsql/linux-arm64-gnu': '0.4.7',
|
||||||
|
'@libsql/linux-arm64-musl': '0.4.7',
|
||||||
|
'@strongtz/win32-arm64-msvc': '0.4.7',
|
||||||
|
|
||||||
|
'@napi-rs/system-ocr-darwin-arm64': '1.0.2',
|
||||||
|
'@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
const allX64 = {
|
||||||
|
'@img/sharp-darwin-x64': '0.34.3',
|
||||||
|
'@img/sharp-linux-x64': '0.34.3',
|
||||||
|
'@img/sharp-linuxmusl-x64': '0.34.3',
|
||||||
|
'@img/sharp-win32-x64': '0.34.3',
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64': '1.2.0',
|
||||||
|
'@img/sharp-libvips-linux-x64': '1.2.0',
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': '1.2.0',
|
||||||
|
|
||||||
|
'@libsql/darwin-x64': '0.4.7',
|
||||||
|
'@libsql/linux-x64-gnu': '0.4.7',
|
||||||
|
'@libsql/linux-x64-musl': '0.4.7',
|
||||||
|
'@libsql/win32-x64-msvc': '0.4.7',
|
||||||
|
|
||||||
|
'@napi-rs/system-ocr-darwin-x64': '1.0.2',
|
||||||
|
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformToArch = {
|
||||||
|
mac: 'darwin',
|
||||||
|
windows: 'win32',
|
||||||
|
linux: 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.default = async function (context) {
|
||||||
|
const arch = context.arch
|
||||||
|
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
||||||
|
const platform = context.packager.platform.name
|
||||||
|
|
||||||
|
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
||||||
|
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
||||||
|
|
||||||
|
const downloadPackages = async (packages) => {
|
||||||
|
console.log('downloading packages ......')
|
||||||
|
const downloadPromises = []
|
||||||
|
|
||||||
|
for (const name of Object.keys(packages)) {
|
||||||
|
if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) {
|
||||||
|
downloadPromises.push(
|
||||||
|
downloadNpmPackage(
|
||||||
|
name,
|
||||||
|
`https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(downloadPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
|
||||||
|
await downloadPackages(packages)
|
||||||
|
// remove filters for the target architecture (allow inclusion)
|
||||||
|
|
||||||
|
let filters = context.packager.config.files[0].filter
|
||||||
|
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
||||||
|
// add filters for other architectures (exclude them)
|
||||||
|
filters.push(...filtersToExclude)
|
||||||
|
|
||||||
|
context.packager.config.files[0].filter = filters
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arch === Arch.arm64) {
|
||||||
|
await changeFilters(allArm64, x64Filters, arm64Filters)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arch === Arch.x64) {
|
||||||
|
await changeFilters(allX64, arm64Filters, x64Filters)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,88 +0,0 @@
|
|||||||
const { downloadNpmPackage } = require('./utils')
|
|
||||||
|
|
||||||
async function downloadNpm(platform) {
|
|
||||||
if (!platform || platform === 'mac') {
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@libsql/darwin-arm64',
|
|
||||||
'https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage('@libsql/darwin-x64', 'https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz')
|
|
||||||
|
|
||||||
// sharp for macOS
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-darwin-arm64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-darwin-x64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-libvips-darwin-arm64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-libvips-darwin-x64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!platform || platform === 'linux') {
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@libsql/linux-arm64-gnu',
|
|
||||||
'https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@libsql/linux-arm64-musl',
|
|
||||||
'https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@libsql/linux-x64-gnu',
|
|
||||||
'https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@libsql/linux-x64-musl',
|
|
||||||
'https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-libvips-linux-arm64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-libvips-linux-x64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!platform || platform === 'windows') {
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@libsql/win32-x64-msvc',
|
|
||||||
'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@strongtz/win32-arm64-msvc',
|
|
||||||
'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz'
|
|
||||||
)
|
|
||||||
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-win32-arm64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz'
|
|
||||||
)
|
|
||||||
downloadNpmPackage(
|
|
||||||
'@img/sharp-win32-x64',
|
|
||||||
'https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformArg = process.argv[2]
|
|
||||||
downloadNpm(platformArg)
|
|
||||||
@ -66,7 +66,7 @@ ${JSON.stringify({
|
|||||||
confirm: '确定要备份数据吗?',
|
confirm: '确定要备份数据吗?',
|
||||||
select_model: '选择模型',
|
select_model: '选择模型',
|
||||||
title: '文件',
|
title: '文件',
|
||||||
deeply_thought: '已深度思考(用时 {{secounds}} 秒)'
|
deeply_thought: '已深度思考(用时 {{seconds}} 秒)'
|
||||||
})}
|
})}
|
||||||
######################################################
|
######################################################
|
||||||
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
|
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
|
const zlib = require('zlib')
|
||||||
|
const tar = require('tar')
|
||||||
|
const { pipeline } = require('stream/promises')
|
||||||
|
|
||||||
function downloadNpmPackage(packageName, url) {
|
async function downloadNpmPackage(packageName, url) {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-'))
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-'))
|
||||||
|
|
||||||
const targetDir = path.join('./node_modules/', packageName)
|
const targetDir = path.join('./node_modules/', packageName)
|
||||||
const filename = packageName.replace('/', '-') + '.tgz'
|
const filename = path.join(tempDir, packageName.replace('/', '-') + '.tgz')
|
||||||
|
const extractDir = path.join(tempDir, 'extract')
|
||||||
|
|
||||||
// Skip if directory already exists
|
// Skip if directory already exists
|
||||||
if (fs.existsSync(targetDir)) {
|
if (fs.existsSync(targetDir)) {
|
||||||
@ -16,23 +19,44 @@ function downloadNpmPackage(packageName, url) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Downloading ${packageName}...`, url)
|
console.log(`Downloading ${packageName}...`, url)
|
||||||
const { execSync } = require('child_process')
|
|
||||||
execSync(`curl --fail -o ${filename} ${url}`)
|
// Download file using fetch API
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(filename)
|
||||||
|
await pipeline(response.body, fileStream)
|
||||||
|
|
||||||
console.log(`Extracting ${filename}...`)
|
console.log(`Extracting ${filename}...`)
|
||||||
execSync(`tar -xvf ${filename}`)
|
|
||||||
execSync(`rm -rf ${filename}`)
|
// Create extraction directory
|
||||||
execSync(`mkdir -p ${targetDir}`)
|
fs.mkdirSync(extractDir, { recursive: true })
|
||||||
execSync(`mv package/* ${targetDir}/`)
|
|
||||||
|
// Extract tar.gz file using Node.js streams
|
||||||
|
await pipeline(fs.createReadStream(filename), zlib.createGunzip(), tar.extract({ cwd: extractDir }))
|
||||||
|
|
||||||
|
// Remove the downloaded file
|
||||||
|
fs.rmSync(filename, { force: true })
|
||||||
|
|
||||||
|
// Create target directory
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
// Move extracted package contents to target directory
|
||||||
|
const packageDir = path.join(extractDir, 'package')
|
||||||
|
if (fs.existsSync(packageDir)) {
|
||||||
|
fs.cpSync(packageDir, targetDir, { recursive: true })
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing ${packageName}: ${error.message}`)
|
console.error(`Error processing ${packageName}: ${error.message}`)
|
||||||
if (fs.existsSync(filename)) {
|
|
||||||
fs.unlinkSync(filename)
|
|
||||||
}
|
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// Clean up temp directory
|
||||||
|
if (fs.existsSync(tempDir)) {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@ -323,7 +323,7 @@ class CodeToolsService {
|
|||||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
|
||||||
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
|
||||||
|
|
||||||
const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
|
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
|
||||||
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
|
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -499,6 +499,8 @@ export class WindowService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.setupWebContentsHandlers(this.miniWindow)
|
||||||
|
|
||||||
miniWindowState.manage(this.miniWindow)
|
miniWindowState.manage(this.miniWindow)
|
||||||
|
|
||||||
//miniWindow should show in current desktop
|
//miniWindow should show in current desktop
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { isLinux } from '@main/constant'
|
||||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||||
|
|
||||||
import { systemOcrService } from './builtin/SystemOcrService'
|
|
||||||
import { tesseractService } from './builtin/TesseractService'
|
import { tesseractService } from './builtin/TesseractService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('OcrService')
|
const logger = loggerService.withContext('OcrService')
|
||||||
@ -33,4 +33,8 @@ export const ocrService = new OcrService()
|
|||||||
|
|
||||||
// Register built-in providers
|
// Register built-in providers
|
||||||
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
||||||
ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
|
||||||
|
if (!isLinux) {
|
||||||
|
const { systemOcrService } = require('./builtin/SystemOcrService')
|
||||||
|
ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { isMac, isWin } from '@main/constant'
|
import { isLinux, isWin } from '@main/constant'
|
||||||
import { loadOcrImage } from '@main/utils/ocr'
|
import { loadOcrImage } from '@main/utils/ocr'
|
||||||
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
|
|
||||||
import {
|
import {
|
||||||
ImageFileMetadata,
|
ImageFileMetadata,
|
||||||
isImageFileMetadata as isImageFileMetadata,
|
isImageFileMetadata as isImageFileMetadata,
|
||||||
@ -15,12 +14,14 @@ import { OcrBaseService } from './OcrBaseService'
|
|||||||
export class SystemOcrService extends OcrBaseService {
|
export class SystemOcrService extends OcrBaseService {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
if (!isWin && !isMac) {
|
|
||||||
throw new Error('System OCR is only supported on Windows and macOS')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise<OcrResult> {
|
private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise<OcrResult> {
|
||||||
|
if (isLinux) {
|
||||||
|
return { text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { OcrAccuracy, recognize } = require('@napi-rs/system-ocr')
|
||||||
const buffer = await loadOcrImage(file)
|
const buffer = await loadOcrImage(file)
|
||||||
const langs = isWin ? options?.langs : undefined
|
const langs = isWin ? options?.langs : undefined
|
||||||
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
|
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { ImageFileMetadata } from '@types'
|
import { ImageFileMetadata } from '@types'
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import sharp from 'sharp'
|
|
||||||
|
|
||||||
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
|
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
|
||||||
|
const sharp = require('sharp')
|
||||||
return sharp(buffer)
|
return sharp(buffer)
|
||||||
.grayscale() // 转为灰度
|
.grayscale() // 转为灰度
|
||||||
.normalize()
|
.normalize()
|
||||||
|
|||||||
@ -60,6 +60,8 @@ import {
|
|||||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||||
import { Message } from '@renderer/types/newMessage'
|
import { Message } from '@renderer/types/newMessage'
|
||||||
import {
|
import {
|
||||||
|
OpenAIExtraBody,
|
||||||
|
OpenAIModality,
|
||||||
OpenAISdkMessageParam,
|
OpenAISdkMessageParam,
|
||||||
OpenAISdkParams,
|
OpenAISdkParams,
|
||||||
OpenAISdkRawChunk,
|
OpenAISdkRawChunk,
|
||||||
@ -564,7 +566,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
messages: OpenAISdkMessageParam[]
|
messages: OpenAISdkMessageParam[]
|
||||||
metadata: Record<string, any>
|
metadata: Record<string, any>
|
||||||
}> => {
|
}> => {
|
||||||
const { messages, mcpTools, maxTokens, enableWebSearch } = coreRequest
|
const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest
|
||||||
let { streamOutput } = coreRequest
|
let { streamOutput } = coreRequest
|
||||||
|
|
||||||
// Qwen3商业版(思考模式)、Qwen3开源版、QwQ、QVQ只支持流式输出。
|
// Qwen3商业版(思考模式)、Qwen3开源版、QwQ、QVQ只支持流式输出。
|
||||||
@ -572,18 +574,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
streamOutput = true
|
streamOutput = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const extra_body: Record<string, any> = {}
|
const extra_body: OpenAIExtraBody = {}
|
||||||
|
|
||||||
if (isQwenMTModel(model)) {
|
if (isQwenMTModel(model)) {
|
||||||
if (isTranslateAssistant(assistant)) {
|
if (isTranslateAssistant(assistant)) {
|
||||||
const targetLanguage = assistant.targetLanguage
|
const targetLanguage = mapLanguageToQwenMTModel(assistant.targetLanguage)
|
||||||
|
if (!targetLanguage) {
|
||||||
|
throw new Error(t('translate.error.not_supported', { language: assistant.targetLanguage.value }))
|
||||||
|
}
|
||||||
const translationOptions = {
|
const translationOptions = {
|
||||||
source_lang: 'auto',
|
source_lang: 'auto',
|
||||||
target_lang: mapLanguageToQwenMTModel(targetLanguage)
|
target_lang: targetLanguage
|
||||||
} as const
|
} as const
|
||||||
if (!translationOptions.target_lang) {
|
|
||||||
throw new Error(t('translate.error.not_supported', { language: targetLanguage.value }))
|
|
||||||
}
|
|
||||||
extra_body.translation_options = translationOptions
|
extra_body.translation_options = translationOptions
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t('translate.error.chat_qwen_mt'))
|
throw new Error(t('translate.error.chat_qwen_mt'))
|
||||||
@ -684,6 +686,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
reasoningEffort.reasoning_effort = 'low'
|
reasoningEffort.reasoning_effort = 'low'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modalities: {
|
||||||
|
modalities?: OpenAIModality[]
|
||||||
|
} = {}
|
||||||
|
// for openrouter generate image
|
||||||
|
// https://openrouter.ai/docs/features/multimodal/image-generation
|
||||||
|
if (enableGenerateImage && this.provider.id === SystemProviderIds.openrouter) {
|
||||||
|
modalities.modalities = ['image', 'text']
|
||||||
|
}
|
||||||
|
|
||||||
const commonParams: OpenAISdkParams = {
|
const commonParams: OpenAISdkParams = {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
messages:
|
messages:
|
||||||
@ -696,6 +707,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
tools: tools.length > 0 ? tools : undefined,
|
tools: tools.length > 0 ? tools : undefined,
|
||||||
stream: streamOutput,
|
stream: streamOutput,
|
||||||
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}),
|
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}),
|
||||||
|
...modalities,
|
||||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||||
...this.getProviderSpecificParameters(assistant, model),
|
...this.getProviderSpecificParameters(assistant, model),
|
||||||
@ -703,7 +715,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
|||||||
...getOpenAIWebSearchParams(model, enableWebSearch),
|
...getOpenAIWebSearchParams(model, enableWebSearch),
|
||||||
// OpenRouter usage tracking
|
// OpenRouter usage tracking
|
||||||
...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}),
|
...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}),
|
||||||
...(isQwenMTModel(model) ? extra_body : {}),
|
...extra_body,
|
||||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||||
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
|
||||||
|
|||||||
1
src/renderer/src/assets/images/apps/longcat.svg
Normal file
1
src/renderer/src/assets/images/apps/longcat.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="40" height="40" viewBox="0 0 40 40"><g><g><rect x="0" y="0" width="40" height="40" rx="10" fill="#FFFFFF" fill-opacity="1"/></g><g><g><path d="M3.7180590000000002,31.9163C3.24403,31.9163,2.9002264,31.4649,3.0262264,31.0079L9.07884,9.056280000000001C9.337579999999999,8.117891,10.43555,7.703758,11.24956,8.237532L19.2136,13.459869999999999C19.6909,13.77281,20.3081,13.77329,20.7859,13.46111L28.7837,8.234667C29.5984,7.702255,30.6956,8.11792,30.953,9.056519999999999L36.974,31.0089C37.0993,31.4656,36.7556,31.9163,36.2819,31.9163L28.5948,31.9163C29.9941,30.2961,30.7641,28.2266,30.7641,26.0857L30.7641,25.8358C30.7641,23.7417,30.0023,21.719,28.6209,20.1451L27.6344,15.19322C27.5764,14.90227,27.321,14.69275,27.0243,14.69275C26.8898,14.69275,26.7588,14.7364,26.6511,14.81715L22.9316,17.60681C22.6667,17.80548,22.3241,17.868740000000003,22.0057,17.77777C20.6944,17.403100000000002,19.3043,17.403100000000002,17.9929,17.77777C17.674599999999998,17.868740000000003,17.332,17.80548,17.0671,17.60681L13.3459,14.815909999999999C13.2393,14.73596,13.1096,14.69275,12.97639,14.69275C12.67948,14.69275,12.42483,14.90461,12.37083,15.196570000000001L11.41369,20.371000000000002C10.01719,21.7911,9.2346,23.703,9.2346,25.6947L9.2346,26.168C9.2346,28.2566,9.98171,30.2762,11.3409,31.8619L11.38755,31.9163L3.7180590000000002,31.9163Z" fill-rule="evenodd" fill="#29E154" fill-opacity="1"/></g><g><path d="M16.05224895477295,27.610614743041992L18.20519895477295,27.610614743041992L18.20519895477295,22.587064743041992L16.37845295477295,22.587064743041992L16.05224895477295,27.610614743041992ZM23.94638895477295,27.610614743041992L21.79344895477295,27.610614743041992L21.79344895477295,22.587064743041992L23.62018895477295,22.587064743041992L23.94638895477295,27.610614743041992Z" fill="#000000" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@ -202,7 +202,7 @@
|
|||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 10px 0;
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
@ -321,6 +321,10 @@ emoji-picker {
|
|||||||
--border-size: 0;
|
--border-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-wrapper + .block-wrapper {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.katex,
|
.katex,
|
||||||
mjx-container {
|
mjx-container {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@ -148,6 +148,12 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure drag handles and plus buttons remain interactive */
|
||||||
|
.drag-handle,
|
||||||
|
.plus-button {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show placeholder only when focused or when it's the only empty node */
|
/* Show placeholder only when focused or when it's the only empty node */
|
||||||
@ -471,6 +477,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom spacer to create viewport padding */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
height: 50px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code block wrapper and header styles
|
// Code block wrapper and header styles
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface CodeViewerProps {
|
|||||||
wrapped?: boolean
|
wrapped?: boolean
|
||||||
onHeightChange?: (scrollHeight: number) => void
|
onHeightChange?: (scrollHeight: number) => void
|
||||||
className?: string
|
className?: string
|
||||||
|
height?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,7 +26,7 @@ interface CodeViewerProps {
|
|||||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||||
* - 并发安全
|
* - 并发安全
|
||||||
*/
|
*/
|
||||||
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className }: CodeViewerProps) => {
|
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => {
|
||||||
const { codeShowLineNumbers, fontSize } = useSettings()
|
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||||
@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
|||||||
}, [rawLines.length, onHeightChange])
|
}, [rawLines.length, onHeightChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={shikiThemeRef}>
|
<div ref={shikiThemeRef} style={height ? { height } : undefined}>
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
className="shiki-scroller"
|
className="shiki-scroller"
|
||||||
$wrap={wrapped}
|
$wrap={wrapped}
|
||||||
$expanded={expanded}
|
$expanded={expanded}
|
||||||
$lineHeight={estimateSize()}
|
$lineHeight={estimateSize()}
|
||||||
|
$height={height}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--gutter-width': `${gutterDigits}ch`,
|
'--gutter-width': `${gutterDigits}ch`,
|
||||||
fontSize: `${fontSize - 1}px`,
|
fontSize: `${fontSize - 1}px`,
|
||||||
maxHeight: expanded ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
maxHeight: expanded ? undefined : height ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
||||||
|
height: height,
|
||||||
overflowY: expanded ? 'hidden' : 'auto'
|
overflowY: expanded ? 'hidden' : 'auto'
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}>
|
}>
|
||||||
@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{
|
|||||||
$wrap?: boolean
|
$wrap?: boolean
|
||||||
$expanded?: boolean
|
$expanded?: boolean
|
||||||
$lineHeight?: number
|
$lineHeight?: number
|
||||||
|
$height?: string | number
|
||||||
}>`
|
}>`
|
||||||
display: block;
|
display: block;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
66
src/renderer/src/components/Popups/GeneralPopup.tsx
Normal file
66
src/renderer/src/components/Popups/GeneralPopup.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { TopView } from '@renderer/components/TopView'
|
||||||
|
import { Modal, ModalProps } from 'antd'
|
||||||
|
import { ReactNode, useState } from 'react'
|
||||||
|
|
||||||
|
interface ShowParams extends ModalProps {
|
||||||
|
content: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends ShowParams {
|
||||||
|
resolve: (data: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupContainer: React.FC<Props> = ({ content, resolve, ...rest }) => {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
|
const onOk = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
resolve({})
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneralPopup.hide = onCancel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOk={onOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
afterClose={onClose}
|
||||||
|
transitionName="animation-move-down"
|
||||||
|
centered
|
||||||
|
{...rest}>
|
||||||
|
{content}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopViewKey = 'GeneralPopup'
|
||||||
|
|
||||||
|
/** 在这个 Popup 中展示任意内容 */
|
||||||
|
export default class GeneralPopup {
|
||||||
|
static topviewId = 0
|
||||||
|
static hide() {
|
||||||
|
TopView.hide(TopViewKey)
|
||||||
|
}
|
||||||
|
static show(props: ShowParams) {
|
||||||
|
return new Promise<any>((resolve) => {
|
||||||
|
TopView.show(
|
||||||
|
<PopupContainer
|
||||||
|
{...props}
|
||||||
|
resolve={(v) => {
|
||||||
|
resolve(v)
|
||||||
|
TopView.hide(TopViewKey)
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
TopViewKey
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -107,8 +107,7 @@ const PopupContainer: React.FC<Props> = ({
|
|||||||
onContentChange={handleContentChange}
|
onContentChange={handleContentChange}
|
||||||
onMarkdownChange={handleMarkdownChange}
|
onMarkdownChange={handleMarkdownChange}
|
||||||
onCommandsReady={handleCommandsReady}
|
onCommandsReady={handleCommandsReady}
|
||||||
minHeight={300}
|
minHeight={window.innerHeight * 0.7}
|
||||||
maxHeight={500}
|
|
||||||
isFullWidth={true}
|
isFullWidth={true}
|
||||||
className="rich-edit-popup-editor"
|
className="rich-edit-popup-editor"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const MermaidPreview = ({
|
|||||||
enableToolbar = false,
|
enableToolbar = false,
|
||||||
ref
|
ref
|
||||||
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
|
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
|
||||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError, forceRenderKey } = useMermaid()
|
||||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ const MermaidPreview = ({
|
|||||||
document.body.removeChild(measureEl)
|
document.body.removeChild(measureEl)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[diagramId, mermaid]
|
[diagramId, mermaid, forceRenderKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 可见性检测函数
|
// 可见性检测函数
|
||||||
|
|||||||
@ -60,7 +60,8 @@ describe('MermaidPreview', () => {
|
|||||||
mocks.useMermaid.mockReturnValue({
|
mocks.useMermaid.mockReturnValue({
|
||||||
mermaid: mockMermaid,
|
mermaid: mockMermaid,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null
|
error: null,
|
||||||
|
forceRenderKey: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
||||||
@ -116,7 +117,8 @@ describe('MermaidPreview', () => {
|
|||||||
mocks.useMermaid.mockReturnValue({
|
mocks.useMermaid.mockReturnValue({
|
||||||
mermaid: mockMermaid,
|
mermaid: mockMermaid,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null
|
error: null,
|
||||||
|
forceRenderKey: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
@ -145,7 +147,8 @@ describe('MermaidPreview', () => {
|
|||||||
mocks.useMermaid.mockReturnValue({
|
mocks.useMermaid.mockReturnValue({
|
||||||
mermaid: mockMermaid,
|
mermaid: mockMermaid,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: mermaidError
|
error: mermaidError,
|
||||||
|
forceRenderKey: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||||
@ -173,7 +176,8 @@ describe('MermaidPreview', () => {
|
|||||||
mocks.useMermaid.mockReturnValue({
|
mocks.useMermaid.mockReturnValue({
|
||||||
mermaid: mockMermaid,
|
mermaid: mockMermaid,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: mermaidError
|
error: mermaidError,
|
||||||
|
forceRenderKey: 0
|
||||||
})
|
})
|
||||||
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
|
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
|
||||||
|
|
||||||
|
|||||||
@ -61,15 +61,13 @@ export const ToolbarButton = styled.button<{
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'transparent')};
|
background: transparent;
|
||||||
color: ${({ $active, $disabled }) =>
|
|
||||||
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-white)' : 'var(--color-text)'};
|
|
||||||
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
|
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
flex-shrink: 0; /* 防止按钮收缩 */
|
flex-shrink: 0; /* 防止按钮收缩 */
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'var(--color-hover)')};
|
background: var(--color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import type { TFunction } from 'i18next'
|
import type { TFunction } from 'i18next'
|
||||||
import React, { useEffect, useState } from 'react'
|
import { LucideProps } from 'lucide-react'
|
||||||
|
import React, { ForwardRefExoticComponent, RefAttributes, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { getCommandsByGroup } from './command'
|
import { getCommandsByGroup } from './command'
|
||||||
@ -12,7 +13,7 @@ import type { FormattingCommand, FormattingState, ToolbarProps } from './types'
|
|||||||
interface ToolbarItemInternal {
|
interface ToolbarItemInternal {
|
||||||
id: string
|
id: string
|
||||||
command?: FormattingCommand
|
command?: FormattingCommand
|
||||||
icon?: React.ComponentType
|
icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
|
||||||
type?: 'divider'
|
type?: 'divider'
|
||||||
handler?: () => void
|
handler?: () => void
|
||||||
}
|
}
|
||||||
@ -170,7 +171,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({ editor, formattingState, onCom
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onClick={() => handleCommand(command)}
|
onClick={() => handleCommand(command)}
|
||||||
data-testid={`toolbar-${command}`}>
|
data-testid={`toolbar-${command}`}>
|
||||||
<Icon />
|
<Icon color={isActive ? 'var(--color-primary)' : 'var(--color-text)'} />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,7 @@ import {
|
|||||||
htmlToMarkdown,
|
htmlToMarkdown,
|
||||||
isMarkdownContent,
|
isMarkdownContent,
|
||||||
markdownToHtml,
|
markdownToHtml,
|
||||||
markdownToPreviewText,
|
markdownToPreviewText
|
||||||
markdownToSafeHtml,
|
|
||||||
sanitizeHtml
|
|
||||||
} from '@renderer/utils/markdownConverter'
|
} from '@renderer/utils/markdownConverter'
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||||
@ -135,7 +133,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
|
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
if (!markdown) return ''
|
if (!markdown) return ''
|
||||||
return markdownToSafeHtml(markdown)
|
return markdownToHtml(markdown)
|
||||||
}, [markdown])
|
}, [markdown])
|
||||||
|
|
||||||
const previewText = useMemo(() => {
|
const previewText = useMemo(() => {
|
||||||
@ -423,8 +421,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
|
|
||||||
onContentChange?.(content)
|
onContentChange?.(content)
|
||||||
if (onHtmlChange) {
|
if (onHtmlChange) {
|
||||||
const safeHtml = sanitizeHtml(htmlContent)
|
onHtmlChange(htmlContent)
|
||||||
onHtmlChange(safeHtml)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error converting HTML to markdown:', error as Error)
|
logger.error('Error converting HTML to markdown:', error as Error)
|
||||||
@ -502,7 +499,10 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
try {
|
try {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (editor && !editor.isDestroyed) {
|
if (editor && !editor.isDestroyed) {
|
||||||
editor.commands.focus('end')
|
const isLong = editor.getText().length > 2000
|
||||||
|
if (!isLong) {
|
||||||
|
editor.commands.focus('end')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -724,7 +724,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
setMarkdownState(content)
|
setMarkdownState(content)
|
||||||
onChange?.(content)
|
onChange?.(content)
|
||||||
|
|
||||||
const convertedHtml = markdownToSafeHtml(content)
|
const convertedHtml = markdownToHtml(content)
|
||||||
|
|
||||||
editor.commands.setContent(convertedHtml)
|
editor.commands.setContent(convertedHtml)
|
||||||
|
|
||||||
@ -771,7 +771,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
|||||||
|
|
||||||
const toSafeHtml = useCallback((content: string): string => {
|
const toSafeHtml = useCallback((content: string): string => {
|
||||||
try {
|
try {
|
||||||
return markdownToSafeHtml(content)
|
return markdownToHtml(content)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error converting markdown to safe HTML:', error as Error)
|
logger.error('Error converting markdown to safe HTML:', error as Error)
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
|
|||||||
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
|
import KimiAppLogo from '@renderer/assets/images/apps/kimi.webp?url'
|
||||||
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
|
import LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?url'
|
||||||
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
|
import LeChatLogo from '@renderer/assets/images/apps/lechat.png?url'
|
||||||
|
import LongCatAppLogo from '@renderer/assets/images/apps/longcat.svg?url'
|
||||||
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
|
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
|
||||||
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
|
import MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
|
||||||
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
|
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
|
||||||
@ -476,6 +477,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
style: {
|
style: {
|
||||||
padding: 5
|
padding: 5
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'longcat',
|
||||||
|
name: 'LongCat',
|
||||||
|
logo: LongCatAppLogo,
|
||||||
|
url: 'https://longcat.chat/',
|
||||||
|
bodered: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -166,242 +166,6 @@ import OpenAI from 'openai'
|
|||||||
|
|
||||||
import { getWebSearchTools } from './tools'
|
import { getWebSearchTools } from './tools'
|
||||||
|
|
||||||
// Vision models
|
|
||||||
const visionAllowedModels = [
|
|
||||||
'llava',
|
|
||||||
'moondream',
|
|
||||||
'minicpm',
|
|
||||||
'gemini-1\\.5',
|
|
||||||
'gemini-2\\.0',
|
|
||||||
'gemini-2\\.5',
|
|
||||||
'gemini-exp',
|
|
||||||
'claude-3',
|
|
||||||
'claude-sonnet-4',
|
|
||||||
'claude-opus-4',
|
|
||||||
'vision',
|
|
||||||
'glm-4(?:\\.\\d+)?v(?:-[\\w-]+)?',
|
|
||||||
'qwen-vl',
|
|
||||||
'qwen2-vl',
|
|
||||||
'qwen2.5-vl',
|
|
||||||
'qwen2.5-omni',
|
|
||||||
'qvq',
|
|
||||||
'internvl2',
|
|
||||||
'grok-vision-beta',
|
|
||||||
'grok-4(?:-[\\w-]+)?',
|
|
||||||
'pixtral',
|
|
||||||
'gpt-4(?:-[\\w-]+)',
|
|
||||||
'gpt-4.1(?:-[\\w-]+)?',
|
|
||||||
'gpt-4o(?:-[\\w-]+)?',
|
|
||||||
'gpt-4.5(?:-[\\w-]+)',
|
|
||||||
'gpt-5(?:-[\\w-]+)?',
|
|
||||||
'chatgpt-4o(?:-[\\w-]+)?',
|
|
||||||
'o1(?:-[\\w-]+)?',
|
|
||||||
'o3(?:-[\\w-]+)?',
|
|
||||||
'o4(?:-[\\w-]+)?',
|
|
||||||
'deepseek-vl(?:[\\w-]+)?',
|
|
||||||
'kimi-latest',
|
|
||||||
'gemma-3(?:-[\\w-]+)',
|
|
||||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
|
|
||||||
'kimi-thinking-preview',
|
|
||||||
`gemma3(?:[-:\\w]+)?`,
|
|
||||||
'kimi-vl-a3b-thinking(?:-[\\w-]+)?',
|
|
||||||
'llama-guard-4(?:-[\\w-]+)?',
|
|
||||||
'llama-4(?:-[\\w-]+)?',
|
|
||||||
'step-1o(?:.*vision)?',
|
|
||||||
'step-1v(?:-[\\w-]+)?'
|
|
||||||
]
|
|
||||||
|
|
||||||
const visionExcludedModels = [
|
|
||||||
'gpt-4-\\d+-preview',
|
|
||||||
'gpt-4-turbo-preview',
|
|
||||||
'gpt-4-32k',
|
|
||||||
'gpt-4-\\d+',
|
|
||||||
'o1-mini',
|
|
||||||
'o3-mini',
|
|
||||||
'o1-preview',
|
|
||||||
'AIDC-AI/Marco-o1'
|
|
||||||
]
|
|
||||||
export const VISION_REGEX = new RegExp(
|
|
||||||
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
|
|
||||||
'i'
|
|
||||||
)
|
|
||||||
|
|
||||||
// For middleware to identify models that must use the dedicated Image API
|
|
||||||
export const DEDICATED_IMAGE_MODELS = ['grok-2-image', 'dall-e-3', 'dall-e-2', 'gpt-image-1']
|
|
||||||
export const isDedicatedImageGenerationModel = (model: Model): boolean => {
|
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
|
||||||
return DEDICATED_IMAGE_MODELS.filter((m) => modelId.includes(m)).length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text to image models
|
|
||||||
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|image|gpt-image/i
|
|
||||||
|
|
||||||
// Reasoning models
|
|
||||||
export const REASONING_REGEX =
|
|
||||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
|
|
||||||
|
|
||||||
// Embedding models
|
|
||||||
export const EMBEDDING_REGEX =
|
|
||||||
/(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings|voyage-)/i
|
|
||||||
|
|
||||||
// Rerank models
|
|
||||||
export const RERANKING_REGEX = /(?:rerank|re-rank|re-ranker|re-ranking|retrieval|retriever)/i
|
|
||||||
|
|
||||||
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
|
|
||||||
|
|
||||||
// Tool calling models
|
|
||||||
export const FUNCTION_CALLING_MODELS = [
|
|
||||||
'gpt-4o',
|
|
||||||
'gpt-4o-mini',
|
|
||||||
'gpt-4',
|
|
||||||
'gpt-4.5',
|
|
||||||
'gpt-oss(?:-[\\w-]+)',
|
|
||||||
'gpt-5(?:-[0-9-]+)?',
|
|
||||||
'o(1|3|4)(?:-[\\w-]+)?',
|
|
||||||
'claude',
|
|
||||||
'qwen',
|
|
||||||
'qwen3',
|
|
||||||
'hunyuan',
|
|
||||||
'deepseek',
|
|
||||||
'glm-4(?:-[\\w-]+)?',
|
|
||||||
'glm-4.5(?:-[\\w-]+)?',
|
|
||||||
'learnlm(?:-[\\w-]+)?',
|
|
||||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
|
||||||
'grok-3(?:-[\\w-]+)?',
|
|
||||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
|
|
||||||
'kimi-k2(?:-[\\w-]+)?'
|
|
||||||
]
|
|
||||||
|
|
||||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
|
||||||
'aqa(?:-[\\w-]+)?',
|
|
||||||
'imagen(?:-[\\w-]+)?',
|
|
||||||
'o1-mini',
|
|
||||||
'o1-preview',
|
|
||||||
'AIDC-AI/Marco-o1',
|
|
||||||
'gemini-1(?:\\.[\\w-]+)?',
|
|
||||||
'qwen-mt(?:-[\\w-]+)?',
|
|
||||||
'gpt-5-chat(?:-[\\w-]+)?',
|
|
||||||
'glm-4\\.5v'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const FUNCTION_CALLING_REGEX = new RegExp(
|
|
||||||
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
|
|
||||||
'i'
|
|
||||||
)
|
|
||||||
|
|
||||||
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
|
||||||
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
|
|
||||||
'i'
|
|
||||||
)
|
|
||||||
|
|
||||||
// 模型类型到支持的reasoning_effort的映射表
|
|
||||||
// TODO: refactor this. too many identical options
|
|
||||||
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
|
||||||
default: ['low', 'medium', 'high'] as const,
|
|
||||||
o: ['low', 'medium', 'high'] as const,
|
|
||||||
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
|
||||||
grok: ['low', 'high'] as const,
|
|
||||||
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
|
||||||
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
|
||||||
qwen: ['low', 'medium', 'high'] as const,
|
|
||||||
qwen_thinking: ['low', 'medium', 'high'] as const,
|
|
||||||
doubao: ['auto', 'high'] as const,
|
|
||||||
doubao_no_auto: ['high'] as const,
|
|
||||||
hunyuan: ['auto'] as const,
|
|
||||||
zhipu: ['auto'] as const,
|
|
||||||
perplexity: ['low', 'medium', 'high'] as const,
|
|
||||||
deepseek_hybrid: ['auto'] as const
|
|
||||||
} as const
|
|
||||||
|
|
||||||
// 模型类型到支持选项的映射表
|
|
||||||
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
|
||||||
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
|
||||||
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
|
||||||
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
|
||||||
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
|
||||||
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
|
||||||
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
|
||||||
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
|
||||||
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
|
|
||||||
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
|
||||||
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
|
||||||
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
|
||||||
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
|
||||||
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
|
||||||
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
|
||||||
let thinkingModelType: ThinkingModelType = 'default'
|
|
||||||
if (isGPT5SeriesModel(model)) {
|
|
||||||
thinkingModelType = 'gpt5'
|
|
||||||
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
|
||||||
thinkingModelType = 'o'
|
|
||||||
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
|
||||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
|
||||||
thinkingModelType = 'gemini'
|
|
||||||
} else {
|
|
||||||
thinkingModelType = 'gemini_pro'
|
|
||||||
}
|
|
||||||
} else if (isSupportedReasoningEffortGrokModel(model)) thinkingModelType = 'grok'
|
|
||||||
else if (isSupportedThinkingTokenQwenModel(model)) {
|
|
||||||
if (isQwenAlwaysThinkModel(model)) {
|
|
||||||
thinkingModelType = 'qwen_thinking'
|
|
||||||
}
|
|
||||||
thinkingModelType = 'qwen'
|
|
||||||
} else if (isSupportedThinkingTokenDoubaoModel(model)) {
|
|
||||||
if (isDoubaoThinkingAutoModel(model)) {
|
|
||||||
thinkingModelType = 'doubao'
|
|
||||||
} else {
|
|
||||||
thinkingModelType = 'doubao_no_auto'
|
|
||||||
}
|
|
||||||
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
|
|
||||||
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
|
|
||||||
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
|
|
||||||
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
|
|
||||||
return thinkingModelType
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFunctionCallingModel(model?: Model): boolean {
|
|
||||||
if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelId = getLowerBaseModelName(model.id)
|
|
||||||
|
|
||||||
if (isUserSelectedModelType(model, 'function_calling') !== undefined) {
|
|
||||||
return isUserSelectedModelType(model, 'function_calling')!
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.provider === 'qiniu') {
|
|
||||||
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(modelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.provider === 'doubao' || modelId.includes('doubao')) {
|
|
||||||
return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['deepseek', 'anthropic', 'kimi', 'moonshot'].includes(model.provider)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2025/08/26 百炼与火山引擎均不支持 v3.1 函数调用
|
|
||||||
// 先默认支持
|
|
||||||
if (isDeepSeekHybridInferenceModel(model)) {
|
|
||||||
if (isSystemProviderId(model.provider)) {
|
|
||||||
switch (model.provider) {
|
|
||||||
case 'dashscope':
|
|
||||||
case 'doubao':
|
|
||||||
// case 'nvidia': // nvidia api 太烂了 测不了能不能用 先假设能用
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return FUNCTION_CALLING_REGEX.test(modelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModelLogo(modelId: string) {
|
export function getModelLogo(modelId: string) {
|
||||||
const isLight = true
|
const isLight = true
|
||||||
|
|
||||||
@ -2317,107 +2081,293 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEXT_TO_IMAGES_MODELS = [
|
// Vision models
|
||||||
{
|
const visionAllowedModels = [
|
||||||
id: 'Kwai-Kolors/Kolors',
|
'llava',
|
||||||
provider: 'silicon',
|
'moondream',
|
||||||
name: 'Kolors',
|
'minicpm',
|
||||||
group: 'Kwai-Kolors'
|
'gemini-1\\.5',
|
||||||
|
'gemini-2\\.0',
|
||||||
|
'gemini-2\\.5',
|
||||||
|
'gemini-exp',
|
||||||
|
'claude-3',
|
||||||
|
'claude-sonnet-4',
|
||||||
|
'claude-opus-4',
|
||||||
|
'vision',
|
||||||
|
'glm-4(?:\\.\\d+)?v(?:-[\\w-]+)?',
|
||||||
|
'qwen-vl',
|
||||||
|
'qwen2-vl',
|
||||||
|
'qwen2.5-vl',
|
||||||
|
'qwen2.5-omni',
|
||||||
|
'qvq',
|
||||||
|
'internvl2',
|
||||||
|
'grok-vision-beta',
|
||||||
|
'grok-4(?:-[\\w-]+)?',
|
||||||
|
'pixtral',
|
||||||
|
'gpt-4(?:-[\\w-]+)',
|
||||||
|
'gpt-4.1(?:-[\\w-]+)?',
|
||||||
|
'gpt-4o(?:-[\\w-]+)?',
|
||||||
|
'gpt-4.5(?:-[\\w-]+)',
|
||||||
|
'gpt-5(?:-[\\w-]+)?',
|
||||||
|
'chatgpt-4o(?:-[\\w-]+)?',
|
||||||
|
'o1(?:-[\\w-]+)?',
|
||||||
|
'o3(?:-[\\w-]+)?',
|
||||||
|
'o4(?:-[\\w-]+)?',
|
||||||
|
'deepseek-vl(?:[\\w-]+)?',
|
||||||
|
'kimi-latest',
|
||||||
|
'gemma-3(?:-[\\w-]+)',
|
||||||
|
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
|
||||||
|
'kimi-thinking-preview',
|
||||||
|
`gemma3(?:[-:\\w]+)?`,
|
||||||
|
'kimi-vl-a3b-thinking(?:-[\\w-]+)?',
|
||||||
|
'llama-guard-4(?:-[\\w-]+)?',
|
||||||
|
'llama-4(?:-[\\w-]+)?',
|
||||||
|
'step-1o(?:.*vision)?',
|
||||||
|
'step-1v(?:-[\\w-]+)?'
|
||||||
|
]
|
||||||
|
|
||||||
|
const visionExcludedModels = [
|
||||||
|
'gpt-4-\\d+-preview',
|
||||||
|
'gpt-4-turbo-preview',
|
||||||
|
'gpt-4-32k',
|
||||||
|
'gpt-4-\\d+',
|
||||||
|
'o1-mini',
|
||||||
|
'o3-mini',
|
||||||
|
'o1-preview',
|
||||||
|
'AIDC-AI/Marco-o1'
|
||||||
|
]
|
||||||
|
export const VISION_REGEX = new RegExp(
|
||||||
|
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
|
||||||
|
'i'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Text to image models
|
||||||
|
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|image|gpt-image/i
|
||||||
|
|
||||||
|
// Reasoning models
|
||||||
|
export const REASONING_REGEX =
|
||||||
|
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*|.*\bgrok-(?:3-mini|4)(?:-[\w-]+)?\b.*)$/i
|
||||||
|
|
||||||
|
// Embedding models
|
||||||
|
export const EMBEDDING_REGEX =
|
||||||
|
/(?:^text-|embed|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings|voyage-)/i
|
||||||
|
|
||||||
|
// Rerank models
|
||||||
|
export const RERANKING_REGEX = /(?:rerank|re-rank|re-ranker|re-ranking|retrieval|retriever)/i
|
||||||
|
|
||||||
|
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
|
||||||
|
|
||||||
|
// Tool calling models
|
||||||
|
export const FUNCTION_CALLING_MODELS = [
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
'gpt-4',
|
||||||
|
'gpt-4.5',
|
||||||
|
'gpt-oss(?:-[\\w-]+)',
|
||||||
|
'gpt-5(?:-[0-9-]+)?',
|
||||||
|
'o(1|3|4)(?:-[\\w-]+)?',
|
||||||
|
'claude',
|
||||||
|
'qwen',
|
||||||
|
'qwen3',
|
||||||
|
'hunyuan',
|
||||||
|
'deepseek',
|
||||||
|
'glm-4(?:-[\\w-]+)?',
|
||||||
|
'glm-4.5(?:-[\\w-]+)?',
|
||||||
|
'learnlm(?:-[\\w-]+)?',
|
||||||
|
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||||
|
'grok-3(?:-[\\w-]+)?',
|
||||||
|
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
|
||||||
|
'kimi-k2(?:-[\\w-]+)?'
|
||||||
|
]
|
||||||
|
|
||||||
|
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||||
|
'aqa(?:-[\\w-]+)?',
|
||||||
|
'imagen(?:-[\\w-]+)?',
|
||||||
|
'o1-mini',
|
||||||
|
'o1-preview',
|
||||||
|
'AIDC-AI/Marco-o1',
|
||||||
|
'gemini-1(?:\\.[\\w-]+)?',
|
||||||
|
'qwen-mt(?:-[\\w-]+)?',
|
||||||
|
'gpt-5-chat(?:-[\\w-]+)?',
|
||||||
|
'glm-4\\.5v'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FUNCTION_CALLING_REGEX = new RegExp(
|
||||||
|
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
|
||||||
|
'i'
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||||
|
`\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-sonnet-4(?:-[\\w-]+)?|claude-opus-4(?:-[\\w-]+)?)\\b`,
|
||||||
|
'i'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 模型类型到支持的reasoning_effort的映射表
|
||||||
|
// TODO: refactor this. too many identical options
|
||||||
|
export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
|
||||||
|
default: ['low', 'medium', 'high'] as const,
|
||||||
|
o: ['low', 'medium', 'high'] as const,
|
||||||
|
gpt5: ['minimal', 'low', 'medium', 'high'] as const,
|
||||||
|
grok: ['low', 'high'] as const,
|
||||||
|
gemini: ['low', 'medium', 'high', 'auto'] as const,
|
||||||
|
gemini_pro: ['low', 'medium', 'high', 'auto'] as const,
|
||||||
|
qwen: ['low', 'medium', 'high'] as const,
|
||||||
|
qwen_thinking: ['low', 'medium', 'high'] as const,
|
||||||
|
doubao: ['auto', 'high'] as const,
|
||||||
|
doubao_no_auto: ['high'] as const,
|
||||||
|
hunyuan: ['auto'] as const,
|
||||||
|
zhipu: ['auto'] as const,
|
||||||
|
perplexity: ['low', 'medium', 'high'] as const,
|
||||||
|
deepseek_hybrid: ['auto'] as const
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 模型类型到支持选项的映射表
|
||||||
|
export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
|
||||||
|
default: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.default] as const,
|
||||||
|
o: MODEL_SUPPORTED_REASONING_EFFORT.o,
|
||||||
|
gpt5: [...MODEL_SUPPORTED_REASONING_EFFORT.gpt5] as const,
|
||||||
|
grok: MODEL_SUPPORTED_REASONING_EFFORT.grok,
|
||||||
|
gemini: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini] as const,
|
||||||
|
gemini_pro: MODEL_SUPPORTED_REASONING_EFFORT.gemini_pro,
|
||||||
|
qwen: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.qwen] as const,
|
||||||
|
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
|
||||||
|
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
|
||||||
|
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
|
||||||
|
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
|
||||||
|
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
|
||||||
|
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
|
||||||
|
deepseek_hybrid: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.deepseek_hybrid] as const
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const getThinkModelType = (model: Model): ThinkingModelType => {
|
||||||
|
let thinkingModelType: ThinkingModelType = 'default'
|
||||||
|
if (isGPT5SeriesModel(model)) {
|
||||||
|
thinkingModelType = 'gpt5'
|
||||||
|
} else if (isSupportedReasoningEffortOpenAIModel(model)) {
|
||||||
|
thinkingModelType = 'o'
|
||||||
|
} else if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||||
|
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||||
|
thinkingModelType = 'gemini'
|
||||||
|
} else {
|
||||||
|
thinkingModelType = 'gemini_pro'
|
||||||
|
}
|
||||||
|
} else if (isSupportedReasoningEffortGrokModel(model)) thinkingModelType = 'grok'
|
||||||
|
else if (isSupportedThinkingTokenQwenModel(model)) {
|
||||||
|
if (isQwenAlwaysThinkModel(model)) {
|
||||||
|
thinkingModelType = 'qwen_thinking'
|
||||||
|
}
|
||||||
|
thinkingModelType = 'qwen'
|
||||||
|
} else if (isSupportedThinkingTokenDoubaoModel(model)) {
|
||||||
|
if (isDoubaoThinkingAutoModel(model)) {
|
||||||
|
thinkingModelType = 'doubao'
|
||||||
|
} else {
|
||||||
|
thinkingModelType = 'doubao_no_auto'
|
||||||
|
}
|
||||||
|
} else if (isSupportedThinkingTokenHunyuanModel(model)) thinkingModelType = 'hunyuan'
|
||||||
|
else if (isSupportedReasoningEffortPerplexityModel(model)) thinkingModelType = 'perplexity'
|
||||||
|
else if (isSupportedThinkingTokenZhipuModel(model)) thinkingModelType = 'zhipu'
|
||||||
|
else if (isDeepSeekHybridInferenceModel(model)) thinkingModelType = 'deepseek_hybrid'
|
||||||
|
return thinkingModelType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunctionCallingModel(model?: Model): boolean {
|
||||||
|
if (!model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model)) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// id: 'black-forest-labs/FLUX.1-schnell',
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'FLUX.1 Schnell',
|
if (isUserSelectedModelType(model, 'function_calling') !== undefined) {
|
||||||
// group: 'FLUX'
|
return isUserSelectedModelType(model, 'function_calling')!
|
||||||
// },
|
}
|
||||||
// {
|
|
||||||
// id: 'black-forest-labs/FLUX.1-dev',
|
if (model.provider === 'qiniu') {
|
||||||
// provider: 'silicon',
|
return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(modelId)
|
||||||
// name: 'FLUX.1 Dev',
|
}
|
||||||
// group: 'FLUX'
|
|
||||||
// },
|
if (model.provider === 'doubao' || modelId.includes('doubao')) {
|
||||||
// {
|
return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name)
|
||||||
// id: 'black-forest-labs/FLUX.1-pro',
|
}
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'FLUX.1 Pro',
|
if (['deepseek', 'anthropic', 'kimi', 'moonshot'].includes(model.provider)) {
|
||||||
// group: 'FLUX'
|
return true
|
||||||
// },
|
}
|
||||||
// {
|
|
||||||
// id: 'Pro/black-forest-labs/FLUX.1-schnell',
|
// 2025/08/26 百炼与火山引擎均不支持 v3.1 函数调用
|
||||||
// provider: 'silicon',
|
// 先默认支持
|
||||||
// name: 'FLUX.1 Schnell Pro',
|
if (isDeepSeekHybridInferenceModel(model)) {
|
||||||
// group: 'FLUX'
|
if (isSystemProviderId(model.provider)) {
|
||||||
// },
|
switch (model.provider) {
|
||||||
// {
|
case 'dashscope':
|
||||||
// id: 'LoRA/black-forest-labs/FLUX.1-dev',
|
case 'doubao':
|
||||||
// provider: 'silicon',
|
// case 'nvidia': // nvidia api 太烂了 测不了能不能用 先假设能用
|
||||||
// name: 'FLUX.1 Dev LoRA',
|
return false
|
||||||
// group: 'FLUX'
|
}
|
||||||
// },
|
}
|
||||||
// {
|
return true
|
||||||
// id: 'deepseek-ai/Janus-Pro-7B',
|
}
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'Janus-Pro-7B',
|
return FUNCTION_CALLING_REGEX.test(modelId)
|
||||||
// group: 'deepseek-ai'
|
}
|
||||||
// },
|
|
||||||
// {
|
// For middleware to identify models that must use the dedicated Image API
|
||||||
// id: 'stabilityai/stable-diffusion-3-5-large',
|
export const DEDICATED_IMAGE_MODELS = [
|
||||||
// provider: 'silicon',
|
'grok-2-image',
|
||||||
// name: 'Stable Diffusion 3.5 Large',
|
'grok-2-image-1212',
|
||||||
// group: 'Stable Diffusion'
|
'grok-2-image-latest',
|
||||||
// },
|
'dall-e-3',
|
||||||
// {
|
'dall-e-2',
|
||||||
// id: 'stabilityai/stable-diffusion-3-5-large-turbo',
|
'gpt-image-1'
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'Stable Diffusion 3.5 Large Turbo',
|
|
||||||
// group: 'Stable Diffusion'
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'stabilityai/stable-diffusion-3-medium',
|
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'Stable Diffusion 3 Medium',
|
|
||||||
// group: 'Stable Diffusion'
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'stabilityai/stable-diffusion-2-1',
|
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'Stable Diffusion 2.1',
|
|
||||||
// group: 'Stable Diffusion'
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'stabilityai/stable-diffusion-xl-base-1.0',
|
|
||||||
// provider: 'silicon',
|
|
||||||
// name: 'Stable Diffusion XL Base 1.0',
|
|
||||||
// group: 'Stable Diffusion'
|
|
||||||
// }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
export const OPENAI_IMAGE_GENERATION_MODELS = [
|
||||||
'stabilityai/stable-diffusion-2-1',
|
'o3',
|
||||||
'stabilityai/stable-diffusion-xl-base-1.0'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
|
||||||
'gemini-2.0-flash-exp',
|
|
||||||
'gpt-4o',
|
'gpt-4o',
|
||||||
'gpt-4o-mini',
|
'gpt-4o-mini',
|
||||||
'gpt-4.1',
|
'gpt-4.1',
|
||||||
'gpt-4.1-mini',
|
'gpt-4.1-mini',
|
||||||
'gpt-4.1-nano',
|
'gpt-4.1-nano',
|
||||||
'o3'
|
'gpt-5',
|
||||||
|
'gpt-image-1'
|
||||||
]
|
]
|
||||||
|
|
||||||
export const GENERATE_IMAGE_MODELS = [
|
export const GENERATE_IMAGE_MODELS = [
|
||||||
|
'gemini-2.0-flash-exp',
|
||||||
'gemini-2.0-flash-exp-image-generation',
|
'gemini-2.0-flash-exp-image-generation',
|
||||||
'gemini-2.0-flash-preview-image-generation',
|
'gemini-2.0-flash-preview-image-generation',
|
||||||
'gemini-2.5-flash-image-preview',
|
'gemini-2.5-flash-image-preview',
|
||||||
'grok-2-image-1212',
|
...DEDICATED_IMAGE_MODELS
|
||||||
'grok-2-image',
|
|
||||||
'grok-2-image-latest',
|
|
||||||
'gpt-image-1',
|
|
||||||
...SUPPORTED_DISABLE_GENERATION_MODELS
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const isDedicatedImageGenerationModel = (model: Model): boolean => {
|
||||||
|
const modelId = getLowerBaseModelName(model.id)
|
||||||
|
return DEDICATED_IMAGE_MODELS.filter((m) => modelId.includes(m)).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGenerateImageModel(model: Model): boolean {
|
||||||
|
if (!model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProviderByModel(model)
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmbeddingModel(model)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = getLowerBaseModelName(model.id, '/')
|
||||||
|
|
||||||
|
if (provider && provider.type === 'openai-response') {
|
||||||
|
return OPENAI_IMAGE_GENERATION_MODELS.some((imageModel) => modelId.includes(imageModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
return GENERATE_IMAGE_MODELS.some((imageModel) => modelId.includes(imageModel))
|
||||||
|
}
|
||||||
|
|
||||||
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
||||||
|
|
||||||
export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini']
|
export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini']
|
||||||
@ -2584,9 +2534,9 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
|||||||
|
|
||||||
// Specifically for DeepSeek V3.1. White list for now
|
// Specifically for DeepSeek V3.1. White list for now
|
||||||
if (isDeepSeekHybridInferenceModel(model)) {
|
if (isDeepSeekHybridInferenceModel(model)) {
|
||||||
return (['openrouter', 'dashscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]).some(
|
return (
|
||||||
(id) => id === model.provider
|
['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]
|
||||||
)
|
).some((id) => id === model.provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -3027,38 +2977,6 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
|
|||||||
return isOpenAIWebSearchChatCompletionOnlyModel(model) || modelId.includes('sonar')
|
return isOpenAIWebSearchChatCompletionOnlyModel(model) || modelId.includes('sonar')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGenerateImageModel(model: Model): boolean {
|
|
||||||
if (!model) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = getProviderByModel(model)
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEmbedding = isEmbeddingModel(model)
|
|
||||||
|
|
||||||
if (isEmbedding) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelId = getLowerBaseModelName(model.id, '/')
|
|
||||||
if (GENERATE_IMAGE_MODELS.includes(modelId)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSupportedDisableGenerationModel(model: Model): boolean {
|
|
||||||
if (!model) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getLowerBaseModelName(model.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
|
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
|
||||||
if (!isEnableWebSearch) {
|
if (!isEnableWebSearch) {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export const useMermaid = () => {
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [forceRenderKey, setForceRenderKey] = useState(0)
|
||||||
|
|
||||||
// 初始化 mermaid 并监听主题变化
|
// 初始化 mermaid 并监听主题变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -51,6 +52,7 @@ export const useMermaid = () => {
|
|||||||
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setForceRenderKey((prev) => prev + 1)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid')
|
setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid')
|
||||||
@ -71,6 +73,7 @@ export const useMermaid = () => {
|
|||||||
return {
|
return {
|
||||||
mermaid: mermaidModule,
|
mermaid: mermaidModule,
|
||||||
isLoading,
|
isLoading,
|
||||||
error
|
error,
|
||||||
|
forceRenderKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
setLaunchToTray,
|
setLaunchToTray,
|
||||||
setPinTopicsToTop,
|
setPinTopicsToTop,
|
||||||
setSendMessageShortcut as _setSendMessageShortcut,
|
setSendMessageShortcut as _setSendMessageShortcut,
|
||||||
setShowTokens,
|
|
||||||
setSidebarIcons,
|
setSidebarIcons,
|
||||||
setTargetLanguage,
|
setTargetLanguage,
|
||||||
setTestChannel as _setTestChannel,
|
setTestChannel as _setTestChannel,
|
||||||
@ -98,9 +97,6 @@ export function useSettings() {
|
|||||||
},
|
},
|
||||||
setAssistantIconType(assistantIconType: AssistantIconType) {
|
setAssistantIconType(assistantIconType: AssistantIconType) {
|
||||||
dispatch(setAssistantIconType(assistantIconType))
|
dispatch(setAssistantIconType(assistantIconType))
|
||||||
},
|
|
||||||
setShowTokens(showTokens: boolean) {
|
|
||||||
dispatch(setShowTokens(showTokens))
|
|
||||||
}
|
}
|
||||||
// setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) {
|
// setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) {
|
||||||
// dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration))
|
// dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration))
|
||||||
|
|||||||
@ -677,6 +677,7 @@
|
|||||||
"model_placeholder": "Select the model to use",
|
"model_placeholder": "Select the model to use",
|
||||||
"model_required": "Please select a model",
|
"model_required": "Please select a model",
|
||||||
"select_folder": "Select Folder",
|
"select_folder": "Select Folder",
|
||||||
|
"supported_providers": "Supported Providers",
|
||||||
"title": "Code Tools",
|
"title": "Code Tools",
|
||||||
"update_options": "Update Options",
|
"update_options": "Update Options",
|
||||||
"working_directory": "Working Directory"
|
"working_directory": "Working Directory"
|
||||||
@ -820,6 +821,10 @@
|
|||||||
"devtools": "Open debug panel",
|
"devtools": "Open debug panel",
|
||||||
"message": "It seems that something went wrong...",
|
"message": "It seems that something went wrong...",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"details": "Details",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "Invalid MCP server"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@ -1315,7 +1320,8 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"content": "Deleting a group message will delete the user's question and all assistant's answers",
|
"content": "Deleting a group message will delete the user's question and all assistant's answers",
|
||||||
"title": "Delete Group Message"
|
"title": "Delete Group Message"
|
||||||
}
|
},
|
||||||
|
"retry_failed": "Retry failed messages"
|
||||||
},
|
},
|
||||||
"ignore": {
|
"ignore": {
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
@ -3342,6 +3348,8 @@
|
|||||||
"label": "Grid detail trigger"
|
"label": "Grid detail trigger"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"confirm_delete_message": "Confirm before deleting messages",
|
||||||
|
"confirm_regenerate_message": "Confirm before regenerating messages",
|
||||||
"enable_quick_triggers": "Enable / and @ triggers",
|
"enable_quick_triggers": "Enable / and @ triggers",
|
||||||
"paste_long_text_as_file": "Paste long text as file",
|
"paste_long_text_as_file": "Paste long text as file",
|
||||||
"paste_long_text_threshold": "Paste long text length",
|
"paste_long_text_threshold": "Paste long text length",
|
||||||
|
|||||||
@ -677,6 +677,7 @@
|
|||||||
"model_placeholder": "使用するモデルを選択してください",
|
"model_placeholder": "使用するモデルを選択してください",
|
||||||
"model_required": "モデルを選択してください",
|
"model_required": "モデルを選択してください",
|
||||||
"select_folder": "フォルダを選択",
|
"select_folder": "フォルダを選択",
|
||||||
|
"supported_providers": "サポートされているプロバイダー",
|
||||||
"title": "コードツール",
|
"title": "コードツール",
|
||||||
"update_options": "更新オプション",
|
"update_options": "更新オプション",
|
||||||
"working_directory": "作業ディレクトリ"
|
"working_directory": "作業ディレクトリ"
|
||||||
@ -820,6 +821,10 @@
|
|||||||
"devtools": "デバッグパネルを開く",
|
"devtools": "デバッグパネルを開く",
|
||||||
"message": "何か問題が発生したようです...",
|
"message": "何か問題が発生したようです...",
|
||||||
"reload": "再読み込み"
|
"reload": "再読み込み"
|
||||||
|
},
|
||||||
|
"details": "詳細情報",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "無効なMCPサーバー"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@ -1315,7 +1320,8 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
|
"content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
|
||||||
"title": "分組メッセージを削除"
|
"title": "分組メッセージを削除"
|
||||||
}
|
},
|
||||||
|
"retry_failed": "エラーになったメッセージを再試行"
|
||||||
},
|
},
|
||||||
"ignore": {
|
"ignore": {
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
@ -3342,6 +3348,8 @@
|
|||||||
"label": "グリッド詳細トリガー"
|
"label": "グリッド詳細トリガー"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"confirm_delete_message": "メッセージ削除前に確認",
|
||||||
|
"confirm_regenerate_message": "メッセージ再生成前に確認",
|
||||||
"enable_quick_triggers": "/ と @ を有効にしてクイックメニューを表示します。",
|
"enable_quick_triggers": "/ と @ を有効にしてクイックメニューを表示します。",
|
||||||
"paste_long_text_as_file": "長いテキストをファイルとして貼り付け",
|
"paste_long_text_as_file": "長いテキストをファイルとして貼り付け",
|
||||||
"paste_long_text_threshold": "長いテキストの長さ",
|
"paste_long_text_threshold": "長いテキストの長さ",
|
||||||
|
|||||||
@ -677,6 +677,7 @@
|
|||||||
"model_placeholder": "Выберите модель для использования",
|
"model_placeholder": "Выберите модель для использования",
|
||||||
"model_required": "Пожалуйста, выберите модель",
|
"model_required": "Пожалуйста, выберите модель",
|
||||||
"select_folder": "Выберите папку",
|
"select_folder": "Выберите папку",
|
||||||
|
"supported_providers": "Поддерживаемые поставщики",
|
||||||
"title": "Инструменты кода",
|
"title": "Инструменты кода",
|
||||||
"update_options": "Параметры обновления",
|
"update_options": "Параметры обновления",
|
||||||
"working_directory": "Рабочая директория"
|
"working_directory": "Рабочая директория"
|
||||||
@ -820,6 +821,10 @@
|
|||||||
"devtools": "Открыть панель отладки",
|
"devtools": "Открыть панель отладки",
|
||||||
"message": "Похоже, возникла какая-то проблема...",
|
"message": "Похоже, возникла какая-то проблема...",
|
||||||
"reload": "Перезагрузить"
|
"reload": "Перезагрузить"
|
||||||
|
},
|
||||||
|
"details": "Подробности",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "Недействительный сервер MCP"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@ -1315,7 +1320,8 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
|
"content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
|
||||||
"title": "Удалить группу сообщений"
|
"title": "Удалить группу сообщений"
|
||||||
}
|
},
|
||||||
|
"retry_failed": "Повторить неудавшиеся сообщения"
|
||||||
},
|
},
|
||||||
"ignore": {
|
"ignore": {
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
@ -3342,6 +3348,8 @@
|
|||||||
"label": "Триггер для отображения подробной информации в сетке"
|
"label": "Триггер для отображения подробной информации в сетке"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"confirm_delete_message": "Подтверждать перед удалением сообщений",
|
||||||
|
"confirm_regenerate_message": "Подтверждать перед пересозданием сообщений",
|
||||||
"enable_quick_triggers": "Включите / и @, чтобы вызвать быстрое меню.",
|
"enable_quick_triggers": "Включите / и @, чтобы вызвать быстрое меню.",
|
||||||
"paste_long_text_as_file": "Вставлять длинный текст как файл",
|
"paste_long_text_as_file": "Вставлять длинный текст как файл",
|
||||||
"paste_long_text_threshold": "Длина вставки длинного текста",
|
"paste_long_text_threshold": "Длина вставки длинного текста",
|
||||||
|
|||||||
@ -677,6 +677,7 @@
|
|||||||
"model_placeholder": "选择要使用的模型",
|
"model_placeholder": "选择要使用的模型",
|
||||||
"model_required": "请选择模型",
|
"model_required": "请选择模型",
|
||||||
"select_folder": "选择文件夹",
|
"select_folder": "选择文件夹",
|
||||||
|
"supported_providers": "支持的服务商",
|
||||||
"title": "代码工具",
|
"title": "代码工具",
|
||||||
"update_options": "更新选项",
|
"update_options": "更新选项",
|
||||||
"working_directory": "工作目录"
|
"working_directory": "工作目录"
|
||||||
@ -820,6 +821,10 @@
|
|||||||
"devtools": "打开调试面板",
|
"devtools": "打开调试面板",
|
||||||
"message": "似乎出现了一些问题...",
|
"message": "似乎出现了一些问题...",
|
||||||
"reload": "重新加载"
|
"reload": "重新加载"
|
||||||
|
},
|
||||||
|
"details": "详细信息",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "无效的MCP服务器"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@ -1315,7 +1320,8 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"content": "删除分组消息会删除用户提问和所有助手的回答",
|
"content": "删除分组消息会删除用户提问和所有助手的回答",
|
||||||
"title": "删除分组消息"
|
"title": "删除分组消息"
|
||||||
}
|
},
|
||||||
|
"retry_failed": "重试出错的消息"
|
||||||
},
|
},
|
||||||
"ignore": {
|
"ignore": {
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
@ -3342,6 +3348,8 @@
|
|||||||
"label": "网格详情触发"
|
"label": "网格详情触发"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"confirm_delete_message": "删除消息前确认",
|
||||||
|
"confirm_regenerate_message": "重新生成消息前确认",
|
||||||
"enable_quick_triggers": "启用 / 和 @ 触发快捷菜单",
|
"enable_quick_triggers": "启用 / 和 @ 触发快捷菜单",
|
||||||
"paste_long_text_as_file": "长文本粘贴为文件",
|
"paste_long_text_as_file": "长文本粘贴为文件",
|
||||||
"paste_long_text_threshold": "长文本长度",
|
"paste_long_text_threshold": "长文本长度",
|
||||||
|
|||||||
@ -677,6 +677,7 @@
|
|||||||
"model_placeholder": "選擇要使用的模型",
|
"model_placeholder": "選擇要使用的模型",
|
||||||
"model_required": "請選擇模型",
|
"model_required": "請選擇模型",
|
||||||
"select_folder": "選擇資料夾",
|
"select_folder": "選擇資料夾",
|
||||||
|
"supported_providers": "支援的供應商",
|
||||||
"title": "程式碼工具",
|
"title": "程式碼工具",
|
||||||
"update_options": "更新選項",
|
"update_options": "更新選項",
|
||||||
"working_directory": "工作目錄"
|
"working_directory": "工作目錄"
|
||||||
@ -820,6 +821,10 @@
|
|||||||
"devtools": "打開除錯面板",
|
"devtools": "打開除錯面板",
|
||||||
"message": "似乎出現了一些問題...",
|
"message": "似乎出現了一些問題...",
|
||||||
"reload": "重新載入"
|
"reload": "重新載入"
|
||||||
|
},
|
||||||
|
"details": "詳細信息",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "無效的MCP伺服器"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@ -1315,7 +1320,8 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"content": "刪除分組訊息會刪除使用者提問和所有助手的回答",
|
"content": "刪除分組訊息會刪除使用者提問和所有助手的回答",
|
||||||
"title": "刪除分組訊息"
|
"title": "刪除分組訊息"
|
||||||
}
|
},
|
||||||
|
"retry_failed": "重試出錯的訊息"
|
||||||
},
|
},
|
||||||
"ignore": {
|
"ignore": {
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
@ -3342,6 +3348,8 @@
|
|||||||
"label": "網格詳細資訊觸發"
|
"label": "網格詳細資訊觸發"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"confirm_delete_message": "刪除訊息前確認",
|
||||||
|
"confirm_regenerate_message": "重新生成訊息前確認",
|
||||||
"enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
|
"enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
|
||||||
"paste_long_text_as_file": "將長文字貼上為檔案",
|
"paste_long_text_as_file": "將長文字貼上為檔案",
|
||||||
"paste_long_text_threshold": "長文字長度",
|
"paste_long_text_threshold": "長文字長度",
|
||||||
|
|||||||
@ -288,7 +288,7 @@
|
|||||||
"placeholder": "Αναζήτηση"
|
"placeholder": "Αναζήτηση"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeply_thought": "Έχει βαθιά σκεφτεί (χρήση {{secounds}} δευτερόλεπτα)",
|
"deeply_thought": "Έχει βαθιά σκεφτεί (χρήση {{seconds}} δευτερόλεπτα)",
|
||||||
"default": {
|
"default": {
|
||||||
"description": "Γεια σου, είμαι ο προεπαγγελματικός βοηθός. Μπορείς να ξεκινήσεις να μου μιλάς αμέσως.",
|
"description": "Γεια σου, είμαι ο προεπαγγελματικός βοηθός. Μπορείς να ξεκινήσεις να μου μιλάς αμέσως.",
|
||||||
"name": "Προεπαγγελματικός βοηθός",
|
"name": "Προεπαγγελματικός βοηθός",
|
||||||
@ -820,6 +820,10 @@
|
|||||||
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
|
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
|
||||||
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
|
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
|
||||||
"reload": "Επαναφόρτωση"
|
"reload": "Επαναφόρτωση"
|
||||||
|
},
|
||||||
|
"details": "Λεπτομέρειες",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "Μη έγκυρος διακομιστής MCP"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
|
|||||||
@ -288,7 +288,7 @@
|
|||||||
"placeholder": "Buscar"
|
"placeholder": "Buscar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeply_thought": "Profundamente pensado (tomó {{secounds}} segundos)",
|
"deeply_thought": "Profundamente pensado (tomó {{seconds}} segundos)",
|
||||||
"default": {
|
"default": {
|
||||||
"description": "Hola, soy el asistente predeterminado. Puedes comenzar a conversar conmigo de inmediato.",
|
"description": "Hola, soy el asistente predeterminado. Puedes comenzar a conversar conmigo de inmediato.",
|
||||||
"name": "Asistente predeterminado",
|
"name": "Asistente predeterminado",
|
||||||
@ -820,6 +820,10 @@
|
|||||||
"devtools": "Abrir el panel de depuración",
|
"devtools": "Abrir el panel de depuración",
|
||||||
"message": "Parece que ha surgido un problema...",
|
"message": "Parece que ha surgido un problema...",
|
||||||
"reload": "Recargar"
|
"reload": "Recargar"
|
||||||
|
},
|
||||||
|
"details": "Detalles",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "Servidor MCP no válido"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
|
|||||||
@ -288,7 +288,7 @@
|
|||||||
"placeholder": "Rechercher"
|
"placeholder": "Rechercher"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeply_thought": "Profondément réfléchi ({{secounds}} secondes)",
|
"deeply_thought": "Profondément réfléchi ({{seconds}} secondes)",
|
||||||
"default": {
|
"default": {
|
||||||
"description": "Bonjour, je suis l'assistant par défaut. Vous pouvez commencer à discuter avec moi tout de suite.",
|
"description": "Bonjour, je suis l'assistant par défaut. Vous pouvez commencer à discuter avec moi tout de suite.",
|
||||||
"name": "Assistant par défaut",
|
"name": "Assistant par défaut",
|
||||||
@ -820,6 +820,10 @@
|
|||||||
"devtools": "Ouvrir le panneau de débogage",
|
"devtools": "Ouvrir le panneau de débogage",
|
||||||
"message": "Il semble que quelques problèmes soient survenus...",
|
"message": "Il semble que quelques problèmes soient survenus...",
|
||||||
"reload": "Recharger"
|
"reload": "Recharger"
|
||||||
|
},
|
||||||
|
"details": "Détails",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "Serveur MCP invalide"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
|
|||||||
@ -288,7 +288,7 @@
|
|||||||
"placeholder": "Pesquisar"
|
"placeholder": "Pesquisar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deeply_thought": "Profundamente pensado (demorou {{secounds}} segundos)",
|
"deeply_thought": "Profundamente pensado (demorou {{seconds}} segundos)",
|
||||||
"default": {
|
"default": {
|
||||||
"description": "Olá, eu sou o assistente padrão. Você pode começar a conversar comigo agora.",
|
"description": "Olá, eu sou o assistente padrão. Você pode começar a conversar comigo agora.",
|
||||||
"name": "Assistente Padrão",
|
"name": "Assistente Padrão",
|
||||||
@ -820,6 +820,10 @@
|
|||||||
"devtools": "Abrir o painel de depuração",
|
"devtools": "Abrir o painel de depuração",
|
||||||
"message": "Parece que ocorreu um problema...",
|
"message": "Parece que ocorreu um problema...",
|
||||||
"reload": "Recarregar"
|
"reload": "Recarregar"
|
||||||
|
},
|
||||||
|
"details": "Detalhes",
|
||||||
|
"mcp": {
|
||||||
|
"invalid": "Servidor MCP inválido"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
|
|||||||
@ -2,19 +2,22 @@ import AiProvider from '@renderer/aiCore'
|
|||||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||||
import ModelSelector from '@renderer/components/ModelSelector'
|
import ModelSelector from '@renderer/components/ModelSelector'
|
||||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||||
|
import { getProviderLogo } from '@renderer/config/providers'
|
||||||
import { useCodeTools } from '@renderer/hooks/useCodeTools'
|
import { useCodeTools } from '@renderer/hooks/useCodeTools'
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
|
import { getProviderLabel } from '@renderer/i18n/label'
|
||||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||||
import { loggerService } from '@renderer/services/LoggerService'
|
import { loggerService } from '@renderer/services/LoggerService'
|
||||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||||
import { Model } from '@renderer/types'
|
import { Model } from '@renderer/types'
|
||||||
import { Alert, Button, Checkbox, Input, Select, Space } from 'antd'
|
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space } from 'antd'
|
||||||
import { Download, Terminal, X } from 'lucide-react'
|
import { ArrowUpRight, Download, HelpCircle, Terminal, X } from 'lucide-react'
|
||||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -22,6 +25,7 @@ import {
|
|||||||
CLI_TOOL_PROVIDER_MAP,
|
CLI_TOOL_PROVIDER_MAP,
|
||||||
CLI_TOOLS,
|
CLI_TOOLS,
|
||||||
generateToolEnvironment,
|
generateToolEnvironment,
|
||||||
|
getClaudeSupportedProviders,
|
||||||
parseEnvironmentVariables
|
parseEnvironmentVariables
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
@ -30,6 +34,7 @@ const logger = loggerService.withContext('CodeToolsPage')
|
|||||||
const CodeToolsPage: FC = () => {
|
const CodeToolsPage: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { providers } = useProviders()
|
const { providers } = useProviders()
|
||||||
|
const allProviders = useAllProviders()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
|
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
|
||||||
const {
|
const {
|
||||||
@ -258,7 +263,35 @@ const CodeToolsPage: FC = () => {
|
|||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem>
|
<SettingsItem>
|
||||||
<div className="settings-label">{t('code.model')}</div>
|
<div className="settings-label">
|
||||||
|
{t('code.model')}
|
||||||
|
{selectedCliTool === 'claude-code' && (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div style={{ width: 200 }}>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('code.supported_providers')}</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{getClaudeSupportedProviders(allProviders).map((provider) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={provider.id}
|
||||||
|
style={{ color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
to={`/settings/provider?id=${provider.id}`}>
|
||||||
|
<ProviderLogo shape="square" src={getProviderLogo(provider.id)} size={20} />
|
||||||
|
{getProviderLabel(provider.id)}
|
||||||
|
<ArrowUpRight size={14} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
trigger="hover"
|
||||||
|
placement="right">
|
||||||
|
<HelpCircle size={14} style={{ color: 'var(--color-text-3)', cursor: 'pointer' }} />
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
providers={availableProviders}
|
providers={availableProviders}
|
||||||
predicate={modelPredicate}
|
predicate={modelPredicate}
|
||||||
@ -395,4 +428,8 @@ const BunInstallAlert = styled.div`
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const ProviderLogo = styled(Avatar)`
|
||||||
|
border-radius: 4px;
|
||||||
|
`
|
||||||
|
|
||||||
export default CodeToolsPage
|
export default CodeToolsPage
|
||||||
|
|||||||
@ -16,14 +16,14 @@ export interface ToolEnvironmentConfig {
|
|||||||
|
|
||||||
// CLI 工具选项
|
// CLI 工具选项
|
||||||
export const CLI_TOOLS = [
|
export const CLI_TOOLS = [
|
||||||
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
|
||||||
{ value: codeTools.claudeCode, label: 'Claude Code' },
|
{ value: codeTools.claudeCode, label: 'Claude Code' },
|
||||||
|
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
||||||
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
|
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
|
||||||
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' }
|
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
|
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
|
||||||
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu']
|
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
|
||||||
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
|
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
|
||||||
|
|
||||||
// Provider 过滤映射
|
// Provider 过滤映射
|
||||||
@ -57,6 +57,16 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
|||||||
anthropic: {
|
anthropic: {
|
||||||
api_base_url: 'https://open.bigmodel.cn/api/anthropic'
|
api_base_url: 'https://open.bigmodel.cn/api/anthropic'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dashscope: {
|
||||||
|
anthropic: {
|
||||||
|
api_base_url: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modelscope: {
|
||||||
|
anthropic: {
|
||||||
|
api_base_url: 'https://api-inference.modelscope.cn'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,4 +142,8 @@ export const generateToolEnvironment = ({
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getClaudeSupportedProviders = (providers: Provider[]) => {
|
||||||
|
return providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id))
|
||||||
|
}
|
||||||
|
|
||||||
export { default } from './CodeToolsPage'
|
export { default } from './CodeToolsPage'
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { Assistant, Topic } from '@renderer/types'
|
|||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Flex } from 'antd'
|
import { Flex } from 'antd'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import React, { FC, useState } from 'react'
|
import React, { FC, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
@ -44,7 +45,6 @@ const Chat: FC<Props> = (props) => {
|
|||||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||||
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
||||||
|
|
||||||
const maxWidth = useChatMaxWidth()
|
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
useHotkeys('esc', () => {
|
useHotkeys('esc', () => {
|
||||||
@ -132,7 +132,7 @@ const Chat: FC<Props> = (props) => {
|
|||||||
vertical
|
vertical
|
||||||
flex={1}
|
flex={1}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
style={{ maxWidth, height: mainHeight }}>
|
style={{ maxWidth: '100%', height: mainHeight }}>
|
||||||
<Messages
|
<Messages
|
||||||
key={props.activeTopic.id}
|
key={props.activeTopic.id}
|
||||||
assistant={assistant}
|
assistant={assistant}
|
||||||
@ -154,15 +154,24 @@ const Chat: FC<Props> = (props) => {
|
|||||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||||
</QuickPanelProvider>
|
</QuickPanelProvider>
|
||||||
</Main>
|
</Main>
|
||||||
{topicPosition === 'right' && showTopics && (
|
<AnimatePresence initial={false}>
|
||||||
<Tabs
|
{topicPosition === 'right' && showTopics && (
|
||||||
activeAssistant={assistant}
|
<motion.div
|
||||||
activeTopic={props.activeTopic}
|
initial={{ width: 0, opacity: 0 }}
|
||||||
setActiveAssistant={props.setActiveAssistant}
|
animate={{ width: 'auto', opacity: 1 }}
|
||||||
setActiveTopic={props.setActiveTopic}
|
exit={{ width: 0, opacity: 0 }}
|
||||||
position="right"
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
/>
|
style={{ overflow: 'hidden' }}>
|
||||||
)}
|
<Tabs
|
||||||
|
activeAssistant={assistant}
|
||||||
|
activeTopic={props.activeTopic}
|
||||||
|
setActiveAssistant={props.setActiveAssistant}
|
||||||
|
setActiveTopic={props.setActiveTopic}
|
||||||
|
position="right"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { Assistant, Topic } from '@renderer/types'
|
|||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -80,11 +81,19 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
|||||||
</NavbarIcon>
|
</NavbarIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!showAssistants && (
|
<AnimatePresence initial={false}>
|
||||||
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
|
{!showAssistants && (
|
||||||
<Menu size={18} />
|
<motion.div
|
||||||
</NavbarIcon>
|
initial={{ width: 0, opacity: 0 }}
|
||||||
)}
|
animate={{ width: 'auto', opacity: 1 }}
|
||||||
|
exit={{ width: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}>
|
||||||
|
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
|
||||||
|
<Menu size={18} />
|
||||||
|
</NavbarIcon>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<SelectModelButton assistant={assistant} />
|
<SelectModelButton assistant={assistant} />
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack alignItems="center" gap={8}>
|
<HStack alignItems="center" gap={8}>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import NavigationService from '@renderer/services/NavigationService'
|
|||||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||||
import { useDispatch } from 'react-redux'
|
import { useDispatch } from 'react-redux'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
@ -101,17 +102,26 @@ const HomePage: FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>
|
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>
|
||||||
{showAssistants && (
|
<AnimatePresence initial={false}>
|
||||||
<ErrorBoundary>
|
{showAssistants && (
|
||||||
<HomeTabs
|
<ErrorBoundary>
|
||||||
activeAssistant={activeAssistant}
|
<motion.div
|
||||||
activeTopic={activeTopic}
|
initial={{ width: 0, opacity: 0 }}
|
||||||
setActiveAssistant={setActiveAssistant}
|
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
|
||||||
setActiveTopic={setActiveTopic}
|
exit={{ width: 0, opacity: 0 }}
|
||||||
position="left"
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
/>
|
style={{ overflow: 'hidden' }}>
|
||||||
</ErrorBoundary>
|
<HomeTabs
|
||||||
)}
|
activeAssistant={activeAssistant}
|
||||||
|
activeTopic={activeTopic}
|
||||||
|
setActiveAssistant={setActiveAssistant}
|
||||||
|
setActiveTopic={setActiveTopic}
|
||||||
|
position="left"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Chat
|
<Chat
|
||||||
assistant={activeAssistant}
|
assistant={activeAssistant}
|
||||||
|
|||||||
@ -3,10 +3,10 @@ import { loggerService } from '@logger'
|
|||||||
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||||
import TranslateButton from '@renderer/components/TranslateButton'
|
import TranslateButton from '@renderer/components/TranslateButton'
|
||||||
import {
|
import {
|
||||||
|
isDedicatedImageGenerationModel,
|
||||||
isGenerateImageModel,
|
isGenerateImageModel,
|
||||||
isGenerateImageModels,
|
isGenerateImageModels,
|
||||||
isMandatoryWebSearchModel,
|
isMandatoryWebSearchModel,
|
||||||
isSupportedDisableGenerationModel,
|
|
||||||
isSupportedReasoningEffortModel,
|
isSupportedReasoningEffortModel,
|
||||||
isSupportedThinkingTokenModel,
|
isSupportedThinkingTokenModel,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
@ -210,7 +210,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sendMessage = useCallback(async () => {
|
const sendMessage = useCallback(async () => {
|
||||||
if (inputEmpty || loading) {
|
if (inputEmpty) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (checkRateLimit(assistant)) {
|
if (checkRateLimit(assistant)) {
|
||||||
@ -258,7 +258,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
logger.warn('Failed to send message:', error as Error)
|
logger.warn('Failed to send message:', error as Error)
|
||||||
parent?.recordException(error as Error)
|
parent?.recordException(error as Error)
|
||||||
}
|
}
|
||||||
}, [assistant, dispatch, files, inputEmpty, loading, mentionedModels, resizeTextArea, setTimeoutTimer, text, topic])
|
}, [assistant, dispatch, files, inputEmpty, mentionedModels, resizeTextArea, setTimeoutTimer, text, topic])
|
||||||
|
|
||||||
const translate = useCallback(async () => {
|
const translate = useCallback(async () => {
|
||||||
if (isTranslating) {
|
if (isTranslating) {
|
||||||
@ -783,7 +783,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {
|
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {
|
||||||
updateAssistant({ ...assistant, enableGenerateImage: false })
|
updateAssistant({ ...assistant, enableGenerateImage: false })
|
||||||
}
|
}
|
||||||
if (isGenerateImageModel(model) && !assistant.enableGenerateImage && !isSupportedDisableGenerationModel(model)) {
|
if (isDedicatedImageGenerationModel(model) && !assistant.enableGenerateImage) {
|
||||||
updateAssistant({ ...assistant, enableGenerateImage: true })
|
updateAssistant({ ...assistant, enableGenerateImage: true })
|
||||||
}
|
}
|
||||||
}, [assistant, model, updateAssistant])
|
}, [assistant, model, updateAssistant])
|
||||||
@ -927,6 +927,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
onClick={onNewContext}
|
onClick={onNewContext}
|
||||||
/>
|
/>
|
||||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||||
|
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
|
||||||
{loading && (
|
{loading && (
|
||||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2 }}>
|
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2 }}>
|
||||||
@ -934,7 +935,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!loading && <SendMessageButton sendMessage={sendMessage} disabled={loading || inputEmpty} />}
|
|
||||||
</ToolbarMenu>
|
</ToolbarMenu>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</InputBarContainer>
|
</InputBarContainer>
|
||||||
|
|||||||
@ -7,32 +7,42 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
block: ImageMessageBlock
|
block: ImageMessageBlock
|
||||||
|
isSingle?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageBlock: React.FC<Props> = ({ block }) => {
|
const ImageBlock: React.FC<Props> = ({ block, isSingle = false }) => {
|
||||||
if (block.status === MessageBlockStatus.PENDING) return <Skeleton.Image active style={{ width: 200, height: 200 }} />
|
if (block.status === MessageBlockStatus.PENDING) {
|
||||||
|
return <Skeleton.Image active style={{ width: 200, height: 200 }} />
|
||||||
|
}
|
||||||
|
|
||||||
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
|
if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
|
||||||
const images = block.metadata?.generateImageResponse?.images?.length
|
const images = block.metadata?.generateImageResponse?.images?.length
|
||||||
? block.metadata?.generateImageResponse?.images
|
? block.metadata?.generateImageResponse?.images
|
||||||
: block?.file
|
: block?.file
|
||||||
? [`file://${FileManager.getFilePath(block?.file)}`]
|
? [`file://${FileManager.getFilePath(block?.file)}`]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
{images.map((src, index) => (
|
{images.map((src, index) => (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={src}
|
src={src}
|
||||||
key={`image-${index}`}
|
key={`image-${index}`}
|
||||||
style={{ maxWidth: 500, maxHeight: 'min(500px, 50vh)', padding: 0, borderRadius: 8 }}
|
style={
|
||||||
|
isSingle
|
||||||
|
? { maxWidth: 500, maxHeight: 'min(500px, 50vh)', padding: 0, borderRadius: 8 }
|
||||||
|
: { width: 280, height: 280, objectFit: 'cover', padding: 0, borderRadius: 8 }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
} else return null
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
`
|
`
|
||||||
export default React.memo(ImageBlock)
|
export default React.memo(ImageBlock)
|
||||||
|
|||||||
@ -87,11 +87,20 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
|||||||
{groupedBlocks.map((block) => {
|
{groupedBlocks.map((block) => {
|
||||||
if (Array.isArray(block)) {
|
if (Array.isArray(block)) {
|
||||||
const groupKey = block.map((imageBlock) => imageBlock.id).join('-')
|
const groupKey = block.map((imageBlock) => imageBlock.id).join('-')
|
||||||
|
// 单张图片不使用 ImageBlockGroup 包装
|
||||||
|
if (block.length === 1) {
|
||||||
|
return (
|
||||||
|
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
||||||
|
<ImageBlock key={block[0].id} block={block[0] as ImageMessageBlock} isSingle={true} />
|
||||||
|
</AnimatedBlockWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 多张图片使用 ImageBlockGroup 包装
|
||||||
return (
|
return (
|
||||||
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
||||||
<ImageBlockGroup count={block.length}>
|
<ImageBlockGroup count={block.length}>
|
||||||
{block.map((imageBlock) => (
|
{block.map((imageBlock) => (
|
||||||
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} />
|
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} isSingle={false} />
|
||||||
))}
|
))}
|
||||||
</ImageBlockGroup>
|
</ImageBlockGroup>
|
||||||
</AnimatedBlockWrapper>
|
</AnimatedBlockWrapper>
|
||||||
@ -166,8 +175,8 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
|||||||
export default React.memo(MessageBlockRenderer)
|
export default React.memo(MessageBlockRenderer)
|
||||||
|
|
||||||
const ImageBlockGroup = styled.div<{ count: number }>`
|
const ImageBlockGroup = styled.div<{ count: number }>`
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(${({ count }) => Math.min(count, 3)}, minmax(200px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
max-width: 960px;
|
max-width: 100%;
|
||||||
`
|
`
|
||||||
|
|||||||
@ -60,7 +60,6 @@ const MessageItem: FC<Props> = ({
|
|||||||
index,
|
index,
|
||||||
hideMenuBar = false,
|
hideMenuBar = false,
|
||||||
isGrouped,
|
isGrouped,
|
||||||
isStreaming = false,
|
|
||||||
onUpdateUseful,
|
onUpdateUseful,
|
||||||
isGroupContextMessage
|
isGroupContextMessage
|
||||||
}) => {
|
}) => {
|
||||||
@ -116,7 +115,7 @@ const MessageItem: FC<Props> = ({
|
|||||||
|
|
||||||
const isLastMessage = index === 0 || !!isGrouped
|
const isLastMessage = index === 0 || !!isGrouped
|
||||||
const isAssistantMessage = message.role === 'assistant'
|
const isAssistantMessage = message.role === 'assistant'
|
||||||
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
|
const showMenubar = !hideMenuBar && !isEditing
|
||||||
|
|
||||||
const messageHighlightHandler = useCallback(
|
const messageHighlightHandler = useCallback(
|
||||||
(highlight: boolean = true) => {
|
(highlight: boolean = true) => {
|
||||||
|
|||||||
@ -3,13 +3,17 @@ import {
|
|||||||
ColumnWidthOutlined,
|
ColumnWidthOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
NumberOutlined
|
NumberOutlined,
|
||||||
|
ReloadOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||||
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||||
import type { Topic } from '@renderer/types'
|
import type { Topic } from '@renderer/types'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
|
import { AssistantMessageStatus } from '@renderer/types/newMessage'
|
||||||
|
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||||
import { Button, Tooltip } from 'antd'
|
import { Button, Tooltip } from 'antd'
|
||||||
import { FC, memo } from 'react'
|
import { FC, memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -36,7 +40,8 @@ const MessageGroupMenuBar: FC<Props> = ({
|
|||||||
topic
|
topic
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { deleteGroupMessages } = useMessageOperations(topic)
|
const { deleteGroupMessages, regenerateAssistantMessage } = useMessageOperations(topic)
|
||||||
|
const { assistant } = useAssistant(messages[0]?.assistantId)
|
||||||
|
|
||||||
const handleDeleteGroup = async () => {
|
const handleDeleteGroup = async () => {
|
||||||
const askId = messages[0]?.askId
|
const askId = messages[0]?.askId
|
||||||
@ -54,6 +59,39 @@ const MessageGroupMenuBar: FC<Props> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFailedMessage = (m: Message) => {
|
||||||
|
if (m.role !== 'assistant') return false
|
||||||
|
const isError = (m.status || '').toLowerCase() === 'error'
|
||||||
|
const content = getMainTextContent(m)
|
||||||
|
const noContent = !content || content.trim().length === 0
|
||||||
|
const noBlocks = !m.blocks || m.blocks.length === 0
|
||||||
|
return isError || noContent || noBlocks
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTransmittingMessage = (m: Message) => {
|
||||||
|
if (m.role !== 'assistant') return false
|
||||||
|
const status = m.status as AssistantMessageStatus
|
||||||
|
return (
|
||||||
|
status === AssistantMessageStatus.PROCESSING ||
|
||||||
|
status === AssistantMessageStatus.PENDING ||
|
||||||
|
status === AssistantMessageStatus.SEARCHING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFailedMessages = messages.some((m) => isFailedMessage(m) && !isTransmittingMessage(m))
|
||||||
|
|
||||||
|
const handleRetryAll = async () => {
|
||||||
|
const candidates = messages.filter((m) => isFailedMessage(m) && !isTransmittingMessage(m))
|
||||||
|
|
||||||
|
for (const msg of candidates) {
|
||||||
|
try {
|
||||||
|
await regenerateAssistantMessage(msg, assistant)
|
||||||
|
} catch (e) {
|
||||||
|
// swallow per-item errors to continue others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const multiModelMessageStyleTextByLayout = {
|
const multiModelMessageStyleTextByLayout = {
|
||||||
fold: t('message.message.multi_model_style.fold.label'),
|
fold: t('message.message.multi_model_style.fold.label'),
|
||||||
vertical: t('message.message.multi_model_style.vertical'),
|
vertical: t('message.message.multi_model_style.vertical'),
|
||||||
@ -95,6 +133,17 @@ const MessageGroupMenuBar: FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
|
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
{hasFailedMessages && (
|
||||||
|
<Tooltip title={t('message.group.retry_failed')} mouseEnterDelay={0.6}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleRetryAll}
|
||||||
|
style={{ marginRight: 4 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
// import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { CopyIcon, DeleteIcon, EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
import { CopyIcon, DeleteIcon, EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
||||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||||
@ -7,9 +7,9 @@ import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
|||||||
import { isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
import { isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
|
||||||
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||||
import { useEnableDeveloperMode, useMessageStyle } from '@renderer/hooks/useSettings'
|
import { useEnableDeveloperMode, useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||||
import useTranslate from '@renderer/hooks/useTranslate'
|
import useTranslate from '@renderer/hooks/useTranslate'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
@ -84,7 +84,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
const { toggleMultiSelectMode } = useChatContext(props.topic)
|
const { toggleMultiSelectMode } = useChatContext(props.topic)
|
||||||
const [copied, setCopied] = useTemporaryValue(false, 2000)
|
const [copied, setCopied] = useTemporaryValue(false, 2000)
|
||||||
const [isTranslating, setIsTranslating] = useState(false)
|
const [isTranslating, setIsTranslating] = useState(false)
|
||||||
const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false)
|
// remove confirm for regenerate; tooltip stays simple
|
||||||
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
|
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
|
||||||
const { translateLanguages } = useTranslate()
|
const { translateLanguages } = useTranslate()
|
||||||
// const assistantModel = assistant?.model
|
// const assistantModel = assistant?.model
|
||||||
@ -99,8 +99,9 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
|
|
||||||
const { isBubbleStyle } = useMessageStyle()
|
const { isBubbleStyle } = useMessageStyle()
|
||||||
const { enableDeveloperMode } = useEnableDeveloperMode()
|
const { enableDeveloperMode } = useEnableDeveloperMode()
|
||||||
|
const { confirmDeleteMessage, confirmRegenerateMessage } = useSettings()
|
||||||
|
|
||||||
const loading = useTopicLoading(topic)
|
// const loading = useTopicLoading(topic)
|
||||||
|
|
||||||
const isUserMessage = message.role === 'user'
|
const isUserMessage = message.role === 'user'
|
||||||
|
|
||||||
@ -145,18 +146,15 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onNewBranch = useCallback(async () => {
|
const onNewBranch = useCallback(async () => {
|
||||||
if (loading) return
|
|
||||||
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
|
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
|
||||||
window.message.success({ content: t('chat.message.new.branch.created'), key: 'new-branch' })
|
window.message.success({ content: t('chat.message.new.branch.created'), key: 'new-branch' })
|
||||||
}, [index, t, loading])
|
}, [index, t])
|
||||||
|
|
||||||
const handleResendUserMessage = useCallback(
|
const handleResendUserMessage = useCallback(
|
||||||
async (messageUpdate?: Message) => {
|
async (messageUpdate?: Message) => {
|
||||||
if (!loading) {
|
await resendMessage(messageUpdate ?? message, assistant)
|
||||||
await resendMessage(messageUpdate ?? message, assistant)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[assistant, loading, message, resendMessage]
|
[assistant, message, resendMessage]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { startEditing } = useMessageEditing()
|
const { startEditing } = useMessageEditing()
|
||||||
@ -392,7 +390,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
|
|
||||||
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||||
e?.stopPropagation?.()
|
e?.stopPropagation?.()
|
||||||
if (loading) return
|
|
||||||
// No need to reset or edit the message anymore
|
// No need to reset or edit the message anymore
|
||||||
// const selectedModel = isGrouped ? model : assistantModel
|
// const selectedModel = isGrouped ? model : assistantModel
|
||||||
// const _message = resetAssistantMessage(message, selectedModel)
|
// const _message = resetAssistantMessage(message, selectedModel)
|
||||||
@ -438,12 +435,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
const onMentionModel = useCallback(
|
const onMentionModel = useCallback(
|
||||||
async (e: React.MouseEvent) => {
|
async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (loading) return
|
|
||||||
const selectedModel = await SelectModelPopup.show({ model, filter: mentionModelFilter })
|
const selectedModel = await SelectModelPopup.show({ model, filter: mentionModelFilter })
|
||||||
if (!selectedModel) return
|
if (!selectedModel) return
|
||||||
appendAssistantResponse(message, selectedModel, { ...assistant, model: selectedModel })
|
appendAssistantResponse(message, selectedModel, { ...assistant, model: selectedModel })
|
||||||
},
|
},
|
||||||
[appendAssistantResponse, assistant, loading, mentionModelFilter, message, model]
|
[appendAssistantResponse, assistant, mentionModelFilter, message, model]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onUseful = useCallback(
|
const onUseful = useCallback(
|
||||||
@ -469,16 +465,32 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
{showMessageTokens && <MessageTokens message={message} />}
|
{showMessageTokens && <MessageTokens message={message} />}
|
||||||
<MenusBar
|
<MenusBar
|
||||||
className={classNames({ menubar: true, show: isLastMessage, 'user-bubble-style': isUserBubbleStyleMessage })}>
|
className={classNames({ menubar: true, show: isLastMessage, 'user-bubble-style': isUserBubbleStyleMessage })}>
|
||||||
{message.role === 'user' && (
|
{message.role === 'user' &&
|
||||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
(confirmRegenerateMessage ? (
|
||||||
<ActionButton
|
<Popconfirm
|
||||||
className="message-action-button"
|
title={t('message.regenerate.confirm')}
|
||||||
onClick={() => handleResendUserMessage()}
|
okButtonProps={{ danger: true }}
|
||||||
$softHoverBg={isBubbleStyle}>
|
onConfirm={() => handleResendUserMessage()}
|
||||||
<RefreshIcon size={15} />
|
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
|
||||||
</ActionButton>
|
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||||
</Tooltip>
|
<ActionButton
|
||||||
)}
|
className="message-action-button"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
$softHoverBg={isBubbleStyle}>
|
||||||
|
<RefreshIcon size={15} />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||||
|
<ActionButton
|
||||||
|
className="message-action-button"
|
||||||
|
onClick={() => handleResendUserMessage()}
|
||||||
|
$softHoverBg={isBubbleStyle}>
|
||||||
|
<RefreshIcon size={15} />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
{message.role === 'user' && (
|
{message.role === 'user' && (
|
||||||
<Tooltip title={t('common.edit')} mouseEnterDelay={0.8}>
|
<Tooltip title={t('common.edit')} mouseEnterDelay={0.8}>
|
||||||
<ActionButton className="message-action-button" onClick={onEdit} $softHoverBg={softHoverBg}>
|
<ActionButton className="message-action-button" onClick={onEdit} $softHoverBg={softHoverBg}>
|
||||||
@ -492,24 +504,29 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
{copied && <Check size={15} color="var(--color-primary)" />}
|
{copied && <Check size={15} color="var(--color-primary)" />}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isAssistantMessage && (
|
{isAssistantMessage &&
|
||||||
<Popconfirm
|
(confirmRegenerateMessage ? (
|
||||||
title={t('message.regenerate.confirm')}
|
<Popconfirm
|
||||||
okButtonProps={{ danger: true }}
|
title={t('message.regenerate.confirm')}
|
||||||
icon={<InfoCircleOutlined style={{ color: 'red' }} />}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={onRegenerate}
|
onConfirm={onRegenerate}
|
||||||
onOpenChange={(open) => open && setShowRegenerateTooltip(false)}>
|
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
|
||||||
<Tooltip
|
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||||
title={t('common.regenerate')}
|
<ActionButton
|
||||||
mouseEnterDelay={0.8}
|
className="message-action-button"
|
||||||
open={showRegenerateTooltip}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onOpenChange={setShowRegenerateTooltip}>
|
$softHoverBg={softHoverBg}>
|
||||||
<ActionButton className="message-action-button" $softHoverBg={softHoverBg}>
|
<RefreshIcon size={15} />
|
||||||
|
</ActionButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||||
|
<ActionButton className="message-action-button" onClick={onRegenerate} $softHoverBg={softHoverBg}>
|
||||||
<RefreshIcon size={15} />
|
<RefreshIcon size={15} />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popconfirm>
|
))}
|
||||||
)}
|
|
||||||
{isAssistantMessage && (
|
{isAssistantMessage && (
|
||||||
<Tooltip title={t('message.mention.title')} mouseEnterDelay={0.8}>
|
<Tooltip title={t('message.mention.title')} mouseEnterDelay={0.8}>
|
||||||
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
|
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
|
||||||
@ -603,15 +620,32 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Popconfirm
|
{confirmDeleteMessage ? (
|
||||||
title={t('message.message.delete.content')}
|
<Popconfirm
|
||||||
okButtonProps={{ danger: true }}
|
title={t('message.message.delete.content')}
|
||||||
icon={<InfoCircleOutlined style={{ color: 'red' }} />}
|
okButtonProps={{ danger: true }}
|
||||||
onOpenChange={(open) => open && setShowDeleteTooltip(false)}
|
onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}
|
||||||
onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}>
|
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
|
||||||
|
<ActionButton
|
||||||
|
className="message-action-button"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
$softHoverBg={softHoverBg}>
|
||||||
|
<Tooltip
|
||||||
|
title={t('common.delete')}
|
||||||
|
mouseEnterDelay={1}
|
||||||
|
open={showDeleteTooltip}
|
||||||
|
onOpenChange={setShowDeleteTooltip}>
|
||||||
|
<DeleteIcon size={15} />
|
||||||
|
</Tooltip>
|
||||||
|
</ActionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className="message-action-button"
|
className="message-action-button"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deleteMessage(message.id, message.traceId, message.model?.name)
|
||||||
|
}}
|
||||||
$softHoverBg={softHoverBg}>
|
$softHoverBg={softHoverBg}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={t('common.delete')}
|
title={t('common.delete')}
|
||||||
@ -621,7 +655,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
<DeleteIcon size={15} />
|
<DeleteIcon size={15} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</Popconfirm>
|
)}
|
||||||
{enableDeveloperMode && message.traceId && (
|
{enableDeveloperMode && message.traceId && (
|
||||||
<Tooltip title={t('trace.label')} mouseEnterDelay={0.8}>
|
<Tooltip title={t('trace.label')} mouseEnterDelay={0.8}>
|
||||||
<ActionButton className="message-action-button" onClick={() => handleTraceUserMessage()}>
|
<ActionButton className="message-action-button" onClick={() => handleTraceUserMessage()}>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// import { useRuntime } from '@renderer/hooks/useRuntime'
|
// import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { Popover } from 'antd'
|
import { Popover } from 'antd'
|
||||||
@ -12,7 +11,6 @@ interface MessageTokensProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||||
const { showTokens } = useSettings()
|
|
||||||
// const { generating } = useRuntime()
|
// const { generating } = useRuntime()
|
||||||
const locateMessage = () => {
|
const locateMessage = () => {
|
||||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
||||||
@ -59,7 +57,7 @@ const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
|||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
return (
|
return (
|
||||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||||
{showTokens && `Tokens: ${message?.usage?.total_tokens}`}
|
{`Tokens: ${message?.usage?.total_tokens}`}
|
||||||
</MessageMetadata>
|
</MessageMetadata>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -88,17 +86,15 @@ const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
showTokens && (
|
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
{hasMetrics ? (
|
||||||
{hasMetrics ? (
|
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
|
||||||
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
|
{tokensInfo}
|
||||||
{tokensInfo}
|
</Popover>
|
||||||
</Popover>
|
) : (
|
||||||
) : (
|
tokensInfo
|
||||||
tokensInfo
|
)}
|
||||||
)}
|
</MessageMetadata>
|
||||||
</MessageMetadata>
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { Assistant, Topic } from '@renderer/types'
|
|||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
import { Menu, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
import { Menu, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -68,20 +69,29 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar className="home-navbar">
|
<Navbar className="home-navbar">
|
||||||
{showAssistants && (
|
<AnimatePresence initial={false}>
|
||||||
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
{showAssistants && (
|
||||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
<motion.div
|
||||||
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
|
initial={{ width: 0, opacity: 0 }}
|
||||||
<PanelLeftClose size={18} />
|
animate={{ width: 'auto', opacity: 1 }}
|
||||||
</NavbarIcon>
|
exit={{ width: 0, opacity: 0 }}
|
||||||
</Tooltip>
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
|
style={{ overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
|
||||||
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}>
|
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
||||||
<MessageSquareDiff size={18} />
|
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||||
</NavbarIcon>
|
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
|
||||||
</Tooltip>
|
<PanelLeftClose size={18} />
|
||||||
</NavbarLeft>
|
</NavbarIcon>
|
||||||
)}
|
</Tooltip>
|
||||||
|
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
|
||||||
|
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}>
|
||||||
|
<MessageSquareDiff size={18} />
|
||||||
|
</NavbarIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</NavbarLeft>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="home-navbar-right">
|
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="home-navbar-right">
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
{!showAssistants && (
|
{!showAssistants && (
|
||||||
@ -93,11 +103,20 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
|||||||
</NavbarIcon>
|
</NavbarIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!showAssistants && (
|
<AnimatePresence initial={false}>
|
||||||
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
|
{!showAssistants && (
|
||||||
<Menu size={18} />
|
<motion.div
|
||||||
</NavbarIcon>
|
initial={{ width: 0, opacity: 0 }}
|
||||||
)}
|
animate={{ width: 'auto', opacity: 1 }}
|
||||||
|
exit={{ width: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}>
|
||||||
|
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
|
||||||
|
<Menu size={18} />
|
||||||
|
</NavbarIcon>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<SelectModelButton assistant={assistant} />
|
<SelectModelButton assistant={assistant} />
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack alignItems="center" gap={8}>
|
<HStack alignItems="center" gap={8}>
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import {
|
|||||||
setCodeShowLineNumbers,
|
setCodeShowLineNumbers,
|
||||||
setCodeViewer,
|
setCodeViewer,
|
||||||
setCodeWrappable,
|
setCodeWrappable,
|
||||||
|
setConfirmDeleteMessage,
|
||||||
|
setConfirmRegenerateMessage,
|
||||||
setEnableQuickPanelTriggers,
|
setEnableQuickPanelTriggers,
|
||||||
setFontSize,
|
setFontSize,
|
||||||
setMathEnableSingleDollar,
|
setMathEnableSingleDollar,
|
||||||
@ -106,7 +108,9 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
messageNavigation,
|
messageNavigation,
|
||||||
enableQuickPanelTriggers,
|
enableQuickPanelTriggers,
|
||||||
showTranslateConfirm,
|
showTranslateConfirm,
|
||||||
showMessageOutline
|
showMessageOutline,
|
||||||
|
confirmDeleteMessage,
|
||||||
|
confirmRegenerateMessage
|
||||||
} = useSettings()
|
} = useSettings()
|
||||||
|
|
||||||
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
||||||
@ -647,6 +651,24 @@ const SettingsTab: FC<Props> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitleSmall>{t('settings.messages.input.confirm_delete_message')}</SettingRowTitleSmall>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={confirmDeleteMessage}
|
||||||
|
onChange={(checked) => dispatch(setConfirmDeleteMessage(checked))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingRow>
|
||||||
|
<SettingRowTitleSmall>{t('settings.messages.input.confirm_regenerate_message')}</SettingRowTitleSmall>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={confirmRegenerateMessage}
|
||||||
|
onChange={(checked) => dispatch(setConfirmRegenerateMessage(checked))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitleSmall>{t('settings.input.target_language.label')}</SettingRowTitleSmall>
|
<SettingRowTitleSmall>{t('settings.input.target_language.label')}</SettingRowTitleSmall>
|
||||||
<Selector
|
<Selector
|
||||||
|
|||||||
@ -109,7 +109,9 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => {
|
|||||||
}, [activeNode, notesTree])
|
}, [activeNode, notesTree])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavbarHeader className="home-navbar" style={{ justifyContent: 'flex-start' }}>
|
<NavbarHeader
|
||||||
|
className="home-navbar"
|
||||||
|
style={{ justifyContent: 'flex-start', borderBottom: '0.5px solid var(--color-border)' }}>
|
||||||
<HStack alignItems="center" flex="0 0 auto">
|
<HStack alignItems="center" flex="0 0 auto">
|
||||||
{showWorkspace && (
|
{showWorkspace && (
|
||||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||||
|
|||||||
@ -145,6 +145,7 @@ const RichEditorContainer = styled.div`
|
|||||||
|
|
||||||
.notes-rich-editor {
|
.notes-rich-editor {
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
@ -173,7 +174,7 @@ const RichEditorContainer = styled.div`
|
|||||||
|
|
||||||
const BottomPanel = styled.div`
|
const BottomPanel = styled.div`
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 0.5px solid var(--color-border);
|
||||||
background: var(--color-background-soft);
|
background: var(--color-background-soft);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
|||||||
@ -53,6 +53,7 @@ const NotesPage: FC = () => {
|
|||||||
const isSyncingTreeRef = useRef(false)
|
const isSyncingTreeRef = useRef(false)
|
||||||
const isEditorInitialized = useRef(false)
|
const isEditorInitialized = useRef(false)
|
||||||
const lastContentRef = useRef<string>('')
|
const lastContentRef = useRef<string>('')
|
||||||
|
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||||
const isInitialSortApplied = useRef(false)
|
const isInitialSortApplied = useRef(false)
|
||||||
const isRenamingRef = useRef(false)
|
const isRenamingRef = useRef(false)
|
||||||
const isCreatingNoteRef = useRef(false)
|
const isCreatingNoteRef = useRef(false)
|
||||||
@ -82,13 +83,14 @@ const NotesPage: FC = () => {
|
|||||||
|
|
||||||
// 保存当前笔记内容
|
// 保存当前笔记内容
|
||||||
const saveCurrentNote = useCallback(
|
const saveCurrentNote = useCallback(
|
||||||
async (content: string) => {
|
async (content: string, filePath?: string) => {
|
||||||
if (!activeFilePath || content === currentContent) return
|
const targetPath = filePath || activeFilePath
|
||||||
|
if (!targetPath || content === currentContent) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.api.file.write(activeFilePath, content)
|
await window.api.file.write(targetPath, content)
|
||||||
// 保存后立即刷新缓存,确保下次读取时获取最新内容
|
// 保存后立即刷新缓存,确保下次读取时获取最新内容
|
||||||
invalidateFileContent(activeFilePath)
|
invalidateFileContent(targetPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to save note:', error as Error)
|
logger.error('Failed to save note:', error as Error)
|
||||||
}
|
}
|
||||||
@ -99,19 +101,22 @@ const NotesPage: FC = () => {
|
|||||||
// 防抖保存函数,在停止输入后才保存,避免输入过程中的文件写入
|
// 防抖保存函数,在停止输入后才保存,避免输入过程中的文件写入
|
||||||
const debouncedSave = useMemo(
|
const debouncedSave = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((content: string) => {
|
debounce((content: string, filePath: string | undefined) => {
|
||||||
saveCurrentNote(content)
|
saveCurrentNote(content, filePath)
|
||||||
}, 800), // 800ms防抖延迟
|
}, 800), // 800ms防抖延迟
|
||||||
[saveCurrentNote]
|
[saveCurrentNote]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMarkdownChange = useCallback(
|
const handleMarkdownChange = useCallback(
|
||||||
(newMarkdown: string) => {
|
(newMarkdown: string) => {
|
||||||
// 记录最新内容,用于兜底保存
|
// 记录最新内容和文件路径,用于兜底保存
|
||||||
lastContentRef.current = newMarkdown
|
lastContentRef.current = newMarkdown
|
||||||
debouncedSave(newMarkdown)
|
lastFilePathRef.current = activeFilePath
|
||||||
|
// 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件
|
||||||
|
const currentFilePath = activeFilePath
|
||||||
|
debouncedSave(newMarkdown, currentFilePath)
|
||||||
},
|
},
|
||||||
[debouncedSave]
|
[debouncedSave, activeFilePath]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -148,16 +153,16 @@ const NotesPage: FC = () => {
|
|||||||
// 处理树同步时的状态管理
|
// 处理树同步时的状态管理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notesTree.length === 0) return
|
if (notesTree.length === 0) return
|
||||||
|
|
||||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||||
if (
|
const shouldClearPath =
|
||||||
activeFilePath &&
|
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||||
!activeNode &&
|
|
||||||
!isSyncingTreeRef.current &&
|
if (shouldClearPath) {
|
||||||
!isRenamingRef.current &&
|
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||||
!isCreatingNoteRef.current
|
activeFilePath,
|
||||||
) {
|
reason: 'Node not found in current tree'
|
||||||
|
})
|
||||||
dispatch(setActiveFilePath(undefined))
|
dispatch(setActiveFilePath(undefined))
|
||||||
}
|
}
|
||||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||||
@ -257,8 +262,8 @@ const NotesPage: FC = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 如果有未保存的内容,立即保存
|
// 如果有未保存的内容,立即保存
|
||||||
if (lastContentRef.current && lastContentRef.current !== currentContent) {
|
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||||
saveCurrentNote(lastContentRef.current).catch((error) => {
|
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||||
logger.error('Emergency save failed:', error as Error)
|
logger.error('Emergency save failed:', error as Error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -288,8 +293,8 @@ const NotesPage: FC = () => {
|
|||||||
|
|
||||||
// 切换文件时重置编辑器初始化状态并兜底保存
|
// 切换文件时重置编辑器初始化状态并兜底保存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastContentRef.current && lastContentRef.current !== currentContent) {
|
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||||
saveCurrentNote(lastContentRef.current).catch((error) => {
|
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||||
logger.error('Emergency save before file switch failed:', error as Error)
|
logger.error('Emergency save before file switch failed:', error as Error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -297,6 +302,7 @@ const NotesPage: FC = () => {
|
|||||||
// 重置状态
|
// 重置状态
|
||||||
isEditorInitialized.current = false
|
isEditorInitialized.current = false
|
||||||
lastContentRef.current = ''
|
lastContentRef.current = ''
|
||||||
|
lastFilePathRef.current = undefined
|
||||||
}, [activeFilePath, currentContent, saveCurrentNote])
|
}, [activeFilePath, currentContent, saveCurrentNote])
|
||||||
|
|
||||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||||
@ -425,6 +431,7 @@ const NotesPage: FC = () => {
|
|||||||
if (node.type === 'file') {
|
if (node.type === 'file') {
|
||||||
try {
|
try {
|
||||||
dispatch(setActiveFilePath(node.externalPath))
|
dispatch(setActiveFilePath(node.externalPath))
|
||||||
|
invalidateFileContent(node.externalPath)
|
||||||
// 清除文件夹选择状态
|
// 清除文件夹选择状态
|
||||||
setSelectedFolderId(null)
|
setSelectedFolderId(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -435,7 +442,7 @@ const NotesPage: FC = () => {
|
|||||||
await handleToggleExpanded(node.id)
|
await handleToggleExpanded(node.id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, handleToggleExpanded]
|
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 删除节点
|
// 删除节点
|
||||||
|
|||||||
@ -522,7 +522,7 @@ const SidebarContainer = styled.div`
|
|||||||
width: 250px;
|
width: 250px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 0.5px solid var(--color-border);
|
||||||
border-top-left-radius: 10px;
|
border-top-left-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -568,7 +568,7 @@ const TreeNodeContainer = styled.div<{
|
|||||||
if (props.active) return 'var(--color-background-soft)'
|
if (props.active) return 'var(--color-background-soft)'
|
||||||
return 'transparent'
|
return 'transparent'
|
||||||
}};
|
}};
|
||||||
border: 1px solid
|
border: 0.5px solid
|
||||||
${(props) => {
|
${(props) => {
|
||||||
if (props.isDragInside) return 'var(--color-primary)'
|
if (props.isDragInside) return 'var(--color-primary)'
|
||||||
if (props.active) return 'var(--color-border)'
|
if (props.active) return 'var(--color-border)'
|
||||||
@ -669,7 +669,7 @@ const EditInput = styled(Input)`
|
|||||||
.ant-input {
|
.ant-input {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: 1px solid var(--color-primary);
|
border: 0.5px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -138,9 +138,10 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
|||||||
|
|
||||||
const SidebarHeader = styled.div<{ isStarView?: boolean; isSearchView?: boolean }>`
|
const SidebarHeader = styled.div<{ isStarView?: boolean; isSearchView?: boolean }>`
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 0.5px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')};
|
justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')};
|
||||||
|
height: var(--navbar-height);
|
||||||
`
|
`
|
||||||
|
|
||||||
const HeaderActions = styled.div`
|
const HeaderActions = styled.div`
|
||||||
|
|||||||
@ -99,9 +99,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
|||||||
const aihubmixProvider = providers.find((p) => p.id === 'aihubmix')!
|
const aihubmixProvider = providers.find((p) => p.id === 'aihubmix')!
|
||||||
|
|
||||||
const modeOptions = [
|
const modeOptions = [
|
||||||
{ label: t('paintings.mode.generate'), value: 'generate' },
|
{ label: t('paintings.mode.generate'), value: 'aihubmix_image_generate' },
|
||||||
{ label: t('paintings.mode.remix'), value: 'remix' },
|
{ label: t('paintings.mode.remix'), value: 'aihubmix_image_remix' },
|
||||||
{ label: t('paintings.mode.upscale'), value: 'upscale' }
|
{ label: t('paintings.mode.upscale'), value: 'aihubmix_image_upscale' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const getNewPainting = useCallback(() => {
|
const getNewPainting = useCallback(() => {
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { HStack, VStack } from '@renderer/components/Layout'
|
|||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import TranslateButton from '@renderer/components/TranslateButton'
|
import TranslateButton from '@renderer/components/TranslateButton'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
|
|
||||||
import { LanguagesEnum } from '@renderer/config/translate'
|
import { LanguagesEnum } from '@renderer/config/translate'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||||
@ -42,6 +41,15 @@ import Artboard from './components/Artboard'
|
|||||||
import PaintingsList from './components/PaintingsList'
|
import PaintingsList from './components/PaintingsList'
|
||||||
import { checkProviderEnabled } from './utils'
|
import { checkProviderEnabled } from './utils'
|
||||||
|
|
||||||
|
export const TEXT_TO_IMAGES_MODELS = [
|
||||||
|
{
|
||||||
|
id: 'Kwai-Kolors/Kolors',
|
||||||
|
provider: 'silicon',
|
||||||
|
name: 'Kolors',
|
||||||
|
group: 'Kwai-Kolors'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const logger = loggerService.withContext('SiliconPage')
|
const logger = loggerService.withContext('SiliconPage')
|
||||||
|
|
||||||
const IMAGE_SIZES = [
|
const IMAGE_SIZES = [
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import 'emoji-picker-element'
|
import 'emoji-picker-element'
|
||||||
|
|
||||||
import { CloseCircleFilled } from '@ant-design/icons'
|
import { CloseCircleFilled } from '@ant-design/icons'
|
||||||
|
import CodeEditor from '@renderer/components/CodeEditor'
|
||||||
|
import CodeViewer from '@renderer/components/CodeViewer'
|
||||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||||
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||||
import RichEditor from '@renderer/components/RichEditor'
|
|
||||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
|
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
|
||||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||||
@ -47,9 +48,7 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onUpdate = () => {
|
const onUpdate = () => {
|
||||||
const text = editorRef.current?.getMarkdown() || ''
|
const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
|
||||||
setPrompt(text)
|
|
||||||
const _assistant = { ...assistant, name: name.trim(), emoji, prompt: text }
|
|
||||||
updateAssistant(_assistant)
|
updateAssistant(_assistant)
|
||||||
window.message.success(t('common.saved'))
|
window.message.success(t('common.saved'))
|
||||||
}
|
}
|
||||||
@ -68,13 +67,6 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
|||||||
|
|
||||||
const promptVarsContent = <pre>{t('agents.add.prompt.variables.tip.content')}</pre>
|
const promptVarsContent = <pre>{t('agents.add.prompt.variables.tip.content')}</pre>
|
||||||
|
|
||||||
const handleCommandsReady = (commandAPI: Pick<RichEditorRef, 'unregisterCommand'>) => {
|
|
||||||
const disabledCommands = ['image', 'inlineMath']
|
|
||||||
disabledCommands.forEach((commandId) => {
|
|
||||||
commandAPI.unregisterCommand(commandId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Box mb={8} style={{ fontWeight: 'bold' }}>
|
<Box mb={8} style={{ fontWeight: 'bold' }}>
|
||||||
@ -129,18 +121,20 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
|||||||
</HStack>
|
</HStack>
|
||||||
<TextAreaContainer>
|
<TextAreaContainer>
|
||||||
<RichEditorContainer>
|
<RichEditorContainer>
|
||||||
<RichEditor
|
{showPreview ? (
|
||||||
key={showPreview ? 'preview' : 'edit'}
|
<CodeViewer children={processedPrompt} language="markdown" expanded={true} height="100%" />
|
||||||
ref={editorRef}
|
) : (
|
||||||
initialContent={showPreview ? processedPrompt : prompt}
|
<CodeEditor
|
||||||
onCommandsReady={handleCommandsReady}
|
value={prompt}
|
||||||
showToolbar={!showPreview}
|
language="markdown"
|
||||||
editable={!showPreview}
|
onChange={setPrompt}
|
||||||
showTableOfContents={false}
|
height="100%"
|
||||||
enableContentSearch={false}
|
expanded={false}
|
||||||
isFullWidth={true}
|
style={{
|
||||||
className="prompt-rich-editor"
|
height: '100%'
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</RichEditorContainer>
|
</RichEditorContainer>
|
||||||
</TextAreaContainer>
|
</TextAreaContainer>
|
||||||
<HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px">
|
<HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px">
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import CodeEditor from '@renderer/components/CodeEditor'
|
|||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setMCPServerActive } from '@renderer/store/mcp'
|
import { setMCPServerActive } from '@renderer/store/mcp'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer, objectKeys, safeValidateMcpConfig } from '@renderer/types'
|
||||||
|
import { parseJSON } from '@renderer/utils'
|
||||||
|
import { formatZodError } from '@renderer/utils/error'
|
||||||
import { Button, Form, Modal, Upload } from 'antd'
|
import { Button, Form, Modal, Upload } from 'antd'
|
||||||
import { FC, useCallback, useEffect, useState } from 'react'
|
import { FC, useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -80,6 +82,49 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
setImportMethod(initialImportMethod)
|
setImportMethod(initialImportMethod)
|
||||||
}, [initialImportMethod])
|
}, [initialImportMethod])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON字符串中解析MCP服务器配置
|
||||||
|
* @param inputValue - JSON格式的服务器配置字符串
|
||||||
|
* @returns 包含解析后的服务器配置和可能的错误信息的对象
|
||||||
|
* - serverToAdd: 解析成功时返回服务器配置对象,失败时返回null
|
||||||
|
* - error: 解析失败时返回错误信息,成功时返回null
|
||||||
|
*/
|
||||||
|
const getServerFromJson = (
|
||||||
|
inputValue: string
|
||||||
|
): { serverToAdd: Partial<ParsedServerData>; error: null } | { serverToAdd: null; error: string } => {
|
||||||
|
const trimmedInput = inputValue.trim()
|
||||||
|
const parsedJson = parseJSON(trimmedInput)
|
||||||
|
if (parsedJson === null) {
|
||||||
|
logger.error('Failed to parse json.', { input: trimmedInput })
|
||||||
|
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: validConfig, error } = safeValidateMcpConfig(parsedJson)
|
||||||
|
if (error) {
|
||||||
|
logger.error('Failed to validate json.', { parsedJson, error })
|
||||||
|
return { serverToAdd: null, error: formatZodError(error, t('settings.mcp.addServer.importFrom.invalid')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverToAdd: Partial<ParsedServerData> | null = null
|
||||||
|
|
||||||
|
if (objectKeys(validConfig.mcpServers).length > 1) {
|
||||||
|
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectKeys(validConfig.mcpServers).length > 0) {
|
||||||
|
const key = objectKeys(validConfig.mcpServers)[0]
|
||||||
|
serverToAdd = validConfig.mcpServers[key]
|
||||||
|
if (!serverToAdd.name) {
|
||||||
|
serverToAdd.name = key
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// zod 太好用了你们知道吗
|
||||||
|
return { serverToAdd, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
const handleOk = async () => {
|
const handleOk = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -194,9 +239,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
const inputValue = values.serverConfig.trim()
|
const inputValue = values.serverConfig.trim()
|
||||||
|
|
||||||
const { serverToAdd, error } = parseAndExtractServer(inputValue, t)
|
const { serverToAdd, error } = getServerFromJson(inputValue)
|
||||||
|
|
||||||
if (error) {
|
if (error !== null) {
|
||||||
form.setFields([
|
form.setFields([
|
||||||
{
|
{
|
||||||
name: 'serverConfig',
|
name: 'serverConfig',
|
||||||
@ -208,11 +253,11 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 檢查重複名稱
|
// 檢查重複名稱
|
||||||
if (existingServers && existingServers.some((server) => server.name === serverToAdd!.name)) {
|
if (existingServers && existingServers.some((server) => server.name === serverToAdd.name)) {
|
||||||
form.setFields([
|
form.setFields([
|
||||||
{
|
{
|
||||||
name: 'serverConfig',
|
name: 'serverConfig',
|
||||||
errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })]
|
errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd.name })]
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -222,9 +267,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
// 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框
|
// 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框
|
||||||
const newServer: MCPServer = {
|
const newServer: MCPServer = {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
...serverToAdd!,
|
...serverToAdd,
|
||||||
name: serverToAdd!.name || t('settings.mcp.newServer'),
|
name: serverToAdd.name || t('settings.mcp.newServer'),
|
||||||
baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '',
|
baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '',
|
||||||
isActive: false // 初始狀態為非啟用
|
isActive: false // 初始狀態為非啟用
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,93 +375,4 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 JSON 提取伺服器資料
|
|
||||||
const parseAndExtractServer = (
|
|
||||||
inputValue: string,
|
|
||||||
t: (key: string, options?: any) => string
|
|
||||||
): { serverToAdd: Partial<ParsedServerData> | null; error: string | null } => {
|
|
||||||
const trimmedInput = inputValue.trim()
|
|
||||||
|
|
||||||
let parsedJson
|
|
||||||
try {
|
|
||||||
parsedJson = JSON.parse(trimmedInput)
|
|
||||||
} catch (e) {
|
|
||||||
// JSON 解析失敗,返回錯誤
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
|
||||||
}
|
|
||||||
|
|
||||||
let serverToAdd: Partial<ParsedServerData> | null = null
|
|
||||||
|
|
||||||
// 檢查是否包含多個伺服器配置 (適用於 JSON 格式)
|
|
||||||
if (
|
|
||||||
parsedJson.mcpServers &&
|
|
||||||
typeof parsedJson.mcpServers === 'object' &&
|
|
||||||
Object.keys(parsedJson.mcpServers).length > 1
|
|
||||||
) {
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') }
|
|
||||||
} else if (Array.isArray(parsedJson) && parsedJson.length > 1) {
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
parsedJson.mcpServers &&
|
|
||||||
typeof parsedJson.mcpServers === 'object' &&
|
|
||||||
Object.keys(parsedJson.mcpServers).length > 0
|
|
||||||
) {
|
|
||||||
// Case 1: {"mcpServers": {"serverName": {...}}}
|
|
||||||
const firstServerKey = Object.keys(parsedJson.mcpServers)[0]
|
|
||||||
const potentialServer = parsedJson.mcpServers[firstServerKey]
|
|
||||||
if (typeof potentialServer === 'object' && potentialServer !== null) {
|
|
||||||
serverToAdd = { ...potentialServer }
|
|
||||||
serverToAdd!.name = potentialServer.name ?? firstServerKey
|
|
||||||
} else {
|
|
||||||
logger.error('Invalid server data under mcpServers key:', potentialServer)
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(parsedJson) && parsedJson.length > 0) {
|
|
||||||
// Case 2: [{...}, ...] - 取第一個伺服器,確保它是物件
|
|
||||||
if (typeof parsedJson[0] === 'object' && parsedJson[0] !== null) {
|
|
||||||
serverToAdd = { ...parsedJson[0] }
|
|
||||||
serverToAdd!.name = parsedJson[0].name ?? t('settings.mcp.newServer')
|
|
||||||
} else {
|
|
||||||
logger.error('Invalid server data in array:', parsedJson[0])
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
typeof parsedJson === 'object' &&
|
|
||||||
!Array.isArray(parsedJson) &&
|
|
||||||
!parsedJson.mcpServers // 確保是直接的伺服器物件
|
|
||||||
) {
|
|
||||||
// Case 3: {...} (單一伺服器物件)
|
|
||||||
// 檢查物件是否為空
|
|
||||||
if (Object.keys(parsedJson).length > 0) {
|
|
||||||
serverToAdd = { ...parsedJson }
|
|
||||||
serverToAdd!.name = parsedJson.name ?? t('settings.mcp.newServer')
|
|
||||||
} else {
|
|
||||||
// 空物件,視為無效
|
|
||||||
serverToAdd = null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 無效結構或空的 mcpServers
|
|
||||||
serverToAdd = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 確保 serverToAdd 存在且 name 存在
|
|
||||||
if (!serverToAdd || !serverToAdd.name) {
|
|
||||||
logger.error('Invalid JSON structure for server config or missing name:', parsedJson)
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure tags is string[]
|
|
||||||
if (
|
|
||||||
serverToAdd.tags &&
|
|
||||||
(!Array.isArray(serverToAdd.tags) || !serverToAdd.tags.every((tag) => typeof tag === 'string'))
|
|
||||||
) {
|
|
||||||
logger.error('Tags must be an array of strings:', serverToAdd.tags)
|
|
||||||
return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { serverToAdd, error: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AddMcpServerModal
|
export default AddMcpServerModal
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import CodeEditor from '@renderer/components/CodeEditor'
|
|||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||||
import { setMCPServers } from '@renderer/store/mcp'
|
import { setMCPServers } from '@renderer/store/mcp'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer, safeValidateMcpConfig } from '@renderer/types'
|
||||||
|
import { parseJSON } from '@renderer/utils'
|
||||||
|
import { formatZodError } from '@renderer/utils/error'
|
||||||
import { Modal, Spin, Typography } from 'antd'
|
import { Modal, Spin, Typography } from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -62,15 +64,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedConfig = JSON.parse(jsonConfig)
|
const parsedJson = parseJSON(jsonConfig)
|
||||||
|
if (parseJSON === null) {
|
||||||
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
|
|
||||||
throw new Error(t('settings.mcp.addServer.importFrom.invalid'))
|
throw new Error(t('settings.mcp.addServer.importFrom.invalid'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: parsedServers, error } = safeValidateMcpConfig(parsedJson)
|
||||||
|
if (error) {
|
||||||
|
throw new Error(formatZodError(error, t('settings.mcp.addServer.importFrom.invalid')))
|
||||||
|
}
|
||||||
|
|
||||||
const serversArray: MCPServer[] = []
|
const serversArray: MCPServer[] = []
|
||||||
|
|
||||||
for (const [id, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
|
for (const [id, serverConfig] of Object.entries(parsedServers.mcpServers)) {
|
||||||
const server: MCPServer = {
|
const server: MCPServer = {
|
||||||
id,
|
id,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
@ -117,13 +123,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
afterClose={onClose}
|
afterClose={onClose}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
width={800}
|
width={800}
|
||||||
height="80vh"
|
|
||||||
loading={jsonSaving}
|
loading={jsonSaving}
|
||||||
transitionName="animation-move-down"
|
transitionName="animation-move-down"
|
||||||
centered>
|
centered>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text style={{ width: '100%' }} type="danger">
|
||||||
{jsonError ? <span style={{ color: 'red' }}>{jsonError}</span> : ''}
|
{jsonError ? <pre>{jsonError}</pre> : ''}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
|
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||||
import { DeleteIcon } from '@renderer/components/Icons'
|
import { DeleteIcon } from '@renderer/components/Icons'
|
||||||
|
import GeneralPopup from '@renderer/components/Popups/GeneralPopup'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer } from '@renderer/types'
|
||||||
import { Button, Switch, Tag, Typography } from 'antd'
|
import { formatErrorMessage } from '@renderer/utils/error'
|
||||||
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
import { Alert, Button, Space, Switch, Tag, Tooltip, Typography } from 'antd'
|
||||||
import { FC } from 'react'
|
import { CircleXIcon, Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||||
|
import { FC, useCallback } from 'react'
|
||||||
|
import { FallbackProps } from 'react-error-boundary'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface McpServerCardProps {
|
interface McpServerCardProps {
|
||||||
@ -26,6 +31,7 @@ const McpServerCard: FC<McpServerCardProps> = ({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onOpenUrl
|
onOpenUrl
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const handleOpenUrl = (e: React.MouseEvent) => {
|
const handleOpenUrl = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (server.providerUrl) {
|
if (server.providerUrl) {
|
||||||
@ -33,61 +39,135 @@ const McpServerCard: FC<McpServerCardProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Fallback = useCallback(
|
||||||
|
(props: FallbackProps) => {
|
||||||
|
const { error } = props
|
||||||
|
const errorDetails = formatErrorMessage(error)
|
||||||
|
|
||||||
|
const ErrorDetails = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
textWrap: 'pretty',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
userSelect: 'text',
|
||||||
|
marginRight: 20,
|
||||||
|
color: 'var(--color-status-error)'
|
||||||
|
}}>
|
||||||
|
{errorDetails}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickDetails = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
GeneralPopup.show({ content: <ErrorDetails /> })
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message={t('error.boundary.mcp.invalid')}
|
||||||
|
showIcon
|
||||||
|
type="error"
|
||||||
|
style={{ height: 125, alignItems: 'flex-start', padding: 12 }}
|
||||||
|
description={
|
||||||
|
<Typography.Paragraph style={{ color: 'var(--color-status-error)' }} ellipsis={{ rows: 3 }}>
|
||||||
|
{errorDetails}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
}
|
||||||
|
onClick={onClickDetails}
|
||||||
|
action={
|
||||||
|
<Space.Compact>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="text"
|
||||||
|
icon={
|
||||||
|
<Tooltip title={t('error.boundary.details')}>
|
||||||
|
<CircleXIcon size={16} />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
onClick={onClickDetails}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="text"
|
||||||
|
icon={
|
||||||
|
<Tooltip title={t('common.delete')}>
|
||||||
|
<DeleteIcon size={16} />
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[onDelete, t]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardContainer $isActive={server.isActive} onClick={onEdit}>
|
<ErrorBoundary fallbackComponent={Fallback}>
|
||||||
<ServerHeader>
|
<CardContainer $isActive={server.isActive} onClick={onEdit}>
|
||||||
<ServerNameWrapper>
|
<ServerHeader>
|
||||||
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
<ServerNameWrapper>
|
||||||
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
|
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
||||||
{server.providerUrl && (
|
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
|
||||||
<Button
|
{server.providerUrl && (
|
||||||
type="text"
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
icon={<SquareArrowOutUpRight size={14} />}
|
||||||
|
onClick={handleOpenUrl}
|
||||||
|
data-no-dnd
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ServerNameWrapper>
|
||||||
|
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Switch
|
||||||
|
value={server.isActive}
|
||||||
|
key={server.id}
|
||||||
|
loading={isLoading}
|
||||||
|
onChange={onToggle}
|
||||||
size="small"
|
size="small"
|
||||||
shape="circle"
|
|
||||||
icon={<SquareArrowOutUpRight size={14} />}
|
|
||||||
onClick={handleOpenUrl}
|
|
||||||
data-no-dnd
|
data-no-dnd
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<DeleteIcon size={14} className="lucide-custom" />}
|
||||||
|
danger
|
||||||
|
onClick={onDelete}
|
||||||
|
data-no-dnd
|
||||||
|
/>
|
||||||
|
<Button type="text" shape="circle" icon={<Settings2 size={14} />} onClick={onEdit} data-no-dnd />
|
||||||
|
</ToolbarWrapper>
|
||||||
|
</ServerHeader>
|
||||||
|
<ServerDescription>{server.description}</ServerDescription>
|
||||||
|
<ServerFooter>
|
||||||
|
{version && (
|
||||||
|
<VersionBadge color="#108ee9">
|
||||||
|
<VersionText ellipsis={{ tooltip: true }}>{version}</VersionText>
|
||||||
|
</VersionBadge>
|
||||||
)}
|
)}
|
||||||
</ServerNameWrapper>
|
<ServerTag color="processing">{getMcpTypeLabel(server.type ?? 'stdio')}</ServerTag>
|
||||||
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
|
{server.provider && <ServerTag color="success">{server.provider}</ServerTag>}
|
||||||
<Switch
|
{server.tags
|
||||||
value={server.isActive}
|
?.filter((tag): tag is string => typeof tag === 'string') // Avoid existing non-string tags crash the UI
|
||||||
key={server.id}
|
.map((tag) => (
|
||||||
loading={isLoading}
|
<ServerTag key={tag} color="default">
|
||||||
onChange={onToggle}
|
{tag}
|
||||||
size="small"
|
</ServerTag>
|
||||||
data-no-dnd
|
))}
|
||||||
/>
|
</ServerFooter>
|
||||||
<Button
|
</CardContainer>
|
||||||
type="text"
|
</ErrorBoundary>
|
||||||
shape="circle"
|
|
||||||
icon={<DeleteIcon size={14} className="lucide-custom" />}
|
|
||||||
danger
|
|
||||||
onClick={onDelete}
|
|
||||||
data-no-dnd
|
|
||||||
/>
|
|
||||||
<Button type="text" shape="circle" icon={<Settings2 size={14} />} onClick={onEdit} data-no-dnd />
|
|
||||||
</ToolbarWrapper>
|
|
||||||
</ServerHeader>
|
|
||||||
<ServerDescription>{server.description}</ServerDescription>
|
|
||||||
<ServerFooter>
|
|
||||||
{version && (
|
|
||||||
<VersionBadge color="#108ee9">
|
|
||||||
<VersionText ellipsis={{ tooltip: true }}>{version}</VersionText>
|
|
||||||
</VersionBadge>
|
|
||||||
)}
|
|
||||||
<ServerTag color="processing">{getMcpTypeLabel(server.type ?? 'stdio')}</ServerTag>
|
|
||||||
{server.provider && <ServerTag color="success">{server.provider}</ServerTag>}
|
|
||||||
{server.tags
|
|
||||||
?.filter((tag): tag is string => typeof tag === 'string') // Avoid existing non-string tags crash the UI
|
|
||||||
.map((tag) => (
|
|
||||||
<ServerTag key={tag} color="default">
|
|
||||||
{tag}
|
|
||||||
</ServerTag>
|
|
||||||
))}
|
|
||||||
</ServerFooter>
|
|
||||||
</CardContainer>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
|
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
@ -30,26 +31,28 @@ const MCPSettings: FC = () => {
|
|||||||
</BackButtonContainer>
|
</BackButtonContainer>
|
||||||
)}
|
)}
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<Routes>
|
<ErrorBoundary>
|
||||||
<Route path="/" element={<McpServersList />} />
|
<Routes>
|
||||||
<Route path="settings/:serverId" element={<McpSettings />} />
|
<Route path="/" element={<McpServersList />} />
|
||||||
<Route
|
<Route path="settings/:serverId" element={<McpSettings />} />
|
||||||
path="npx-search"
|
<Route
|
||||||
element={
|
path="npx-search"
|
||||||
<SettingContainer theme={theme}>
|
element={
|
||||||
<NpxSearch />
|
<SettingContainer theme={theme}>
|
||||||
</SettingContainer>
|
<NpxSearch />
|
||||||
}
|
</SettingContainer>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="mcp-install"
|
<Route
|
||||||
element={
|
path="mcp-install"
|
||||||
<SettingContainer theme={theme}>
|
element={
|
||||||
<InstallNpxUv />
|
<SettingContainer theme={theme}>
|
||||||
</SettingContainer>
|
<InstallNpxUv />
|
||||||
}
|
</SettingContainer>
|
||||||
/>
|
}
|
||||||
</Routes>
|
/>
|
||||||
|
</Routes>
|
||||||
|
</ErrorBoundary>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import ImageStorage from '@renderer/services/ImageStorage'
|
|||||||
import { Provider, ProviderType } from '@renderer/types'
|
import { Provider, ProviderType } from '@renderer/types'
|
||||||
import { compressImage, generateColorFromChar, getForegroundColor } from '@renderer/utils'
|
import { compressImage, generateColorFromChar, getForegroundColor } from '@renderer/utils'
|
||||||
import { Divider, Dropdown, Form, Input, Modal, Popover, Select, Upload } from 'antd'
|
import { Divider, Dropdown, Form, Input, Modal, Popover, Select, Upload } from 'antd'
|
||||||
import React, { useEffect, useState } from 'react'
|
import { ItemType } from 'antd/es/menu/interface'
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
const [logoPickerOpen, setLogoPickerOpen] = useState(false)
|
const [logoPickerOpen, setLogoPickerOpen] = useState(false)
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const uploadRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (provider?.id) {
|
if (provider?.id) {
|
||||||
@ -107,80 +109,67 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
|
|||||||
{
|
{
|
||||||
key: 'upload',
|
key: 'upload',
|
||||||
label: (
|
label: (
|
||||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
<Upload
|
||||||
<Upload
|
customRequest={() => {}}
|
||||||
customRequest={() => {}}
|
accept="image/png, image/jpeg, image/gif"
|
||||||
accept="image/png, image/jpeg, image/gif"
|
itemRender={() => null}
|
||||||
itemRender={() => null}
|
maxCount={1}
|
||||||
maxCount={1}
|
onChange={async ({ file }) => {
|
||||||
onChange={async ({ file }) => {
|
try {
|
||||||
try {
|
const _file = file.originFileObj as File
|
||||||
const _file = file.originFileObj as File
|
let logoData: string | Blob
|
||||||
let logoData: string | Blob
|
|
||||||
|
|
||||||
if (_file.type === 'image/gif') {
|
if (_file.type === 'image/gif') {
|
||||||
logoData = _file
|
logoData = _file
|
||||||
} else {
|
} else {
|
||||||
logoData = await compressImage(_file)
|
logoData = await compressImage(_file)
|
||||||
}
|
|
||||||
|
|
||||||
if (provider?.id) {
|
|
||||||
if (logoData instanceof Blob && !(logoData instanceof File)) {
|
|
||||||
const fileFromBlob = new File([logoData], 'logo.png', { type: logoData.type })
|
|
||||||
await ImageStorage.set(`provider-${provider.id}`, fileFromBlob)
|
|
||||||
} else {
|
|
||||||
await ImageStorage.set(`provider-${provider.id}`, logoData)
|
|
||||||
}
|
|
||||||
const savedLogo = await ImageStorage.get(`provider-${provider.id}`)
|
|
||||||
setLogo(savedLogo)
|
|
||||||
} else {
|
|
||||||
// 临时保存在内存中,等创建 provider 后会在调用方保存
|
|
||||||
const tempUrl = await new Promise<string>((resolve) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => resolve(reader.result as string)
|
|
||||||
reader.readAsDataURL(logoData)
|
|
||||||
})
|
|
||||||
setLogo(tempUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
setDropdownOpen(false)
|
|
||||||
} catch (error: any) {
|
|
||||||
window.message.error(error.message)
|
|
||||||
}
|
}
|
||||||
}}>
|
|
||||||
{t('settings.general.image_upload')}
|
if (provider?.id) {
|
||||||
</Upload>
|
if (logoData instanceof Blob && !(logoData instanceof File)) {
|
||||||
</div>
|
const fileFromBlob = new File([logoData], 'logo.png', { type: logoData.type })
|
||||||
)
|
await ImageStorage.set(`provider-${provider.id}`, fileFromBlob)
|
||||||
|
} else {
|
||||||
|
await ImageStorage.set(`provider-${provider.id}`, logoData)
|
||||||
|
}
|
||||||
|
const savedLogo = await ImageStorage.get(`provider-${provider.id}`)
|
||||||
|
setLogo(savedLogo)
|
||||||
|
} else {
|
||||||
|
// 临时保存在内存中,等创建 provider 后会在调用方保存
|
||||||
|
const tempUrl = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.readAsDataURL(logoData)
|
||||||
|
})
|
||||||
|
setLogo(tempUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDropdownOpen(false)
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error(error.message)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<MenuItem ref={uploadRef}>{t('settings.general.image_upload')}</MenuItem>
|
||||||
|
</Upload>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
uploadRef.current?.click()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'builtin',
|
key: 'builtin',
|
||||||
label: (
|
label: <MenuItem>{t('settings.general.avatar.builtin')}</MenuItem>,
|
||||||
<div
|
onClick: () => {
|
||||||
style={{ width: '100%', textAlign: 'center' }}
|
setDropdownOpen(false)
|
||||||
onClick={(e) => {
|
setLogoPickerOpen(true)
|
||||||
e.stopPropagation()
|
}
|
||||||
setDropdownOpen(false)
|
|
||||||
setLogoPickerOpen(true)
|
|
||||||
}}>
|
|
||||||
{t('settings.general.avatar.builtin')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reset',
|
key: 'reset',
|
||||||
label: (
|
label: <MenuItem>{t('settings.general.avatar.reset')}</MenuItem>,
|
||||||
<div
|
onClick: handleReset
|
||||||
style={{ width: '100%', textAlign: 'center' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleReset()
|
|
||||||
}}>
|
|
||||||
{t('settings.general.avatar.reset')}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
]
|
] satisfies ItemType[]
|
||||||
|
|
||||||
// for logo
|
// for logo
|
||||||
const backgroundColor = generateColorFromChar(name)
|
const backgroundColor = generateColorFromChar(name)
|
||||||
@ -302,6 +291,11 @@ const ProviderInitialsLogo = styled.div`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const MenuItem = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
`
|
||||||
|
|
||||||
export default class AddProviderPopup {
|
export default class AddProviderPopup {
|
||||||
static topviewId = 0
|
static topviewId = 0
|
||||||
static hide() {
|
static hide() {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
isOpenRouterBuiltInWebSearchModel,
|
isOpenRouterBuiltInWebSearchModel,
|
||||||
isQwenMTModel,
|
isQwenMTModel,
|
||||||
isReasoningModel,
|
isReasoningModel,
|
||||||
isSupportedDisableGenerationModel,
|
|
||||||
isSupportedReasoningEffortModel,
|
isSupportedReasoningEffortModel,
|
||||||
isSupportedThinkingTokenModel,
|
isSupportedThinkingTokenModel,
|
||||||
isWebSearchModel
|
isWebSearchModel
|
||||||
@ -85,7 +84,7 @@ async function fetchExternalTool(
|
|||||||
// 可能会有重复?
|
// 可能会有重复?
|
||||||
const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
|
const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
|
||||||
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
|
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
|
||||||
const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
|
const knowledgeRecognition = assistant.knowledgeRecognition || 'off'
|
||||||
const webSearchProvider = WebSearchService.getWebSearchProvider(assistant.webSearchProviderId)
|
const webSearchProvider = WebSearchService.getWebSearchProvider(assistant.webSearchProviderId)
|
||||||
|
|
||||||
// 使用外部搜索工具
|
// 使用外部搜索工具
|
||||||
@ -463,10 +462,15 @@ export async function fetchChatCompletion({
|
|||||||
|
|
||||||
const filteredMessages4 = filterAdjacentUserMessaegs(filteredMessages3)
|
const filteredMessages4 = filterAdjacentUserMessaegs(filteredMessages3)
|
||||||
|
|
||||||
const _messages = filterUserRoleStartMessages(
|
let _messages = filterUserRoleStartMessages(
|
||||||
filterEmptyMessages(filterAfterContextClearMessages(takeRight(filteredMessages4, contextCount + 2))) // 取原来几个provider的最大值
|
filterEmptyMessages(filterAfterContextClearMessages(takeRight(filteredMessages4, contextCount + 2))) // 取原来几个provider的最大值
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fallback: ensure at least the last user message is present to avoid empty payloads
|
||||||
|
if ((!_messages || _messages.length === 0) && lastUserMessage) {
|
||||||
|
_messages = [lastUserMessage]
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: qwen3即使关闭思考仍然会导致enableReasoning的结果为true
|
// FIXME: qwen3即使关闭思考仍然会导致enableReasoning的结果为true
|
||||||
const enableReasoning =
|
const enableReasoning =
|
||||||
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&
|
||||||
@ -483,8 +487,7 @@ export async function fetchChatCompletion({
|
|||||||
|
|
||||||
const enableUrlContext = assistant.enableUrlContext || false
|
const enableUrlContext = assistant.enableUrlContext || false
|
||||||
|
|
||||||
const enableGenerateImage =
|
const enableGenerateImage = isGenerateImageModel(model) && assistant.enableGenerateImage
|
||||||
isGenerateImageModel(model) && (isSupportedDisableGenerationModel(model) ? assistant.enableGenerateImage : true)
|
|
||||||
|
|
||||||
// --- Call AI Completions ---
|
// --- Call AI Completions ---
|
||||||
onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED })
|
onChunkReceived({ type: ChunkType.LLM_RESPONSE_CREATED })
|
||||||
|
|||||||
@ -103,8 +103,8 @@ vi.mock('@renderer/config/prompts', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@renderer/config/systemModels', () => ({
|
vi.mock('@renderer/config/systemModels', () => ({
|
||||||
GENERATE_IMAGE_MODELS: [],
|
OPENAI_IMAGE_GENERATION_MODELS: [],
|
||||||
SUPPORTED_DISABLE_GENERATION_MODELS: []
|
GENERATE_IMAGE_MODELS: []
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@renderer/config/tools', () => ({
|
vi.mock('@renderer/config/tools', () => ({
|
||||||
|
|||||||
@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
|||||||
{
|
{
|
||||||
key: 'cherry-studio',
|
key: 'cherry-studio',
|
||||||
storage,
|
storage,
|
||||||
version: 142,
|
version: 144,
|
||||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||||
migrate
|
migrate
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1605,7 +1605,6 @@ const migrateConfig = {
|
|||||||
if (state.paintings && !state.paintings.tokenflux_paintings) {
|
if (state.paintings && !state.paintings.tokenflux_paintings) {
|
||||||
state.paintings.tokenflux_paintings = []
|
state.paintings.tokenflux_paintings = []
|
||||||
}
|
}
|
||||||
state.settings.showTokens = true
|
|
||||||
state.settings.testPlan = false
|
state.settings.testPlan = false
|
||||||
return state
|
return state
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2315,6 +2314,26 @@ const migrateConfig = {
|
|||||||
logger.error('migrate 142 error', error as Error)
|
logger.error('migrate 142 error', error as Error)
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'143': (state: RootState) => {
|
||||||
|
try {
|
||||||
|
addMiniApp(state, 'longcat')
|
||||||
|
return state
|
||||||
|
} catch (error) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'144': (state: RootState) => {
|
||||||
|
try {
|
||||||
|
if (state.settings) {
|
||||||
|
state.settings.confirmDeleteMessage = settingsInitialState.confirmDeleteMessage
|
||||||
|
state.settings.confirmRegenerateMessage = settingsInitialState.confirmRegenerateMessage
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('migrate 144 error', error as Error)
|
||||||
|
return state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,6 @@ export interface SettingsState {
|
|||||||
userName: string
|
userName: string
|
||||||
userId: string
|
userId: string
|
||||||
showPrompt: boolean
|
showPrompt: boolean
|
||||||
showTokens: boolean
|
|
||||||
showMessageDivider: boolean
|
showMessageDivider: boolean
|
||||||
messageFont: 'system' | 'serif'
|
messageFont: 'system' | 'serif'
|
||||||
showInputEstimatedTokens: boolean
|
showInputEstimatedTokens: boolean
|
||||||
@ -124,6 +123,9 @@ export interface SettingsState {
|
|||||||
enableTopicNaming: boolean
|
enableTopicNaming: boolean
|
||||||
customCss: string
|
customCss: string
|
||||||
topicNamingPrompt: string
|
topicNamingPrompt: string
|
||||||
|
// 消息操作确认设置
|
||||||
|
confirmDeleteMessage: boolean
|
||||||
|
confirmRegenerateMessage: boolean
|
||||||
// Sidebar icons
|
// Sidebar icons
|
||||||
sidebarIcons: {
|
sidebarIcons: {
|
||||||
visible: SidebarIcon[]
|
visible: SidebarIcon[]
|
||||||
@ -233,7 +235,6 @@ export const initialState: SettingsState = {
|
|||||||
userName: '',
|
userName: '',
|
||||||
userId: uuid(),
|
userId: uuid(),
|
||||||
showPrompt: true,
|
showPrompt: true,
|
||||||
showTokens: true,
|
|
||||||
showMessageDivider: true,
|
showMessageDivider: true,
|
||||||
messageFont: 'system',
|
messageFont: 'system',
|
||||||
showInputEstimatedTokens: false,
|
showInputEstimatedTokens: false,
|
||||||
@ -349,6 +350,9 @@ export const initialState: SettingsState = {
|
|||||||
enableSpellCheck: false,
|
enableSpellCheck: false,
|
||||||
spellCheckLanguages: [],
|
spellCheckLanguages: [],
|
||||||
enableQuickPanelTriggers: false,
|
enableQuickPanelTriggers: false,
|
||||||
|
// 消息操作确认设置
|
||||||
|
confirmDeleteMessage: true,
|
||||||
|
confirmRegenerateMessage: true,
|
||||||
// 硬件加速设置
|
// 硬件加速设置
|
||||||
disableHardwareAcceleration: false,
|
disableHardwareAcceleration: false,
|
||||||
exportMenuOptions: {
|
exportMenuOptions: {
|
||||||
@ -454,9 +458,6 @@ const settingsSlice = createSlice({
|
|||||||
setShowPrompt: (state, action: PayloadAction<boolean>) => {
|
setShowPrompt: (state, action: PayloadAction<boolean>) => {
|
||||||
state.showPrompt = action.payload
|
state.showPrompt = action.payload
|
||||||
},
|
},
|
||||||
setShowTokens: (state, action: PayloadAction<boolean>) => {
|
|
||||||
state.showTokens = action.payload
|
|
||||||
},
|
|
||||||
setShowMessageDivider: (state, action: PayloadAction<boolean>) => {
|
setShowMessageDivider: (state, action: PayloadAction<boolean>) => {
|
||||||
state.showMessageDivider = action.payload
|
state.showMessageDivider = action.payload
|
||||||
},
|
},
|
||||||
@ -776,6 +777,12 @@ const settingsSlice = createSlice({
|
|||||||
setEnableQuickPanelTriggers: (state, action: PayloadAction<boolean>) => {
|
setEnableQuickPanelTriggers: (state, action: PayloadAction<boolean>) => {
|
||||||
state.enableQuickPanelTriggers = action.payload
|
state.enableQuickPanelTriggers = action.payload
|
||||||
},
|
},
|
||||||
|
setConfirmDeleteMessage: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.confirmDeleteMessage = action.payload
|
||||||
|
},
|
||||||
|
setConfirmRegenerateMessage: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.confirmRegenerateMessage = action.payload
|
||||||
|
},
|
||||||
// setDisableHardwareAcceleration: (state, action: PayloadAction<boolean>) => {
|
// setDisableHardwareAcceleration: (state, action: PayloadAction<boolean>) => {
|
||||||
// state.disableHardwareAcceleration = action.payload
|
// state.disableHardwareAcceleration = action.payload
|
||||||
// },
|
// },
|
||||||
@ -866,7 +873,6 @@ export const {
|
|||||||
setProxyBypassRules,
|
setProxyBypassRules,
|
||||||
setUserName,
|
setUserName,
|
||||||
setShowPrompt,
|
setShowPrompt,
|
||||||
setShowTokens,
|
|
||||||
setShowMessageDivider,
|
setShowMessageDivider,
|
||||||
setMessageFont,
|
setMessageFont,
|
||||||
setShowInputEstimatedTokens,
|
setShowInputEstimatedTokens,
|
||||||
@ -956,6 +962,8 @@ export const {
|
|||||||
setSpellCheckLanguages,
|
setSpellCheckLanguages,
|
||||||
setExportMenuOptions,
|
setExportMenuOptions,
|
||||||
setEnableQuickPanelTriggers,
|
setEnableQuickPanelTriggers,
|
||||||
|
setConfirmDeleteMessage,
|
||||||
|
setConfirmRegenerateMessage,
|
||||||
// setDisableHardwareAcceleration,
|
// setDisableHardwareAcceleration,
|
||||||
setOpenAISummaryText,
|
setOpenAISummaryText,
|
||||||
setOpenAIVerbosity,
|
setOpenAIVerbosity,
|
||||||
|
|||||||
@ -338,6 +338,15 @@ const fetchAndProcessAssistantResponseImpl = async (
|
|||||||
messagesForContext = contextSlice.filter((m) => m && !m.status?.includes('ing'))
|
messagesForContext = contextSlice.filter((m) => m && !m.status?.includes('ing'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure at least the triggering user message is present to avoid empty payloads
|
||||||
|
if ((!messagesForContext || messagesForContext.length === 0) && userMessageId) {
|
||||||
|
const stateAfter = getState()
|
||||||
|
const maybeUserMessage = stateAfter.messages.entities[userMessageId]
|
||||||
|
if (maybeUserMessage) {
|
||||||
|
messagesForContext = [maybeUserMessage]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
callbacks = createCallbacks({
|
callbacks = createCallbacks({
|
||||||
blockManager,
|
blockManager,
|
||||||
dispatch,
|
dispatch,
|
||||||
|
|||||||
@ -8,8 +8,10 @@ export * from './file'
|
|||||||
export * from './note'
|
export * from './note'
|
||||||
|
|
||||||
import type { FileMetadata } from './file'
|
import type { FileMetadata } from './file'
|
||||||
|
import { MCPConfigSample, McpServerType } from './mcp'
|
||||||
import type { Message } from './newMessage'
|
import type { Message } from './newMessage'
|
||||||
|
|
||||||
|
export * from './mcp'
|
||||||
export * from './ocr'
|
export * from './ocr'
|
||||||
|
|
||||||
export type Assistant = {
|
export type Assistant = {
|
||||||
@ -825,6 +827,7 @@ export type KnowledgeReference = {
|
|||||||
file?: FileMetadata
|
file?: FileMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: 把 mcp 相关类型定义迁移到独立文件中
|
||||||
export type MCPArgType = 'string' | 'list' | 'number'
|
export type MCPArgType = 'string' | 'list' | 'number'
|
||||||
export type MCPEnvType = 'string' | 'number'
|
export type MCPEnvType = 'string' | 'number'
|
||||||
export type MCPArgParameter = { [key: string]: MCPArgType }
|
export type MCPArgParameter = { [key: string]: MCPArgType }
|
||||||
@ -836,29 +839,17 @@ export interface MCPServerParameter {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MCPConfigSample {
|
|
||||||
command: string
|
|
||||||
args: string[]
|
|
||||||
env?: Record<string, string> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MCPServer {
|
export interface MCPServer {
|
||||||
id: string
|
id: string // internal id
|
||||||
name: string
|
name: string // mcp name, generally as unique key
|
||||||
type?: 'stdio' | 'sse' | 'inMemory' | 'streamableHttp'
|
type?: McpServerType | 'inMemory'
|
||||||
description?: string
|
description?: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
command?: string
|
command?: string
|
||||||
registryUrl?: string
|
registryUrl?: string
|
||||||
args?: string[]
|
args?: string[]
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
shouldConfig?: boolean
|
|
||||||
isActive: boolean
|
|
||||||
disabledTools?: string[] // List of tool names that are disabled for this server
|
|
||||||
disabledAutoApproveTools?: string[] // Whether to auto-approve tools for this server
|
|
||||||
configSample?: MCPConfigSample
|
|
||||||
headers?: Record<string, string> // Custom headers to be sent with requests to this server
|
headers?: Record<string, string> // Custom headers to be sent with requests to this server
|
||||||
searchKey?: string
|
|
||||||
provider?: string // Provider name for this server like ModelScope, Higress, etc.
|
provider?: string // Provider name for this server like ModelScope, Higress, etc.
|
||||||
providerUrl?: string // URL of the MCP server in provider's website or documentation
|
providerUrl?: string // URL of the MCP server in provider's website or documentation
|
||||||
logoUrl?: string // URL of the MCP server's logo
|
logoUrl?: string // URL of the MCP server's logo
|
||||||
@ -868,6 +859,17 @@ export interface MCPServer {
|
|||||||
dxtVersion?: string // Version of the DXT package
|
dxtVersion?: string // Version of the DXT package
|
||||||
dxtPath?: string // Path where the DXT package was extracted
|
dxtPath?: string // Path where the DXT package was extracted
|
||||||
reference?: string // Reference link for the server, e.g., documentation or homepage
|
reference?: string // Reference link for the server, e.g., documentation or homepage
|
||||||
|
searchKey?: string
|
||||||
|
configSample?: MCPConfigSample
|
||||||
|
/** List of tool names that are disabled for this server */
|
||||||
|
disabledTools?: string[]
|
||||||
|
/** Whether to auto-approve tools for this server */
|
||||||
|
disabledAutoApproveTools?: string[]
|
||||||
|
|
||||||
|
/** 用于标记内置 MCP 是否需要配置 */
|
||||||
|
shouldConfig?: boolean
|
||||||
|
/** 用于标记服务器是否运行中 */
|
||||||
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BuiltinMCPServer = MCPServer & {
|
export type BuiltinMCPServer = MCPServer & {
|
||||||
@ -1242,6 +1244,28 @@ export type AtLeast<T extends string, U> = {
|
|||||||
[key: string]: U
|
[key: string]: U
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从对象中移除指定的属性键,返回新对象
|
||||||
|
* @template T - 源对象类型
|
||||||
|
* @template K - 要移除的属性键类型,必须是T的键
|
||||||
|
* @param obj - 源对象
|
||||||
|
* @param keys - 要移除的属性键列表
|
||||||
|
* @returns 移除指定属性后的新对象
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const obj = { a: 1, b: 2, c: 3 };
|
||||||
|
* const result = strip(obj, ['a', 'b']);
|
||||||
|
* // result = { c: 3 }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function strip<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||||
|
const result = { ...obj }
|
||||||
|
for (const key of keys) {
|
||||||
|
delete (result as any)[key] // 类型上 Omit 已保证安全
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export type HexColor = string
|
export type HexColor = string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
227
src/renderer/src/types/mcp.ts
Normal file
227
src/renderer/src/types/mcp.ts
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import z from 'zod'
|
||||||
|
|
||||||
|
import { isBuiltinMCPServerName } from '.'
|
||||||
|
|
||||||
|
export const MCPConfigSampleSchema = z.object({
|
||||||
|
command: z.string(),
|
||||||
|
args: z.array(z.string()),
|
||||||
|
env: z.record(z.string(), z.string()).optional()
|
||||||
|
})
|
||||||
|
export type MCPConfigSample = z.infer<typeof MCPConfigSampleSchema>
|
||||||
|
/**
|
||||||
|
* 定义 MCP 服务器的通信类型。
|
||||||
|
* stdio: 通过标准输入/输出与子进程通信 (最常见)。
|
||||||
|
* sse: 通过HTTP Server-Sent Events 通信。
|
||||||
|
*
|
||||||
|
* 允许 inMemory 作为合法字段,需要额外校验 name 是否 builtin
|
||||||
|
*/
|
||||||
|
export const McpServerTypeSchema = z
|
||||||
|
.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')])
|
||||||
|
.default('stdio') // 大多数情况下默认使用 stdio
|
||||||
|
/**
|
||||||
|
* 定义单个 MCP 服务器的配置。
|
||||||
|
* FIXME: 为了兼容性,暂时允许用户编辑任意字段,这可能会导致问题。
|
||||||
|
* 除了类型匹配以外,目前唯一显式禁止的行为是将 type 设置为 inMemory
|
||||||
|
*/
|
||||||
|
export const McpServerConfigSchema = z
|
||||||
|
.object({
|
||||||
|
/**
|
||||||
|
* 服务器内部ID
|
||||||
|
* 可选。用于内部标识服务器的唯一标识符。
|
||||||
|
*/
|
||||||
|
id: z.string().optional().describe('Server internal id.'),
|
||||||
|
/**
|
||||||
|
* 服务器名称
|
||||||
|
* 可选。用于标识和显示服务器。
|
||||||
|
*/
|
||||||
|
name: z.string().optional().describe('Server name for identification and display'),
|
||||||
|
/**
|
||||||
|
* 服务器的通信类型。
|
||||||
|
* 可选。如果未指定,默认为 "stdio"。
|
||||||
|
*/
|
||||||
|
type: McpServerTypeSchema.optional(),
|
||||||
|
/**
|
||||||
|
* 服务器描述
|
||||||
|
* 可选。用于描述服务器的功能和用途。
|
||||||
|
*/
|
||||||
|
description: z.string().optional().describe('Server description'),
|
||||||
|
/**
|
||||||
|
* 服务器的URL地址
|
||||||
|
* 可选。用于指定服务器的访问地址。
|
||||||
|
*/
|
||||||
|
url: z.string().optional().describe('Server URL address'),
|
||||||
|
/**
|
||||||
|
* url 的内部别名,优先使用 baseUrl 字段。
|
||||||
|
* 可选。用于指定服务器的访问地址。
|
||||||
|
*/
|
||||||
|
baseUrl: z.string().optional().describe('Server URL address'),
|
||||||
|
/**
|
||||||
|
* 启动服务器的命令 (如 "uvx", "npx")。
|
||||||
|
* 可选。
|
||||||
|
*/
|
||||||
|
command: z.string().optional().describe("The command to execute (e.g., 'uvx', 'npx')"),
|
||||||
|
/**
|
||||||
|
* registry URL
|
||||||
|
* 可选。用于指定服务器的 registry 地址。
|
||||||
|
*/
|
||||||
|
registryUrl: z.string().optional().describe('Registry URL for the server'),
|
||||||
|
/**
|
||||||
|
* 传递给命令的参数数组。
|
||||||
|
* 通常第一个参数是脚本路径或包名。
|
||||||
|
* 可选。
|
||||||
|
*/
|
||||||
|
args: z.array(z.string()).optional().describe('The arguments to pass to the command'),
|
||||||
|
/**
|
||||||
|
* 启动时注入的环境变量对象。
|
||||||
|
* 键为变量名,值为字符串。
|
||||||
|
* 可选。
|
||||||
|
*/
|
||||||
|
env: z.record(z.string(), z.string()).optional().describe('Environment variables for the server process'),
|
||||||
|
/**
|
||||||
|
* 请求头配置
|
||||||
|
* 可选。用于设置请求时的自定义headers。
|
||||||
|
*/
|
||||||
|
headers: z.record(z.string(), z.string()).optional().describe('Custom headers configuration'),
|
||||||
|
/**
|
||||||
|
* provider 名称
|
||||||
|
* 可选。用于指定服务器的提供商。
|
||||||
|
*/
|
||||||
|
provider: z.string().optional().describe('Provider name for the server'),
|
||||||
|
/**
|
||||||
|
* provider URL
|
||||||
|
* 可选。用于指定服务器提供商的网站或文档地址。
|
||||||
|
*/
|
||||||
|
providerUrl: z.string().optional().describe('URL of the provider website or documentation'),
|
||||||
|
/**
|
||||||
|
* logo URL
|
||||||
|
* 可选。用于指定服务器的logo图片地址。
|
||||||
|
*/
|
||||||
|
logoUrl: z.string().optional().describe('URL of the server logo'),
|
||||||
|
/**
|
||||||
|
* 服务器标签
|
||||||
|
* 可选。用于对服务器进行分类和标记。
|
||||||
|
*/
|
||||||
|
tags: z.array(z.string()).optional().describe('Server tags for categorization'),
|
||||||
|
/**
|
||||||
|
* 是否为长期运行的服务器
|
||||||
|
* 可选。用于标识服务器是否需要持续运行。
|
||||||
|
*/
|
||||||
|
longRunning: z.boolean().optional().describe('Whether the server is long running'),
|
||||||
|
/**
|
||||||
|
* 请求超时时间
|
||||||
|
* 可选。单位为秒,默认为60秒。
|
||||||
|
*/
|
||||||
|
timeout: z.number().optional().describe('Timeout in seconds for requests to this server'),
|
||||||
|
/**
|
||||||
|
* DXT包版本号
|
||||||
|
* 可选。用于标识DXT包的版本。
|
||||||
|
*/
|
||||||
|
dxtVersion: z.string().optional().describe('Version of the DXT package'),
|
||||||
|
/**
|
||||||
|
* DXT包解压路径
|
||||||
|
* 可选。指定DXT包解压后的存放路径。
|
||||||
|
*/
|
||||||
|
dxtPath: z.string().optional().describe('Path where the DXT package was extracted'),
|
||||||
|
/**
|
||||||
|
* 参考链接
|
||||||
|
* 可选。服务器的文档或主页链接。
|
||||||
|
*/
|
||||||
|
reference: z.string().optional().describe('Reference link for the server'),
|
||||||
|
/**
|
||||||
|
* 搜索关键字
|
||||||
|
* 可选。用于服务器搜索的关键字。
|
||||||
|
*/
|
||||||
|
searchKey: z.string().optional().describe('Search key for the server'),
|
||||||
|
/**
|
||||||
|
* 配置示例
|
||||||
|
* 可选。服务器配置的示例。
|
||||||
|
*/
|
||||||
|
configSample: MCPConfigSampleSchema.optional().describe('Configuration sample for the server'),
|
||||||
|
/**
|
||||||
|
* 禁用的工具列表
|
||||||
|
* 可选。用于指定该服务器上禁用的工具。
|
||||||
|
*/
|
||||||
|
disabledTools: z.array(z.string()).optional().describe('List of disabled tools for this server'),
|
||||||
|
/**
|
||||||
|
* 禁用自动批准的工具列表
|
||||||
|
* 可选。用于指定该服务器上禁用自动批准的工具。
|
||||||
|
*/
|
||||||
|
disabledAutoApproveTools: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('List of tools that are disabled for auto-approval on this server'),
|
||||||
|
/**
|
||||||
|
* 是否应该配置
|
||||||
|
* 可选。用于标识服务器是否需要配置。
|
||||||
|
*/
|
||||||
|
shouldConfig: z.boolean().optional().describe('Whether the server should be configured'),
|
||||||
|
/**
|
||||||
|
* 是否激活
|
||||||
|
* 可选。用于标识服务器是否处于激活状态。
|
||||||
|
*/
|
||||||
|
isActive: z.boolean().optional().describe('Whether the server is active')
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
// 在这里定义额外的校验逻辑
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
if (schema.type === 'inMemory' && schema.name && !isBuiltinMCPServerName(schema.name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Server type is inMemory but this is not a builtin MCP server, which is not allowed'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* 将服务器别名(字符串ID)映射到其配置的对象。
|
||||||
|
* 例如: { "my-tools": { command: "...", args: [...] }, "github": { ... } }
|
||||||
|
*/
|
||||||
|
export const McpServersMapSchema = z.record(z.string(), McpServerConfigSchema)
|
||||||
|
/**
|
||||||
|
* 顶层配置对象Schema。
|
||||||
|
* 表示整个MCP配置文件的结构。
|
||||||
|
*/
|
||||||
|
export const McpConfigSchema = z.object({
|
||||||
|
/**
|
||||||
|
* 包含一个或多个MCP服务器定义的映射。
|
||||||
|
* 名称(键)是用户定义的别名。
|
||||||
|
* 此字段为必需。
|
||||||
|
*/
|
||||||
|
// 不在这里 refine 服务器数量,因为在类型定义文件中不能用 i18n 处理错误信息
|
||||||
|
mcpServers: McpServersMapSchema.describe('Mapping of server aliases to their configurations')
|
||||||
|
})
|
||||||
|
// 数据校验用类型,McpServerType 复用于 MCPServer
|
||||||
|
|
||||||
|
export type McpServerType = z.infer<typeof McpServerTypeSchema>
|
||||||
|
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>
|
||||||
|
export type McpServersMap = z.infer<typeof McpServersMapSchema>
|
||||||
|
export type McpConfig = z.infer<typeof McpConfigSchema>
|
||||||
|
/**
|
||||||
|
* 验证一个未知对象是否为合法的MCP配置。
|
||||||
|
* @param config - 要验证的配置对象
|
||||||
|
* @returns 如果有效则为解析后的 `McpConfig` 对象,否则抛出 ZodError。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function validateMcpConfig(config: unknown): McpConfig {
|
||||||
|
return McpConfigSchema.parse(config)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 安全地验证一个未知对象,返回结果和可能的错误。
|
||||||
|
* @param config - 要验证的配置对象
|
||||||
|
* @returns 包含成功/失败状态和数据的 `SafeParseResult`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function safeValidateMcpConfig(config: unknown) {
|
||||||
|
return McpConfigSchema.safeParse(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地验证一个未知对象是否为合法的MCP服务器配置。
|
||||||
|
* @param config - 要验证的配置对象
|
||||||
|
* @returns 包含成功/失败状态和数据的 `SafeParseResult`。
|
||||||
|
*/
|
||||||
|
export function safeValidateMcpServerConfig(config: unknown) {
|
||||||
|
return McpServerConfigSchema.safeParse(config)
|
||||||
|
}
|
||||||
@ -72,7 +72,7 @@ export type RequestOptions = Anthropic.RequestOptions | OpenAI.RequestOptions |
|
|||||||
* OpenAI
|
* OpenAI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type OpenAIParamsWithoutReasoningEffort = Omit<OpenAI.Chat.Completions.ChatCompletionCreateParams, 'reasoning_effort'>
|
type OpenAIParamsPurified = Omit<OpenAI.Chat.Completions.ChatCompletionCreateParams, 'reasoning_effort' | 'modalities'>
|
||||||
|
|
||||||
export type ReasoningEffortOptionalParams = {
|
export type ReasoningEffortOptionalParams = {
|
||||||
thinking?: { type: 'disabled' | 'enabled' | 'auto'; budget_tokens?: number }
|
thinking?: { type: 'disabled' | 'enabled' | 'auto'; budget_tokens?: number }
|
||||||
@ -97,7 +97,7 @@ export type ReasoningEffortOptionalParams = {
|
|||||||
// Add any other potential reasoning-related keys here if they exist
|
// Add any other potential reasoning-related keys here if they exist
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenAISdkParams = OpenAIParamsWithoutReasoningEffort & ReasoningEffortOptionalParams
|
export type OpenAISdkParams = OpenAIParamsPurified & ReasoningEffortOptionalParams & OpenAIModalities & OpenAIExtraBody
|
||||||
|
|
||||||
// OpenRouter may include additional fields like cost
|
// OpenRouter may include additional fields like cost
|
||||||
export type OpenAISdkRawChunk =
|
export type OpenAISdkRawChunk =
|
||||||
@ -116,7 +116,18 @@ export type OpenAISdkRawContentSource =
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type OpenAISdkMessageParam = OpenAI.Chat.Completions.ChatCompletionMessageParam
|
export type OpenAISdkMessageParam = OpenAI.Chat.Completions.ChatCompletionMessageParam
|
||||||
|
export type OpenAIExtraBody = {
|
||||||
|
// for qwen mt
|
||||||
|
translation_options?: {
|
||||||
|
source_lang: 'auto'
|
||||||
|
target_lang: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// image is for openrouter. audio is ignored for now
|
||||||
|
export type OpenAIModality = OpenAI.ChatCompletionModality | 'image'
|
||||||
|
export type OpenAIModalities = {
|
||||||
|
modalities?: OpenAIModality[]
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* OpenAI Response
|
* OpenAI Response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -56,7 +56,6 @@ describe('error', () => {
|
|||||||
const error = new Error('Test error')
|
const error = new Error('Test error')
|
||||||
const result = formatErrorMessage(error)
|
const result = formatErrorMessage(error)
|
||||||
|
|
||||||
expect(console.error).toHaveBeenCalled()
|
|
||||||
expect(result).toContain('Error Details:')
|
expect(result).toContain('Error Details:')
|
||||||
expect(result).toContain(' {')
|
expect(result).toContain(' {')
|
||||||
expect(result).toContain(' "message": "Test error"')
|
expect(result).toContain(' "message": "Test error"')
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { htmlToMarkdown, markdownToHtml, markdownToSafeHtml, sanitizeHtml } from '../markdownConverter'
|
import { htmlToMarkdown, markdownToHtml } from '../markdownConverter'
|
||||||
|
|
||||||
describe('markdownConverter', () => {
|
describe('markdownConverter', () => {
|
||||||
describe('htmlToMarkdown', () => {
|
describe('htmlToMarkdown', () => {
|
||||||
@ -294,33 +294,6 @@ describe('markdownConverter', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('sanitizeHtml', () => {
|
|
||||||
it('should sanitize HTML content and remove scripts', () => {
|
|
||||||
const html = '<h1>Hello</h1><script>alert("xss")</script>'
|
|
||||||
const result = sanitizeHtml(html)
|
|
||||||
expect(result).toContain('<h1>Hello</h1>')
|
|
||||||
expect(result).not.toContain('<script>')
|
|
||||||
expect(result).not.toContain('alert')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should preserve task list HTML elements', () => {
|
|
||||||
const html =
|
|
||||||
'<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><input type="checkbox" checked disabled> Task item</li></ul>'
|
|
||||||
const result = sanitizeHtml(html)
|
|
||||||
expect(result).toContain('data-type="taskList"')
|
|
||||||
expect(result).toContain('data-type="taskItem"')
|
|
||||||
expect(result).toContain('data-checked="true"')
|
|
||||||
expect(result).toContain('<input type="checkbox"')
|
|
||||||
expect(result).toContain('checked')
|
|
||||||
expect(result).toContain('disabled')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle empty HTML', () => {
|
|
||||||
const result = sanitizeHtml('')
|
|
||||||
expect(result).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Task List with Labels', () => {
|
describe('Task List with Labels', () => {
|
||||||
it('should wrap task items with labels when label option is true', () => {
|
it('should wrap task items with labels when label option is true', () => {
|
||||||
const markdown = '- [ ] abcd\n\n- [x] efgh'
|
const markdown = '- [ ] abcd\n\n- [x] efgh'
|
||||||
@ -329,15 +302,6 @@ describe('markdownConverter', () => {
|
|||||||
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<p><label><input type="checkbox" disabled> abcd</label></p>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="true">\n<p><label><input type="checkbox" checked disabled> efgh</label></p>\n</li>\n</ul>\n'
|
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<p><label><input type="checkbox" disabled> abcd</label></p>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="true">\n<p><label><input type="checkbox" checked disabled> efgh</label></p>\n</li>\n</ul>\n'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve labels in sanitized HTML', () => {
|
|
||||||
const html =
|
|
||||||
'<ul data-type="taskList"><li data-type="taskItem"><label><input type="checkbox" checked disabled> Task with label</label></li></ul>'
|
|
||||||
const result = sanitizeHtml(html)
|
|
||||||
expect(result).toContain('<label>')
|
|
||||||
expect(result).toContain('<input type="checkbox" checked')
|
|
||||||
expect(result).toContain('Task with label')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Task List Round Trip', () => {
|
describe('Task List Round Trip', () => {
|
||||||
@ -423,8 +387,8 @@ describe('markdownConverter', () => {
|
|||||||
|
|
||||||
it('should handle images with spaces in file:// protocol paths', () => {
|
it('should handle images with spaces in file:// protocol paths', () => {
|
||||||
const markdown = ''
|
const markdown = ''
|
||||||
const result = markdownToSafeHtml(markdown)
|
const result = htmlToMarkdown(markdownToHtml(markdown))
|
||||||
expect(result).toContain('<img src="file:///path/to/my image with spaces.png" alt="My Image">')
|
expect(result).toBe(markdown)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shoud img label to markdown', () => {
|
it('shoud img label to markdown', () => {
|
||||||
@ -439,4 +403,65 @@ describe('markdownConverter', () => {
|
|||||||
const result = markdownToHtml(markdown)
|
const result = markdownToHtml(markdown)
|
||||||
expect(result).toBe('<p>Text with <br />\nindentation <br />\nand without indentation</p>\n')
|
expect(result).toBe('<p>Text with <br />\nindentation <br />\nand without indentation</p>\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Custom XML Tags Preservation', () => {
|
||||||
|
it('should preserve custom XML tags through markdown-to-HTML and HTML-to-markdown conversion', () => {
|
||||||
|
const markdown = 'Some text with <custom-tag>content</custom-tag> and more text'
|
||||||
|
const html = markdownToHtml(markdown)
|
||||||
|
const backToMarkdown = htmlToMarkdown(html)
|
||||||
|
|
||||||
|
expect(html).toContain('Some text with <custom-tag>content</custom-tag> and more text')
|
||||||
|
expect(backToMarkdown).toBe('Some text with <custom-tag>content</custom-tag> and more text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve single custom XML tags', () => {
|
||||||
|
const markdown = '<abc>'
|
||||||
|
const html = markdownToHtml(markdown)
|
||||||
|
const backToMarkdown = htmlToMarkdown(html)
|
||||||
|
|
||||||
|
expect(html).toBe('<p><abc></p>')
|
||||||
|
expect(backToMarkdown).toBe('<abc>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve single custom XML tags in html', () => {
|
||||||
|
const html = '<p><abc></p>'
|
||||||
|
const markdown = htmlToMarkdown(html)
|
||||||
|
const backToHtml = markdownToHtml(markdown)
|
||||||
|
|
||||||
|
expect(markdown).toBe('<abc>')
|
||||||
|
expect(backToHtml).toBe('<p><abc></p>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve custom XML tags mixed with regular markdown', () => {
|
||||||
|
const markdown = '# Heading\n\n<custom-widget id="widget1">Widget content</custom-widget>\n\n**Bold text**'
|
||||||
|
const html = markdownToHtml(markdown)
|
||||||
|
const backToMarkdown = htmlToMarkdown(html)
|
||||||
|
|
||||||
|
expect(html).toContain('<h1>Heading</h1>')
|
||||||
|
expect(html).toContain('<custom-widget id="widget1">Widget content</custom-widget>')
|
||||||
|
expect(html).toContain('<strong>Bold text</strong>')
|
||||||
|
expect(backToMarkdown).toContain('# Heading')
|
||||||
|
expect(backToMarkdown).toContain('<custom-widget id="widget1">Widget content</custom-widget>')
|
||||||
|
expect(backToMarkdown).toContain('**Bold text**')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Typing behavior issues', () => {
|
||||||
|
it('should not add unwanted line breaks during simple text typing', () => {
|
||||||
|
const html = '<p>Hello world</p>'
|
||||||
|
const markdown = htmlToMarkdown(html)
|
||||||
|
const backToHtml = markdownToHtml(markdown)
|
||||||
|
|
||||||
|
expect(markdown).toBe('Hello world')
|
||||||
|
expect(backToHtml).toBe('<p>Hello world</p>\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve simple paragraph structure during round-trip conversion', () => {
|
||||||
|
const originalHtml = '<p>This is a simple paragraph being typed</p>'
|
||||||
|
const markdown = htmlToMarkdown(originalHtml)
|
||||||
|
const backToHtml = markdownToHtml(markdown)
|
||||||
|
expect(markdown).toBe('This is a simple paragraph being typed')
|
||||||
|
expect(backToHtml).toBe('<p>This is a simple paragraph being typed</p>\n')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -174,7 +174,6 @@ describe('naming', () => {
|
|||||||
|
|
||||||
it('should return original id if no delimiter found', () => {
|
it('should return original id if no delimiter found', () => {
|
||||||
expect(getBaseModelName('deepseek-r1')).toBe('deepseek-r1')
|
expect(getBaseModelName('deepseek-r1')).toBe('deepseek-r1')
|
||||||
expect(getBaseModelName('deepseek-r1:free')).toBe('deepseek-r1:free')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle edge cases', () => {
|
it('should handle edge cases', () => {
|
||||||
@ -209,7 +208,7 @@ describe('naming', () => {
|
|||||||
it('should return lowercase original id if no delimiter found', () => {
|
it('should return lowercase original id if no delimiter found', () => {
|
||||||
// 验证没有分隔符时返回小写原始ID
|
// 验证没有分隔符时返回小写原始ID
|
||||||
expect(getLowerBaseModelName('DeepSeek-R1')).toBe('deepseek-r1')
|
expect(getLowerBaseModelName('DeepSeek-R1')).toBe('deepseek-r1')
|
||||||
expect(getLowerBaseModelName('GPT-4:Free')).toBe('gpt-4:free')
|
expect(getLowerBaseModelName('GPT-4')).toBe('gpt-4')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle edge cases', () => {
|
it('should handle edge cases', () => {
|
||||||
@ -219,6 +218,10 @@ describe('naming', () => {
|
|||||||
expect(getLowerBaseModelName('/Model')).toBe('model')
|
expect(getLowerBaseModelName('/Model')).toBe('model')
|
||||||
expect(getLowerBaseModelName('Model//Name')).toBe('name')
|
expect(getLowerBaseModelName('Model//Name')).toBe('name')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should remove trailing :free', () => {
|
||||||
|
expect(getLowerBaseModelName('gpt-4:free')).toBe('gpt-4')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getFirstCharacter', () => {
|
describe('getFirstCharacter', () => {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { loggerService } from '@logger'
|
// import { loggerService } from '@logger'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
|
import z from 'zod'
|
||||||
|
|
||||||
const logger = loggerService.withContext('Utils:error')
|
// const logger = loggerService.withContext('Utils:error')
|
||||||
|
|
||||||
export function getErrorDetails(err: any, seen = new WeakSet()): any {
|
export function getErrorDetails(err: any, seen = new WeakSet()): any {
|
||||||
// Handle circular references
|
// Handle circular references
|
||||||
@ -31,8 +32,6 @@ export function getErrorDetails(err: any, seen = new WeakSet()): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatErrorMessage(error: any): string {
|
export function formatErrorMessage(error: any): string {
|
||||||
logger.error('Original error:', error)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detailedError = getErrorDetails(error)
|
const detailedError = getErrorDetails(error)
|
||||||
delete detailedError?.headers
|
delete detailedError?.headers
|
||||||
@ -102,3 +101,15 @@ export const formatMcpError = (error: any) => {
|
|||||||
}
|
}
|
||||||
return error.message
|
return error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 Zod 验证错误信息为可读的字符串
|
||||||
|
* @param error - Zod 验证错误对象
|
||||||
|
* @param title - 可选的错误标题,会作为前缀添加到错误信息中
|
||||||
|
* @returns 格式化后的错误信息字符串。
|
||||||
|
*/
|
||||||
|
export const formatZodError = (error: z.ZodError, title?: string) => {
|
||||||
|
const readableErrors = error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||||
|
const errorMessage = readableErrors.join('\n')
|
||||||
|
return title ? `${title}: \n${errorMessage}` : errorMessage
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export function isJSON(str: any): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: unknown 代替 any
|
||||||
/**
|
/**
|
||||||
* 尝试解析 JSON 字符串,如果解析失败则返回 null。
|
* 尝试解析 JSON 字符串,如果解析失败则返回 null。
|
||||||
* @param {string} str 要解析的字符串
|
* @param {string} str 要解析的字符串
|
||||||
|
|||||||
@ -1,13 +1,77 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { TurndownPlugin } from '@truto/turndown-plugin-gfm'
|
import { TurndownPlugin } from '@truto/turndown-plugin-gfm'
|
||||||
import DOMPurify from 'dompurify'
|
|
||||||
import he from 'he'
|
import he from 'he'
|
||||||
|
import htmlTags, { type HtmlTags } from 'html-tags'
|
||||||
|
import * as htmlparser2 from 'htmlparser2'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import striptags from 'striptags'
|
import striptags from 'striptags'
|
||||||
import TurndownService from 'turndown'
|
import TurndownService from 'turndown'
|
||||||
|
|
||||||
const logger = loggerService.withContext('markdownConverter')
|
const logger = loggerService.withContext('markdownConverter')
|
||||||
|
|
||||||
|
function escapeCustomTags(html: string) {
|
||||||
|
let result = ''
|
||||||
|
let currentPos = 0
|
||||||
|
const processedPositions = new Set<number>()
|
||||||
|
|
||||||
|
const parser = new htmlparser2.Parser({
|
||||||
|
onopentagname(tagname) {
|
||||||
|
const startPos = parser.startIndex
|
||||||
|
const endPos = parser.endIndex
|
||||||
|
|
||||||
|
// Add content before this tag
|
||||||
|
result += html.slice(currentPos, startPos)
|
||||||
|
|
||||||
|
if (!htmlTags.includes(tagname as HtmlTags)) {
|
||||||
|
// This is a custom tag, escape it
|
||||||
|
const tagHtml = html.slice(startPos, endPos + 1)
|
||||||
|
result += tagHtml.replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
} else {
|
||||||
|
// This is a standard HTML tag, keep it as-is
|
||||||
|
result += html.slice(startPos, endPos + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPos = endPos + 1
|
||||||
|
},
|
||||||
|
|
||||||
|
onclosetag(tagname) {
|
||||||
|
const startPos = parser.startIndex
|
||||||
|
const endPos = parser.endIndex
|
||||||
|
|
||||||
|
// Skip if we've already processed this position (handles malformed HTML)
|
||||||
|
if (processedPositions.has(endPos) || endPos + 1 <= currentPos) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processedPositions.add(endPos)
|
||||||
|
|
||||||
|
// Get the actual HTML content at this position to verify what tag it really is
|
||||||
|
const actualTagHtml = html.slice(startPos, endPos + 1)
|
||||||
|
const actualTagMatch = actualTagHtml.match(/<\/([^>]+)>/)
|
||||||
|
const actualTagName = actualTagMatch ? actualTagMatch[1] : tagname
|
||||||
|
|
||||||
|
if (!htmlTags.includes(actualTagName as HtmlTags)) {
|
||||||
|
// This is a custom tag, escape it
|
||||||
|
result += html.slice(currentPos, startPos)
|
||||||
|
result += actualTagHtml.replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
currentPos = endPos + 1
|
||||||
|
} else {
|
||||||
|
// This is a standard HTML tag, add content up to and including the closing tag
|
||||||
|
result += html.slice(currentPos, endPos + 1)
|
||||||
|
currentPos = endPos + 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onend() {
|
||||||
|
result += html.slice(currentPos)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
parser.write(html)
|
||||||
|
parser.end()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskListOptions {
|
export interface TaskListOptions {
|
||||||
label?: boolean
|
label?: boolean
|
||||||
}
|
}
|
||||||
@ -537,7 +601,10 @@ export const htmlToMarkdown = (html: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return turndownService.turndown(html).trim()
|
const encodedHtml = escapeCustomTags(html)
|
||||||
|
const turndownResult = turndownService.turndown(encodedHtml).trim()
|
||||||
|
const finalResult = he.decode(turndownResult)
|
||||||
|
return finalResult
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error converting HTML to Markdown:', error as Error)
|
logger.error('Error converting HTML to Markdown:', error as Error)
|
||||||
return ''
|
return ''
|
||||||
@ -572,94 +639,24 @@ export const markdownToHtml = (markdown: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return md.render(processedMarkdown)
|
let html = md.render(processedMarkdown)
|
||||||
|
const trimmedMarkdown = processedMarkdown.trim()
|
||||||
|
if (html.trim() === trimmedMarkdown) {
|
||||||
|
const singleTagMatch = trimmedMarkdown.match(/^<([a-zA-Z][^>\s]*)\/?>$/)
|
||||||
|
if (singleTagMatch) {
|
||||||
|
const tagName = singleTagMatch[1]
|
||||||
|
if (!htmlTags.includes(tagName.toLowerCase() as any)) {
|
||||||
|
html = `<p>${html}</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error converting Markdown to HTML:', error as Error)
|
logger.error('Error converting Markdown to HTML:', error as Error)
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes HTML content using DOMPurify
|
|
||||||
* @param html - HTML string to sanitize
|
|
||||||
* @returns Sanitized HTML string
|
|
||||||
*/
|
|
||||||
export const sanitizeHtml = (html: string): string => {
|
|
||||||
if (!html || typeof html !== 'string') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return DOMPurify.sanitize(html, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
'h1',
|
|
||||||
'h2',
|
|
||||||
'h3',
|
|
||||||
'h4',
|
|
||||||
'h5',
|
|
||||||
'h6',
|
|
||||||
'div',
|
|
||||||
'span',
|
|
||||||
'p',
|
|
||||||
'br',
|
|
||||||
'hr',
|
|
||||||
'strong',
|
|
||||||
'b',
|
|
||||||
'em',
|
|
||||||
'i',
|
|
||||||
'u',
|
|
||||||
's',
|
|
||||||
'del',
|
|
||||||
'ul',
|
|
||||||
'ol',
|
|
||||||
'li',
|
|
||||||
'blockquote',
|
|
||||||
'code',
|
|
||||||
'pre',
|
|
||||||
'a',
|
|
||||||
'img',
|
|
||||||
'table',
|
|
||||||
'thead',
|
|
||||||
'tbody',
|
|
||||||
'tfoot',
|
|
||||||
'tr',
|
|
||||||
'td',
|
|
||||||
'th',
|
|
||||||
'input',
|
|
||||||
'label',
|
|
||||||
'details',
|
|
||||||
'summary'
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [
|
|
||||||
'href',
|
|
||||||
'title',
|
|
||||||
'alt',
|
|
||||||
'src',
|
|
||||||
'class',
|
|
||||||
'id',
|
|
||||||
'colspan',
|
|
||||||
'rowspan',
|
|
||||||
'type',
|
|
||||||
'checked',
|
|
||||||
'disabled',
|
|
||||||
'width',
|
|
||||||
'height',
|
|
||||||
'loading'
|
|
||||||
],
|
|
||||||
ALLOW_DATA_ATTR: true,
|
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|file|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\-:]|$))/i
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts Markdown to safe HTML (combines conversion and sanitization)
|
|
||||||
* @param markdown - Markdown string to convert
|
|
||||||
* @returns Safe HTML string
|
|
||||||
*/
|
|
||||||
export const markdownToSafeHtml = (markdown: string): string => {
|
|
||||||
const html = markdownToHtml(markdown)
|
|
||||||
return sanitizeHtml(html)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets plain text preview from Markdown content
|
* Gets plain text preview from Markdown content
|
||||||
* @param markdown - Markdown string
|
* @param markdown - Markdown string
|
||||||
|
|||||||
@ -73,7 +73,12 @@ export const getBaseModelName = (id: string, delimiter: string = '/'): string =>
|
|||||||
* @returns {string} 小写的基础名称
|
* @returns {string} 小写的基础名称
|
||||||
*/
|
*/
|
||||||
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
|
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||||
return getBaseModelName(id, delimiter).toLowerCase()
|
const baseModelName = getBaseModelName(id, delimiter).toLowerCase()
|
||||||
|
// for openrouter
|
||||||
|
if (baseModelName.endsWith(':free')) {
|
||||||
|
return baseModelName.replace(':free', '')
|
||||||
|
}
|
||||||
|
return baseModelName
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
23
yarn.lock
23
yarn.lock
@ -9692,7 +9692,9 @@ __metadata:
|
|||||||
google-auth-library: "npm:^9.15.1"
|
google-auth-library: "npm:^9.15.1"
|
||||||
graceful-fs: "npm:^4.2.11"
|
graceful-fs: "npm:^4.2.11"
|
||||||
he: "npm:^1.2.0"
|
he: "npm:^1.2.0"
|
||||||
|
html-tags: "npm:^5.1.0"
|
||||||
html-to-image: "npm:^1.11.13"
|
html-to-image: "npm:^1.11.13"
|
||||||
|
htmlparser2: "npm:^10.0.0"
|
||||||
husky: "npm:^9.1.7"
|
husky: "npm:^9.1.7"
|
||||||
i18next: "npm:^23.11.5"
|
i18next: "npm:^23.11.5"
|
||||||
iconv-lite: "npm:^0.6.3"
|
iconv-lite: "npm:^0.6.3"
|
||||||
@ -12625,7 +12627,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"domutils@npm:^3.0.1":
|
"domutils@npm:^3.0.1, domutils@npm:^3.2.1":
|
||||||
version: 3.2.2
|
version: 3.2.2
|
||||||
resolution: "domutils@npm:3.2.2"
|
resolution: "domutils@npm:3.2.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -15260,6 +15262,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"html-tags@npm:^5.1.0":
|
||||||
|
version: 5.1.0
|
||||||
|
resolution: "html-tags@npm:5.1.0"
|
||||||
|
checksum: 10c0/2dda19bc07e75837d0c52984558d92e8b82768050e4d6421b3164b1cb6ca5e73719209c2b23c0fa71faf097a7a3d18cf7f2021b488f1b1f270fca516c4c634c9
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"html-to-image@npm:^1.11.13":
|
"html-to-image@npm:^1.11.13":
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
resolution: "html-to-image@npm:1.11.13"
|
resolution: "html-to-image@npm:1.11.13"
|
||||||
@ -15294,6 +15303,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"htmlparser2@npm:^10.0.0":
|
||||||
|
version: 10.0.0
|
||||||
|
resolution: "htmlparser2@npm:10.0.0"
|
||||||
|
dependencies:
|
||||||
|
domelementtype: "npm:^2.3.0"
|
||||||
|
domhandler: "npm:^5.0.3"
|
||||||
|
domutils: "npm:^3.2.1"
|
||||||
|
entities: "npm:^6.0.0"
|
||||||
|
checksum: 10c0/47cfa37e529c86a7ba9a1e0e6f951ad26ef8ca5af898ab6e8916fa02c0264c1453b4a65f28b7b8a7f9d0d29b5a70abead8203bf8b3f07bc69407e85e7d9a68e4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"htmlparser2@npm:^8.0.2":
|
"htmlparser2@npm:^8.0.2":
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
resolution: "htmlparser2@npm:8.0.2"
|
resolution: "htmlparser2@npm:8.0.2"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user