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 @@
Featured|HelloGitHub - kangfenmao%2Fcherry-studio | Trendshift + CherryHQ%2Fcherry-studio | Trendshift Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt
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: ( -
{ 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 }) => ( {alt} - ) + ), + 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 && ( + +