mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-12 08:59:02 +08:00
Merge branch 'main' into fix-taborder
This commit is contained in:
commit
20b3cb62eb
7
.github/workflows/nightly-build.yml
vendored
7
.github/workflows/nightly-build.yml
vendored
@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
@ -94,7 +94,6 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get install -y rpm
|
||||
yarn build:npm linux
|
||||
yarn build:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -107,7 +106,6 @@ jobs:
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
yarn build:npm mac
|
||||
yarn build:mac
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
@ -125,7 +123,6 @@ jobs:
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
yarn build:npm windows
|
||||
yarn build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -229,7 +226,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
path: all-artifacts
|
||||
merge-multiple: false
|
||||
|
||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -80,7 +80,6 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get install -y rpm
|
||||
yarn build:npm linux
|
||||
yarn build:linux
|
||||
|
||||
env:
|
||||
@ -95,7 +94,6 @@ jobs:
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
sudo -H pip install setuptools
|
||||
yarn build:npm mac
|
||||
yarn build:mac
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
@ -113,7 +111,6 @@ jobs:
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
yarn build:npm windows
|
||||
yarn build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -60,6 +60,9 @@ coverage
|
||||
.vitest-cache
|
||||
vitest.config.*.timestamp-*
|
||||
|
||||
# TypeScript incremental build
|
||||
.tsbuildinfo
|
||||
|
||||
# playwright
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
30
.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch
vendored
Normal file
30
.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index dc071739e79876dff88e1be06a9168e294222d13..b9df7525c62bdf777e89e732e1b0c81f84d872f2 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -380,7 +380,7 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
|
||||
}
|
||||
}
|
||||
|
||||
-if (!nativeBinding) {
|
||||
+if (!nativeBinding && process.platform !== 'linux') {
|
||||
if (loadErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Cannot find native binding. ` +
|
||||
@@ -392,6 +392,13 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
-module.exports = nativeBinding
|
||||
-module.exports.OcrAccuracy = nativeBinding.OcrAccuracy
|
||||
-module.exports.recognize = nativeBinding.recognize
|
||||
+if (process.platform === 'linux') {
|
||||
+ module.exports = {OcrAccuracy: {
|
||||
+ Fast: 0,
|
||||
+ Accurate: 1
|
||||
+ }, recognize: () => Promise.resolve({text: '', confidence: 1.0})}
|
||||
+}else{
|
||||
+ module.exports = nativeBinding
|
||||
+ module.exports.OcrAccuracy = nativeBinding.OcrAccuracy
|
||||
+ module.exports.recognize = nativeBinding.recognize
|
||||
+}
|
||||
@ -57,7 +57,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank" style="text-decoration: none"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" width="220" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/14318" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/14318" alt="CherryHQ%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" width="220" height="55" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -62,6 +62,7 @@ files:
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- '**/*.{metal,exp,lib}'
|
||||
- 'node_modules/@img/sharp-libvips-*/**'
|
||||
win:
|
||||
executableName: Cherry Studio
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
@ -114,16 +115,30 @@ publish:
|
||||
url: https://releases.cherry-ai.com
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
beforePack: scripts/before-pack.js
|
||||
afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
输入框快捷菜单增加清除按钮
|
||||
侧边栏增加代码工具入口,代码工具增加环境变量设置
|
||||
小程序增加多语言显示
|
||||
优化 MCP 服务器列表
|
||||
新增 Web 搜索图标
|
||||
优化 SVG 预览,优化 HTML 内容样式
|
||||
修复知识库文档预处理失败问题
|
||||
稳定性改进和错误修复
|
||||
✨ 重要更新:
|
||||
- 新增笔记模块,支持富文本编辑和管理
|
||||
- 内置 GLM-4.5-Flash 免费模型(由智谱开放平台提供)
|
||||
- 内置 Qwen3-8B 免费模型(由硅基流动提供)
|
||||
- 新增 Nano Banana(Gemini 2.5 Flash Image)模型支持
|
||||
- 新增系统 OCR 功能 (macOS & Windows)
|
||||
- 新增图片 OCR 识别和翻译功能
|
||||
- 模型切换支持通过标签筛选
|
||||
- 翻译功能增强:历史搜索和收藏
|
||||
|
||||
🔧 性能优化:
|
||||
- 优化历史页面搜索性能
|
||||
- 优化拖拽列表组件交互
|
||||
- 升级 Electron 到 37.4.0
|
||||
|
||||
🐛 修复问题:
|
||||
- 修复知识库加密 PDF 文档处理
|
||||
- 修复导航栏在左侧时笔记侧边栏按钮缺失
|
||||
- 修复多个模型兼容性问题
|
||||
- 修复 MCP 相关问题
|
||||
- 其他稳定性改进
|
||||
|
||||
@ -26,7 +26,20 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
|
||||
external: [
|
||||
'@libsql/client',
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
'jsdom',
|
||||
'electron',
|
||||
'graceful-fs',
|
||||
'selection-hook',
|
||||
'@napi-rs/system-ocr',
|
||||
'@strongtz/win32-arm64-msvc',
|
||||
'os-proxy-config',
|
||||
'sharp',
|
||||
'turndown'
|
||||
],
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
|
||||
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.5.8",
|
||||
"version": "1.5.9",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -40,7 +40,6 @@
|
||||
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
|
||||
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
|
||||
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
|
||||
"build:npm": "node scripts/build-npm.js",
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
@ -48,7 +47,7 @@
|
||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "tsx scripts/check-i18n.ts",
|
||||
@ -73,9 +72,10 @@
|
||||
"dependencies": {
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "^1.0.2",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
@ -200,6 +200,7 @@
|
||||
"cli-progress": "^3.12.0",
|
||||
"code-inspector-plugin": "^0.20.14",
|
||||
"color": "^5.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"country-flag-emoji-polyfill": "0.1.8",
|
||||
"dayjs": "^1.11.11",
|
||||
"dexie": "^4.0.8",
|
||||
@ -229,7 +230,9 @@
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"he": "^1.2.0",
|
||||
"html-tags": "^5.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^23.11.5",
|
||||
"iconv-lite": "^0.6.3",
|
||||
|
||||
@ -36,6 +36,7 @@ export enum IpcChannel {
|
||||
App_LogToMain = 'app:log-to-main',
|
||||
App_SaveData = 'app:save-data',
|
||||
App_SetFullScreen = 'app:set-full-screen',
|
||||
App_IsFullScreen = 'app:is-full-screen',
|
||||
|
||||
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
|
||||
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
|
||||
|
||||
@ -1,89 +1,10 @@
|
||||
const { Arch } = require('electron-builder')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
exports.default = async function (context) {
|
||||
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'])
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 删除 macOS 专用的 OCR 包
|
||||
removeMacOnlyPackages(node_modules_path)
|
||||
}
|
||||
|
||||
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'])
|
||||
}
|
||||
|
||||
removeMacOnlyPackages(node_modules_path)
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true })
|
||||
fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 macOS 专用的包
|
||||
* @param {string} nodeModulesPath
|
||||
*/
|
||||
function removeMacOnlyPackages(nodeModulesPath) {
|
||||
const macOnlyPackages = []
|
||||
|
||||
macOnlyPackages.forEach((packageName) => {
|
||||
const packagePath = path.join(nodeModulesPath, packageName)
|
||||
if (fs.existsSync(packagePath)) {
|
||||
fs.rmSync(packagePath, { recursive: true, force: true })
|
||||
console.log(`[After Pack] Removed macOS-only package: ${packageName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定架构的 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)
|
||||
})
|
||||
}
|
||||
|
||||
91
scripts/before-pack.js
Normal file
91
scripts/before-pack.js
Normal file
@ -0,0 +1,91 @@
|
||||
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-libvips-darwin-arm64': '1.2.0',
|
||||
'@img/sharp-libvips-linux-arm64': '1.2.0',
|
||||
|
||||
'@libsql/darwin-arm64': '0.4.7',
|
||||
'@libsql/linux-arm64-gnu': '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-win32-x64': '0.34.3',
|
||||
|
||||
'@img/sharp-libvips-darwin-x64': '1.2.0',
|
||||
'@img/sharp-libvips-linux-x64': '1.2.0',
|
||||
|
||||
'@libsql/darwin-x64': '0.4.7',
|
||||
'@libsql/linux-x64-gnu': '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,44 +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')
|
||||
}
|
||||
|
||||
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'
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const platformArg = process.argv[2]
|
||||
downloadNpm(platformArg)
|
||||
@ -66,7 +66,7 @@ ${JSON.stringify({
|
||||
confirm: '确定要备份数据吗?',
|
||||
select_model: '选择模型',
|
||||
title: '文件',
|
||||
deeply_thought: '已深度思考(用时 {{secounds}} 秒)'
|
||||
deeply_thought: '已深度思考(用时 {{seconds}} 秒)'
|
||||
})}
|
||||
######################################################
|
||||
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
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 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
|
||||
if (fs.existsSync(targetDir)) {
|
||||
@ -16,23 +19,44 @@ function downloadNpmPackage(packageName, url) {
|
||||
|
||||
try {
|
||||
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}...`)
|
||||
execSync(`tar -xvf ${filename}`)
|
||||
execSync(`rm -rf ${filename}`)
|
||||
execSync(`mkdir -p ${targetDir}`)
|
||||
execSync(`mv package/* ${targetDir}/`)
|
||||
|
||||
// Create extraction directory
|
||||
fs.mkdirSync(extractDir, { recursive: true })
|
||||
|
||||
// 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) {
|
||||
console.error(`Error processing ${packageName}: ${error.message}`)
|
||||
if (fs.existsSync(filename)) {
|
||||
fs.unlinkSync(filename)
|
||||
}
|
||||
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 = {
|
||||
|
||||
@ -86,6 +86,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// Initialize Python service with main window
|
||||
pythonService.setMainWindow(mainWindow)
|
||||
|
||||
const checkMainWindow = () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
throw new Error('Main window does not exist or has been destroyed')
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(IpcChannel.App_Info, () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
@ -206,6 +212,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
mainWindow.setFullScreen(value)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsFullScreen, (): boolean => {
|
||||
return mainWindow.isFullScreen()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
@ -558,19 +568,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// window
|
||||
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
|
||||
mainWindow?.setMinimumSize(width, height)
|
||||
checkMainWindow()
|
||||
mainWindow.setMinimumSize(width, height)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
|
||||
mainWindow?.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
|
||||
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
|
||||
checkMainWindow()
|
||||
|
||||
mainWindow.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
|
||||
const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
|
||||
if (width < MIN_WINDOW_WIDTH) {
|
||||
mainWindow?.setSize(MIN_WINDOW_WIDTH, height)
|
||||
mainWindow.setSize(MIN_WINDOW_WIDTH, height)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Windows_GetSize, () => {
|
||||
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
|
||||
checkMainWindow()
|
||||
const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
|
||||
return [width, height]
|
||||
})
|
||||
|
||||
|
||||
@ -323,7 +323,7 @@ class CodeToolsService {
|
||||
? `set "BUN_INSTALL=${bunInstallPath}" && set "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}`
|
||||
}
|
||||
|
||||
|
||||
@ -499,6 +499,8 @@ export class WindowService {
|
||||
}
|
||||
})
|
||||
|
||||
this.setupWebContentsHandlers(this.miniWindow)
|
||||
|
||||
miniWindowState.manage(this.miniWindow)
|
||||
|
||||
//miniWindow should show in current desktop
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isLinux } from '@main/constant'
|
||||
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
|
||||
|
||||
import { systemOcrService } from './builtin/SystemOcrService'
|
||||
@ -33,4 +34,5 @@ export const ocrService = new OcrService()
|
||||
|
||||
// Register built-in providers
|
||||
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
|
||||
ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||
|
||||
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { isLinux, isWin } from '@main/constant'
|
||||
import { loadOcrImage } from '@main/utils/ocr'
|
||||
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
|
||||
import {
|
||||
@ -15,12 +15,12 @@ import { OcrBaseService } from './OcrBaseService'
|
||||
export class SystemOcrService extends OcrBaseService {
|
||||
constructor() {
|
||||
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> {
|
||||
if (isLinux) {
|
||||
return { text: '' }
|
||||
}
|
||||
const buffer = await loadOcrImage(file)
|
||||
const langs = isWin ? options?.langs : undefined
|
||||
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
|
||||
|
||||
@ -311,8 +311,7 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr
|
||||
*/
|
||||
export function getName(baseDir: string, fileName: string, isFile: boolean): string {
|
||||
// 首先清理文件名
|
||||
const sanitizedName = sanitizeFilename(fileName)
|
||||
const baseName = sanitizedName.replace(/\d+$/, '')
|
||||
const baseName = sanitizeFilename(fileName)
|
||||
let candidate = isFile ? baseName + '.md' : baseName
|
||||
let counter = 1
|
||||
|
||||
|
||||
@ -81,6 +81,7 @@ const api = {
|
||||
logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
|
||||
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
|
||||
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
|
||||
mac: {
|
||||
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
||||
|
||||
@ -60,6 +60,8 @@ import {
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
OpenAIExtraBody,
|
||||
OpenAIModality,
|
||||
OpenAISdkMessageParam,
|
||||
OpenAISdkParams,
|
||||
OpenAISdkRawChunk,
|
||||
@ -564,7 +566,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
messages: OpenAISdkMessageParam[]
|
||||
metadata: Record<string, any>
|
||||
}> => {
|
||||
const { messages, mcpTools, maxTokens, enableWebSearch } = coreRequest
|
||||
const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest
|
||||
let { streamOutput } = coreRequest
|
||||
|
||||
// Qwen3商业版(思考模式)、Qwen3开源版、QwQ、QVQ只支持流式输出。
|
||||
@ -572,18 +574,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
streamOutput = true
|
||||
}
|
||||
|
||||
const extra_body: Record<string, any> = {}
|
||||
const extra_body: OpenAIExtraBody = {}
|
||||
|
||||
if (isQwenMTModel(model)) {
|
||||
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 = {
|
||||
source_lang: 'auto',
|
||||
target_lang: mapLanguageToQwenMTModel(targetLanguage)
|
||||
target_lang: targetLanguage
|
||||
} as const
|
||||
if (!translationOptions.target_lang) {
|
||||
throw new Error(t('translate.error.not_supported', { language: targetLanguage.value }))
|
||||
}
|
||||
extra_body.translation_options = translationOptions
|
||||
} else {
|
||||
throw new Error(t('translate.error.chat_qwen_mt'))
|
||||
@ -639,12 +641,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
if (this.provider.id === SystemProviderIds.poe) {
|
||||
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
|
||||
let suffix = ''
|
||||
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
|
||||
lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
|
||||
suffix = ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
|
||||
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
|
||||
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
|
||||
suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
|
||||
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
|
||||
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
|
||||
suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
|
||||
}
|
||||
// FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题
|
||||
// 临时解决方案是强制poe用string content,但是其实poe部分支持array
|
||||
if (typeof lastUserMsg.content === 'string') {
|
||||
lastUserMsg.content += suffix
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -678,6 +686,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
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 = {
|
||||
model: model.id,
|
||||
messages:
|
||||
@ -690,6 +707,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
stream: streamOutput,
|
||||
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}),
|
||||
...modalities,
|
||||
// groq 有不同的 service tier 配置,不符合 openai 接口类型
|
||||
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
@ -697,7 +715,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
...getOpenAIWebSearchParams(model, enableWebSearch),
|
||||
// OpenRouter usage tracking
|
||||
...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}),
|
||||
...(isQwenMTModel(model) ? extra_body : {}),
|
||||
...extra_body,
|
||||
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
|
||||
// 注意:用户自定义参数总是应该覆盖其他参数
|
||||
...(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 |
BIN
src/renderer/src/assets/images/banner.png
Normal file
BIN
src/renderer/src/assets/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
@ -202,7 +202,7 @@
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
a,
|
||||
@ -321,6 +321,10 @@ emoji-picker {
|
||||
--border-size: 0;
|
||||
}
|
||||
|
||||
.block-wrapper + .block-wrapper {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.katex,
|
||||
mjx-container {
|
||||
display: inline-block;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
.tiptap {
|
||||
padding: 12px 60px;
|
||||
// 预留5px给scrollbar
|
||||
padding: 12px 55px 12px 60px;
|
||||
outline: none;
|
||||
min-height: 120px;
|
||||
overflow-wrap: break-word;
|
||||
@ -147,6 +148,12 @@
|
||||
left: 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 */
|
||||
@ -470,6 +477,14 @@
|
||||
align-items: center;
|
||||
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
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getNormalizedExtension } from '../utils'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getExtensionByLanguage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/code-language', () => ({
|
||||
getExtensionByLanguage: mocks.getExtensionByLanguage
|
||||
}))
|
||||
|
||||
describe('getNormalizedExtension', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return custom mapping for custom language', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
||||
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
|
||||
await expect(getNormalizedExtension('SVG')).resolves.toBe('xml')
|
||||
})
|
||||
|
||||
it('should prefer custom mapping when both custom and linguist exist', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue('.svg')
|
||||
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
|
||||
})
|
||||
|
||||
it('should return linguist mapping when available (strip leading dot)', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue('.ts')
|
||||
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
|
||||
})
|
||||
|
||||
it('should return extension when input already looks like extension (leading dot)', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
||||
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
|
||||
})
|
||||
|
||||
it('should return language as-is when no rules matched', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
||||
await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage')
|
||||
})
|
||||
})
|
||||
@ -8,7 +8,9 @@ import { getNormalizedExtension } from './utils'
|
||||
|
||||
const logger = loggerService.withContext('CodeEditorHooks')
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
/** 语言对应的 linter 加载器
|
||||
* key: 语言文件扩展名(不包含 `.`)
|
||||
*/
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
@ -64,13 +66,15 @@ async function loadLanguageExtension(language: string): Promise<Extension | null
|
||||
* 加载 linter 扩展
|
||||
*/
|
||||
async function loadLinterExtension(language: string): Promise<Extension | null> {
|
||||
const loader = linterLoaders[language]
|
||||
const fileExt = await getNormalizedExtension(language)
|
||||
|
||||
const loader = linterLoaders[fileExt]
|
||||
if (!loader) return null
|
||||
|
||||
try {
|
||||
return await loader()
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load linter for ${language}`, error as Error)
|
||||
logger.debug(`Failed to load linter for ${language} (${fileExt})`, error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,13 @@ export interface CodeEditorProps {
|
||||
value: string
|
||||
/** Placeholder when the editor content is empty. */
|
||||
placeholder?: string | HTMLElement
|
||||
/** Code language, supports aliases. */
|
||||
/**
|
||||
* Code language string.
|
||||
* - Case-insensitive.
|
||||
* - Supports common names: javascript, json, python, etc.
|
||||
* - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
|
||||
* - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc.
|
||||
*/
|
||||
language: string
|
||||
/** Fired when ref.save() is called or the save shortcut is triggered. */
|
||||
onSave?: (newContent: string) => void
|
||||
|
||||
@ -13,6 +13,7 @@ const _customLanguageExtensions: Record<string, string> = {
|
||||
* 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs
|
||||
* - 先搜索自定义扩展名
|
||||
* - 再搜索 github linguist 扩展名
|
||||
* - 最后假定名称已经是扩展名
|
||||
* @param language 语言名称
|
||||
* @returns 扩展名(不包含 `.`)
|
||||
*/
|
||||
@ -29,6 +30,11 @@ export async function getNormalizedExtension(language: string) {
|
||||
return linguistExt.slice(1)
|
||||
}
|
||||
|
||||
// 如果语言名称像扩展名
|
||||
if (language.startsWith('.') && language.length > 1) {
|
||||
return language.slice(1)
|
||||
}
|
||||
|
||||
// 回退到语言名称
|
||||
return language
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ interface CodeViewerProps {
|
||||
wrapped?: boolean
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
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 { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
}, [rawLines.length, onHeightChange])
|
||||
|
||||
return (
|
||||
<div ref={shikiThemeRef}>
|
||||
<div ref={shikiThemeRef} style={height ? { height } : undefined}>
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={wrapped}
|
||||
$expanded={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
$height={height}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
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'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{
|
||||
$wrap?: boolean
|
||||
$expanded?: boolean
|
||||
$lineHeight?: number
|
||||
$height?: string | number
|
||||
}>`
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
|
||||
@ -15,7 +15,7 @@ type Props = {
|
||||
|
||||
const ModelSelectButton = ({ model, onSelectModel, modelFilter, noTooltip, tooltipProps }: Props) => {
|
||||
const onClick = useCallback(async () => {
|
||||
const selectedModel = await SelectModelPopup.show({ model, modelFilter })
|
||||
const selectedModel = await SelectModelPopup.show({ model, filter: modelFilter })
|
||||
if (selectedModel) {
|
||||
onSelectModel?.(selectedModel)
|
||||
}
|
||||
|
||||
145
src/renderer/src/components/OGCard.tsx
Normal file
145
src/renderer/src/components/OGCard.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import CherryLogo from '@renderer/assets/images/banner.png'
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
|
||||
import { Skeleton, Typography } from 'antd'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
const { Title, Paragraph } = Typography
|
||||
|
||||
type Props = {
|
||||
link: string
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export const OGCard = ({ link, show }: Props) => {
|
||||
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
|
||||
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
|
||||
|
||||
const hasImage = !!metadata['og:image']
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(link).hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [link])
|
||||
|
||||
useEffect(() => {
|
||||
// use show to lazy loading
|
||||
if (show && isLoading) {
|
||||
parseMetadata()
|
||||
}
|
||||
}, [parseMetadata, isLoading, show])
|
||||
|
||||
if (isLoading) {
|
||||
return <CardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewContainer hasImage={hasImage}>
|
||||
{hasImage && (
|
||||
<PreviewImageContainer>
|
||||
<PreviewImage src={metadata['og:image']} alt={metadata['og:imageAlt'] || link} />
|
||||
</PreviewImageContainer>
|
||||
)}
|
||||
{!hasImage && (
|
||||
<PreviewImageContainer>
|
||||
<PreviewImage src={CherryLogo} alt={'no image'} />
|
||||
</PreviewImageContainer>
|
||||
)}
|
||||
|
||||
<PreviewContent>
|
||||
<StyledHyperLink>
|
||||
{hostname && <Favicon hostname={hostname} alt={link} />}
|
||||
<Title
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.2',
|
||||
color: 'var(--color-text)'
|
||||
}}>
|
||||
{metadata['og:title'] || hostname}
|
||||
</Title>
|
||||
</StyledHyperLink>
|
||||
<Paragraph
|
||||
title={metadata['og:description'] || link}
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.2',
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}>
|
||||
{metadata['og:description'] || link}
|
||||
</Paragraph>
|
||||
</PreviewContent>
|
||||
</PreviewContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const CardSkeleton = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<Skeleton.Image style={{ width: '100%', height: 140 }} active />
|
||||
<Skeleton
|
||||
paragraph={{
|
||||
rows: 1,
|
||||
style: {
|
||||
margin: '8px 0'
|
||||
}
|
||||
}}
|
||||
active
|
||||
/>
|
||||
</SkeletonContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledHyperLink = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const PreviewContainer = styled.div<{ hasImage?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
width: 380px;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const PreviewImageContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const PreviewContent = styled.div`
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const PreviewImage = styled.img`
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
`
|
||||
|
||||
const SkeletonContainer = styled.div`
|
||||
width: 380px;
|
||||
height: 220px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
gap: 16px;
|
||||
`
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -97,6 +97,7 @@ const PopupContainer: React.FC<Props> = ({
|
||||
afterClose={onClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
centered>
|
||||
<EditorContainer>
|
||||
<RichEditor
|
||||
@ -106,8 +107,8 @@ const PopupContainer: React.FC<Props> = ({
|
||||
onContentChange={handleContentChange}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
onCommandsReady={handleCommandsReady}
|
||||
minHeight={300}
|
||||
maxHeight={500}
|
||||
minHeight={window.innerHeight * 0.7}
|
||||
isFullWidth={true}
|
||||
className="rich-edit-popup-editor"
|
||||
/>
|
||||
</EditorContainer>
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
EmbeddingTag,
|
||||
FreeTag,
|
||||
ReasoningTag,
|
||||
RerankerTag,
|
||||
ToolsCallingTag,
|
||||
VisionTag,
|
||||
WebSearchTag
|
||||
} from '@renderer/components/Tags/Model'
|
||||
import { ModelTag } from '@renderer/types'
|
||||
import { Flex } from 'antd'
|
||||
import React, { startTransition, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const logger = loggerService.withContext('TagFilterSection')
|
||||
|
||||
interface TagFilterSectionProps {
|
||||
availableTags: ModelTag[]
|
||||
tagSelection: Record<ModelTag, boolean>
|
||||
onToggleTag: (tag: ModelTag) => void
|
||||
}
|
||||
|
||||
const TagFilterSection: React.FC<TagFilterSectionProps> = ({ availableTags, tagSelection, onToggleTag }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleTagClick = useCallback(
|
||||
(tag: ModelTag) => {
|
||||
startTransition(() => onToggleTag(tag))
|
||||
},
|
||||
[onToggleTag]
|
||||
)
|
||||
|
||||
// 标签组件
|
||||
const tagComponents = useMemo(
|
||||
() => ({
|
||||
vision: VisionTag,
|
||||
embedding: EmbeddingTag,
|
||||
reasoning: ReasoningTag,
|
||||
function_calling: ToolsCallingTag,
|
||||
web_search: WebSearchTag,
|
||||
rerank: RerankerTag,
|
||||
free: FreeTag
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<FilterContainer>
|
||||
<Flex wrap="wrap" gap={4}>
|
||||
<FilterText>{t('models.filter.by_tag')}</FilterText>
|
||||
{availableTags.map((tag) => {
|
||||
const TagElement = tagComponents[tag]
|
||||
if (!TagElement) {
|
||||
logger.error(`Tag element not found for tag: ${tag}`)
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TagElement
|
||||
key={`tag-${tag}`}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
inactive={!tagSelection[tag]}
|
||||
showLabel
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
</FilterContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const FilterContainer = styled.div`
|
||||
padding: 8px;
|
||||
padding-left: 18px;
|
||||
`
|
||||
|
||||
const FilterText = styled.span`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default TagFilterSection
|
||||
@ -0,0 +1,110 @@
|
||||
import type { ModelTag } from '@renderer/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagFilterSection from '../TagFilterSection'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
createTagComponent: (name: string) => {
|
||||
// Create a simple button component exposing props for assertions
|
||||
return ({ onClick, inactive, showLabel }: { onClick?: () => void; inactive?: boolean; showLabel?: boolean }) => {
|
||||
const React = require('react')
|
||||
return React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
'aria-label': `tag-${name}`,
|
||||
'data-inactive': String(Boolean(inactive)),
|
||||
onClick
|
||||
},
|
||||
showLabel ? name : ''
|
||||
)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mocks.t })
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Tags/Model', () => ({
|
||||
VisionTag: mocks.createTagComponent('vision'),
|
||||
EmbeddingTag: mocks.createTagComponent('embedding'),
|
||||
ReasoningTag: mocks.createTagComponent('reasoning'),
|
||||
ToolsCallingTag: mocks.createTagComponent('function_calling'),
|
||||
WebSearchTag: mocks.createTagComponent('web_search'),
|
||||
RerankerTag: mocks.createTagComponent('rerank'),
|
||||
FreeTag: mocks.createTagComponent('free')
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Flex: ({ children }: { children: React.ReactNode }) => children
|
||||
}))
|
||||
|
||||
function createSelection(overrides: Partial<Record<ModelTag, boolean>> = {}): Record<ModelTag, boolean> {
|
||||
const base: Record<ModelTag, boolean> = {
|
||||
vision: true,
|
||||
embedding: true,
|
||||
reasoning: true,
|
||||
function_calling: true,
|
||||
web_search: true,
|
||||
rerank: true,
|
||||
free: true
|
||||
}
|
||||
return { ...base, ...overrides }
|
||||
}
|
||||
|
||||
const allTags: ModelTag[] = ['vision', 'embedding', 'reasoning', 'function_calling', 'web_search', 'rerank', 'free']
|
||||
|
||||
describe('TagFilterSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(
|
||||
<TagFilterSection availableTags={allTags} tagSelection={createSelection()} onToggleTag={vi.fn()} />
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should reflect inactive state based on tagSelection', () => {
|
||||
render(
|
||||
<TagFilterSection
|
||||
availableTags={['vision']}
|
||||
tagSelection={createSelection({ vision: false })}
|
||||
onToggleTag={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const visionBtn = screen.getByRole('button', { name: 'tag-vision' })
|
||||
expect(visionBtn).toHaveAttribute('data-inactive', 'true')
|
||||
})
|
||||
|
||||
it('should skip unknown tags', () => {
|
||||
render(
|
||||
<TagFilterSection
|
||||
availableTags={['unknown' as unknown as ModelTag, 'vision']}
|
||||
tagSelection={createSelection()}
|
||||
onToggleTag={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'tag-unknown' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'tag-vision' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('functionality', () => {
|
||||
it('should call onToggleTag when a tag is clicked', () => {
|
||||
const handleToggle = vi.fn()
|
||||
render(<TagFilterSection availableTags={allTags} tagSelection={createSelection()} onToggleTag={handleToggle} />)
|
||||
|
||||
const visionBtn = screen.getByRole('button', { name: 'tag-vision' })
|
||||
fireEvent.click(visionBtn)
|
||||
|
||||
expect(handleToggle).toHaveBeenCalledTimes(1)
|
||||
expect(handleToggle).toHaveBeenCalledWith('vision')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TagFilterSection > rendering > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
padding: 8px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<span
|
||||
class="c1"
|
||||
>
|
||||
models.filter.by_tag
|
||||
</span>
|
||||
<button
|
||||
aria-label="tag-vision"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
vision
|
||||
</button>
|
||||
<button
|
||||
aria-label="tag-embedding"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
embedding
|
||||
</button>
|
||||
<button
|
||||
aria-label="tag-reasoning"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
reasoning
|
||||
</button>
|
||||
<button
|
||||
aria-label="tag-function_calling"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
function_calling
|
||||
</button>
|
||||
<button
|
||||
aria-label="tag-web_search"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
web_search
|
||||
</button>
|
||||
<button
|
||||
aria-label="tag-rerank"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
rerank
|
||||
</button>
|
||||
<button
|
||||
aria-label="tag-free"
|
||||
data-inactive="false"
|
||||
type="button"
|
||||
>
|
||||
free
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -0,0 +1,122 @@
|
||||
import type { Model } from '@renderer/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useModelTagFilter } from '../filters'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isVisionModel: vi.fn(),
|
||||
isEmbeddingModel: vi.fn(),
|
||||
isReasoningModel: vi.fn(),
|
||||
isFunctionCallingModel: vi.fn(),
|
||||
isWebSearchModel: vi.fn(),
|
||||
isRerankModel: vi.fn(),
|
||||
isFreeModel: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/config/models', () => ({
|
||||
isEmbeddingModel: mocks.isEmbeddingModel,
|
||||
isFunctionCallingModel: mocks.isFunctionCallingModel,
|
||||
isReasoningModel: mocks.isReasoningModel,
|
||||
isRerankModel: mocks.isRerankModel,
|
||||
isVisionModel: mocks.isVisionModel,
|
||||
isWebSearchModel: mocks.isWebSearchModel
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/model', () => ({
|
||||
isFreeModel: mocks.isFreeModel
|
||||
}))
|
||||
|
||||
function createModel(overrides: Partial<Model> = {}): Model {
|
||||
return {
|
||||
id: 'm1',
|
||||
provider: 'openai',
|
||||
name: 'Model-1',
|
||||
group: 'default',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useModelTagFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should have all tags unselected initially', () => {
|
||||
const { result } = renderHook(() => useModelTagFilter())
|
||||
|
||||
expect(result.current.tagSelection).toEqual({
|
||||
vision: false,
|
||||
embedding: false,
|
||||
reasoning: false,
|
||||
function_calling: false,
|
||||
web_search: false,
|
||||
rerank: false,
|
||||
free: false
|
||||
})
|
||||
expect(result.current.selectedTags).toEqual([])
|
||||
})
|
||||
|
||||
it('should toggle a tag state', () => {
|
||||
const { result } = renderHook(() => useModelTagFilter())
|
||||
|
||||
act(() => result.current.toggleTag('vision'))
|
||||
expect(result.current.tagSelection.vision).toBe(true)
|
||||
expect(result.current.selectedTags).toEqual(['vision'])
|
||||
|
||||
act(() => result.current.toggleTag('vision'))
|
||||
expect(result.current.tagSelection.vision).toBe(false)
|
||||
expect(result.current.selectedTags).toEqual([])
|
||||
})
|
||||
|
||||
it('should reset all tags to false', () => {
|
||||
const { result } = renderHook(() => useModelTagFilter())
|
||||
|
||||
act(() => result.current.toggleTag('vision'))
|
||||
act(() => result.current.toggleTag('embedding'))
|
||||
expect(result.current.selectedTags.sort()).toEqual(['embedding', 'vision'])
|
||||
|
||||
act(() => result.current.resetTags())
|
||||
expect(result.current.selectedTags).toEqual([])
|
||||
expect(Object.values(result.current.tagSelection).every((v) => v === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('tagFilter returns true when no tags selected', () => {
|
||||
const { result } = renderHook(() => useModelTagFilter())
|
||||
const model = createModel()
|
||||
const passed = result.current.tagFilter(model)
|
||||
expect(passed).toBe(true)
|
||||
expect(mocks.isVisionModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tagFilter uses single selected tag predicate', () => {
|
||||
const { result } = renderHook(() => useModelTagFilter())
|
||||
const model = createModel()
|
||||
|
||||
mocks.isVisionModel.mockReturnValueOnce(true)
|
||||
act(() => result.current.toggleTag('vision'))
|
||||
|
||||
const ok = result.current.tagFilter(model)
|
||||
expect(ok).toBe(true)
|
||||
expect(mocks.isVisionModel).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.isVisionModel).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('tagFilter requires all selected tags to match (AND logic)', () => {
|
||||
const { result } = renderHook(() => useModelTagFilter())
|
||||
const model = createModel()
|
||||
|
||||
act(() => result.current.toggleTag('vision'))
|
||||
act(() => result.current.toggleTag('embedding'))
|
||||
|
||||
// 第一次:vision=true, embedding=false => 应为 false
|
||||
mocks.isVisionModel.mockReturnValueOnce(true)
|
||||
mocks.isEmbeddingModel.mockReturnValueOnce(false)
|
||||
expect(result.current.tagFilter(model)).toBe(false)
|
||||
|
||||
// 第二次:vision=true, embedding=true => 应为 true
|
||||
mocks.isVisionModel.mockReturnValueOnce(true)
|
||||
mocks.isEmbeddingModel.mockReturnValueOnce(true)
|
||||
expect(result.current.tagFilter(model)).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import {
|
||||
isEmbeddingModel,
|
||||
isFunctionCallingModel,
|
||||
isReasoningModel,
|
||||
isRerankModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import { Model, ModelTag, objectEntries } from '@renderer/types'
|
||||
import { isFreeModel } from '@renderer/utils/model'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
type ModelPredict = (m: Model) => boolean
|
||||
|
||||
const initialTagSelection: Record<ModelTag, boolean> = {
|
||||
vision: false,
|
||||
embedding: false,
|
||||
reasoning: false,
|
||||
function_calling: false,
|
||||
web_search: false,
|
||||
rerank: false,
|
||||
free: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签筛选 hook,仅关注标签过滤逻辑
|
||||
*/
|
||||
export function useModelTagFilter() {
|
||||
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
|
||||
() => ({
|
||||
vision: isVisionModel,
|
||||
embedding: isEmbeddingModel,
|
||||
reasoning: isReasoningModel,
|
||||
function_calling: isFunctionCallingModel,
|
||||
web_search: isWebSearchModel,
|
||||
rerank: isRerankModel,
|
||||
free: isFreeModel
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const [tagSelection, setTagSelection] = useState<Record<ModelTag, boolean>>(initialTagSelection)
|
||||
|
||||
// 已选中的标签
|
||||
const selectedTags = useMemo(
|
||||
() =>
|
||||
objectEntries(tagSelection)
|
||||
.filter(([, state]) => state)
|
||||
.map(([tag]) => tag),
|
||||
[tagSelection]
|
||||
)
|
||||
|
||||
// 切换标签
|
||||
const toggleTag = useCallback((tag: ModelTag) => {
|
||||
setTagSelection((prev) => ({ ...prev, [tag]: !prev[tag] }))
|
||||
}, [])
|
||||
|
||||
// 重置标签
|
||||
const resetTags = useCallback(() => {
|
||||
setTagSelection(initialTagSelection)
|
||||
}, [])
|
||||
|
||||
// 根据标签过滤模型
|
||||
const tagFilter = useCallback(
|
||||
(model: Model) => {
|
||||
if (selectedTags.length === 0) return true
|
||||
return selectedTags.map((tag) => filterConfig[tag]).every((predict) => predict(model))
|
||||
},
|
||||
[filterConfig, selectedTags]
|
||||
)
|
||||
|
||||
return {
|
||||
tagSelection,
|
||||
selectedTags,
|
||||
tagFilter,
|
||||
toggleTag,
|
||||
resetTags
|
||||
}
|
||||
}
|
||||
@ -1,37 +1,19 @@
|
||||
import { PushpinOutlined } from '@ant-design/icons'
|
||||
import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag'
|
||||
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
|
||||
import {
|
||||
EmbeddingTag,
|
||||
FreeTag,
|
||||
ReasoningTag,
|
||||
RerankerTag,
|
||||
ToolsCallingTag,
|
||||
VisionTag,
|
||||
WebSearchTag
|
||||
} from '@renderer/components/Tags/Model'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
|
||||
import {
|
||||
getModelLogo,
|
||||
isEmbeddingModel,
|
||||
isFunctionCallingModel,
|
||||
isReasoningModel,
|
||||
isRerankModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel
|
||||
} from '@renderer/config/models'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types'
|
||||
import { Model, ModelType, objectEntries, Provider } from '@renderer/types'
|
||||
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
|
||||
import { getModelTags, isFreeModel } from '@renderer/utils/model'
|
||||
import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd'
|
||||
import { getModelTags } from '@renderer/utils/model'
|
||||
import { Avatar, Divider, Empty, Modal, Tooltip } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import React, {
|
||||
ReactNode,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
@ -44,18 +26,20 @@ import React, {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { useModelTagFilter } from './filters'
|
||||
import SelectModelSearchBar from './searchbar'
|
||||
import TagFilterSection from './TagFilterSection'
|
||||
import { FlatListItem, FlatListModel } from './types'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
type ModelPredict = (m: Model) => boolean
|
||||
|
||||
interface PopupParams {
|
||||
model?: Model
|
||||
modelFilter?: (model: Model) => boolean
|
||||
userFilterDisabled?: boolean
|
||||
/** Basic model filter */
|
||||
filter?: (model: Model) => boolean
|
||||
/** Show tag filter section */
|
||||
showTagFilter?: boolean
|
||||
}
|
||||
|
||||
interface Props extends PopupParams {
|
||||
@ -66,7 +50,7 @@ export type FilterType = Exclude<ModelType, 'text'> | 'free'
|
||||
|
||||
// const logger = loggerService.withContext('SelectModelPopup')
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilterDisabled }) => {
|
||||
const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFilter = true, resolve }) => {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
|
||||
@ -75,11 +59,6 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
const [_searchText, setSearchText] = useState('')
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
|
||||
const allModels: Model[] = useMemo(
|
||||
() => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)),
|
||||
[modelFilter, providers]
|
||||
)
|
||||
|
||||
// 当前选中的模型ID
|
||||
const currentModelId = model ? getModelUniqId(model) : ''
|
||||
|
||||
@ -94,95 +73,16 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 管理用户筛选状态
|
||||
/** 从模型列表获取的需要显示的标签 */
|
||||
const availableTags = useMemo(
|
||||
() =>
|
||||
objectEntries(getModelTags(allModels))
|
||||
.filter(([, state]) => state)
|
||||
.map(([tag]) => tag),
|
||||
[allModels]
|
||||
)
|
||||
const { tagSelection, selectedTags, tagFilter, toggleTag } = useModelTagFilter()
|
||||
|
||||
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
|
||||
() => ({
|
||||
vision: isVisionModel,
|
||||
embedding: isEmbeddingModel,
|
||||
reasoning: isReasoningModel,
|
||||
function_calling: isFunctionCallingModel,
|
||||
web_search: isWebSearchModel,
|
||||
rerank: isRerankModel,
|
||||
free: isFreeModel
|
||||
}),
|
||||
[]
|
||||
)
|
||||
// 计算要显示的可用标签列表
|
||||
const availableTags = useMemo(() => {
|
||||
const models = providers.flatMap((p) => p.models).filter(baseFilter ?? (() => true))
|
||||
return objectEntries(getModelTags(models))
|
||||
.filter(([, state]) => state)
|
||||
.map(([tag]) => tag)
|
||||
}, [providers, baseFilter])
|
||||
|
||||
/** 当前选择的标签,表示是否启用特定tag的筛选 */
|
||||
const [filterTags, setFilterTags] = useState<Record<ModelTag, boolean>>({
|
||||
vision: false,
|
||||
embedding: false,
|
||||
reasoning: false,
|
||||
function_calling: false,
|
||||
web_search: false,
|
||||
rerank: false,
|
||||
free: false
|
||||
})
|
||||
const selectedFilterTags = useMemo(
|
||||
() =>
|
||||
objectEntries(filterTags)
|
||||
.filter(([, state]) => state)
|
||||
.map(([tag]) => tag),
|
||||
[filterTags]
|
||||
)
|
||||
|
||||
const userFilter = useCallback(
|
||||
(model: Model) => {
|
||||
return selectedFilterTags
|
||||
.map((tag) => [tag, filterConfig[tag]] as const)
|
||||
.reduce((prev, [tag, predict]) => {
|
||||
return prev && (!filterTags[tag] || predict(model))
|
||||
}, true)
|
||||
},
|
||||
[filterConfig, filterTags, selectedFilterTags]
|
||||
)
|
||||
|
||||
const onClickTag = useCallback((type: ModelTag) => {
|
||||
startTransition(() => {
|
||||
setFilterTags((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 筛选项列表
|
||||
const tagsItems: Record<ModelTag, ReactNode> = useMemo(
|
||||
() => ({
|
||||
vision: <VisionTag showLabel inactive={!filterTags.vision} onClick={() => onClickTag('vision')} />,
|
||||
embedding: <EmbeddingTag inactive={!filterTags.embedding} onClick={() => onClickTag('embedding')} />,
|
||||
reasoning: <ReasoningTag showLabel inactive={!filterTags.reasoning} onClick={() => onClickTag('reasoning')} />,
|
||||
function_calling: (
|
||||
<ToolsCallingTag
|
||||
showLabel
|
||||
inactive={!filterTags.function_calling}
|
||||
onClick={() => onClickTag('function_calling')}
|
||||
/>
|
||||
),
|
||||
web_search: <WebSearchTag showLabel inactive={!filterTags.web_search} onClick={() => onClickTag('web_search')} />,
|
||||
rerank: <RerankerTag inactive={!filterTags.rerank} onClick={() => onClickTag('rerank')} />,
|
||||
free: <FreeTag inactive={!filterTags.free} onClick={() => onClickTag('free')} />
|
||||
}),
|
||||
[
|
||||
filterTags.embedding,
|
||||
filterTags.free,
|
||||
filterTags.function_calling,
|
||||
filterTags.reasoning,
|
||||
filterTags.rerank,
|
||||
filterTags.vision,
|
||||
filterTags.web_search,
|
||||
onClickTag
|
||||
]
|
||||
)
|
||||
|
||||
// 要显示的筛选项
|
||||
const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems])
|
||||
// 根据输入的文本筛选模型
|
||||
const searchFilter = useCallback(
|
||||
(provider: Provider) => {
|
||||
@ -237,9 +137,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
const items: FlatListItem[] = []
|
||||
const pinnedModelIds = new Set(pinnedModels)
|
||||
const finalModelFilter = (model: Model) => {
|
||||
const _userFilter = userFilterDisabled || userFilter(model)
|
||||
const _modelFilter = modelFilter === undefined || modelFilter(model)
|
||||
return _userFilter && _modelFilter
|
||||
const _tagFilter = !showTagFilter || tagFilter(model)
|
||||
const _baseFilter = baseFilter === undefined || baseFilter(model)
|
||||
return _tagFilter && _baseFilter
|
||||
}
|
||||
|
||||
// 添加置顶模型分组(仅在无搜索文本时)
|
||||
@ -279,11 +179,10 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
name: getFancyProviderName(p),
|
||||
actions: (
|
||||
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<Settings2 size={12} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />}
|
||||
<Settings2
|
||||
size={12}
|
||||
color="var(--color-text)"
|
||||
className="action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
@ -306,9 +205,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
pinnedModels,
|
||||
searchText.length,
|
||||
providers,
|
||||
userFilterDisabled,
|
||||
userFilter,
|
||||
modelFilter,
|
||||
showTagFilter,
|
||||
tagFilter,
|
||||
baseFilter,
|
||||
createModelItem,
|
||||
t,
|
||||
searchFilter,
|
||||
@ -319,7 +218,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
|
||||
}, [listItems.length])
|
||||
|
||||
// 处理程序化滚动(加载、搜索开始、搜索清空)
|
||||
// 处理程序化滚动(加载、搜索开始、搜索清空、tag 筛选)
|
||||
useLayoutEffect(() => {
|
||||
if (loading) return
|
||||
|
||||
@ -330,8 +229,8 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
|
||||
let targetItemKey: string | undefined
|
||||
|
||||
// 启动搜索时,滚动到第一个 item
|
||||
if (searchText) {
|
||||
// 启动搜索或 tag 筛选时,滚动到第一个 item
|
||||
if (searchText || selectedTags.length > 0) {
|
||||
targetItemKey = modelItems[0]?.key
|
||||
}
|
||||
// 初始加载或清空搜索时,滚动到 selected item
|
||||
@ -352,7 +251,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight])
|
||||
}, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight, selectedTags.length])
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: FlatListItem) => {
|
||||
@ -511,9 +410,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
paddingBottom: 16,
|
||||
// 需要稳定高度避免布局偏移
|
||||
height: userFilterDisabled ? undefined : 530
|
||||
paddingBottom: 16
|
||||
},
|
||||
body: {
|
||||
maxHeight: 'inherit',
|
||||
@ -525,14 +422,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
{/* 搜索框 */}
|
||||
<SelectModelSearchBar onSearch={setSearchText} />
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
{!userFilterDisabled && (
|
||||
{showTagFilter && (
|
||||
<>
|
||||
<FilterContainer>
|
||||
<Flex wrap="wrap" gap={4}>
|
||||
<FilterText>{t('models.filter.by_tag')}</FilterText>
|
||||
{displayedTags.map((item) => item)}
|
||||
</Flex>
|
||||
</FilterContainer>
|
||||
<TagFilterSection availableTags={availableTags} tagSelection={tagSelection} onToggleTag={toggleTag} />
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
@ -561,16 +453,6 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
|
||||
)
|
||||
}
|
||||
|
||||
const FilterContainer = styled.div`
|
||||
padding: 8px;
|
||||
padding-left: 18px;
|
||||
`
|
||||
|
||||
const FilterText = styled.span`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const ListContainer = styled.div`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -579,25 +461,28 @@ const ListContainer = styled.div`
|
||||
const GroupItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
line-height: 1;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
height: ${ITEM_HEIGHT}px;
|
||||
padding: 5px 12px 5px 18px;
|
||||
padding: 5px 18px;
|
||||
color: var(--color-text-3);
|
||||
z-index: 1;
|
||||
background: var(--modal-background);
|
||||
|
||||
&:hover {
|
||||
.ant-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
.action-icon {
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
&:hover .action-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ const MermaidPreview = ({
|
||||
enableToolbar = false,
|
||||
ref
|
||||
}: 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 [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
@ -56,7 +56,7 @@ const MermaidPreview = ({
|
||||
document.body.removeChild(measureEl)
|
||||
}
|
||||
},
|
||||
[diagramId, mermaid]
|
||||
[diagramId, mermaid, forceRenderKey]
|
||||
)
|
||||
|
||||
// 可见性检测函数
|
||||
|
||||
@ -60,7 +60,8 @@ describe('MermaidPreview', () => {
|
||||
mocks.useMermaid.mockReturnValue({
|
||||
mermaid: mockMermaid,
|
||||
isLoading: false,
|
||||
error: null
|
||||
error: null,
|
||||
forceRenderKey: 0
|
||||
})
|
||||
|
||||
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
|
||||
@ -116,7 +117,8 @@ describe('MermaidPreview', () => {
|
||||
mocks.useMermaid.mockReturnValue({
|
||||
mermaid: mockMermaid,
|
||||
isLoading: true,
|
||||
error: null
|
||||
error: null,
|
||||
forceRenderKey: 0
|
||||
})
|
||||
|
||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||
@ -145,7 +147,8 @@ describe('MermaidPreview', () => {
|
||||
mocks.useMermaid.mockReturnValue({
|
||||
mermaid: mockMermaid,
|
||||
isLoading: false,
|
||||
error: mermaidError
|
||||
error: mermaidError,
|
||||
forceRenderKey: 0
|
||||
})
|
||||
|
||||
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
|
||||
@ -173,7 +176,8 @@ describe('MermaidPreview', () => {
|
||||
mocks.useMermaid.mockReturnValue({
|
||||
mermaid: mockMermaid,
|
||||
isLoading: false,
|
||||
error: mermaidError
|
||||
error: mermaidError,
|
||||
forceRenderKey: 0
|
||||
})
|
||||
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
|
||||
|
||||
|
||||
@ -61,15 +61,13 @@ export const ToolbarButton = styled.button<{
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'transparent')};
|
||||
color: ${({ $active, $disabled }) =>
|
||||
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-white)' : 'var(--color-text)'};
|
||||
background: transparent;
|
||||
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0; /* 防止按钮收缩 */
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'var(--color-hover)')};
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Tooltip } from 'antd'
|
||||
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 { getCommandsByGroup } from './command'
|
||||
@ -12,7 +13,7 @@ import type { FormattingCommand, FormattingState, ToolbarProps } from './types'
|
||||
interface ToolbarItemInternal {
|
||||
id: string
|
||||
command?: FormattingCommand
|
||||
icon?: React.ComponentType
|
||||
icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>
|
||||
type?: 'divider'
|
||||
handler?: () => void
|
||||
}
|
||||
@ -170,7 +171,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({ editor, formattingState, onCom
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleCommand(command)}
|
||||
data-testid={`toolbar-${command}`}>
|
||||
<Icon />
|
||||
<Icon color={isActive ? 'var(--color-primary)' : 'var(--color-text)'} />
|
||||
</ToolbarButton>
|
||||
)
|
||||
|
||||
|
||||
@ -8,9 +8,7 @@ import {
|
||||
htmlToMarkdown,
|
||||
isMarkdownContent,
|
||||
markdownToHtml,
|
||||
markdownToPreviewText,
|
||||
markdownToSafeHtml,
|
||||
sanitizeHtml
|
||||
markdownToPreviewText
|
||||
} from '@renderer/utils/markdownConverter'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||
@ -135,7 +133,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
|
||||
const html = useMemo(() => {
|
||||
if (!markdown) return ''
|
||||
return markdownToSafeHtml(markdown)
|
||||
return markdownToHtml(markdown)
|
||||
}, [markdown])
|
||||
|
||||
const previewText = useMemo(() => {
|
||||
@ -423,8 +421,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
|
||||
onContentChange?.(content)
|
||||
if (onHtmlChange) {
|
||||
const safeHtml = sanitizeHtml(htmlContent)
|
||||
onHtmlChange(safeHtml)
|
||||
onHtmlChange(htmlContent)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error converting HTML to markdown:', error as Error)
|
||||
@ -502,7 +499,10 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
try {
|
||||
setTimeout(() => {
|
||||
if (editor && !editor.isDestroyed) {
|
||||
editor.commands.focus('end')
|
||||
const isLong = editor.getText().length > 2000
|
||||
if (!isLong) {
|
||||
editor.commands.focus('end')
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
@ -724,7 +724,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
setMarkdownState(content)
|
||||
onChange?.(content)
|
||||
|
||||
const convertedHtml = markdownToSafeHtml(content)
|
||||
const convertedHtml = markdownToHtml(content)
|
||||
|
||||
editor.commands.setContent(convertedHtml)
|
||||
|
||||
@ -771,7 +771,7 @@ export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditor
|
||||
|
||||
const toSafeHtml = useCallback((content: string): string => {
|
||||
try {
|
||||
return markdownToSafeHtml(content)
|
||||
return markdownToHtml(content)
|
||||
} catch (error) {
|
||||
logger.error('Error converting markdown to safe HTML:', error as Error)
|
||||
return ''
|
||||
|
||||
@ -37,8 +37,12 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
$color={actualColor}
|
||||
$size={size}
|
||||
$closable={closable}
|
||||
$clickable={!disabled && !!onClick}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
style={{ cursor: disabled ? 'not-allowed' : onClick ? 'pointer' : 'auto', ...style }}>
|
||||
style={{
|
||||
...(disabled && { cursor: 'not-allowed' }),
|
||||
...style
|
||||
}}>
|
||||
{icon && icon} {children}
|
||||
{closable && (
|
||||
<CloseIcon
|
||||
@ -66,7 +70,7 @@ const CustomTag: FC<CustomTagProps> = ({
|
||||
|
||||
export default memo(CustomTag)
|
||||
|
||||
const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>`
|
||||
const Tag = styled.div<{ $color: string; $size: number; $closable: boolean; $clickable: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@ -79,10 +83,16 @@ const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>`
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'auto')};
|
||||
.iconfont {
|
||||
font-size: ${({ $size }) => $size}px;
|
||||
color: ${({ $color }) => $color};
|
||||
}
|
||||
|
||||
transition: opacity 0.2s ease;
|
||||
&:hover {
|
||||
opacity: ${({ $clickable }) => ($clickable ? 0.8 : 1)};
|
||||
}
|
||||
`
|
||||
|
||||
const CloseIcon = styled(CloseOutlined)<{ $size: number; $color: string }>`
|
||||
|
||||
@ -41,4 +41,15 @@ describe('CustomTag', () => {
|
||||
expect(document.querySelector('.ant-tooltip')).toBeNull()
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not allow click when disabled', async () => {
|
||||
render(
|
||||
<CustomTag color={COLOR} disabled>
|
||||
custom-tag
|
||||
</CustomTag>
|
||||
)
|
||||
const tag = screen.getByText('custom-tag')
|
||||
expect(tag).toBeInTheDocument()
|
||||
expect(tag).toHaveStyle({ cursor: 'not-allowed' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 LambdaChatLogo from '@renderer/assets/images/apps/lambdachat.webp?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 MonicaLogo from '@renderer/assets/images/apps/monica.webp?url'
|
||||
import n8nLogo from '@renderer/assets/images/apps/n8n.svg?url'
|
||||
@ -476,6 +477,13 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
style: {
|
||||
padding: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'longcat',
|
||||
name: 'LongCat',
|
||||
logo: LongCatAppLogo,
|
||||
url: 'https://longcat.chat/',
|
||||
bodered: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -166,242 +166,6 @@ import OpenAI from 'openai'
|
||||
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from './prompts'
|
||||
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) {
|
||||
const isLight = true
|
||||
|
||||
@ -975,6 +739,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
provider: 'gemini',
|
||||
name: 'Gemini 2.0 Flash',
|
||||
group: 'Gemini 2.0'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash-image-preview',
|
||||
provider: 'gemini',
|
||||
name: 'Gemini 2.5 Flash Image',
|
||||
group: 'Gemini 2.5'
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
@ -1800,6 +1570,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
}
|
||||
],
|
||||
openrouter: [
|
||||
{
|
||||
id: 'google/gemini-2.5-flash-image-preview',
|
||||
provider: 'openrouter',
|
||||
name: 'Google: Gemini 2.5 Flash Image',
|
||||
group: 'google'
|
||||
},
|
||||
{
|
||||
id: 'google/gemini-2.5-flash-preview',
|
||||
provider: 'openrouter',
|
||||
@ -2317,107 +2093,305 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
|
||||
]
|
||||
}
|
||||
|
||||
export const TEXT_TO_IMAGES_MODELS = [
|
||||
{
|
||||
id: 'Kwai-Kolors/Kolors',
|
||||
provider: 'silicon',
|
||||
name: 'Kolors',
|
||||
group: 'Kwai-Kolors'
|
||||
// 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'
|
||||
)
|
||||
|
||||
// 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',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Schnell',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'black-forest-labs/FLUX.1-dev',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Dev',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'black-forest-labs/FLUX.1-pro',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Pro',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'Pro/black-forest-labs/FLUX.1-schnell',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Schnell Pro',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'LoRA/black-forest-labs/FLUX.1-dev',
|
||||
// provider: 'silicon',
|
||||
// name: 'FLUX.1 Dev LoRA',
|
||||
// group: 'FLUX'
|
||||
// },
|
||||
// {
|
||||
// id: 'deepseek-ai/Janus-Pro-7B',
|
||||
// provider: 'silicon',
|
||||
// name: 'Janus-Pro-7B',
|
||||
// group: 'deepseek-ai'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-3-5-large',
|
||||
// provider: 'silicon',
|
||||
// name: 'Stable Diffusion 3.5 Large',
|
||||
// group: 'Stable Diffusion'
|
||||
// },
|
||||
// {
|
||||
// id: 'stabilityai/stable-diffusion-3-5-large-turbo',
|
||||
// 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'
|
||||
// }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// For middleware to identify models that must use the dedicated Image API
|
||||
export const DEDICATED_IMAGE_MODELS = [
|
||||
'grok-2-image',
|
||||
'grok-2-image-1212',
|
||||
'grok-2-image-latest',
|
||||
'dall-e-3',
|
||||
'dall-e-2',
|
||||
'gpt-image-1'
|
||||
]
|
||||
|
||||
export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
'stabilityai/stable-diffusion-2-1',
|
||||
'stabilityai/stable-diffusion-xl-base-1.0'
|
||||
]
|
||||
// Models that should auto-enable image generation button when selected
|
||||
export const AUTO_ENABLE_IMAGE_MODELS = ['gemini-2.5-flash-image-preview', ...DEDICATED_IMAGE_MODELS]
|
||||
|
||||
export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
||||
'gemini-2.0-flash-exp',
|
||||
export const OPENAI_IMAGE_GENERATION_MODELS = [
|
||||
'o3',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4.1',
|
||||
'gpt-4.1-mini',
|
||||
'gpt-4.1-nano',
|
||||
'o3'
|
||||
'gpt-5',
|
||||
'gpt-image-1'
|
||||
]
|
||||
|
||||
export const GENERATE_IMAGE_MODELS = [
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.5-flash-image-preview',
|
||||
'grok-2-image-1212',
|
||||
'grok-2-image',
|
||||
'grok-2-image-latest',
|
||||
'gpt-image-1',
|
||||
...SUPPORTED_DISABLE_GENERATION_MODELS
|
||||
...DEDICATED_IMAGE_MODELS
|
||||
]
|
||||
|
||||
export const isDedicatedImageGenerationModel = (model: Model): boolean => {
|
||||
if (!model) return false
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return DEDICATED_IMAGE_MODELS.some((m) => modelId.includes(m))
|
||||
}
|
||||
|
||||
export const isAutoEnableImageGenerationModel = (model: Model): boolean => {
|
||||
if (!model) return false
|
||||
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
return AUTO_ENABLE_IMAGE_MODELS.some((m) => modelId.includes(m))
|
||||
}
|
||||
|
||||
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 OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini']
|
||||
@ -2584,9 +2558,9 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
||||
|
||||
// Specifically for DeepSeek V3.1. White list for now
|
||||
if (isDeepSeekHybridInferenceModel(model)) {
|
||||
return (['openrouter', 'dashscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]).some(
|
||||
(id) => id === model.provider
|
||||
)
|
||||
return (
|
||||
['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[]
|
||||
).some((id) => id === model.provider)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -3027,38 +3001,6 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
|
||||
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> {
|
||||
if (!isEnableWebSearch) {
|
||||
return {}
|
||||
|
||||
@ -1262,7 +1262,8 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
|
||||
'deepseek',
|
||||
'baichuan',
|
||||
'minimax',
|
||||
'xirang'
|
||||
'xirang',
|
||||
'poe'
|
||||
] as const satisfies SystemProviderId[]
|
||||
|
||||
/**
|
||||
|
||||
@ -5,6 +5,11 @@ export function useFullscreen() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 首次挂载时请求一次状态
|
||||
window.api.isFullScreen().then((value) => {
|
||||
setIsFullscreen(value)
|
||||
})
|
||||
|
||||
const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, fullscreen) => {
|
||||
setIsFullscreen(fullscreen)
|
||||
})
|
||||
|
||||
@ -33,6 +33,7 @@ export const useMermaid = () => {
|
||||
const { theme } = useTheme()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [forceRenderKey, setForceRenderKey] = useState(0)
|
||||
|
||||
// 初始化 mermaid 并监听主题变化
|
||||
useEffect(() => {
|
||||
@ -51,6 +52,7 @@ export const useMermaid = () => {
|
||||
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
||||
})
|
||||
|
||||
setForceRenderKey((prev) => prev + 1)
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid')
|
||||
@ -71,6 +73,7 @@ export const useMermaid = () => {
|
||||
return {
|
||||
mermaid: mermaidModule,
|
||||
isLoading,
|
||||
error
|
||||
error,
|
||||
forceRenderKey
|
||||
}
|
||||
}
|
||||
|
||||
78
src/renderer/src/hooks/useMetaDataParser.ts
Normal file
78
src/renderer/src/hooks/useMetaDataParser.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import axios from 'axios'
|
||||
import * as htmlparser2 from 'htmlparser2'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useMetaDataParser<T extends string>(
|
||||
link: string,
|
||||
properties: readonly T[],
|
||||
options?: {
|
||||
timeout?: number
|
||||
}
|
||||
) {
|
||||
const { timeout = 5000 } = options || {}
|
||||
|
||||
const [metadata, setMetadata] = useState<Record<T, string>>({} as Record<T, string>)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const parseMetadata = useCallback(async () => {
|
||||
if (!link || !isLoading) return
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await axios.get(link, { timeout, signal: controller.signal })
|
||||
|
||||
const htmlContent = response.data
|
||||
const parsedMetadata = {} as Record<T, string>
|
||||
|
||||
const parser = new htmlparser2.Parser({
|
||||
onopentag(tagName, attributes) {
|
||||
if (tagName === 'meta') {
|
||||
const { name: metaName, property: metaProperty, content } = attributes
|
||||
const metaKey = metaName || metaProperty
|
||||
if (!metaKey || !properties.includes(metaKey as T)) return
|
||||
parsedMetadata[metaKey as T] = content
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
parser.parseComplete(htmlContent)
|
||||
|
||||
setMetadata(parsedMetadata)
|
||||
} catch (err) {
|
||||
// Don't set error if request was aborted
|
||||
if (axios.isCancel(err) || (err instanceof Error && err.name === 'AbortError')) {
|
||||
return
|
||||
}
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch HTML'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isLoading, link, properties, timeout])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
metadata,
|
||||
isLoading,
|
||||
error,
|
||||
parseMetadata
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,6 @@ import {
|
||||
setNavbarPosition,
|
||||
setPinTopicsToTop,
|
||||
setSendMessageShortcut as _setSendMessageShortcut,
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTestChannel as _setTestChannel,
|
||||
@ -101,9 +100,6 @@ export function useSettings() {
|
||||
setAssistantIconType(assistantIconType: AssistantIconType) {
|
||||
dispatch(setAssistantIconType(assistantIconType))
|
||||
},
|
||||
setShowTokens(showTokens: boolean) {
|
||||
dispatch(setShowTokens(showTokens))
|
||||
},
|
||||
setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) {
|
||||
dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration))
|
||||
window.api.setDisableHardwareAcceleration(disableHardwareAcceleration)
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "Select the model to use",
|
||||
"model_required": "Please select a model",
|
||||
"select_folder": "Select Folder",
|
||||
"supported_providers": "Supported Providers",
|
||||
"title": "Code Tools",
|
||||
"update_options": "Update Options",
|
||||
"working_directory": "Working Directory"
|
||||
@ -820,6 +821,10 @@
|
||||
"devtools": "Open debug panel",
|
||||
"message": "It seems that something went wrong...",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"details": "Details",
|
||||
"mcp": {
|
||||
"invalid": "Invalid MCP server"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@ -1315,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "Deleting a group message will delete the user's question and all assistant's answers",
|
||||
"title": "Delete Group Message"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Retry failed messages"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1615,9 +1621,12 @@
|
||||
"new_folder": "New Folder",
|
||||
"new_note": "Create a new note",
|
||||
"no_content_to_copy": "No content to copy",
|
||||
"no_file_selected": "Please select the file to upload",
|
||||
"only_markdown": "Only Markdown files are supported",
|
||||
"only_one_file_allowed": "Only one file can be uploaded",
|
||||
"open_folder": "Open an external folder",
|
||||
"rename": "Rename",
|
||||
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
||||
"save": "Save to Notes",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3339,6 +3348,8 @@
|
||||
"label": "Grid detail trigger"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Confirm before deleting messages",
|
||||
"confirm_regenerate_message": "Confirm before regenerating messages",
|
||||
"enable_quick_triggers": "Enable / and @ triggers",
|
||||
"paste_long_text_as_file": "Paste long text as file",
|
||||
"paste_long_text_threshold": "Paste long text length",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "使用するモデルを選択してください",
|
||||
"model_required": "モデルを選択してください",
|
||||
"select_folder": "フォルダを選択",
|
||||
"supported_providers": "サポートされているプロバイダー",
|
||||
"title": "コードツール",
|
||||
"update_options": "更新オプション",
|
||||
"working_directory": "作業ディレクトリ"
|
||||
@ -820,6 +821,10 @@
|
||||
"devtools": "デバッグパネルを開く",
|
||||
"message": "何か問題が発生したようです...",
|
||||
"reload": "再読み込み"
|
||||
},
|
||||
"details": "詳細情報",
|
||||
"mcp": {
|
||||
"invalid": "無効なMCPサーバー"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@ -1315,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
|
||||
"title": "分組メッセージを削除"
|
||||
}
|
||||
},
|
||||
"retry_failed": "エラーになったメッセージを再試行"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1615,9 +1621,12 @@
|
||||
"new_folder": "新しいフォルダーを作成する",
|
||||
"new_note": "新規ノート作成",
|
||||
"no_content_to_copy": "コピーするコンテンツはありません",
|
||||
"no_file_selected": "アップロードするファイルを選択してください",
|
||||
"only_markdown": "Markdown ファイルのみをアップロードできます",
|
||||
"only_one_file_allowed": "アップロードできるファイルは1つだけです",
|
||||
"open_folder": "外部フォルダーを開きます",
|
||||
"rename": "名前の変更",
|
||||
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
|
||||
"save": "メモに保存する",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3339,6 +3348,8 @@
|
||||
"label": "グリッド詳細トリガー"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "メッセージ削除前に確認",
|
||||
"confirm_regenerate_message": "メッセージ再生成前に確認",
|
||||
"enable_quick_triggers": "/ と @ を有効にしてクイックメニューを表示します。",
|
||||
"paste_long_text_as_file": "長いテキストをファイルとして貼り付け",
|
||||
"paste_long_text_threshold": "長いテキストの長さ",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "Выберите модель для использования",
|
||||
"model_required": "Пожалуйста, выберите модель",
|
||||
"select_folder": "Выберите папку",
|
||||
"supported_providers": "Поддерживаемые поставщики",
|
||||
"title": "Инструменты кода",
|
||||
"update_options": "Параметры обновления",
|
||||
"working_directory": "Рабочая директория"
|
||||
@ -820,6 +821,10 @@
|
||||
"devtools": "Открыть панель отладки",
|
||||
"message": "Похоже, возникла какая-то проблема...",
|
||||
"reload": "Перезагрузить"
|
||||
},
|
||||
"details": "Подробности",
|
||||
"mcp": {
|
||||
"invalid": "Недействительный сервер MCP"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@ -1315,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
|
||||
"title": "Удалить группу сообщений"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Повторить неудавшиеся сообщения"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1615,9 +1621,12 @@
|
||||
"new_folder": "Новая папка",
|
||||
"new_note": "Создать заметку",
|
||||
"no_content_to_copy": "Нет контента для копирования",
|
||||
"no_file_selected": "Пожалуйста, выберите файл для загрузки",
|
||||
"only_markdown": "Только Markdown",
|
||||
"only_one_file_allowed": "Можно загрузить только один файл",
|
||||
"open_folder": "Откройте внешнюю папку",
|
||||
"rename": "переименовать",
|
||||
"rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}",
|
||||
"save": "Сохранить в заметки",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3339,6 +3348,8 @@
|
||||
"label": "Триггер для отображения подробной информации в сетке"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Подтверждать перед удалением сообщений",
|
||||
"confirm_regenerate_message": "Подтверждать перед пересозданием сообщений",
|
||||
"enable_quick_triggers": "Включите / и @, чтобы вызвать быстрое меню.",
|
||||
"paste_long_text_as_file": "Вставлять длинный текст как файл",
|
||||
"paste_long_text_threshold": "Длина вставки длинного текста",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "选择要使用的模型",
|
||||
"model_required": "请选择模型",
|
||||
"select_folder": "选择文件夹",
|
||||
"supported_providers": "支持的服务商",
|
||||
"title": "代码工具",
|
||||
"update_options": "更新选项",
|
||||
"working_directory": "工作目录"
|
||||
@ -820,6 +821,10 @@
|
||||
"devtools": "打开调试面板",
|
||||
"message": "似乎出现了一些问题...",
|
||||
"reload": "重新加载"
|
||||
},
|
||||
"details": "详细信息",
|
||||
"mcp": {
|
||||
"invalid": "无效的MCP服务器"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@ -1315,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "删除分组消息会删除用户提问和所有助手的回答",
|
||||
"title": "删除分组消息"
|
||||
}
|
||||
},
|
||||
"retry_failed": "重试出错的消息"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1615,9 +1621,12 @@
|
||||
"new_folder": "新建文件夹",
|
||||
"new_note": "新建笔记",
|
||||
"no_content_to_copy": "没有内容可复制",
|
||||
"no_file_selected": "请选择要上传的文件",
|
||||
"only_markdown": "仅支持 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上传一个文件",
|
||||
"open_folder": "打开外部文件夹",
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
||||
"save": "保存到笔记",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3339,6 +3348,8 @@
|
||||
"label": "网格详情触发"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "删除消息前确认",
|
||||
"confirm_regenerate_message": "重新生成消息前确认",
|
||||
"enable_quick_triggers": "启用 / 和 @ 触发快捷菜单",
|
||||
"paste_long_text_as_file": "长文本粘贴为文件",
|
||||
"paste_long_text_threshold": "长文本长度",
|
||||
|
||||
@ -677,6 +677,7 @@
|
||||
"model_placeholder": "選擇要使用的模型",
|
||||
"model_required": "請選擇模型",
|
||||
"select_folder": "選擇資料夾",
|
||||
"supported_providers": "支援的供應商",
|
||||
"title": "程式碼工具",
|
||||
"update_options": "更新選項",
|
||||
"working_directory": "工作目錄"
|
||||
@ -820,6 +821,10 @@
|
||||
"devtools": "打開除錯面板",
|
||||
"message": "似乎出現了一些問題...",
|
||||
"reload": "重新載入"
|
||||
},
|
||||
"details": "詳細信息",
|
||||
"mcp": {
|
||||
"invalid": "無效的MCP伺服器"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@ -1315,7 +1320,8 @@
|
||||
"delete": {
|
||||
"content": "刪除分組訊息會刪除使用者提問和所有助手的回答",
|
||||
"title": "刪除分組訊息"
|
||||
}
|
||||
},
|
||||
"retry_failed": "重試出錯的訊息"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@ -1615,9 +1621,12 @@
|
||||
"new_folder": "新建文件夾",
|
||||
"new_note": "新建筆記",
|
||||
"no_content_to_copy": "沒有內容可複制",
|
||||
"no_file_selected": "請選擇要上傳的文件",
|
||||
"only_markdown": "僅支援 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上傳一個文件",
|
||||
"open_folder": "打開外部文件夾",
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
||||
"save": "儲存到筆記",
|
||||
"settings": {
|
||||
"data": {
|
||||
@ -3339,6 +3348,8 @@
|
||||
"label": "網格詳細資訊觸發"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "刪除訊息前確認",
|
||||
"confirm_regenerate_message": "重新生成訊息前確認",
|
||||
"enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單",
|
||||
"paste_long_text_as_file": "將長文字貼上為檔案",
|
||||
"paste_long_text_threshold": "長文字長度",
|
||||
|
||||
@ -288,7 +288,7 @@
|
||||
"placeholder": "Αναζήτηση"
|
||||
}
|
||||
},
|
||||
"deeply_thought": "Έχει βαθιά σκεφτεί (χρήση {{secounds}} δευτερόλεπτα)",
|
||||
"deeply_thought": "Έχει βαθιά σκεφτεί (χρήση {{seconds}} δευτερόλεπτα)",
|
||||
"default": {
|
||||
"description": "Γεια σου, είμαι ο προεπαγγελματικός βοηθός. Μπορείς να ξεκινήσεις να μου μιλάς αμέσως.",
|
||||
"name": "Προεπαγγελματικός βοηθός",
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης",
|
||||
"message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...",
|
||||
"reload": "Επαναφόρτωση"
|
||||
},
|
||||
"details": "Λεπτομέρειες",
|
||||
"mcp": {
|
||||
"invalid": "Μη έγκυρος διακομιστής MCP"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -288,7 +288,7 @@
|
||||
"placeholder": "Buscar"
|
||||
}
|
||||
},
|
||||
"deeply_thought": "Profundamente pensado (tomó {{secounds}} segundos)",
|
||||
"deeply_thought": "Profundamente pensado (tomó {{seconds}} segundos)",
|
||||
"default": {
|
||||
"description": "Hola, soy el asistente predeterminado. Puedes comenzar a conversar conmigo de inmediato.",
|
||||
"name": "Asistente predeterminado",
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "Abrir el panel de depuración",
|
||||
"message": "Parece que ha surgido un problema...",
|
||||
"reload": "Recargar"
|
||||
},
|
||||
"details": "Detalles",
|
||||
"mcp": {
|
||||
"invalid": "Servidor MCP no válido"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -288,7 +288,7 @@
|
||||
"placeholder": "Rechercher"
|
||||
}
|
||||
},
|
||||
"deeply_thought": "Profondément réfléchi ({{secounds}} secondes)",
|
||||
"deeply_thought": "Profondément réfléchi ({{seconds}} secondes)",
|
||||
"default": {
|
||||
"description": "Bonjour, je suis l'assistant par défaut. Vous pouvez commencer à discuter avec moi tout de suite.",
|
||||
"name": "Assistant par défaut",
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "Ouvrir le panneau de débogage",
|
||||
"message": "Il semble que quelques problèmes soient survenus...",
|
||||
"reload": "Recharger"
|
||||
},
|
||||
"details": "Détails",
|
||||
"mcp": {
|
||||
"invalid": "Serveur MCP invalide"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -288,7 +288,7 @@
|
||||
"placeholder": "Pesquisar"
|
||||
}
|
||||
},
|
||||
"deeply_thought": "Profundamente pensado (demorou {{secounds}} segundos)",
|
||||
"deeply_thought": "Profundamente pensado (demorou {{seconds}} segundos)",
|
||||
"default": {
|
||||
"description": "Olá, eu sou o assistente padrão. Você pode começar a conversar comigo agora.",
|
||||
"name": "Assistente Padrão",
|
||||
@ -820,6 +820,10 @@
|
||||
"devtools": "Abrir o painel de depuração",
|
||||
"message": "Parece que ocorreu um problema...",
|
||||
"reload": "Recarregar"
|
||||
},
|
||||
"details": "Detalhes",
|
||||
"mcp": {
|
||||
"invalid": "Servidor MCP inválido"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
|
||||
@ -2,19 +2,22 @@ import AiProvider from '@renderer/aiCore'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
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 { getProviderLabel } from '@renderer/i18n/label'
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setIsBunInstalled } from '@renderer/store/mcp'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Alert, Button, Checkbox, Input, Select, Space } from 'antd'
|
||||
import { Download, Terminal, X } from 'lucide-react'
|
||||
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space } from 'antd'
|
||||
import { ArrowUpRight, Download, HelpCircle, Terminal, X } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import {
|
||||
@ -22,6 +25,7 @@ import {
|
||||
CLI_TOOL_PROVIDER_MAP,
|
||||
CLI_TOOLS,
|
||||
generateToolEnvironment,
|
||||
getClaudeSupportedProviders,
|
||||
parseEnvironmentVariables
|
||||
} from '.'
|
||||
|
||||
@ -30,6 +34,7 @@ const logger = loggerService.withContext('CodeToolsPage')
|
||||
const CodeToolsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { providers } = useProviders()
|
||||
const allProviders = useAllProviders()
|
||||
const dispatch = useAppDispatch()
|
||||
const isBunInstalled = useAppSelector((state) => state.mcp.isBunInstalled)
|
||||
const {
|
||||
@ -258,7 +263,35 @@ const CodeToolsPage: FC = () => {
|
||||
</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
|
||||
providers={availableProviders}
|
||||
predicate={modelPredicate}
|
||||
@ -395,4 +428,8 @@ const BunInstallAlert = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
const ProviderLogo = styled(Avatar)`
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
export default CodeToolsPage
|
||||
|
||||
@ -16,14 +16,14 @@ export interface ToolEnvironmentConfig {
|
||||
|
||||
// CLI 工具选项
|
||||
export const CLI_TOOLS = [
|
||||
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
||||
{ value: codeTools.claudeCode, label: 'Claude Code' },
|
||||
{ value: codeTools.qwenCode, label: 'Qwen Code' },
|
||||
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
|
||||
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' }
|
||||
]
|
||||
|
||||
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]
|
||||
|
||||
// Provider 过滤映射
|
||||
@ -57,6 +57,16 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => {
|
||||
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
|
||||
}
|
||||
|
||||
export const getClaudeSupportedProviders = (providers: Provider[]) => {
|
||||
return providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id))
|
||||
}
|
||||
|
||||
export { default } from './CodeToolsPage'
|
||||
|
||||
@ -13,6 +13,7 @@ import { Assistant, Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import React, { FC, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
@ -43,7 +44,6 @@ const Chat: FC<Props> = (props) => {
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
||||
|
||||
const maxWidth = useChatMaxWidth()
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
@ -131,7 +131,7 @@ const Chat: FC<Props> = (props) => {
|
||||
vertical
|
||||
flex={1}
|
||||
justify="space-between"
|
||||
style={{ maxWidth, height: mainHeight }}>
|
||||
style={{ maxWidth: '100%', height: mainHeight }}>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
@ -153,15 +153,24 @@ const Chat: FC<Props> = (props) => {
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
</QuickPanelProvider>
|
||||
</Main>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<Tabs
|
||||
activeAssistant={assistant}
|
||||
activeTopic={props.activeTopic}
|
||||
setActiveAssistant={props.setActiveAssistant}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
position="right"
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'auto', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
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>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ import { Assistant, Topic } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -80,11 +81,19 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!showAssistants && (
|
||||
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
|
||||
<Menu size={18} />
|
||||
</NavbarIcon>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{!showAssistants && (
|
||||
<motion.div
|
||||
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} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
|
||||
@ -7,6 +7,7 @@ import NavigationService from '@renderer/services/NavigationService'
|
||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
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 { useDispatch } from 'react-redux'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
@ -100,17 +101,26 @@ const HomePage: FC = () => {
|
||||
/>
|
||||
)}
|
||||
<ContentContainer id={isLeftNavbar ? 'content-container' : undefined}>
|
||||
{showAssistants && (
|
||||
<ErrorBoundary>
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position="left"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{showAssistants && (
|
||||
<ErrorBoundary>
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}>
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position="left"
|
||||
/>
|
||||
</motion.div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ErrorBoundary>
|
||||
<Chat
|
||||
assistant={activeAssistant}
|
||||
|
||||
@ -3,10 +3,10 @@ import { loggerService } from '@logger'
|
||||
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import {
|
||||
isAutoEnableImageGenerationModel,
|
||||
isGenerateImageModel,
|
||||
isGenerateImageModels,
|
||||
isMandatoryWebSearchModel,
|
||||
isSupportedDisableGenerationModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
isSupportedThinkingTokenModel,
|
||||
isVisionModel,
|
||||
@ -210,7 +210,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
)
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (inputEmpty || loading) {
|
||||
if (inputEmpty) {
|
||||
return
|
||||
}
|
||||
if (checkRateLimit(assistant)) {
|
||||
@ -258,7 +258,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
logger.warn('Failed to send message:', 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 () => {
|
||||
if (isTranslating) {
|
||||
@ -783,7 +783,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {
|
||||
updateAssistant({ ...assistant, enableGenerateImage: false })
|
||||
}
|
||||
if (isGenerateImageModel(model) && !assistant.enableGenerateImage && !isSupportedDisableGenerationModel(model)) {
|
||||
if (isAutoEnableImageGenerationModel(model) && !assistant.enableGenerateImage) {
|
||||
updateAssistant({ ...assistant, enableGenerateImage: true })
|
||||
}
|
||||
}, [assistant, model, updateAssistant])
|
||||
@ -927,6 +927,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
onClick={onNewContext}
|
||||
/>
|
||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
|
||||
{loading && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2 }}>
|
||||
@ -934,7 +935,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!loading && <SendMessageButton sendMessage={sendMessage} disabled={loading || inputEmpty} />}
|
||||
</ToolbarMenu>
|
||||
</Toolbar>
|
||||
</InputBarContainer>
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { OGCard } from '@renderer/components/OGCard'
|
||||
import { Popover } from 'antd'
|
||||
import React, { memo, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import React, { memo, useMemo, useState } from 'react'
|
||||
|
||||
interface HyperLinkProps {
|
||||
children: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
|
||||
const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const link = useMemo(() => {
|
||||
try {
|
||||
return decodeURIComponent(href)
|
||||
@ -16,32 +18,20 @@ const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
||||
}
|
||||
}, [href])
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(link).hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [link])
|
||||
|
||||
if (!href) return children
|
||||
|
||||
return (
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={
|
||||
<StyledHyperLink>
|
||||
{hostname && <Favicon hostname={hostname} alt={link} />}
|
||||
<span>{link}</span>
|
||||
</StyledHyperLink>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
content={<OGCard link={link} show={open} />}
|
||||
placement="top"
|
||||
color="var(--color-background)"
|
||||
styles={{
|
||||
body: {
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '12px',
|
||||
borderRadius: '8px'
|
||||
padding: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
@ -49,17 +39,4 @@ const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const StyledHyperLink = styled.div`
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
span {
|
||||
max-width: min(400px, 70vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(Hyperlink)
|
||||
|
||||
@ -34,7 +34,7 @@ import remarkDisableConstructs from './plugins/remarkDisableConstructs'
|
||||
import Table from './Table'
|
||||
|
||||
const ALLOWED_ELEMENTS =
|
||||
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup)/i
|
||||
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr|svg|path|circle|rect|line|polyline|polygon|text|g|defs|title|desc|tspan|sub|sup|details|summary)/i
|
||||
const DISALLOWED_ELEMENTS = ['iframe', 'script']
|
||||
|
||||
interface Props {
|
||||
|
||||
@ -18,17 +18,55 @@ const mocks = vi.hoisted(() => ({
|
||||
),
|
||||
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
|
||||
<img data-testid="favicon" data-hostname={hostname} alt={alt} />
|
||||
)
|
||||
),
|
||||
Typography: {
|
||||
Title: ({ children }: { children: React.ReactNode }) => <div data-testid="title">{children}</div>,
|
||||
Text: ({ children }: { children: React.ReactNode }) => <div data-testid="text">{children}</div>
|
||||
},
|
||||
Skeleton: () => <div data-testid="skeleton">Loading...</div>,
|
||||
useMetaDataParser: vi.fn(() => ({
|
||||
metadata: {},
|
||||
isLoading: false,
|
||||
isLoaded: true,
|
||||
parseMetadata: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Popover: mocks.Popover
|
||||
Popover: mocks.Popover,
|
||||
Typography: mocks.Typography,
|
||||
Skeleton: mocks.Skeleton
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
|
||||
__esModule: true,
|
||||
default: mocks.Favicon
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/hooks/useMetaDataParser', () => ({
|
||||
useMetaDataParser: mocks.useMetaDataParser
|
||||
}))
|
||||
|
||||
// Mock the OGCard component
|
||||
vi.mock('@renderer/components/OGCard', () => ({
|
||||
OGCard: ({ link }: { link: string; show: boolean }) => {
|
||||
let hostname = ''
|
||||
try {
|
||||
hostname = new URL(link).hostname
|
||||
} catch (e) {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="og-card">
|
||||
{hostname && <mocks.Favicon hostname={hostname} alt={link} />}
|
||||
<mocks.Typography.Title>{hostname}</mocks.Typography.Title>
|
||||
<mocks.Typography.Text>{link}</mocks.Typography.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('Hyperlink', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -69,7 +107,9 @@ describe('Hyperlink', () => {
|
||||
// Content includes decoded url text and favicon with hostname
|
||||
expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com')
|
||||
expect(screen.getByTestId('favicon')).toHaveAttribute('alt', 'https://domain.com/a b')
|
||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('https://domain.com/a b')
|
||||
// The title should show hostname and text should show the full URL
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('domain.com')
|
||||
expect(screen.getByTestId('text')).toHaveTextContent('https://domain.com/a b')
|
||||
})
|
||||
|
||||
it('should not render favicon when URL parsing fails (invalid url)', () => {
|
||||
@ -81,7 +121,9 @@ describe('Hyperlink', () => {
|
||||
|
||||
// decodeURIComponent succeeds => "not/url" is displayed
|
||||
expect(screen.queryByTestId('favicon')).toBeNull()
|
||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('not/url')
|
||||
// Since there's no hostname and no og:title, title shows empty, but text shows the URL
|
||||
expect(screen.getByTestId('title')).toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('text')).toHaveTextContent('not/url')
|
||||
})
|
||||
|
||||
it('should not render favicon for non-http(s) scheme without hostname (mailto:)', () => {
|
||||
@ -93,6 +135,8 @@ describe('Hyperlink', () => {
|
||||
|
||||
// Decoded to mailto:test@example.com, hostname is empty => no favicon
|
||||
expect(screen.queryByTestId('favicon')).toBeNull()
|
||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('mailto:test@example.com')
|
||||
// Since there's no hostname and no og:title, title shows empty, but text shows the decoded URL
|
||||
expect(screen.getByTestId('title')).toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('text')).toHaveTextContent('mailto:test@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,42 +1,34 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Hyperlink > should match snapshot for normal url 1`] = `
|
||||
.c0 {
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.c0 span {
|
||||
max-width: min(400px,70vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
<div>
|
||||
<div
|
||||
data-arrow="false"
|
||||
data-color="var(--color-background)"
|
||||
data-placement="top"
|
||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
||||
data-styles="{"body":{"padding":0,"borderRadius":"8px","overflow":"hidden"}}"
|
||||
data-testid="popover"
|
||||
>
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
>
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="og-card"
|
||||
>
|
||||
<img
|
||||
alt="https://example.com/path with space"
|
||||
data-hostname="example.com"
|
||||
data-testid="favicon"
|
||||
/>
|
||||
<span>
|
||||
<div
|
||||
data-testid="title"
|
||||
>
|
||||
example.com
|
||||
</div>
|
||||
<div
|
||||
data-testid="text"
|
||||
>
|
||||
https://example.com/path with space
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@ -7,32 +7,42 @@ import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
block: ImageMessageBlock
|
||||
isSingle?: boolean
|
||||
}
|
||||
|
||||
const ImageBlock: React.FC<Props> = ({ block }) => {
|
||||
if (block.status === MessageBlockStatus.PENDING) return <Skeleton.Image active style={{ width: 200, height: 200 }} />
|
||||
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.STREAMING || block.status === MessageBlockStatus.SUCCESS) {
|
||||
const images = block.metadata?.generateImageResponse?.images?.length
|
||||
? block.metadata?.generateImageResponse?.images
|
||||
: block?.file
|
||||
? [`file://${FileManager.getFilePath(block?.file)}`]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{images.map((src, index) => (
|
||||
<ImageViewer
|
||||
src={src}
|
||||
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>
|
||||
)
|
||||
} else return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
display: block;
|
||||
`
|
||||
export default React.memo(ImageBlock)
|
||||
|
||||
@ -87,11 +87,20 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
{groupedBlocks.map((block) => {
|
||||
if (Array.isArray(block)) {
|
||||
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 (
|
||||
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
||||
<ImageBlockGroup count={block.length}>
|
||||
{block.map((imageBlock) => (
|
||||
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} />
|
||||
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} isSingle={false} />
|
||||
))}
|
||||
</ImageBlockGroup>
|
||||
</AnimatedBlockWrapper>
|
||||
@ -166,8 +175,8 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
export default React.memo(MessageBlockRenderer)
|
||||
|
||||
const ImageBlockGroup = styled.div<{ count: number }>`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${({ count }) => Math.min(count, 3)}, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
max-width: 960px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
`
|
||||
|
||||
@ -60,7 +60,6 @@ const MessageItem: FC<Props> = ({
|
||||
index,
|
||||
hideMenuBar = false,
|
||||
isGrouped,
|
||||
isStreaming = false,
|
||||
onUpdateUseful,
|
||||
isGroupContextMessage
|
||||
}) => {
|
||||
@ -116,7 +115,7 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
const isLastMessage = index === 0 || !!isGrouped
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
|
||||
const showMenubar = !hideMenuBar && !isEditing
|
||||
|
||||
const messageHighlightHandler = useCallback(
|
||||
(highlight: boolean = true) => {
|
||||
|
||||
@ -3,13 +3,17 @@ import {
|
||||
ColumnWidthOutlined,
|
||||
DeleteOutlined,
|
||||
FolderOutlined,
|
||||
NumberOutlined
|
||||
NumberOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import type { Topic } from '@renderer/types'
|
||||
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 { FC, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -36,7 +40,8 @@ const MessageGroupMenuBar: FC<Props> = ({
|
||||
topic
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { deleteGroupMessages } = useMessageOperations(topic)
|
||||
const { deleteGroupMessages, regenerateAssistantMessage } = useMessageOperations(topic)
|
||||
const { assistant } = useAssistant(messages[0]?.assistantId)
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
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 = {
|
||||
fold: t('message.message.multi_model_style.fold.label'),
|
||||
vertical: t('message.message.multi_model_style.vertical'),
|
||||
@ -95,6 +133,17 @@ const MessageGroupMenuBar: FC<Props> = ({
|
||||
)}
|
||||
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
|
||||
</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
|
||||
type="text"
|
||||
size="small"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
// import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { CopyIcon, DeleteIcon, EditIcon, RefreshIcon } from '@renderer/components/Icons'
|
||||
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 { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||
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 { useEnableDeveloperMode, useMessageStyle } from '@renderer/hooks/useSettings'
|
||||
import { useEnableDeveloperMode, useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
@ -84,7 +84,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const { toggleMultiSelectMode } = useChatContext(props.topic)
|
||||
const [copied, setCopied] = useTemporaryValue(false, 2000)
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false)
|
||||
// remove confirm for regenerate; tooltip stays simple
|
||||
const [showDeleteTooltip, setShowDeleteTooltip] = useState(false)
|
||||
const { translateLanguages } = useTranslate()
|
||||
// const assistantModel = assistant?.model
|
||||
@ -99,8 +99,9 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { enableDeveloperMode } = useEnableDeveloperMode()
|
||||
const { confirmDeleteMessage, confirmRegenerateMessage } = useSettings()
|
||||
|
||||
const loading = useTopicLoading(topic)
|
||||
// const loading = useTopicLoading(topic)
|
||||
|
||||
const isUserMessage = message.role === 'user'
|
||||
|
||||
@ -145,18 +146,15 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
)
|
||||
|
||||
const onNewBranch = useCallback(async () => {
|
||||
if (loading) return
|
||||
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
|
||||
window.message.success({ content: t('chat.message.new.branch.created'), key: 'new-branch' })
|
||||
}, [index, t, loading])
|
||||
}, [index, t])
|
||||
|
||||
const handleResendUserMessage = useCallback(
|
||||
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()
|
||||
@ -392,7 +390,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
|
||||
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||
e?.stopPropagation?.()
|
||||
if (loading) return
|
||||
// No need to reset or edit the message anymore
|
||||
// const selectedModel = isGrouped ? model : assistantModel
|
||||
// const _message = resetAssistantMessage(message, selectedModel)
|
||||
@ -438,12 +435,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const onMentionModel = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (loading) return
|
||||
const selectedModel = await SelectModelPopup.show({ model, modelFilter: mentionModelFilter })
|
||||
const selectedModel = await SelectModelPopup.show({ model, filter: mentionModelFilter })
|
||||
if (!selectedModel) return
|
||||
appendAssistantResponse(message, selectedModel, { ...assistant, model: selectedModel })
|
||||
},
|
||||
[appendAssistantResponse, assistant, loading, mentionModelFilter, message, model]
|
||||
[appendAssistantResponse, assistant, mentionModelFilter, message, model]
|
||||
)
|
||||
|
||||
const onUseful = useCallback(
|
||||
@ -469,16 +465,32 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
{showMessageTokens && <MessageTokens message={message} />}
|
||||
<MenusBar
|
||||
className={classNames({ menubar: true, show: isLastMessage, 'user-bubble-style': isUserBubbleStyleMessage })}>
|
||||
{message.role === 'user' && (
|
||||
<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' &&
|
||||
(confirmRegenerateMessage ? (
|
||||
<Popconfirm
|
||||
title={t('message.regenerate.confirm')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleResendUserMessage()}
|
||||
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<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' && (
|
||||
<Tooltip title={t('common.edit')} mouseEnterDelay={0.8}>
|
||||
<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)" />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{isAssistantMessage && (
|
||||
<Popconfirm
|
||||
title={t('message.regenerate.confirm')}
|
||||
okButtonProps={{ danger: true }}
|
||||
icon={<InfoCircleOutlined style={{ color: 'red' }} />}
|
||||
onConfirm={onRegenerate}
|
||||
onOpenChange={(open) => open && setShowRegenerateTooltip(false)}>
|
||||
<Tooltip
|
||||
title={t('common.regenerate')}
|
||||
mouseEnterDelay={0.8}
|
||||
open={showRegenerateTooltip}
|
||||
onOpenChange={setShowRegenerateTooltip}>
|
||||
<ActionButton className="message-action-button" $softHoverBg={softHoverBg}>
|
||||
{isAssistantMessage &&
|
||||
(confirmRegenerateMessage ? (
|
||||
<Popconfirm
|
||||
title={t('message.regenerate.confirm')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={onRegenerate}
|
||||
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
$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} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
))}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('message.mention.title')} mouseEnterDelay={0.8}>
|
||||
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
|
||||
@ -603,15 +620,32 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('message.message.delete.content')}
|
||||
okButtonProps={{ danger: true }}
|
||||
icon={<InfoCircleOutlined style={{ color: 'red' }} />}
|
||||
onOpenChange={(open) => open && setShowDeleteTooltip(false)}
|
||||
onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}>
|
||||
{confirmDeleteMessage ? (
|
||||
<Popconfirm
|
||||
title={t('message.message.delete.content')}
|
||||
okButtonProps={{ danger: true }}
|
||||
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
|
||||
className="message-action-button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteMessage(message.id, message.traceId, message.model?.name)
|
||||
}}
|
||||
$softHoverBg={softHoverBg}>
|
||||
<Tooltip
|
||||
title={t('common.delete')}
|
||||
@ -621,7 +655,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
<DeleteIcon size={15} />
|
||||
</Tooltip>
|
||||
</ActionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{enableDeveloperMode && message.traceId && (
|
||||
<Tooltip title={t('trace.label')} mouseEnterDelay={0.8}>
|
||||
<ActionButton className="message-action-button" onClick={() => handleTraceUserMessage()}>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { Popover } from 'antd'
|
||||
@ -12,7 +11,6 @@ interface MessageTokensProps {
|
||||
}
|
||||
|
||||
const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
const { showTokens } = useSettings()
|
||||
// const { generating } = useRuntime()
|
||||
const locateMessage = () => {
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
||||
@ -59,7 +57,7 @@ const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
{showTokens && `Tokens: ${message?.usage?.total_tokens}`}
|
||||
{`Tokens: ${message?.usage?.total_tokens}`}
|
||||
</MessageMetadata>
|
||||
)
|
||||
}
|
||||
@ -88,17 +86,15 @@ const MessageTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
)
|
||||
|
||||
return (
|
||||
showTokens && (
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
{hasMetrics ? (
|
||||
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
|
||||
{tokensInfo}
|
||||
</Popover>
|
||||
) : (
|
||||
tokensInfo
|
||||
)}
|
||||
</MessageMetadata>
|
||||
)
|
||||
<MessageMetadata className="message-tokens" onClick={locateMessage}>
|
||||
{hasMetrics ? (
|
||||
<Popover content={metrixs} placement="top" trigger="hover" styles={{ root: { fontSize: 11 } }}>
|
||||
{tokensInfo}
|
||||
</Popover>
|
||||
) : (
|
||||
tokensInfo
|
||||
)}
|
||||
</MessageMetadata>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import { Assistant, Topic } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -68,15 +69,24 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
|
||||
return (
|
||||
<Navbar className="home-navbar">
|
||||
{showAssistants && (
|
||||
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</NavbarLeft>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{showAssistants && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'auto', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
|
||||
<NavbarLeft style={{ justifyContent: 'space-between', borderRight: 'none', padding: 0 }}>
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={toggleShowAssistants} style={{ marginLeft: isMac && !isFullscreen ? 16 : 0 }}>
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</NavbarLeft>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<NavbarRight style={{ justifyContent: 'space-between', flex: 1 }} className="home-navbar-right">
|
||||
<HStack alignItems="center">
|
||||
{!showAssistants && (
|
||||
@ -88,11 +98,20 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!showAssistants && (
|
||||
<NavbarIcon onClick={onShowAssistantsDrawer} style={{ marginRight: 8 }}>
|
||||
<Menu size={18} />
|
||||
</NavbarIcon>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{!showAssistants && (
|
||||
<motion.div
|
||||
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} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
|
||||
@ -26,6 +26,8 @@ import {
|
||||
setCodeShowLineNumbers,
|
||||
setCodeViewer,
|
||||
setCodeWrappable,
|
||||
setConfirmDeleteMessage,
|
||||
setConfirmRegenerateMessage,
|
||||
setEnableQuickPanelTriggers,
|
||||
setFontSize,
|
||||
setMathEnableSingleDollar,
|
||||
@ -105,7 +107,9 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
messageNavigation,
|
||||
enableQuickPanelTriggers,
|
||||
showTranslateConfirm,
|
||||
showMessageOutline
|
||||
showMessageOutline,
|
||||
confirmDeleteMessage,
|
||||
confirmRegenerateMessage
|
||||
} = useSettings()
|
||||
|
||||
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
|
||||
@ -646,6 +650,24 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<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>
|
||||
<SettingRowTitleSmall>{t('settings.input.target_language.label')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
|
||||
@ -155,9 +155,12 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
max-width: var(--assistants-width);
|
||||
min-width: var(--assistants-width);
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
|
||||
&.right {
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
}
|
||||
|
||||
[navbar-position='left'] & {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
|
||||
const onSelectModel = async (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.currentTarget.blur()
|
||||
const selectedModel = await SelectModelPopup.show({ model, modelFilter })
|
||||
const selectedModel = await SelectModelPopup.show({ model, filter: modelFilter })
|
||||
if (selectedModel) {
|
||||
// 避免更新数据造成关闭弹框的卡顿
|
||||
clearTimeout(timerRef.current)
|
||||
|
||||
@ -109,7 +109,9 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent }) => {
|
||||
}, [activeNode, notesTree])
|
||||
|
||||
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">
|
||||
{showWorkspace && (
|
||||
<Tooltip title={t('navbar.hide_sidebar')} mouseEnterDelay={0.8}>
|
||||
|
||||
@ -145,6 +145,7 @@ const RichEditorContainer = styled.div`
|
||||
|
||||
.notes-rich-editor {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
|
||||
@ -173,7 +174,7 @@ const RichEditorContainer = styled.div`
|
||||
|
||||
const BottomPanel = styled.div`
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
background: var(--color-background-soft);
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
} from '@renderer/services/NotesService'
|
||||
import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectActiveFilePath, setActiveFilePath } from '@renderer/store/note'
|
||||
import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { FileChangeEvent } from '@shared/config/types'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
@ -37,6 +37,7 @@ const NotesPage: FC = () => {
|
||||
const { showWorkspace } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
const { settings, notesPath, updateNotesPath } = useNotesSettings()
|
||||
|
||||
// 混合策略:useLiveQuery用于笔记树,React Query用于文件内容
|
||||
@ -52,7 +53,10 @@ const NotesPage: FC = () => {
|
||||
const isSyncingTreeRef = useRef(false)
|
||||
const isEditorInitialized = useRef(false)
|
||||
const lastContentRef = useRef<string>('')
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isInitialSortApplied = useRef(false)
|
||||
const isRenamingRef = useRef(false)
|
||||
const isCreatingNoteRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const updateCharCount = () => {
|
||||
@ -79,34 +83,40 @@ const NotesPage: FC = () => {
|
||||
|
||||
// 保存当前笔记内容
|
||||
const saveCurrentNote = useCallback(
|
||||
async (content: string) => {
|
||||
if (!activeFilePath || content === currentContent) return
|
||||
async (content: string, filePath?: string) => {
|
||||
const targetPath = filePath || activeFilePath
|
||||
if (!targetPath || content === currentContent) return
|
||||
|
||||
try {
|
||||
await window.api.file.write(activeFilePath, content)
|
||||
await window.api.file.write(targetPath, content)
|
||||
// 保存后立即刷新缓存,确保下次读取时获取最新内容
|
||||
invalidateFileContent(targetPath)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save note:', error as Error)
|
||||
}
|
||||
},
|
||||
[activeFilePath, currentContent]
|
||||
[activeFilePath, currentContent, invalidateFileContent]
|
||||
)
|
||||
|
||||
// 防抖保存函数,在停止输入后才保存,避免输入过程中的文件写入
|
||||
const debouncedSave = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
saveCurrentNote(content)
|
||||
debounce((content: string, filePath: string | undefined) => {
|
||||
saveCurrentNote(content, filePath)
|
||||
}, 800), // 800ms防抖延迟
|
||||
[saveCurrentNote]
|
||||
)
|
||||
|
||||
const handleMarkdownChange = useCallback(
|
||||
(newMarkdown: string) => {
|
||||
// 记录最新内容,用于兜底保存
|
||||
// 记录最新内容和文件路径,用于兜底保存
|
||||
lastContentRef.current = newMarkdown
|
||||
debouncedSave(newMarkdown)
|
||||
lastFilePathRef.current = activeFilePath
|
||||
// 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件
|
||||
const currentFilePath = activeFilePath
|
||||
debouncedSave(newMarkdown, currentFilePath)
|
||||
},
|
||||
[debouncedSave]
|
||||
[debouncedSave, activeFilePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -129,7 +139,7 @@ const NotesPage: FC = () => {
|
||||
async function applyInitialSort() {
|
||||
if (notesTree.length > 0 && !isInitialSortApplied.current) {
|
||||
try {
|
||||
await sortAllLevels('sort_a2z')
|
||||
await sortAllLevels(sortType)
|
||||
isInitialSortApplied.current = true
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply initial sorting:', error as Error)
|
||||
@ -138,14 +148,21 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
|
||||
applyInitialSort()
|
||||
}, [notesTree.length])
|
||||
}, [notesTree.length, sortType])
|
||||
|
||||
// 处理树同步时的状态管理
|
||||
useEffect(() => {
|
||||
if (notesTree.length === 0) return
|
||||
|
||||
// 如果有activeFilePath但找不到对应节点,清空选择
|
||||
if (activeFilePath && !activeNode) {
|
||||
// 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空
|
||||
const shouldClearPath =
|
||||
activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current
|
||||
|
||||
if (shouldClearPath) {
|
||||
logger.warn('Clearing activeFilePath - node not found in tree', {
|
||||
activeFilePath,
|
||||
reason: 'Node not found in current tree'
|
||||
})
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
}
|
||||
}, [notesTree, activeFilePath, activeNode, dispatch])
|
||||
@ -189,7 +206,7 @@ const NotesPage: FC = () => {
|
||||
invalidateFileContent(filePath)
|
||||
}
|
||||
} else {
|
||||
await initWorkSpace(notesPath)
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -208,7 +225,7 @@ const NotesPage: FC = () => {
|
||||
|
||||
// 重新同步数据库,useLiveQuery会自动响应数据库变化
|
||||
try {
|
||||
await initWorkSpace(notesPath)
|
||||
await initWorkSpace(notesPath, sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync database:', error as Error)
|
||||
} finally {
|
||||
@ -245,8 +262,8 @@ const NotesPage: FC = () => {
|
||||
})
|
||||
|
||||
// 如果有未保存的内容,立即保存
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent) {
|
||||
saveCurrentNote(lastContentRef.current).catch((error) => {
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save failed:', error as Error)
|
||||
})
|
||||
}
|
||||
@ -262,7 +279,8 @@ const NotesPage: FC = () => {
|
||||
dispatch,
|
||||
currentContent,
|
||||
debouncedSave,
|
||||
saveCurrentNote
|
||||
saveCurrentNote,
|
||||
sortType
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@ -271,12 +289,12 @@ const NotesPage: FC = () => {
|
||||
// 标记编辑器已初始化
|
||||
isEditorInitialized.current = true
|
||||
}
|
||||
}, [currentContent])
|
||||
}, [currentContent, activeFilePath])
|
||||
|
||||
// 切换文件时重置编辑器初始化状态并兜底保存
|
||||
useEffect(() => {
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent) {
|
||||
saveCurrentNote(lastContentRef.current).catch((error) => {
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save before file switch failed:', error as Error)
|
||||
})
|
||||
}
|
||||
@ -284,6 +302,7 @@ const NotesPage: FC = () => {
|
||||
// 重置状态
|
||||
isEditorInitialized.current = false
|
||||
lastContentRef.current = ''
|
||||
lastFilePathRef.current = undefined
|
||||
}, [activeFilePath, currentContent, saveCurrentNote])
|
||||
|
||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||
@ -317,17 +336,27 @@ const NotesPage: FC = () => {
|
||||
const handleCreateNote = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
isCreatingNoteRef.current = true
|
||||
|
||||
const targetPath = getTargetFolderPath()
|
||||
if (!targetPath) {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
const newNote = await createNote(name, '', targetPath)
|
||||
dispatch(setActiveFilePath(newNote.externalPath))
|
||||
setSelectedFolderId(null)
|
||||
|
||||
await sortAllLevels(sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create note:', error as Error)
|
||||
} finally {
|
||||
// 延迟重置标志,给数据库同步一些时间
|
||||
setTimeout(() => {
|
||||
isCreatingNoteRef.current = false
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[dispatch, getTargetFolderPath]
|
||||
[dispatch, getTargetFolderPath, sortType]
|
||||
)
|
||||
|
||||
// 切换展开状态
|
||||
@ -402,20 +431,18 @@ const NotesPage: FC = () => {
|
||||
if (node.type === 'file') {
|
||||
try {
|
||||
dispatch(setActiveFilePath(node.externalPath))
|
||||
invalidateFileContent(node.externalPath)
|
||||
// 清除文件夹选择状态
|
||||
setSelectedFolderId(null)
|
||||
} catch (error) {
|
||||
logger.error('Failed to load note:', error as Error)
|
||||
}
|
||||
} else if (node.type === 'folder') {
|
||||
// 设置选中的文件夹,同时清除活动文件
|
||||
setSelectedFolderId(node.id)
|
||||
// 清除活动文件状态,这样文件的高亮会被清除
|
||||
dispatch(setActiveFilePath(undefined))
|
||||
await handleToggleExpanded(node.id)
|
||||
}
|
||||
},
|
||||
[dispatch, handleToggleExpanded]
|
||||
[dispatch, handleToggleExpanded, invalidateFileContent]
|
||||
)
|
||||
|
||||
// 删除节点
|
||||
@ -430,6 +457,7 @@ const NotesPage: FC = () => {
|
||||
(nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || ''))
|
||||
|
||||
await deleteNode(nodeId)
|
||||
await sortAllLevels(sortType)
|
||||
|
||||
// 如果删除的是当前活动节点或其父节点,清空编辑器
|
||||
if (isActiveNodeOrParent) {
|
||||
@ -442,24 +470,47 @@ const NotesPage: FC = () => {
|
||||
logger.error('Failed to delete node:', error as Error)
|
||||
}
|
||||
},
|
||||
[activeFilePath, activeNode, notesTree, dispatch, findNodeById]
|
||||
[findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch]
|
||||
)
|
||||
|
||||
// 重命名节点
|
||||
const handleRenameNode = useCallback(
|
||||
async (nodeId: string, newName: string) => {
|
||||
try {
|
||||
isRenamingRef.current = true
|
||||
|
||||
const tree = await getNotesTree()
|
||||
const node = findNodeById(tree, nodeId)
|
||||
|
||||
if (node && node.name !== newName) {
|
||||
await renameNode(nodeId, newName)
|
||||
const oldExternalPath = node.externalPath
|
||||
const renamedNode = await renameNode(nodeId, newName)
|
||||
|
||||
if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) {
|
||||
dispatch(setActiveFilePath(renamedNode.externalPath))
|
||||
} else if (
|
||||
renamedNode.type === 'folder' &&
|
||||
activeFilePath &&
|
||||
activeFilePath.startsWith(oldExternalPath + '/')
|
||||
) {
|
||||
const relativePath = activeFilePath.substring(oldExternalPath.length)
|
||||
const newFilePath = renamedNode.externalPath + relativePath
|
||||
dispatch(setActiveFilePath(newFilePath))
|
||||
}
|
||||
await sortAllLevels(sortType)
|
||||
if (renamedNode.name !== newName) {
|
||||
window.message.info(t('notes.rename_changed', { original: newName, final: renamedNode.name }))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename node:', error as Error)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isRenamingRef.current = false
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
[findNodeById]
|
||||
[activeFilePath, dispatch, findNodeById, sortType, t]
|
||||
)
|
||||
|
||||
// 处理文件上传
|
||||
@ -505,22 +556,28 @@ const NotesPage: FC = () => {
|
||||
async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => {
|
||||
try {
|
||||
await moveNode(sourceNodeId, targetNodeId, position)
|
||||
await sortAllLevels(sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to move nodes:', error as Error)
|
||||
}
|
||||
},
|
||||
[]
|
||||
[sortType]
|
||||
)
|
||||
|
||||
// 处理节点排序
|
||||
const handleSortNodes = useCallback(async (sortType: NotesSortType) => {
|
||||
try {
|
||||
await sortAllLevels(sortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort notes:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}, [])
|
||||
const handleSortNodes = useCallback(
|
||||
async (newSortType: NotesSortType) => {
|
||||
try {
|
||||
// 更新Redux中的排序类型
|
||||
dispatch(setSortType(newSortType))
|
||||
await sortAllLevels(newSortType)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sort notes:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
const getCurrentNoteContent = useCallback(() => {
|
||||
if (settings.defaultEditMode === 'source') {
|
||||
|
||||
@ -2,11 +2,14 @@ import { loggerService } from '@logger'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectSortType } from '@renderer/store/note'
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { Dropdown, Input, MenuProps } from 'antd'
|
||||
import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@ -19,7 +22,7 @@ import {
|
||||
Star,
|
||||
StarOff
|
||||
} from 'lucide-react'
|
||||
import { FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -57,8 +60,8 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const { t } = useTranslation()
|
||||
const { bases } = useKnowledgeBases()
|
||||
const { activeNode } = useActiveNode(notesTree)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||
@ -66,8 +69,56 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
const [isShowSearch, setIsShowSearch] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [isDragOverSidebar, setIsDragOverSidebar] = useState(false)
|
||||
const [sortType, setSortType] = useState<NotesSortType>('sort_a2z')
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollbarRef = useRef<any>(null)
|
||||
|
||||
const inPlaceEdit = useInPlaceEdit({
|
||||
onSave: (newName: string) => {
|
||||
if (editingNodeId && newName) {
|
||||
onRenameNode(editingNodeId, newName)
|
||||
logger.debug(`Renamed node ${editingNodeId} to "${newName}"`)
|
||||
}
|
||||
setEditingNodeId(null)
|
||||
},
|
||||
onCancel: () => {
|
||||
setEditingNodeId(null)
|
||||
}
|
||||
})
|
||||
|
||||
// 滚动到活动节点
|
||||
useEffect(() => {
|
||||
if (activeNode?.id && !isShowStarred && !isShowSearch && scrollbarRef.current) {
|
||||
// 延迟一下确保DOM已更新
|
||||
setTimeout(() => {
|
||||
const scrollContainer = scrollbarRef.current as HTMLElement
|
||||
if (scrollContainer) {
|
||||
const activeElement = scrollContainer.querySelector(`[data-node-id="${activeNode.id}"]`) as HTMLElement
|
||||
if (activeElement) {
|
||||
// 获取元素相对于滚动容器的位置
|
||||
const containerHeight = scrollContainer.clientHeight
|
||||
const elementOffsetTop = activeElement.offsetTop
|
||||
const elementHeight = activeElement.offsetHeight
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
|
||||
// 检查元素是否在可视区域内
|
||||
const elementTop = elementOffsetTop
|
||||
const elementBottom = elementOffsetTop + elementHeight
|
||||
const viewTop = currentScrollTop
|
||||
const viewBottom = currentScrollTop + containerHeight
|
||||
|
||||
// 如果元素不在可视区域内,滚动到中心位置
|
||||
if (elementTop < viewTop || elementBottom > viewBottom) {
|
||||
const targetScrollTop = elementOffsetTop - (containerHeight - elementHeight) / 2
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, targetScrollTop),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
}, [activeNode?.id, isShowStarred, isShowSearch])
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
onCreateFolder(t('notes.untitled_folder'))
|
||||
@ -79,30 +130,18 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
const handleSelectSortType = useCallback(
|
||||
(selectedSortType: NotesSortType) => {
|
||||
setSortType(selectedSortType)
|
||||
onSortNodes(selectedSortType)
|
||||
},
|
||||
[onSortNodes]
|
||||
)
|
||||
|
||||
const handleStartEdit = useCallback((node: NotesTreeNode) => {
|
||||
setEditingNodeId(node.id)
|
||||
setEditingName(node.name)
|
||||
}, [])
|
||||
|
||||
const handleFinishEdit = useCallback(() => {
|
||||
if (editingNodeId && editingName.trim()) {
|
||||
onRenameNode(editingNodeId, editingName.trim())
|
||||
}
|
||||
setEditingNodeId(null)
|
||||
setEditingName('')
|
||||
logger.debug(`Renamed node ${editingNodeId} to "${editingName.trim()}"`)
|
||||
}, [editingNodeId, editingName, onRenameNode])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingNodeId(null)
|
||||
setEditingName('')
|
||||
}, [])
|
||||
const handleStartEdit = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
setEditingNodeId(node.id)
|
||||
inPlaceEdit.startEdit(node.name)
|
||||
},
|
||||
[inPlaceEdit]
|
||||
)
|
||||
|
||||
const handleDeleteNode = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
@ -306,8 +345,10 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
const renderTreeNode = useCallback(
|
||||
(node: NotesTreeNode, depth: number = 0) => {
|
||||
const isActive = node.id === activeNode?.id || (node.type === 'folder' && node.id === selectedFolderId)
|
||||
const isEditing = editingNodeId === node.id
|
||||
const isActive = selectedFolderId
|
||||
? node.type === 'folder' && node.id === selectedFolderId
|
||||
: node.id === activeNode?.id
|
||||
const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
@ -328,6 +369,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
isDragInside={isDragInside}
|
||||
isDragAfter={isDragAfter}
|
||||
draggable={!isEditing}
|
||||
data-node-id={node.id}
|
||||
onDragStart={(e) => handleDragStart(e, node)}
|
||||
onDragOver={(e) => handleDragOver(e, node)}
|
||||
onDragLeave={handleDragLeave}
|
||||
@ -361,15 +403,13 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
{isEditing ? (
|
||||
<EditInput
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onPressEnter={handleFinishEdit}
|
||||
onBlur={handleFinishEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancelEdit()
|
||||
}
|
||||
}}
|
||||
ref={inPlaceEdit.inputRef as Ref<InputRef>}
|
||||
value={inPlaceEdit.editValue}
|
||||
onChange={inPlaceEdit.handleInputChange}
|
||||
onPressEnter={inPlaceEdit.saveEdit}
|
||||
onBlur={inPlaceEdit.saveEdit}
|
||||
onKeyDown={inPlaceEdit.handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
size="small"
|
||||
/>
|
||||
@ -388,24 +428,27 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
)
|
||||
},
|
||||
[
|
||||
activeNode,
|
||||
selectedFolderId,
|
||||
activeNode?.id,
|
||||
editingNodeId,
|
||||
editingName,
|
||||
inPlaceEdit.isEditing,
|
||||
inPlaceEdit.inputRef,
|
||||
inPlaceEdit.editValue,
|
||||
inPlaceEdit.handleInputChange,
|
||||
inPlaceEdit.saveEdit,
|
||||
inPlaceEdit.handleKeyDown,
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
onSelectNode,
|
||||
onToggleExpanded,
|
||||
handleFinishEdit,
|
||||
handleCancelEdit,
|
||||
getMenuItems,
|
||||
handleDragLeave,
|
||||
handleDragEnd,
|
||||
t,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
getMenuItems,
|
||||
t
|
||||
onSelectNode,
|
||||
onToggleExpanded
|
||||
]
|
||||
)
|
||||
|
||||
@ -451,7 +494,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
/>
|
||||
|
||||
<NotesTreeContainer>
|
||||
<StyledScrollbar>
|
||||
<StyledScrollbar ref={scrollbarRef}>
|
||||
<TreeContent>
|
||||
{filteredTree.map((node) => renderTreeNode(node))}
|
||||
{!isShowStarred && !isShowSearch && (
|
||||
@ -479,7 +522,8 @@ const SidebarContainer = styled.div`
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
@ -524,7 +568,7 @@ const TreeNodeContainer = styled.div<{
|
||||
if (props.active) return 'var(--color-background-soft)'
|
||||
return 'transparent'
|
||||
}};
|
||||
border: 1px solid
|
||||
border: 0.5px solid
|
||||
${(props) => {
|
||||
if (props.isDragInside) return 'var(--color-primary)'
|
||||
if (props.active) return 'var(--color-border)'
|
||||
@ -625,7 +669,7 @@ const EditInput = styled(Input)`
|
||||
.ant-input {
|
||||
font-size: 13px;
|
||||
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 }>`
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.isStarView || props.isSearchView ? 'flex-start' : 'center')};
|
||||
height: var(--navbar-height);
|
||||
`
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
|
||||
@ -99,9 +99,9 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
const aihubmixProvider = providers.find((p) => p.id === 'aihubmix')!
|
||||
|
||||
const modeOptions = [
|
||||
{ label: t('paintings.mode.generate'), value: 'generate' },
|
||||
{ label: t('paintings.mode.remix'), value: 'remix' },
|
||||
{ label: t('paintings.mode.upscale'), value: 'upscale' }
|
||||
{ label: t('paintings.mode.generate'), value: 'aihubmix_image_generate' },
|
||||
{ label: t('paintings.mode.remix'), value: 'aihubmix_image_remix' },
|
||||
{ label: t('paintings.mode.upscale'), value: 'aihubmix_image_upscale' }
|
||||
]
|
||||
|
||||
const getNewPainting = useCallback(() => {
|
||||
|
||||
@ -12,7 +12,6 @@ import { HStack, VStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
|
||||
import { LanguagesEnum } from '@renderer/config/translate'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
@ -42,6 +41,15 @@ import Artboard from './components/Artboard'
|
||||
import PaintingsList from './components/PaintingsList'
|
||||
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 IMAGE_SIZES = [
|
||||
|
||||
@ -184,7 +184,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
|
||||
const onSelectModel = useCallback(async () => {
|
||||
const currentModel = defaultModel ? assistant?.model : undefined
|
||||
const selectedModel = await SelectModelPopup.show({ model: currentModel, modelFilter })
|
||||
const selectedModel = await SelectModelPopup.show({ model: currentModel, filter: modelFilter })
|
||||
if (selectedModel) {
|
||||
setDefaultModel(selectedModel)
|
||||
updateAssistant({
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import 'emoji-picker-element'
|
||||
|
||||
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 { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||
import RichEditor from '@renderer/components/RichEditor'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
@ -47,9 +48,7 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
})
|
||||
|
||||
const onUpdate = () => {
|
||||
const text = editorRef.current?.getMarkdown() || ''
|
||||
setPrompt(text)
|
||||
const _assistant = { ...assistant, name: name.trim(), emoji, prompt: text }
|
||||
const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
|
||||
updateAssistant(_assistant)
|
||||
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 handleCommandsReady = (commandAPI: Pick<RichEditorRef, 'unregisterCommand'>) => {
|
||||
const disabledCommands = ['image', 'inlineMath']
|
||||
disabledCommands.forEach((commandId) => {
|
||||
commandAPI.unregisterCommand(commandId)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Box mb={8} style={{ fontWeight: 'bold' }}>
|
||||
@ -129,18 +121,20 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
</HStack>
|
||||
<TextAreaContainer>
|
||||
<RichEditorContainer>
|
||||
<RichEditor
|
||||
key={showPreview ? 'preview' : 'edit'}
|
||||
ref={editorRef}
|
||||
initialContent={showPreview ? processedPrompt : prompt}
|
||||
onCommandsReady={handleCommandsReady}
|
||||
showToolbar={!showPreview}
|
||||
editable={!showPreview}
|
||||
showTableOfContents={false}
|
||||
enableContentSearch={false}
|
||||
isFullWidth={true}
|
||||
className="prompt-rich-editor"
|
||||
/>
|
||||
{showPreview ? (
|
||||
<CodeViewer children={processedPrompt} language="markdown" expanded={true} height="100%" />
|
||||
) : (
|
||||
<CodeEditor
|
||||
value={prompt}
|
||||
language="markdown"
|
||||
onChange={setPrompt}
|
||||
height="100%"
|
||||
expanded={false}
|
||||
style={{
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RichEditorContainer>
|
||||
</TextAreaContainer>
|
||||
<HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px">
|
||||
|
||||
@ -47,12 +47,14 @@ const OcrImageSettings = ({ setProvider }: Props) => {
|
||||
}))
|
||||
}, [getOcrProviderName, imageProviders, platformSupport])
|
||||
|
||||
const isSystem = imageProvider.id === BuiltinOcrProviderIds.system
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{!platformSupport && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
|
||||
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
|
||||
<Select
|
||||
value={imageProvider.id}
|
||||
style={{ width: '200px' }}
|
||||
|
||||
@ -5,7 +5,9 @@ import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
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 { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -80,6 +82,49 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
||||
setImportMethod(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 () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@ -194,9 +239,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
||||
const values = await form.validateFields()
|
||||
const inputValue = values.serverConfig.trim()
|
||||
|
||||
const { serverToAdd, error } = parseAndExtractServer(inputValue, t)
|
||||
const { serverToAdd, error } = getServerFromJson(inputValue)
|
||||
|
||||
if (error) {
|
||||
if (error !== null) {
|
||||
form.setFields([
|
||||
{
|
||||
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([
|
||||
{
|
||||
name: 'serverConfig',
|
||||
errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })]
|
||||
errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd.name })]
|
||||
}
|
||||
])
|
||||
setLoading(false)
|
||||
@ -222,9 +267,9 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({
|
||||
// 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框
|
||||
const newServer: MCPServer = {
|
||||
id: nanoid(),
|
||||
...serverToAdd!,
|
||||
name: serverToAdd!.name || t('settings.mcp.newServer'),
|
||||
baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '',
|
||||
...serverToAdd,
|
||||
name: serverToAdd.name || t('settings.mcp.newServer'),
|
||||
baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '',
|
||||
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
|
||||
|
||||
@ -3,7 +3,9 @@ import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
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 { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -62,15 +64,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedConfig = JSON.parse(jsonConfig)
|
||||
|
||||
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
|
||||
const parsedJson = parseJSON(jsonConfig)
|
||||
if (parseJSON === null) {
|
||||
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[] = []
|
||||
|
||||
for (const [id, serverConfig] of Object.entries(parsedConfig.mcpServers)) {
|
||||
for (const [id, serverConfig] of Object.entries(parsedServers.mcpServers)) {
|
||||
const server: MCPServer = {
|
||||
id,
|
||||
isActive: false,
|
||||
@ -117,13 +123,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
afterClose={onClose}
|
||||
maskClosable={false}
|
||||
width={800}
|
||||
height="80vh"
|
||||
loading={jsonSaving}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{jsonError ? <span style={{ color: 'red' }}>{jsonError}</span> : ''}
|
||||
<Typography.Text style={{ width: '100%' }} type="danger">
|
||||
{jsonError ? <pre>{jsonError}</pre> : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import GeneralPopup from '@renderer/components/Popups/GeneralPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { getMcpTypeLabel } from '@renderer/i18n/label'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Button, Switch, Tag, Typography } from 'antd'
|
||||
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Alert, Button, Space, Switch, Tag, Tooltip, Typography } from 'antd'
|
||||
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'
|
||||
|
||||
interface McpServerCardProps {
|
||||
@ -26,6 +31,7 @@ const McpServerCard: FC<McpServerCardProps> = ({
|
||||
onEdit,
|
||||
onOpenUrl
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const handleOpenUrl = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
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 (
|
||||
<CardContainer $isActive={server.isActive} onClick={onEdit}>
|
||||
<ServerHeader>
|
||||
<ServerNameWrapper>
|
||||
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
||||
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
|
||||
{server.providerUrl && (
|
||||
<Button
|
||||
type="text"
|
||||
<ErrorBoundary fallbackComponent={Fallback}>
|
||||
<CardContainer $isActive={server.isActive} onClick={onEdit}>
|
||||
<ServerHeader>
|
||||
<ServerNameWrapper>
|
||||
{server.logoUrl && <ServerLogo src={server.logoUrl} alt={`${server.name} logo`} />}
|
||||
<ServerNameText ellipsis={{ tooltip: true }}>{server.name}</ServerNameText>
|
||||
{server.providerUrl && (
|
||||
<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"
|
||||
shape="circle"
|
||||
icon={<SquareArrowOutUpRight size={14} />}
|
||||
onClick={handleOpenUrl}
|
||||
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>
|
||||
<ToolbarWrapper onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
value={server.isActive}
|
||||
key={server.id}
|
||||
loading={isLoading}
|
||||
onChange={onToggle}
|
||||
size="small"
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
@ -30,26 +31,28 @@ const MCPSettings: FC = () => {
|
||||
</BackButtonContainer>
|
||||
)}
|
||||
<MainContainer>
|
||||
<Routes>
|
||||
<Route path="/" element={<McpServersList />} />
|
||||
<Route path="settings/:serverId" element={<McpSettings />} />
|
||||
<Route
|
||||
path="npx-search"
|
||||
element={
|
||||
<SettingContainer theme={theme}>
|
||||
<NpxSearch />
|
||||
</SettingContainer>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mcp-install"
|
||||
element={
|
||||
<SettingContainer theme={theme}>
|
||||
<InstallNpxUv />
|
||||
</SettingContainer>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<McpServersList />} />
|
||||
<Route path="settings/:serverId" element={<McpSettings />} />
|
||||
<Route
|
||||
path="npx-search"
|
||||
element={
|
||||
<SettingContainer theme={theme}>
|
||||
<NpxSearch />
|
||||
</SettingContainer>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mcp-install"
|
||||
element={
|
||||
<SettingContainer theme={theme}>
|
||||
<InstallNpxUv />
|
||||
</SettingContainer>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</MainContainer>
|
||||
</SettingContainer>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user