diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml
index f886d01f8b..ce07892bc4 100644
--- a/.github/workflows/nightly-build.yml
+++ b/.github/workflows/nightly-build.yml
@@ -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
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index 82058ec2a9..e3c30c2dd0 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 41b915953c..7428aa031e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/.gitignore b/.gitignore
index b74d3d5821..39b5630926 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,6 +60,9 @@ coverage
.vitest-cache
vitest.config.*.timestamp-*
+# TypeScript incremental build
+.tsbuildinfo
+
# playwright
playwright-report
test-results
diff --git a/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch b/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch
new file mode 100644
index 0000000000..5c64db053b
--- /dev/null
+++ b/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch
@@ -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
++}
diff --git a/README.md b/README.md
index 763ff5e542..47f29b0daf 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
index 38c887b211..6bc22e913b 100644
--- a/build/entitlements.mac.plist
+++ b/build/entitlements.mac.plist
@@ -8,5 +8,7 @@
com.apple.security.cs.allow-dyld-environment-variables
+ com.apple.security.cs.disable-library-validation
+
diff --git a/electron-builder.yml b/electron-builder.yml
index b4734ff945..6683c22ff2 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -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 相关问题
+ - 其他稳定性改进
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 69a949f45c..cf3ce3b5b0 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -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 // 内联所有动态导入,这是关键配置
diff --git a/package.json b/package.json
index ab22cab85c..7c6826e47c 100644
--- a/package.json
+++ b/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",
diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts
index 26e6a2764a..c5b7c6ec4e 100644
--- a/packages/shared/IpcChannel.ts
+++ b/packages/shared/IpcChannel.ts
@@ -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',
diff --git a/scripts/after-pack.js b/scripts/after-pack.js
index e392098771..b8e4c8af1d 100644
--- a/scripts/after-pack.js
+++ b/scripts/after-pack.js
@@ -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)
- })
-}
diff --git a/scripts/before-pack.js b/scripts/before-pack.js
new file mode 100644
index 0000000000..59c0a39171
--- /dev/null
+++ b/scripts/before-pack.js
@@ -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
+ }
+}
diff --git a/scripts/build-npm.js b/scripts/build-npm.js
deleted file mode 100644
index 7718410bbb..0000000000
--- a/scripts/build-npm.js
+++ /dev/null
@@ -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)
diff --git a/scripts/update-i18n.ts b/scripts/update-i18n.ts
index 488ffb1c92..72fcca8ab9 100644
--- a/scripts/update-i18n.ts
+++ b/scripts/update-i18n.ts
@@ -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.
diff --git a/scripts/utils.js b/scripts/utils.js
index 6cb5b35c3b..cafa07b681 100644
--- a/scripts/utils.js
+++ b/scripts/utils.js
@@ -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 = {
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 365b6173cd..8b22fee49c 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -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]
})
diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts
index 256b4dcbd6..66575870c7 100644
--- a/src/main/services/CodeToolsService.ts
+++ b/src/main/services/CodeToolsService.ts
@@ -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}`
}
diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts
index 3140bc21c0..8fcbebc642 100644
--- a/src/main/services/WindowService.ts
+++ b/src/main/services/WindowService.ts
@@ -499,6 +499,8 @@ export class WindowService {
}
})
+ this.setupWebContentsHandlers(this.miniWindow)
+
miniWindowState.manage(this.miniWindow)
//miniWindow should show in current desktop
diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts
index 0d7383a24a..dfd796346f 100644
--- a/src/main/services/ocr/OcrService.ts
+++ b/src/main/services/ocr/OcrService.ts
@@ -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))
diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts
index cda52bfec6..34a8bb8ce9 100644
--- a/src/main/services/ocr/builtin/SystemOcrService.ts
+++ b/src/main/services/ocr/builtin/SystemOcrService.ts
@@ -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 {
+ if (isLinux) {
+ return { text: '' }
+ }
const buffer = await loadOcrImage(file)
const langs = isWin ? options?.langs : undefined
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts
index e683f4faea..2f622d3544 100644
--- a/src/main/utils/file.ts
+++ b/src/main/utils/file.ts
@@ -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
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 0f3c358962..2244264753 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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 => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
+ isFullScreen: (): Promise => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
mac: {
isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts
index 8b58e78899..7d527c0577 100644
--- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts
+++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts
@@ -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
}> => {
- 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 = {}
+ 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) : {})
diff --git a/src/renderer/src/assets/images/apps/longcat.svg b/src/renderer/src/assets/images/apps/longcat.svg
new file mode 100644
index 0000000000..7e556fb53b
--- /dev/null
+++ b/src/renderer/src/assets/images/apps/longcat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/renderer/src/assets/images/banner.png b/src/renderer/src/assets/images/banner.png
new file mode 100644
index 0000000000..e29198cf82
Binary files /dev/null and b/src/renderer/src/assets/images/banner.png differ
diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss
index f52d57a7c2..7d4dbf0ce2 100644
--- a/src/renderer/src/assets/styles/markdown.scss
+++ b/src/renderer/src/assets/styles/markdown.scss
@@ -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;
diff --git a/src/renderer/src/assets/styles/richtext.scss b/src/renderer/src/assets/styles/richtext.scss
index 91ebf0940d..7b4e4bdad5 100644
--- a/src/renderer/src/assets/styles/richtext.scss
+++ b/src/renderer/src/assets/styles/richtext.scss
@@ -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
diff --git a/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts b/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts
new file mode 100644
index 0000000000..b02d10e152
--- /dev/null
+++ b/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts
@@ -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')
+ })
+})
diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts
index 7917cebd80..b6689644e9 100644
--- a/src/renderer/src/components/CodeEditor/hooks.ts
+++ b/src/renderer/src/components/CodeEditor/hooks.ts
@@ -8,7 +8,9 @@ import { getNormalizedExtension } from './utils'
const logger = loggerService.withContext('CodeEditorHooks')
-// 语言对应的 linter 加载器
+/** 语言对应的 linter 加载器
+ * key: 语言文件扩展名(不包含 `.`)
+ */
const linterLoaders: Record Promise> = {
json: async () => {
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
@@ -64,13 +66,15 @@ async function loadLanguageExtension(language: string): Promise {
- 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
}
}
diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx
index 749ec57b01..7c541cbdb8 100644
--- a/src/renderer/src/components/CodeEditor/index.tsx
+++ b/src/renderer/src/components/CodeEditor/index.tsx
@@ -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
diff --git a/src/renderer/src/components/CodeEditor/utils.ts b/src/renderer/src/components/CodeEditor/utils.ts
index 251778b9d1..ef5941720e 100644
--- a/src/renderer/src/components/CodeEditor/utils.ts
+++ b/src/renderer/src/components/CodeEditor/utils.ts
@@ -13,6 +13,7 @@ const _customLanguageExtensions: Record = {
* 获取语言的扩展名,用于 @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
}
diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx
index 87a33534b9..440aed9c7c 100644
--- a/src/renderer/src/components/CodeViewer.tsx
+++ b/src/renderer/src/components/CodeViewer.tsx
@@ -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(null)
@@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
}, [rawLines.length, onHeightChange])
return (
-
+
@@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{
$wrap?: boolean
$expanded?: boolean
$lineHeight?: number
+ $height?: string | number
}>`
display: block;
overflow-x: auto;
diff --git a/src/renderer/src/components/ModelSelectButton.tsx b/src/renderer/src/components/ModelSelectButton.tsx
index 40330253cd..fd227f1e76 100644
--- a/src/renderer/src/components/ModelSelectButton.tsx
+++ b/src/renderer/src/components/ModelSelectButton.tsx
@@ -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)
}
diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx
new file mode 100644
index 0000000000..8a0036e8e2
--- /dev/null
+++ b/src/renderer/src/components/OGCard.tsx
@@ -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
+ }
+
+ return (
+
+ {hasImage && (
+
+
+
+ )}
+ {!hasImage && (
+
+
+
+ )}
+
+
+
+ {hostname && }
+
+ {metadata['og:title'] || hostname}
+
+
+
+ {metadata['og:description'] || link}
+
+
+
+ )
+}
+
+const CardSkeleton = () => {
+ return (
+
+
+
+
+ )
+}
+
+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;
+`
diff --git a/src/renderer/src/components/Popups/GeneralPopup.tsx b/src/renderer/src/components/Popups/GeneralPopup.tsx
new file mode 100644
index 0000000000..3307b10162
--- /dev/null
+++ b/src/renderer/src/components/Popups/GeneralPopup.tsx
@@ -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 = ({ content, resolve, ...rest }) => {
+ const [open, setOpen] = useState(true)
+
+ const onOk = () => {
+ setOpen(false)
+ }
+
+ const onCancel = () => {
+ setOpen(false)
+ }
+
+ const onClose = () => {
+ resolve({})
+ }
+
+ GeneralPopup.hide = onCancel
+
+ return (
+
+ {content}
+
+ )
+}
+
+const TopViewKey = 'GeneralPopup'
+
+/** 在这个 Popup 中展示任意内容 */
+export default class GeneralPopup {
+ static topviewId = 0
+ static hide() {
+ TopView.hide(TopViewKey)
+ }
+ static show(props: ShowParams) {
+ return new Promise((resolve) => {
+ TopView.show(
+ {
+ resolve(v)
+ TopView.hide(TopViewKey)
+ }}
+ />,
+ TopViewKey
+ )
+ })
+ }
+}
diff --git a/src/renderer/src/components/Popups/RichEditPopup.tsx b/src/renderer/src/components/Popups/RichEditPopup.tsx
index 1c7b32e188..ed7c3d407c 100644
--- a/src/renderer/src/components/Popups/RichEditPopup.tsx
+++ b/src/renderer/src/components/Popups/RichEditPopup.tsx
@@ -97,6 +97,7 @@ const PopupContainer: React.FC = ({
afterClose={onClose}
afterOpenChange={handleAfterOpenChange}
maskClosable={false}
+ keyboard={false}
centered>
= ({
onContentChange={handleContentChange}
onMarkdownChange={handleMarkdownChange}
onCommandsReady={handleCommandsReady}
- minHeight={300}
- maxHeight={500}
+ minHeight={window.innerHeight * 0.7}
+ isFullWidth={true}
className="rich-edit-popup-editor"
/>
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx
new file mode 100644
index 0000000000..aec91bd803
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx
@@ -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
+ onToggleTag: (tag: ModelTag) => void
+}
+
+const TagFilterSection: React.FC = ({ 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 (
+
+
+ {t('models.filter.by_tag')}
+ {availableTags.map((tag) => {
+ const TagElement = tagComponents[tag]
+ if (!TagElement) {
+ logger.error(`Tag element not found for tag: ${tag}`)
+ return null
+ }
+ return (
+ handleTagClick(tag)}
+ inactive={!tagSelection[tag]}
+ showLabel
+ />
+ )
+ })}
+
+
+ )
+}
+
+const FilterContainer = styled.div`
+ padding: 8px;
+ padding-left: 18px;
+`
+
+const FilterText = styled.span`
+ color: var(--color-text-3);
+ font-size: 12px;
+`
+
+export default TagFilterSection
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/TagFilterSection.test.tsx b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/TagFilterSection.test.tsx
new file mode 100644
index 0000000000..131e924e4e
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/TagFilterSection.test.tsx
@@ -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 {
+ const base: Record = {
+ 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(
+
+ )
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should reflect inactive state based on tagSelection', () => {
+ render(
+
+ )
+ const visionBtn = screen.getByRole('button', { name: 'tag-vision' })
+ expect(visionBtn).toHaveAttribute('data-inactive', 'true')
+ })
+
+ it('should skip unknown tags', () => {
+ render(
+
+ )
+ 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()
+
+ const visionBtn = screen.getByRole('button', { name: 'tag-vision' })
+ fireEvent.click(visionBtn)
+
+ expect(handleToggle).toHaveBeenCalledTimes(1)
+ expect(handleToggle).toHaveBeenCalledWith('vision')
+ })
+ })
+})
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap
new file mode 100644
index 0000000000..297987423c
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap
@@ -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;
+}
+
+
+
+
+ models.filter.by_tag
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/filters.test.ts b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/filters.test.ts
new file mode 100644
index 0000000000..ebdfce59a3
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/filters.test.ts
@@ -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 {
+ 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)
+ })
+})
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/filters.ts b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts
new file mode 100644
index 0000000000..d2ee6c7742
--- /dev/null
+++ b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts
@@ -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 = {
+ vision: false,
+ embedding: false,
+ reasoning: false,
+ function_calling: false,
+ web_search: false,
+ rerank: false,
+ free: false
+}
+
+/**
+ * 标签筛选 hook,仅关注标签过滤逻辑
+ */
+export function useModelTagFilter() {
+ const filterConfig: Record = useMemo(
+ () => ({
+ vision: isVisionModel,
+ embedding: isEmbeddingModel,
+ reasoning: isReasoningModel,
+ function_calling: isFunctionCallingModel,
+ web_search: isWebSearchModel,
+ rerank: isRerankModel,
+ free: isFreeModel
+ }),
+ []
+ )
+
+ const [tagSelection, setTagSelection] = useState>(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
+ }
+}
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
index 6b910b2d78..1cd7926145 100644
--- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
+++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
@@ -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 | 'free'
// const logger = loggerService.withContext('SelectModelPopup')
-const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilterDisabled }) => {
+const PopupContainer: React.FC = ({ 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 = ({ 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 = ({ 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 = 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>({
- 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 = useMemo(
- () => ({
- vision: onClickTag('vision')} />,
- embedding: onClickTag('embedding')} />,
- reasoning: onClickTag('reasoning')} />,
- function_calling: (
- onClickTag('function_calling')}
- />
- ),
- web_search: onClickTag('web_search')} />,
- rerank: onClickTag('rerank')} />,
- free: 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 = ({ 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 = ({ model, resolve, modelFilter, userFilt
name: getFancyProviderName(p),
actions: (
- }
+ {
e.stopPropagation()
setOpen(false)
@@ -306,9 +205,9 @@ const PopupContainer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ model, resolve, modelFilter, userFilt
{/* 搜索框 */}
- {!userFilterDisabled && (
+ {showTagFilter && (
<>
-
-
- {t('models.filter.by_tag')}
- {displayedTags.map((item) => item)}
-
-
+
>
)}
@@ -561,16 +453,6 @@ const PopupContainer: React.FC = ({ 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;
}
`
diff --git a/src/renderer/src/components/Preview/MermaidPreview.tsx b/src/renderer/src/components/Preview/MermaidPreview.tsx
index 86e0339c2f..4c41e3c4b9 100644
--- a/src/renderer/src/components/Preview/MermaidPreview.tsx
+++ b/src/renderer/src/components/Preview/MermaidPreview.tsx
@@ -18,7 +18,7 @@ const MermaidPreview = ({
enableToolbar = false,
ref
}: BasicPreviewProps & { ref?: React.RefObject }) => {
- const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
+ const { mermaid, isLoading: isLoadingMermaid, error: mermaidError, forceRenderKey } = useMermaid()
const diagramId = useRef(`mermaid-${nanoid(6)}`).current
const [isVisible, setIsVisible] = useState(true)
@@ -56,7 +56,7 @@ const MermaidPreview = ({
document.body.removeChild(measureEl)
}
},
- [diagramId, mermaid]
+ [diagramId, mermaid, forceRenderKey]
)
// 可见性检测函数
diff --git a/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx b/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx
index 17ada0668c..2db61dd2dd 100644
--- a/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx
+++ b/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx
@@ -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({mermaidCode})
@@ -145,7 +147,8 @@ describe('MermaidPreview', () => {
mocks.useMermaid.mockReturnValue({
mermaid: mockMermaid,
isLoading: false,
- error: mermaidError
+ error: mermaidError,
+ forceRenderKey: 0
})
render({mermaidCode})
@@ -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 }))
diff --git a/src/renderer/src/components/RichEditor/styles.ts b/src/renderer/src/components/RichEditor/styles.ts
index 2a1cfa8708..8c4bc608cb 100644
--- a/src/renderer/src/components/RichEditor/styles.ts
+++ b/src/renderer/src/components/RichEditor/styles.ts
@@ -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 {
diff --git a/src/renderer/src/components/RichEditor/toolbar.tsx b/src/renderer/src/components/RichEditor/toolbar.tsx
index a7d25efac6..ebed37349e 100644
--- a/src/renderer/src/components/RichEditor/toolbar.tsx
+++ b/src/renderer/src/components/RichEditor/toolbar.tsx
@@ -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 & RefAttributes>
type?: 'divider'
handler?: () => void
}
@@ -170,7 +171,7 @@ export const Toolbar: React.FC = ({ editor, formattingState, onCom
disabled={isDisabled}
onClick={() => handleCommand(command)}
data-testid={`toolbar-${command}`}>
-
+
)
diff --git a/src/renderer/src/components/RichEditor/useRichEditor.ts b/src/renderer/src/components/RichEditor/useRichEditor.ts
index d68841b0a4..8275d3623d 100644
--- a/src/renderer/src/components/RichEditor/useRichEditor.ts
+++ b/src/renderer/src/components/RichEditor/useRichEditor.ts
@@ -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 ''
diff --git a/src/renderer/src/components/Tags/CustomTag.tsx b/src/renderer/src/components/Tags/CustomTag.tsx
index d078d46d38..561b3edf41 100644
--- a/src/renderer/src/components/Tags/CustomTag.tsx
+++ b/src/renderer/src/components/Tags/CustomTag.tsx
@@ -37,8 +37,12 @@ const CustomTag: FC = ({
$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 && (
= ({
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 }>`
diff --git a/src/renderer/src/components/__tests__/CustomTag.test.tsx b/src/renderer/src/components/__tests__/CustomTag.test.tsx
index 12a4620b3d..55359ea5cc 100644
--- a/src/renderer/src/components/__tests__/CustomTag.test.tsx
+++ b/src/renderer/src/components/__tests__/CustomTag.test.tsx
@@ -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(
+
+ custom-tag
+
+ )
+ const tag = screen.getByText('custom-tag')
+ expect(tag).toBeInTheDocument()
+ expect(tag).toHaveStyle({ cursor: 'not-allowed' })
+ })
})
diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts
index 8fdfff9beb..e11718bc62 100644
--- a/src/renderer/src/config/minapps.ts
+++ b/src/renderer/src/config/minapps.ts
@@ -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
}
]
diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts
index ba4217f76f..6e1eacc3eb 100644
--- a/src/renderer/src/config/models.ts
+++ b/src/renderer/src/config/models.ts
@@ -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 =
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 =
}
],
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 =
]
}
-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 {
if (!isEnableWebSearch) {
return {}
diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts
index 2ad123f32f..194d4c20b7 100644
--- a/src/renderer/src/config/providers.ts
+++ b/src/renderer/src/config/providers.ts
@@ -1262,7 +1262,8 @@ const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [
'deepseek',
'baichuan',
'minimax',
- 'xirang'
+ 'xirang',
+ 'poe'
] as const satisfies SystemProviderId[]
/**
diff --git a/src/renderer/src/hooks/useFullscreen.ts b/src/renderer/src/hooks/useFullscreen.ts
index 4a5820ed8e..0a86d31998 100644
--- a/src/renderer/src/hooks/useFullscreen.ts
+++ b/src/renderer/src/hooks/useFullscreen.ts
@@ -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)
})
diff --git a/src/renderer/src/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts
index 1ef9b43069..c1a80be2e6 100644
--- a/src/renderer/src/hooks/useMermaid.ts
+++ b/src/renderer/src/hooks/useMermaid.ts
@@ -33,6 +33,7 @@ export const useMermaid = () => {
const { theme } = useTheme()
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(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
}
}
diff --git a/src/renderer/src/hooks/useMetaDataParser.ts b/src/renderer/src/hooks/useMetaDataParser.ts
new file mode 100644
index 0000000000..10585b1b13
--- /dev/null
+++ b/src/renderer/src/hooks/useMetaDataParser.ts
@@ -0,0 +1,78 @@
+import axios from 'axios'
+import * as htmlparser2 from 'htmlparser2'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+export function useMetaDataParser(
+ link: string,
+ properties: readonly T[],
+ options?: {
+ timeout?: number
+ }
+) {
+ const { timeout = 5000 } = options || {}
+
+ const [metadata, setMetadata] = useState>({} as Record)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const abortControllerRef = useRef(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
+
+ 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
+ }
+}
diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts
index c68873d435..1cd89c45a9 100644
--- a/src/renderer/src/hooks/useSettings.ts
+++ b/src/renderer/src/hooks/useSettings.ts
@@ -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)
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index 28b56831c9..c6b745ac85 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index 7380d0c68e..a00750d71e 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -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": "長いテキストの長さ",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index 0505c0f19f..d6c6d4d198 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -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": "Длина вставки длинного текста",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 219e1ed411..bf3215a35c 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -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": "长文本长度",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 31c4b9a0cc..93881b95f9 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -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": "長文字長度",
diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json
index f50bcf3bd1..43602afe6f 100644
--- a/src/renderer/src/i18n/translate/el-gr.json
+++ b/src/renderer/src/i18n/translate/el-gr.json
@@ -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": {
diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json
index 902aa3fdff..715401149f 100644
--- a/src/renderer/src/i18n/translate/es-es.json
+++ b/src/renderer/src/i18n/translate/es-es.json
@@ -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": {
diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json
index e5d9f07945..794c76a35e 100644
--- a/src/renderer/src/i18n/translate/fr-fr.json
+++ b/src/renderer/src/i18n/translate/fr-fr.json
@@ -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": {
diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json
index 0c94d43538..57d26464e8 100644
--- a/src/renderer/src/i18n/translate/pt-pt.json
+++ b/src/renderer/src/i18n/translate/pt-pt.json
@@ -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": {
diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx
index 2b600547fe..f4cf60bb92 100644
--- a/src/renderer/src/pages/code/CodeToolsPage.tsx
+++ b/src/renderer/src/pages/code/CodeToolsPage.tsx
@@ -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 = () => {
- {t('code.model')}
+
+ {t('code.model')}
+ {selectedCliTool === 'claude-code' && (
+
+ {t('code.supported_providers')}
+
+ {getClaudeSupportedProviders(allProviders).map((provider) => {
+ return (
+
+
+ {getProviderLabel(provider.id)}
+
+
+ )
+ })}
+
+
+ }
+ trigger="hover"
+ placement="right">
+
+
+ )}
+
{
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'
diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx
index 1618d5e633..3c82f34eb9 100644
--- a/src/renderer/src/pages/home/Chat.tsx
+++ b/src/renderer/src/pages/home/Chat.tsx
@@ -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) => {
const contentSearchRef = React.useRef(null)
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
- const maxWidth = useChatMaxWidth()
const { setTimeoutTimer } = useTimer()
useHotkeys('esc', () => {
@@ -131,7 +131,7 @@ const Chat: FC = (props) => {
vertical
flex={1}
justify="space-between"
- style={{ maxWidth, height: mainHeight }}>
+ style={{ maxWidth: '100%', height: mainHeight }}>
= (props) => {
{isMultiSelectMode && }
- {topicPosition === 'right' && showTopics && (
-
- )}
+
+ {topicPosition === 'right' && showTopics && (
+
+
+
+ )}
+
)
diff --git a/src/renderer/src/pages/home/ChatNavbar.tsx b/src/renderer/src/pages/home/ChatNavbar.tsx
index 4ceb89cf5f..4b4f94909f 100644
--- a/src/renderer/src/pages/home/ChatNavbar.tsx
+++ b/src/renderer/src/pages/home/ChatNavbar.tsx
@@ -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 = ({ activeAssistant, setActiveAssistant, activeTo
)}
- {!showAssistants && (
-
-
-
- )}
+
+ {!showAssistants && (
+
+
+
+
+
+ )}
+
diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx
index a32eff2bb1..0e3be2e7b7 100644
--- a/src/renderer/src/pages/home/HomePage.tsx
+++ b/src/renderer/src/pages/home/HomePage.tsx
@@ -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 = () => {
/>
)}
- {showAssistants && (
-
-
-
- )}
+
+ {showAssistants && (
+
+
+
+
+
+ )}
+
= ({ assistant: _assistant, setActiveTopic, topic }) =
)
const sendMessage = useCallback(async () => {
- if (inputEmpty || loading) {
+ if (inputEmpty) {
return
}
if (checkRateLimit(assistant)) {
@@ -258,7 +258,7 @@ const Inputbar: FC = ({ 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 = ({ 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 = ({ assistant: _assistant, setActiveTopic, topic }) =
onClick={onNewContext}
/>
+
{loading && (
@@ -934,7 +935,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) =
)}
- {!loading && }
diff --git a/src/renderer/src/pages/home/Markdown/Hyperlink.tsx b/src/renderer/src/pages/home/Markdown/Hyperlink.tsx
index 6e461ea1a6..ec157270c8 100644
--- a/src/renderer/src/pages/home/Markdown/Hyperlink.tsx
+++ b/src/renderer/src/pages/home/Markdown/Hyperlink.tsx
@@ -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 = ({ children, href }) => {
+ const [open, setOpen] = useState(false)
+
const link = useMemo(() => {
try {
return decodeURIComponent(href)
@@ -16,32 +18,20 @@ const Hyperlink: React.FC = ({ children, href }) => {
}
}, [href])
- const hostname = useMemo(() => {
- try {
- return new URL(link).hostname
- } catch {
- return null
- }
- }, [link])
-
if (!href) return children
return (
- {hostname && }
- {link}
-
- }
+ open={open}
+ onOpenChange={setOpen}
+ content={}
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 = ({ 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)
diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx
index c636b6d784..6ff47e57a8 100644
--- a/src/renderer/src/pages/home/Markdown/Markdown.tsx
+++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx
@@ -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 {
diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx
index bd232b3454..8be594af8b 100644
--- a/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx
+++ b/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx
@@ -18,17 +18,55 @@ const mocks = vi.hoisted(() => ({
),
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
- )
+ ),
+ Typography: {
+ Title: ({ children }: { children: React.ReactNode }) => {children}
,
+ Text: ({ children }: { children: React.ReactNode }) => {children}
+ },
+ Skeleton: () => Loading...
,
+ 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 (
+
+ {hostname && }
+ {hostname}
+ {link}
+
+ )
+ }
+}))
+
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')
})
})
diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap
index 1dad29914b..84c01bd9fa 100644
--- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap
+++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap
@@ -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;
-}
-
![https://example.com/path with space]()
-
+
+ example.com
+
+
https://example.com/path with space
-
+
= ({ block }) => {
- if (block.status === MessageBlockStatus.PENDING) return
+const ImageBlock: React.FC
= ({ block, isSingle = false }) => {
+ if (block.status === MessageBlockStatus.PENDING) {
+ return
+ }
+
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 (
{images.map((src, index) => (
))}
)
- } else return null
+ }
+
+ return null
}
+
const Container = styled.div`
- display: flex;
- flex-direction: row;
- gap: 10px;
+ display: block;
`
export default React.memo(ImageBlock)
diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx
index f19386c3af..a7e86164c1 100644
--- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx
+++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx
@@ -87,11 +87,20 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => {
{groupedBlocks.map((block) => {
if (Array.isArray(block)) {
const groupKey = block.map((imageBlock) => imageBlock.id).join('-')
+ // 单张图片不使用 ImageBlockGroup 包装
+ if (block.length === 1) {
+ return (
+
+
+
+ )
+ }
+ // 多张图片使用 ImageBlockGroup 包装
return (
{block.map((imageBlock) => (
-
+
))}
@@ -166,8 +175,8 @@ const MessageBlockRenderer: React.FC = ({ 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%;
`
diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx
index 10e01bb0b6..48627224ca 100644
--- a/src/renderer/src/pages/home/Messages/Message.tsx
+++ b/src/renderer/src/pages/home/Messages/Message.tsx
@@ -60,7 +60,6 @@ const MessageItem: FC = ({
index,
hideMenuBar = false,
isGrouped,
- isStreaming = false,
onUpdateUseful,
isGroupContextMessage
}) => {
@@ -116,7 +115,7 @@ const MessageItem: FC = ({
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) => {
diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx
index 12fcb3f51d..0e7b7ab289 100644
--- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx
+++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx
@@ -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 = ({
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 = ({
})
}
+ 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 = ({
)}
{multiModelMessageStyle === 'grid' && }
+ {hasFailedMessages && (
+
+ }
+ onClick={handleRetryAll}
+ style={{ marginRight: 4 }}
+ />
+
+ )}