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/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 04ed410d6d..dd672f909f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -62,6 +62,7 @@ files: asarUnpack: - resources/** - '**/*.{metal,exp,lib}' + - 'node_modules/@img/sharp-libvips-*/**' extraResources: - from: 'migrations/sqlite-drizzle' to: 'migrations/sqlite-drizzle' @@ -117,6 +118,7 @@ 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 diff --git a/package.json b/package.json index 696e1f2eb3..9a8d17b560 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.5.8-rc.2", + "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 -", @@ -232,7 +231,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/scripts/after-pack.js b/scripts/after-pack.js index fd3c361788..b8e4c8af1d 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -1,92 +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']) - - keepPackageNodeFiles( - node_modules_path, - '@img', - arch === Arch.arm64 - ? ['sharp-darwin-arm64', 'sharp-libvips-darwin-arm64'] - : ['sharp-darwin-x64', 'sharp-libvips-darwin-x64'] - ) - } - - if (platform === 'linux') { - const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') - const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl'] - keepPackageNodeFiles(node_modules_path, '@libsql', _arch) - - keepPackageNodeFiles( - node_modules_path, - '@img', - arch === Arch.arm64 - ? ['sharp-libvips-linux-arm64', 'sharp-linux-arm64'] - : ['sharp-libvips-linux-x64', 'sharp-linux-x64'] - ) - } - - if (platform === 'windows') { - const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') - if (arch === Arch.arm64) { - keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc']) - keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc']) - } - if (arch === Arch.x64) { - keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc']) - keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) - } - - keepPackageNodeFiles( - node_modules_path, - '@img', - arch === Arch.arm64 - ? ['sharp-win32-arm64', 'sharp-libvips-win32-arm64'] - : ['sharp-win32-x64', 'sharp-libvips-win32-x64'] - ) - } - if (platform === 'windows') { fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true }) fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true }) } } - -/** - * 使用指定架构的 node_modules 文件 - * @param {*} nodeModulesPath - * @param {*} packageName - * @param {*} arch - * @returns - */ -function keepPackageNodeFiles(nodeModulesPath, packageName, arch) { - const modulePath = path.join(nodeModulesPath, packageName) - - if (!fs.existsSync(modulePath)) { - console.log(`[After Pack] Directory does not exist: ${modulePath}`) - return - } - - const dirs = fs.readdirSync(modulePath) - dirs - .filter((dir) => !arch.includes(dir)) - .forEach((dir) => { - fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true }) - console.log(`[After Pack] Removed dir: ${dir}`, arch) - }) -} diff --git a/scripts/before-pack.js b/scripts/before-pack.js new file mode 100644 index 0000000000..f0d4bdb096 --- /dev/null +++ b/scripts/before-pack.js @@ -0,0 +1,97 @@ +const { Arch } = require('electron-builder') +const { downloadNpmPackage } = require('./utils') + +// if you want to add new prebuild binaries packages with different architectures, you can add them here +// please add to allX64 and allArm64 from yarn.lock +const allArm64 = { + '@img/sharp-darwin-arm64': '0.34.3', + '@img/sharp-win32-arm64': '0.34.3', + '@img/sharp-linux-arm64': '0.34.3', + '@img/sharp-linuxmusl-arm64': '0.34.3', + + '@img/sharp-libvips-darwin-arm64': '1.2.0', + '@img/sharp-libvips-linux-arm64': '1.2.0', + '@img/sharp-libvips-linuxmusl-arm64': '1.2.0', + + '@libsql/darwin-arm64': '0.4.7', + '@libsql/linux-arm64-gnu': '0.4.7', + '@libsql/linux-arm64-musl': '0.4.7', + '@strongtz/win32-arm64-msvc': '0.4.7', + + '@napi-rs/system-ocr-darwin-arm64': '1.0.2', + '@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2' +} + +const allX64 = { + '@img/sharp-darwin-x64': '0.34.3', + '@img/sharp-linux-x64': '0.34.3', + '@img/sharp-linuxmusl-x64': '0.34.3', + '@img/sharp-win32-x64': '0.34.3', + + '@img/sharp-libvips-darwin-x64': '1.2.0', + '@img/sharp-libvips-linux-x64': '1.2.0', + '@img/sharp-libvips-linuxmusl-x64': '1.2.0', + + '@libsql/darwin-x64': '0.4.7', + '@libsql/linux-x64-gnu': '0.4.7', + '@libsql/linux-x64-musl': '0.4.7', + '@libsql/win32-x64-msvc': '0.4.7', + + '@napi-rs/system-ocr-darwin-x64': '1.0.2', + '@napi-rs/system-ocr-win32-x64-msvc': '1.0.2' +} + +const platformToArch = { + mac: 'darwin', + windows: 'win32', + linux: 'linux' +} + +exports.default = async function (context) { + const arch = context.arch + const archType = arch === Arch.arm64 ? 'arm64' : 'x64' + const platform = context.packager.platform.name + + const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**') + const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*') + + const downloadPackages = async (packages) => { + console.log('downloading packages ......') + const downloadPromises = [] + + for (const name of Object.keys(packages)) { + if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) { + downloadPromises.push( + downloadNpmPackage( + name, + `https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz` + ) + ) + } + } + + await Promise.all(downloadPromises) + } + + const changeFilters = async (packages, filtersToExclude, filtersToInclude) => { + await downloadPackages(packages) + // remove filters for the target architecture (allow inclusion) + + let filters = context.packager.config.files[0].filter + filters = filters.filter((filter) => !filtersToInclude.includes(filter)) + // add filters for other architectures (exclude them) + filters.push(...filtersToExclude) + + context.packager.config.files[0].filter = filters + } + + if (arch === Arch.arm64) { + await changeFilters(allArm64, x64Filters, arm64Filters) + return + } + + if (arch === Arch.x64) { + await changeFilters(allX64, arm64Filters, x64Filters) + return + } +} diff --git a/scripts/build-npm.js b/scripts/build-npm.js deleted file mode 100644 index 159e77453e..0000000000 --- a/scripts/build-npm.js +++ /dev/null @@ -1,88 +0,0 @@ -const { downloadNpmPackage } = require('./utils') - -async function downloadNpm(platform) { - if (!platform || platform === 'mac') { - downloadNpmPackage( - '@libsql/darwin-arm64', - 'https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz' - ) - downloadNpmPackage('@libsql/darwin-x64', 'https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz') - - // sharp for macOS - downloadNpmPackage( - '@img/sharp-darwin-arm64', - 'https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz' - ) - downloadNpmPackage( - '@img/sharp-darwin-x64', - 'https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz' - ) - downloadNpmPackage( - '@img/sharp-libvips-darwin-arm64', - 'https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz' - ) - downloadNpmPackage( - '@img/sharp-libvips-darwin-x64', - 'https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz' - ) - } - - if (!platform || platform === 'linux') { - downloadNpmPackage( - '@libsql/linux-arm64-gnu', - 'https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz' - ) - downloadNpmPackage( - '@libsql/linux-arm64-musl', - 'https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz' - ) - downloadNpmPackage( - '@libsql/linux-x64-gnu', - 'https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz' - ) - downloadNpmPackage( - '@libsql/linux-x64-musl', - 'https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz' - ) - - downloadNpmPackage( - '@img/sharp-libvips-linux-arm64', - 'https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz' - ) - downloadNpmPackage( - '@img/sharp-libvips-linux-x64', - 'https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz' - ) - downloadNpmPackage( - '@img/sharp-libvips-linuxmusl-arm64', - 'https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz' - ) - downloadNpmPackage( - '@img/sharp-libvips-linuxmusl-x64', - 'https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz' - ) - } - - if (!platform || platform === 'windows') { - downloadNpmPackage( - '@libsql/win32-x64-msvc', - 'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz' - ) - downloadNpmPackage( - '@strongtz/win32-arm64-msvc', - 'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz' - ) - - downloadNpmPackage( - '@img/sharp-win32-arm64', - 'https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz' - ) - downloadNpmPackage( - '@img/sharp-win32-x64', - 'https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz' - ) - } -} - -const platformArg = process.argv[2] -downloadNpm(platformArg) 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/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..20ea201226 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' +import { isLinux } from '@main/constant' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' -import { systemOcrService } from './builtin/SystemOcrService' import { tesseractService } from './builtin/TesseractService' const logger = loggerService.withContext('OcrService') @@ -33,4 +33,8 @@ 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)) + +if (!isLinux) { + const { systemOcrService } = require('./builtin/SystemOcrService') + 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..f6fcfe32a7 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,6 +1,5 @@ -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 { ImageFileMetadata, isImageFileMetadata as isImageFileMetadata, @@ -15,12 +14,14 @@ 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 { OcrAccuracy, recognize } = require('@napi-rs/system-ocr') 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/ocr.ts b/src/main/utils/ocr.ts index 446fbe63d6..d94fb002d1 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -1,8 +1,8 @@ import { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' -import sharp from 'sharp' const preprocessImage = async (buffer: Buffer): Promise => { + const sharp = require('sharp') return sharp(buffer) .grayscale() // 转为灰度 .normalize() diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 92e49cec53..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')) @@ -684,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: @@ -696,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), @@ -703,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/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 3890f013ec..7b4e4bdad5 100644 --- a/src/renderer/src/assets/styles/richtext.scss +++ b/src/renderer/src/assets/styles/richtext.scss @@ -148,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 */ @@ -471,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/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/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 09ddbe1191..ed7c3d407c 100644 --- a/src/renderer/src/components/Popups/RichEditPopup.tsx +++ b/src/renderer/src/components/Popups/RichEditPopup.tsx @@ -107,8 +107,7 @@ const PopupContainer: React.FC = ({ 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/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/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 6453f67c66..5e97206aa6 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -166,242 +166,6 @@ import OpenAI from 'openai' 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 @@ -2317,107 +2081,293 @@ 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' -] - -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 => { + const modelId = getLowerBaseModelName(model.id) + return DEDICATED_IMAGE_MODELS.filter((m) => modelId.includes(m)).length > 0 +} + +export function isGenerateImageModel(model: Model): boolean { + if (!model) { + return false + } + + const provider = getProviderByModel(model) + + if (!provider) { + return false + } + + if (isEmbeddingModel(model)) { + return false + } + + const modelId = getLowerBaseModelName(model.id, '/') + + if (provider && provider.type === 'openai-response') { + return OPENAI_IMAGE_GENERATION_MODELS.some((imageModel) => modelId.includes(imageModel)) + } + + return GENERATE_IMAGE_MODELS.some((imageModel) => modelId.includes(imageModel)) +} + export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i') export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini'] @@ -2584,9 +2534,9 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean { // Specifically for DeepSeek V3.1. White list for now 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 +2977,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/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts index 5c382c5ed9..081db7f487 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/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 6fc07b107c..a66969a543 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -10,7 +10,6 @@ import { setLaunchToTray, setPinTopicsToTop, setSendMessageShortcut as _setSendMessageShortcut, - setShowTokens, setSidebarIcons, setTargetLanguage, setTestChannel as _setTestChannel, @@ -98,9 +97,6 @@ export function useSettings() { }, setAssistantIconType(assistantIconType: AssistantIconType) { dispatch(setAssistantIconType(assistantIconType)) - }, - setShowTokens(showTokens: boolean) { - dispatch(setShowTokens(showTokens)) } // setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) { // dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration)) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 571674567d..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": { @@ -3342,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 cd4762ee83..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": { @@ -3342,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 17036fd103..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": { @@ -3342,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 678cf39df3..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": { @@ -3342,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 c13cb0c370..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": { @@ -3342,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 41106d938b..85483d4b59 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -14,6 +14,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' @@ -44,7 +45,6 @@ const Chat: FC = (props) => { const contentSearchRef = React.useRef(null) const [filterIncludeUser, setFilterIncludeUser] = useState(false) - const maxWidth = useChatMaxWidth() const { setTimeoutTimer } = useTimer() useHotkeys('esc', () => { @@ -132,7 +132,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 372bc5d149..e5dca58f4d 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -8,6 +8,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' @@ -101,17 +102,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 (isDedicatedImageGenerationModel(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/Messages/Blocks/ImageBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx index 4ac6b0b0b0..4ae8b167cf 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx @@ -7,32 +7,42 @@ import styled from 'styled-components' interface Props { block: ImageMessageBlock + isSingle?: boolean } -const ImageBlock: React.FC = ({ 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 && ( + +