Compare commits
1 Commits
v1.4.5
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e845d8194a |
@@ -107,9 +107,11 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
- 新功能:可选数据保存目录
|
||||
- 快捷助手:支持单独选择助手,支持暂停、上下文、思考过程、流式
|
||||
- 划词助手:系统托盘菜单开关
|
||||
- 翻译:新增 Markdown 预览选项
|
||||
- 新供应商:新增 Vertex AI 服务商
|
||||
- 错误修复和界面优化
|
||||
划词助手:支持文本选择快捷键、开关快捷键、思考块支持和引用功能
|
||||
复制功能:新增纯文本复制(去除Markdown格式符号)
|
||||
知识库:支持设置向量维度,修复Ollama分数错误和维度编辑问题
|
||||
多语言:增加模型名称多语言提示和翻译源语言手动选择
|
||||
文件管理:修复主题/消息删除时文件未清理问题,优化文件选择流程
|
||||
模型:修复Gemini模型推理预算、Voyage AI嵌入问题和DeepSeek翻译模型更新
|
||||
图像功能:统一图片查看器,支持Base64图片渲染,修复图片预览相关问题
|
||||
UI:实现标签折叠/拖拽排序,修复气泡溢出,增加引文索引显示
|
||||
|
||||
@@ -68,16 +68,12 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['pyodide'],
|
||||
esbuildOptions: {
|
||||
target: 'esnext' // for dev
|
||||
}
|
||||
exclude: ['pyodide']
|
||||
},
|
||||
worker: {
|
||||
format: 'es'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext', // for build
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/renderer/index.html'),
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.5",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -62,7 +62,6 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"notion-helper": "^1.3.22",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^0.9.23",
|
||||
@@ -177,6 +176,7 @@
|
||||
"mermaid": "^11.6.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
@@ -190,7 +190,7 @@
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
@@ -199,10 +199,10 @@
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^7.1.0",
|
||||
"rehype-mathjax": "^7.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-cjk-friendly": "^1.2.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-cjk-friendly": "^1.1.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"remove-markdown": "^0.6.2",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
|
||||
@@ -15,15 +15,7 @@ export enum IpcChannel {
|
||||
App_SetAutoUpdate = 'app:set-auto-update',
|
||||
App_SetFeedUrl = 'app:set-feed-url',
|
||||
App_HandleZoomFactor = 'app:handle-zoom-factor',
|
||||
App_Select = 'app:select',
|
||||
App_HasWritePermission = 'app:has-write-permission',
|
||||
App_Copy = 'app:copy',
|
||||
App_SetStopQuitApp = 'app:set-stop-quit-app',
|
||||
App_SetAppDataPath = 'app:set-app-data-path',
|
||||
App_GetDataPathFromArgs = 'app:get-data-path-from-args',
|
||||
App_FlushAppData = 'app:flush-app-data',
|
||||
App_IsNotEmptyDir = 'app:is-not-empty-dir',
|
||||
App_RelaunchApp = 'app:relaunch-app',
|
||||
|
||||
App_IsBinaryExist = 'app:is-binary-exist',
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,12 +2,12 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
|
||||
const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version
|
||||
const DEFAULT_BUN_VERSION = '1.2.9' // Default fallback version
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const BUN_PACKAGES = {
|
||||
@@ -66,36 +66,35 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
|
||||
// Extract the zip file using adm-zip
|
||||
console.log(`Extracting ${packageName} to ${binDir}...`)
|
||||
const zip = new StreamZip.async({ file: tempFilename })
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(tempdir, true)
|
||||
|
||||
// Get all entries in the zip file
|
||||
const entries = await zip.entries()
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
const files = fs.readdirSync(sourceDir)
|
||||
|
||||
// Extract files directly to binDir, flattening the directory structure
|
||||
for (const entry of Object.values(entries)) {
|
||||
if (!entry.isDirectory) {
|
||||
// Get just the filename without path
|
||||
const filename = path.basename(entry.name)
|
||||
const outputPath = path.join(binDir, filename)
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const destPath = path.join(binDir, file)
|
||||
|
||||
console.log(`Extracting ${entry.name} -> ${filename}`)
|
||||
await zip.extract(entry.name, outputPath)
|
||||
// Make executable files executable on Unix-like systems
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
}
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
fs.unlinkSync(sourcePath)
|
||||
|
||||
// Set executable permissions for non-Windows platforms
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
// 755 permission: rwxr-xr-x
|
||||
fs.chmodSync(destPath, '755')
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
}
|
||||
}
|
||||
await zip.close()
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
|
||||
console.log(`Successfully installed bun ${version} for ${platformKey}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,33 +2,34 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
const tar = require('tar')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
|
||||
const DEFAULT_UV_VERSION = '0.7.13'
|
||||
const DEFAULT_UV_VERSION = '0.6.14'
|
||||
|
||||
// Mapping of platform+arch to binary package name
|
||||
const UV_PACKAGES = {
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
|
||||
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
|
||||
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
|
||||
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
|
||||
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
|
||||
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
|
||||
'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
|
||||
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
|
||||
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
|
||||
'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
|
||||
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
|
||||
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
|
||||
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
|
||||
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
|
||||
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
|
||||
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
|
||||
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
|
||||
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
|
||||
// MUSL variants
|
||||
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
|
||||
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
|
||||
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
|
||||
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
|
||||
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip'
|
||||
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
|
||||
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
|
||||
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,35 +66,46 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
|
||||
console.log(`Extracting ${packageName} to ${binDir}...`)
|
||||
|
||||
const zip = new StreamZip.async({ file: tempFilename })
|
||||
// 根据文件扩展名选择解压方法
|
||||
if (packageName.endsWith('.zip')) {
|
||||
// 使用 adm-zip 处理 zip 文件
|
||||
const zip = new AdmZip(tempFilename)
|
||||
zip.extractAllTo(binDir, true)
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
} else {
|
||||
// tar.gz 文件的处理保持不变
|
||||
await tar.x({
|
||||
file: tempFilename,
|
||||
cwd: tempdir,
|
||||
z: true
|
||||
})
|
||||
|
||||
// Get all entries in the zip file
|
||||
const entries = await zip.entries()
|
||||
// Move files using Node.js fs
|
||||
const sourceDir = path.join(tempdir, packageName.split('.')[0])
|
||||
const files = fs.readdirSync(sourceDir)
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(sourceDir, file)
|
||||
const destPath = path.join(binDir, file)
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
fs.unlinkSync(sourcePath)
|
||||
|
||||
// Extract files directly to binDir, flattening the directory structure
|
||||
for (const entry of Object.values(entries)) {
|
||||
if (!entry.isDirectory) {
|
||||
// Get just the filename without path
|
||||
const filename = path.basename(entry.name)
|
||||
const outputPath = path.join(binDir, filename)
|
||||
|
||||
console.log(`Extracting ${entry.name} -> ${filename}`)
|
||||
await zip.extract(entry.name, outputPath)
|
||||
// Make executable files executable on Unix-like systems
|
||||
// Set executable permissions for non-Windows platforms
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
fs.chmodSync(destPath, '755')
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to set executable permissions: ${error.message}`)
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
fs.rmSync(sourceDir, { recursive: true })
|
||||
}
|
||||
|
||||
await zip.close()
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { initAppDataDir } from './utils/file'
|
||||
|
||||
app.isPackaged && initAppDataDir()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
import { getDataPath } from './utils'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
if (isDev) {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// don't reorder this file, it's used to initialize the app data dir and
|
||||
// other which should be run before the main process is ready
|
||||
// eslint-disable-next-line
|
||||
import './bootstrap'
|
||||
|
||||
import '@main/config'
|
||||
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
@@ -25,6 +20,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { setUserDataDir } from './utils/file'
|
||||
|
||||
Logger.initialize()
|
||||
|
||||
@@ -76,6 +72,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
} else {
|
||||
// Portable dir must be setup before app ready
|
||||
setUserDataDir()
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { FeedUrl } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell } from 'electron'
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@@ -34,7 +34,7 @@ import { setOpenLinkExternal } from './services/WebviewService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { calculateDirectorySize, getResourcePath } from './utils'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, updateAppDataConfig } from './utils/file'
|
||||
import { getCacheDir, getConfigDir, getFilesDir } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
@@ -175,88 +175,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
let preventQuitListener: ((event: Electron.Event) => void) | null = null
|
||||
ipcMain.handle(IpcChannel.App_SetStopQuitApp, (_, stop: boolean = false, reason: string = '') => {
|
||||
if (stop) {
|
||||
// Only add listener if not already added
|
||||
if (!preventQuitListener) {
|
||||
preventQuitListener = (event: Electron.Event) => {
|
||||
event.preventDefault()
|
||||
notificationService.sendNotification({
|
||||
title: reason,
|
||||
message: reason
|
||||
} as Notification)
|
||||
}
|
||||
app.on('before-quit', preventQuitListener)
|
||||
}
|
||||
} else {
|
||||
// Remove listener if it exists
|
||||
if (preventQuitListener) {
|
||||
app.removeListener('before-quit', preventQuitListener)
|
||||
preventQuitListener = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Select app data path
|
||||
ipcMain.handle(IpcChannel.App_Select, async (_, options: Electron.OpenDialogOptions) => {
|
||||
try {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(options)
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
return filePaths[0]
|
||||
} catch (error: any) {
|
||||
log.error('Failed to select app data path:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_HasWritePermission, async (_, filePath: string) => {
|
||||
return hasWritePermission(filePath)
|
||||
})
|
||||
|
||||
// Set app data path
|
||||
ipcMain.handle(IpcChannel.App_SetAppDataPath, async (_, filePath: string) => {
|
||||
updateAppDataConfig(filePath)
|
||||
app.setPath('userData', filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_GetDataPathFromArgs, () => {
|
||||
return process.argv
|
||||
.slice(1)
|
||||
.find((arg) => arg.startsWith('--new-data-path='))
|
||||
?.split('--new-data-path=')[1]
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_FlushAppData, () => {
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
w.webContents.session.flushStorageData()
|
||||
w.webContents.session.cookies.flushStore()
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.App_IsNotEmptyDir, async (_, path: string) => {
|
||||
return fs.readdirSync(path).length > 0
|
||||
})
|
||||
|
||||
// Copy user data to new location
|
||||
ipcMain.handle(IpcChannel.App_Copy, async (_, oldPath: string, newPath: string) => {
|
||||
try {
|
||||
await fs.promises.cp(oldPath, newPath, { recursive: true })
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
log.error('Failed to copy user data:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
|
||||
app.relaunch(options)
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
// check for update
|
||||
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
|
||||
return await appUpdater.checkForUpdates()
|
||||
|
||||
@@ -9,7 +9,6 @@ import StreamZip from 'node-stream-zip'
|
||||
import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -254,7 +253,7 @@ class BackupManager {
|
||||
Logger.log('[backup] step 3: restore Data directory')
|
||||
// 恢复 Data 目录
|
||||
const sourcePath = path.join(this.tempDir, 'Data')
|
||||
const destPath = getDataPath()
|
||||
const destPath = path.join(app.getPath('userData'), 'Data')
|
||||
|
||||
const dataExists = await fs.pathExists(sourcePath)
|
||||
const dataFiles = dataExists ? await fs.readdir(sourcePath) : []
|
||||
|
||||
@@ -25,12 +25,12 @@ import Embeddings from '@main/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -88,7 +88,7 @@ const loaderTaskIntoOfSet = (loaderTask: LoaderTask): LoaderTaskOfSet => {
|
||||
}
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(getDataPath(), 'KnowledgeBase')
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
// Byte based
|
||||
private workload = 0
|
||||
private processingItemCount = 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { isMac } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
|
||||
|
||||
@@ -6,7 +6,6 @@ import icon from '../../../build/tray_icon.png?asset'
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
@@ -30,14 +29,14 @@ export class TrayService {
|
||||
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
|
||||
const tray = new Tray(iconPath)
|
||||
|
||||
if (isWin) {
|
||||
if (process.platform === 'win32') {
|
||||
tray.setImage(iconPath)
|
||||
} else if (isMac) {
|
||||
} else if (process.platform === 'darwin') {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
resizedImage.setTemplateImage(true)
|
||||
tray.setImage(resizedImage)
|
||||
} else if (isLinux) {
|
||||
} else if (process.platform === 'linux') {
|
||||
const image = nativeImage.createFromPath(iconPath)
|
||||
const resizedImage = image.resize({ width: 16, height: 16 })
|
||||
tray.setImage(resizedImage)
|
||||
@@ -47,7 +46,7 @@ export class TrayService {
|
||||
|
||||
this.updateContextMenu()
|
||||
|
||||
if (isLinux) {
|
||||
if (process.platform === 'linux') {
|
||||
this.tray.setContextMenu(this.contextMenu)
|
||||
}
|
||||
|
||||
@@ -70,31 +69,19 @@ export class TrayService {
|
||||
|
||||
private updateContextMenu() {
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale, selection: selectionLocale } = locale.translation
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
const quickAssistantEnabled = configManager.getEnableQuickAssistant()
|
||||
const selectionAssistantEnabled = configManager.getSelectionAssistantEnabled()
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: trayLocale.show_window,
|
||||
click: () => windowService.showMainWindow()
|
||||
},
|
||||
quickAssistantEnabled && {
|
||||
enableQuickAssistant && {
|
||||
label: trayLocale.show_mini_window,
|
||||
click: () => windowService.showMiniWindow()
|
||||
},
|
||||
isWin && {
|
||||
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||
// type: 'checkbox',
|
||||
// checked: selectionAssistantEnabled,
|
||||
click: () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
this.updateContextMenu()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: trayLocale.quit,
|
||||
@@ -131,10 +118,6 @@ export class TrayService {
|
||||
configManager.subscribe(ConfigKeys.EnableQuickAssistant, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, () => {
|
||||
this.updateContextMenu()
|
||||
})
|
||||
}
|
||||
|
||||
private quit() {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class WindowService {
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: false,
|
||||
transparent: isMac,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
|
||||
@@ -2,26 +2,12 @@ import * as fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isPortable } from '@main/constant'
|
||||
import { isMac } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileType, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
const appDataPath = getAppDataPathFromConfig()
|
||||
if (appDataPath) {
|
||||
app.setPath('userData', appDataPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPortable) {
|
||||
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||
app.setPath('userData', path.join(portableDir || app.getPath('exe'), 'data'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 创建文件类型映射表,提高查找效率
|
||||
const fileTypeMap = new Map<string, FileTypes>()
|
||||
|
||||
@@ -37,85 +23,6 @@ function initFileTypeMap() {
|
||||
// 初始化映射表
|
||||
initFileTypeMap()
|
||||
|
||||
export function hasWritePermission(path: string) {
|
||||
try {
|
||||
fs.accessSync(path, fs.constants.W_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getAppDataPathFromConfig() {
|
||||
try {
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
|
||||
if (!config.appDataPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
let appDataPath = null
|
||||
// 兼容旧版本
|
||||
if (config.appDataPath && typeof config.appDataPath === 'string') {
|
||||
appDataPath = config.appDataPath
|
||||
// 将旧版本数据迁移到新版本
|
||||
appDataPath && updateAppDataConfig(appDataPath)
|
||||
} else {
|
||||
appDataPath = config.appDataPath.find(
|
||||
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
|
||||
)?.dataPath
|
||||
}
|
||||
|
||||
if (appDataPath && fs.existsSync(appDataPath) && hasWritePermission(appDataPath)) {
|
||||
return appDataPath
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAppDataConfig(appDataPath: string) {
|
||||
const configDir = getConfigDir()
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
|
||||
// config.json
|
||||
// appDataPath: [{ executablePath: string, dataPath: string }]
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
if (!config.appDataPath || (config.appDataPath && typeof config.appDataPath !== 'object')) {
|
||||
config.appDataPath = []
|
||||
}
|
||||
|
||||
const existingPath = config.appDataPath.find(
|
||||
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
|
||||
)
|
||||
|
||||
if (existingPath) {
|
||||
existingPath.dataPath = appDataPath
|
||||
} else {
|
||||
config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath })
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
export function getFileType(ext: string): FileTypes {
|
||||
ext = ext.toLowerCase()
|
||||
return fileTypeMap.get(ext) || FileTypes.OTHER
|
||||
@@ -181,3 +88,12 @@ export function getCacheDir() {
|
||||
export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
export function setUserDataDir() {
|
||||
if (!isMac) {
|
||||
const dir = path.join(path.dirname(app.getPath('exe')), 'data')
|
||||
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
||||
app.setPath('userData', dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,6 @@ const api = {
|
||||
handleZoomFactor: (delta: number, reset: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset),
|
||||
setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive),
|
||||
select: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.App_Select, options),
|
||||
hasWritePermission: (path: string) => ipcRenderer.invoke(IpcChannel.App_HasWritePermission, path),
|
||||
setAppDataPath: (path: string) => ipcRenderer.invoke(IpcChannel.App_SetAppDataPath, path),
|
||||
getDataPathFromArgs: () => ipcRenderer.invoke(IpcChannel.App_GetDataPathFromArgs),
|
||||
copy: (oldPath: string, newPath: string) => ipcRenderer.invoke(IpcChannel.App_Copy, oldPath, newPath),
|
||||
setStopQuitApp: (stop: boolean, reason: string) => ipcRenderer.invoke(IpcChannel.App_SetStopQuitApp, stop, reason),
|
||||
flushAppData: () => ipcRenderer.invoke(IpcChannel.App_FlushAppData),
|
||||
isNotEmptyDir: (path: string) => ipcRenderer.invoke(IpcChannel.App_IsNotEmptyDir, path),
|
||||
relaunchApp: (options?: Electron.RelaunchOptions) => ipcRenderer.invoke(IpcChannel.App_RelaunchApp, options),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
SdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
@@ -164,8 +163,8 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
return this.currentClient.getRequestTransformer()
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer(ctx)
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer()
|
||||
}
|
||||
|
||||
convertMcpToolsToSdkTools(mcpTools: MCPTool[]): SdkTool[] {
|
||||
|
||||
@@ -42,8 +42,7 @@ import { defaultTimeout } from '@shared/config/constant'
|
||||
import Logger from 'electron-log/renderer'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { ApiClient, RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
import { ApiClient, RawStreamListener, RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
|
||||
/**
|
||||
* Abstract base class for API clients.
|
||||
@@ -96,7 +95,7 @@ export abstract class BaseApiClient<
|
||||
// 在 CoreRequestToSdkParamsMiddleware中使用
|
||||
abstract getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
|
||||
// 在RawSdkChunkToGenericChunkMiddleware中使用
|
||||
abstract getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
|
||||
abstract getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
|
||||
|
||||
/**
|
||||
* 工具转换
|
||||
@@ -111,7 +110,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
abstract buildSdkMessages(
|
||||
currentReqMessages: TMessageParam[],
|
||||
output: TRawOutput | string | undefined,
|
||||
output: TRawOutput | string,
|
||||
toolResults: TMessageParam[],
|
||||
toolCalls?: TToolCall[]
|
||||
): TMessageParam[]
|
||||
@@ -130,6 +129,17 @@ export abstract class BaseApiClient<
|
||||
*/
|
||||
abstract extractMessagesFromSdkPayload(sdkPayload: TSdkParams): TMessageParam[]
|
||||
|
||||
/**
|
||||
* 附加原始流监听器
|
||||
*/
|
||||
public attachRawStreamListener<TListener extends RawStreamListener<TRawChunk>>(
|
||||
rawOutput: TRawOutput,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_listener: TListener
|
||||
): TRawOutput {
|
||||
return rawOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用函数
|
||||
**/
|
||||
|
||||
@@ -90,7 +90,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
this.sdkInstance = new Anthropic({
|
||||
apiKey: this.apiKey,
|
||||
apiKey: this.getApiKey(),
|
||||
baseURL: this.getBaseURL(),
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
@@ -125,7 +125,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
|
||||
// @ts-ignore sdk未提供
|
||||
override async getEmbeddingDimensions(): Promise<number> {
|
||||
throw new Error("Anthropic SDK doesn't support getEmbeddingDimensions method.")
|
||||
return 0
|
||||
}
|
||||
|
||||
override getTemperature(assistant: Assistant, model: Model): number | undefined {
|
||||
@@ -367,13 +367,12 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
* Anthropic专用的原始流监听器
|
||||
* 处理MessageStream对象的特定事件
|
||||
*/
|
||||
attachRawStreamListener(
|
||||
override attachRawStreamListener(
|
||||
rawOutput: AnthropicSdkRawOutput,
|
||||
listener: RawStreamListener<AnthropicSdkRawChunk>
|
||||
): AnthropicSdkRawOutput {
|
||||
console.log(`[AnthropicApiClient] 附加流监听器到原始输出`)
|
||||
// 专用的Anthropic事件处理
|
||||
const anthropicListener = listener as AnthropicStreamListener
|
||||
|
||||
// 检查是否为MessageStream
|
||||
if (rawOutput instanceof MessageStream) {
|
||||
console.log(`[AnthropicApiClient] 检测到 Anthropic MessageStream,附加专用监听器`)
|
||||
@@ -388,6 +387,9 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
})
|
||||
}
|
||||
|
||||
// 专用的Anthropic事件处理
|
||||
const anthropicListener = listener as AnthropicStreamListener
|
||||
|
||||
if (anthropicListener.onContentBlock) {
|
||||
rawOutput.on('contentBlock', anthropicListener.onContentBlock)
|
||||
}
|
||||
@@ -411,10 +413,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return rawOutput
|
||||
}
|
||||
|
||||
if (anthropicListener.onMessage) {
|
||||
anthropicListener.onMessage(rawOutput)
|
||||
}
|
||||
|
||||
// 对于非MessageStream响应
|
||||
return rawOutput
|
||||
}
|
||||
@@ -520,7 +518,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
case 'message': {
|
||||
let i = 0
|
||||
for (const content of rawChunk.content) {
|
||||
switch (content.type) {
|
||||
case 'text': {
|
||||
@@ -531,8 +528,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'tool_use': {
|
||||
toolCalls[i] = content
|
||||
i++
|
||||
toolCalls[0] = content
|
||||
break
|
||||
}
|
||||
case 'thinking': {
|
||||
@@ -554,22 +550,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
}
|
||||
if (i > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: Object.values(toolCalls)
|
||||
} as MCPToolCreatedChunk)
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: rawChunk.usage.input_tokens || 0,
|
||||
completion_tokens: rawChunk.usage.output_tokens || 0,
|
||||
total_tokens: (rawChunk.usage.input_tokens || 0) + (rawChunk.usage.output_tokens || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'content_block_start': {
|
||||
|
||||
@@ -85,7 +85,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
...rest,
|
||||
config: {
|
||||
...rest.config,
|
||||
abortSignal: options?.signal,
|
||||
abortSignal: options?.abortSignal,
|
||||
httpOptions: {
|
||||
...rest.config?.httpOptions,
|
||||
timeout: options?.timeout
|
||||
@@ -147,12 +147,15 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
override async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
const data = await sdk.models.embedContent({
|
||||
model: model.id,
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
|
||||
})
|
||||
return data.embeddings?.[0]?.values?.length || 0
|
||||
try {
|
||||
const data = await sdk.models.embedContent({
|
||||
model: model.id,
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }]
|
||||
})
|
||||
return data.embeddings?.[0]?.values?.length || 0
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
override async listModels(): Promise<GeminiModel[]> {
|
||||
@@ -413,9 +416,8 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
}
|
||||
|
||||
const { min, max } = findTokenLimit(model.id) || { min: 0, max: 0 }
|
||||
// 计算 budgetTokens,确保不低于 min
|
||||
const budget = Math.floor((max - min) * effortRatio + min)
|
||||
const { max } = findTokenLimit(model.id) || { max: 0 }
|
||||
const budget = Math.floor(max * effortRatio)
|
||||
|
||||
return {
|
||||
thinkingConfig: {
|
||||
@@ -464,7 +466,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
systemInstruction = await buildSystemPrompt(assistant.prompt || '', mcpTools, assistant)
|
||||
}
|
||||
|
||||
let messageContents: Content = { role: 'user', parts: [] } // Initialize messageContents
|
||||
let messageContents: Content
|
||||
const history: Content[] = []
|
||||
// 3. 处理用户消息
|
||||
if (typeof messages === 'string') {
|
||||
@@ -473,13 +475,10 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
parts: [{ text: messages }]
|
||||
}
|
||||
} else {
|
||||
const userLastMessage = messages.pop()
|
||||
if (userLastMessage) {
|
||||
messageContents = await this.convertMessageToSdkParam(userLastMessage)
|
||||
for (const message of messages) {
|
||||
history.push(await this.convertMessageToSdkParam(message))
|
||||
}
|
||||
messages.push(userLastMessage)
|
||||
const userLastMessage = messages.pop()!
|
||||
messageContents = await this.convertMessageToSdkParam(userLastMessage)
|
||||
for (const message of messages) {
|
||||
history.push(await this.convertMessageToSdkParam(message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,10 +491,6 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
if (isGemmaModel(model) && assistant.prompt) {
|
||||
const isFirstMessage = history.length === 0
|
||||
if (isFirstMessage && messageContents) {
|
||||
const userMessageText =
|
||||
messageContents.parts && messageContents.parts.length > 0
|
||||
? (messageContents.parts[0] as Part).text || ''
|
||||
: ''
|
||||
const systemMessage = [
|
||||
{
|
||||
text:
|
||||
@@ -503,7 +498,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
systemInstruction +
|
||||
'<end_of_turn>\n' +
|
||||
'<start_of_turn>user\n' +
|
||||
userMessageText +
|
||||
(messageContents?.parts?.[0] as Part).text +
|
||||
'<end_of_turn>'
|
||||
}
|
||||
] as Part[]
|
||||
@@ -520,7 +515,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
|
||||
const newMessageContents =
|
||||
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
|
||||
? recursiveSdkMessages[recursiveSdkMessages.length - 1]
|
||||
? {
|
||||
...messageContents,
|
||||
parts: [
|
||||
...(messageContents.parts || []),
|
||||
...(recursiveSdkMessages[recursiveSdkMessages.length - 1].parts || [])
|
||||
]
|
||||
}
|
||||
: messageContents
|
||||
|
||||
const generateContentConfig: GenerateContentConfig = {
|
||||
@@ -554,7 +555,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
let toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
@@ -582,8 +583,6 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
]
|
||||
}
|
||||
})
|
||||
} else if (part.functionCall) {
|
||||
toolCalls.push(part.functionCall)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -598,6 +597,9 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (chunk.functionCalls) {
|
||||
toolCalls = toolCalls.concat(chunk.functionCalls)
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
@@ -700,11 +702,12 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
.filter((p) => p !== undefined)
|
||||
)
|
||||
|
||||
const lastMessage = currentReqMessages[currentReqMessages.length - 1]
|
||||
if (lastMessage) {
|
||||
lastMessage.parts?.push(...parts)
|
||||
const userMessage: Content = {
|
||||
role: 'user',
|
||||
parts: parts
|
||||
}
|
||||
return currentReqMessages
|
||||
|
||||
return [...currentReqMessages, userMessage]
|
||||
}
|
||||
|
||||
override estimateMessageTokens(message: GeminiSdkMessageParam): number {
|
||||
@@ -731,20 +734,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
public extractMessagesFromSdkPayload(sdkPayload: GeminiSdkParams): GeminiSdkMessageParam[] {
|
||||
const messageParam: GeminiSdkMessageParam = {
|
||||
role: 'user',
|
||||
parts: []
|
||||
}
|
||||
if (Array.isArray(sdkPayload.message)) {
|
||||
sdkPayload.message.forEach((part) => {
|
||||
if (typeof part === 'string') {
|
||||
messageParam.parts?.push({ text: part })
|
||||
} else if (typeof part === 'object') {
|
||||
messageParam.parts?.push(part)
|
||||
}
|
||||
})
|
||||
}
|
||||
return [messageParam, ...(sdkPayload.history || [])]
|
||||
return sdkPayload.history || []
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileType): Promise<File> {
|
||||
|
||||
@@ -337,14 +337,10 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
public buildSdkMessages(
|
||||
currentReqMessages: OpenAISdkMessageParam[],
|
||||
output: string | undefined,
|
||||
output: string,
|
||||
toolResults: OpenAISdkMessageParam[],
|
||||
toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[]
|
||||
): OpenAISdkMessageParam[] {
|
||||
if (!output && toolCalls.length === 0) {
|
||||
return [...currentReqMessages, ...toolResults]
|
||||
}
|
||||
|
||||
const assistantMessage: OpenAISdkMessageParam = {
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
@@ -494,7 +490,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
// 在RawSdkChunkToGenericChunkMiddleware中使用
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAISdkRawChunk> {
|
||||
getResponseChunkTransformer = (): ResponseChunkTransformer<OpenAISdkRawChunk> => {
|
||||
let hasBeenCollectedWebSearch = false
|
||||
const collectWebSearchData = (
|
||||
chunk: OpenAISdkRawChunk,
|
||||
@@ -588,52 +584,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []
|
||||
let isFinished = false
|
||||
let lastUsageInfo: any = null
|
||||
|
||||
/**
|
||||
* 统一的完成信号发送逻辑
|
||||
* - 有 finish_reason 时
|
||||
* - 无 finish_reason 但是流正常结束时
|
||||
*/
|
||||
const emitCompletionSignals = (controller: TransformStreamDefaultController<GenericChunk>) => {
|
||||
if (isFinished) return
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
|
||||
const usage = lastUsageInfo || {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: { usage }
|
||||
})
|
||||
|
||||
// 防止重复发送
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
if (chunk.usage) {
|
||||
lastUsageInfo = {
|
||||
prompt_tokens: chunk.usage.prompt_tokens || 0,
|
||||
completion_tokens: chunk.usage.completion_tokens || 0,
|
||||
total_tokens: (chunk.usage.prompt_tokens || 0) + (chunk.usage.completion_tokens || 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理chunk
|
||||
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
|
||||
const choice = chunk.choices[0]
|
||||
@@ -698,6 +651,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
// 处理finish_reason,发送流结束信号
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
controller.enqueue({
|
||||
@@ -705,17 +664,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
llm_web_search: webSearchData
|
||||
})
|
||||
}
|
||||
emitCompletionSignals(controller)
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage?.prompt_tokens || 0,
|
||||
completion_tokens: chunk.usage?.completion_tokens || 0,
|
||||
total_tokens: (chunk.usage?.prompt_tokens || 0) + (chunk.usage?.completion_tokens || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 流正常结束时,检查是否需要发送完成信号
|
||||
flush(controller) {
|
||||
if (isFinished) return
|
||||
|
||||
Logger.debug('[OpenAIApiClient] Stream ended without finish_reason, emitting fallback completion signals')
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,13 +85,16 @@ export abstract class OpenAIBaseClient<
|
||||
|
||||
override async getEmbeddingDimensions(model: Model): Promise<number> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
try {
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
override async listModels(): Promise<OpenAI.Models.Model[]> {
|
||||
@@ -135,7 +138,7 @@ export abstract class OpenAIBaseClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
let apiKeyForSdkInstance = this.apiKey
|
||||
let apiKeyForSdkInstance = this.provider.apiKey
|
||||
|
||||
if (this.provider.id === 'copilot') {
|
||||
const defaultHeaders = store.getState().copilot.defaultHeaders
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
|
||||
import {
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
@@ -39,7 +38,6 @@ import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
import { OpenAIAPIClient } from './OpenAIApiClient'
|
||||
@@ -78,7 +76,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiKey: this.provider.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders()
|
||||
@@ -227,29 +225,17 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return
|
||||
}
|
||||
|
||||
private convertResponseToMessageContent(response: OpenAI.Responses.Response): ResponseInput {
|
||||
const content: OpenAI.Responses.ResponseInput = []
|
||||
content.push(...response.output)
|
||||
return content
|
||||
}
|
||||
|
||||
public buildSdkMessages(
|
||||
currentReqMessages: OpenAIResponseSdkMessageParam[],
|
||||
output: OpenAI.Responses.Response | undefined,
|
||||
output: string,
|
||||
toolResults: OpenAIResponseSdkMessageParam[],
|
||||
toolCalls: OpenAIResponseSdkToolCall[]
|
||||
): OpenAIResponseSdkMessageParam[] {
|
||||
if (!output && toolCalls.length === 0) {
|
||||
return [...currentReqMessages, ...toolResults]
|
||||
const assistantMessage: OpenAIResponseSdkMessageParam = {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'input_text', text: output }]
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
return [...currentReqMessages, ...(toolCalls || []), ...(toolResults || [])]
|
||||
}
|
||||
|
||||
const content = this.convertResponseToMessageContent(output)
|
||||
|
||||
const newReqMessages = [...currentReqMessages, ...content, ...(toolResults || [])]
|
||||
const newReqMessages = [...currentReqMessages, assistantMessage, ...(toolCalls || []), ...(toolResults || [])]
|
||||
return newReqMessages
|
||||
}
|
||||
|
||||
@@ -421,17 +407,13 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<OpenAIResponseSdkRawChunk> {
|
||||
const toolCalls: OpenAIResponseSdkToolCall[] = []
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
if ('output' in chunk) {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = chunk
|
||||
}
|
||||
for (const output of chunk.output) {
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
@@ -473,22 +455,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
}
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage?.input_tokens || 0,
|
||||
completion_tokens: chunk.usage?.output_tokens || 0,
|
||||
total_tokens: chunk.usage?.total_tokens || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
switch (chunk.type) {
|
||||
case 'response.output_item.added':
|
||||
@@ -536,8 +502,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
if (outputItem.type === 'function_call') {
|
||||
toolCalls.push({
|
||||
...outputItem,
|
||||
arguments: chunk.arguments,
|
||||
status: 'completed'
|
||||
arguments: chunk.arguments
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -553,26 +518,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
})
|
||||
}
|
||||
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
hasBeenCollectedToolCalls = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'response.completed': {
|
||||
if (ctx._internal?.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = chunk.response
|
||||
}
|
||||
if (toolCalls.length > 0 && !hasBeenCollectedToolCalls) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: toolCalls
|
||||
})
|
||||
hasBeenCollectedToolCalls = true
|
||||
}
|
||||
const completion_tokens = chunk.response.usage?.output_tokens || 0
|
||||
const total_tokens = chunk.response.usage?.total_tokens || 0
|
||||
controller.enqueue({
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@r
|
||||
import { Provider } from '@renderer/types'
|
||||
import {
|
||||
AnthropicSdkRawChunk,
|
||||
OpenAIResponseSdkRawChunk,
|
||||
OpenAIResponseSdkRawOutput,
|
||||
OpenAISdkRawChunk,
|
||||
SdkMessageParam,
|
||||
SdkParams,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
|
||||
/**
|
||||
* 原始流监听器接口
|
||||
@@ -36,14 +33,6 @@ export interface OpenAIStreamListener extends RawStreamListener<OpenAISdkRawChun
|
||||
onFinishReason?: (reason: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Response 专用的流监听器
|
||||
*/
|
||||
export interface OpenAIResponseStreamListener<TChunk extends OpenAIResponseSdkRawChunk = OpenAIResponseSdkRawChunk>
|
||||
extends RawStreamListener<TChunk> {
|
||||
onMessage?: (response: OpenAIResponseSdkRawOutput) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic 专用的流监听器
|
||||
*/
|
||||
@@ -112,7 +101,7 @@ export interface ApiClient<
|
||||
// SDK相关方法
|
||||
getSdkInstance(): Promise<TSdkInstance> | TSdkInstance
|
||||
getRequestTransformer(): RequestTransformer<TSdkParams, TMessageParam>
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<TRawChunk>
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<TRawChunk>
|
||||
|
||||
// 原始流监听方法
|
||||
attachRawStreamListener?(rawOutput: TRawOutput, listener: RawStreamListener<TRawChunk>): TRawOutput
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AnthropicAPIClient } from './clients/anthropic/AnthropicAPIClient'
|
||||
import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient'
|
||||
import { CompletionsMiddlewareBuilder } from './middleware/builder'
|
||||
import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware'
|
||||
import { MIDDLEWARE_NAME as ErrorHandlerMiddlewareName } from './middleware/common/ErrorHandlerMiddleware'
|
||||
import { MIDDLEWARE_NAME as FinalChunkConsumerMiddlewareName } from './middleware/common/FinalChunkConsumerMiddleware'
|
||||
import { applyCompletionsMiddlewares } from './middleware/composer'
|
||||
import { MIDDLEWARE_NAME as McpToolChunkMiddlewareName } from './middleware/core/McpToolChunkMiddleware'
|
||||
@@ -63,7 +62,6 @@ export default class AiProvider {
|
||||
builder.clear()
|
||||
builder
|
||||
.add(MiddlewareRegistry[FinalChunkConsumerMiddlewareName])
|
||||
.add(MiddlewareRegistry[ErrorHandlerMiddlewareName])
|
||||
.add(MiddlewareRegistry[AbortHandlerMiddlewareName])
|
||||
.add(MiddlewareRegistry[ImageGenerationMiddlewareName])
|
||||
} else {
|
||||
@@ -76,7 +74,7 @@ export default class AiProvider {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient)) {
|
||||
builder.remove(RawStreamListenerMiddlewareName)
|
||||
}
|
||||
if (!params.enableWebSearch) {
|
||||
@@ -114,7 +112,7 @@ export default class AiProvider {
|
||||
return dimensions
|
||||
} catch (error) {
|
||||
console.error('Error getting embedding dimensions:', error)
|
||||
throw error
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Chunk } from '@renderer/types/chunk'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
|
||||
import { CompletionsResult } from '../schemas'
|
||||
import { CompletionsContext } from '../types'
|
||||
@@ -25,32 +26,34 @@ export const ErrorHandlerMiddleware =
|
||||
// 尝试执行下一个中间件
|
||||
return await next(ctx, params)
|
||||
} catch (error: any) {
|
||||
console.error('ErrorHandlerMiddleware_error', error)
|
||||
// 1. 使用通用的工具函数将错误解析为标准格式
|
||||
const errorChunk = createErrorChunk(error)
|
||||
// 2. 调用从外部传入的 onError 回调
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
|
||||
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
|
||||
if (shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
|
||||
const errorStream = new ReadableStream<Chunk>({
|
||||
start(controller) {
|
||||
controller.enqueue(errorChunk)
|
||||
controller.close()
|
||||
let errorStream: ReadableStream<Chunk> | undefined
|
||||
// 有些sdk的abort error 是直接抛出的
|
||||
if (!isAbortError(error)) {
|
||||
// 1. 使用通用的工具函数将错误解析为标准格式
|
||||
const errorChunk = createErrorChunk(error)
|
||||
// 2. 调用从外部传入的 onError 回调
|
||||
if (params.onError) {
|
||||
params.onError(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
|
||||
if (shouldThrow) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
|
||||
errorStream = new ReadableStream<Chunk>({
|
||||
start(controller) {
|
||||
controller.enqueue(errorChunk)
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
rawOutput: undefined,
|
||||
stream: errorStream, // 将包含错误的流传递下去
|
||||
controller: undefined,
|
||||
error: typeof error?.message === 'string' ? error.message : 'unknown error',
|
||||
getText: () => '' // 错误情况下没有文本结果
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ function createToolHandlingTransform(
|
||||
if (toolResult.length > 0) {
|
||||
const output = ctx._internal.toolProcessingState?.output
|
||||
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output!, toolResult, toolCalls)
|
||||
await executeWithToolHandling(newParams, depth + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -243,7 +243,7 @@ async function executeToolUseResponses(
|
||||
function buildParamsWithToolResults(
|
||||
ctx: CompletionsContext,
|
||||
currentParams: CompletionsParams,
|
||||
output: SdkRawOutput | string | undefined,
|
||||
output: SdkRawOutput | string,
|
||||
toolResults: SdkMessageParam[],
|
||||
toolCalls: SdkToolCall[]
|
||||
): CompletionsParams {
|
||||
|
||||
@@ -15,6 +15,8 @@ export const RawStreamListenerMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 在这里可以监听到从SDK返回的最原始流
|
||||
if (result.rawOutput) {
|
||||
console.log(`[${MIDDLEWARE_NAME}] 检测到原始SDK输出,准备附加监听器`)
|
||||
|
||||
const providerType = ctx.apiClientInstance.provider.type
|
||||
// TODO: 后面下放到AnthropicAPIClient
|
||||
if (providerType === 'anthropic') {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ResponseTransformMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
|
||||
// 获取响应转换器
|
||||
const responseChunkTransformer = apiClient.getResponseChunkTransformer(ctx)
|
||||
const responseChunkTransformer = apiClient.getResponseChunkTransformer?.()
|
||||
if (!responseChunkTransformer) {
|
||||
Logger.warn(`[${MIDDLEWARE_NAME}] No ResponseChunkTransformer available, skipping transformation`)
|
||||
return result
|
||||
|
||||
@@ -25,6 +25,7 @@ export const StreamAdapterMiddleware: CompletionsMiddleware =
|
||||
// 但是这个中间件的职责是流适配,是否在这调用优待商榷
|
||||
// 调用下游中间件
|
||||
const result = await next(ctx, params)
|
||||
|
||||
if (
|
||||
result.rawOutput &&
|
||||
!(result.rawOutput instanceof ReadableStream) &&
|
||||
|
||||
@@ -14,6 +14,8 @@ export const TransformCoreToSdkParamsMiddleware: CompletionsMiddleware =
|
||||
() =>
|
||||
(next) =>
|
||||
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
|
||||
Logger.debug(`🔄 [${MIDDLEWARE_NAME}] Starting core to SDK params transformation:`, ctx)
|
||||
|
||||
const internal = ctx._internal
|
||||
|
||||
// 🔧 检测递归调用:检查 params 中是否携带了预处理的 SDK 消息
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
|
||||
const { assistant, messages } = params
|
||||
const client = context.apiClientInstance as BaseApiClient<OpenAI>
|
||||
const signal = context._internal?.flowControl?.abortSignal
|
||||
|
||||
if (!assistant.model || !isDedicatedImageGenerationModel(assistant.model) || typeof messages === 'string') {
|
||||
return next(context, params)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface CompletionsResult {
|
||||
rawOutput?: SdkRawOutput
|
||||
stream?: ReadableStream<SdkRawChunk> | ReadableStream<Chunk> | AsyncIterable<Chunk>
|
||||
controller?: AbortController
|
||||
error?: string
|
||||
|
||||
getText: () => string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
<svg width="22" height="22" viewBox="13 -2 25 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="White=False">
|
||||
<g id="if">
|
||||
<path d="M21.2002 3.73454C22.5633 3.73454 23.0666 2.89917 23.0666 1.86812C23.0666 0.837081 22.5623 0.00170898 21.2002 0.00170898C19.838 0.00170898 19.3337 0.837081 19.3337 1.86812C19.3337 2.89917 19.838 3.73454 21.2002 3.73454Z" fill="#0033FF"/>
|
||||
<path d="M27.7336 4.13435V5.33473H24.6668V8.00171H27.7336V14.6687H22.6668V5.33567H15.9998V8.00265H19.7336V14.6696H15.3337V17.3366H35.3337V14.6696H30.6668V8.00265H35.3337V5.33567H30.6668V2.66869H35.3337V0.00170898H31.8671C29.5877 0.00170898 27.7336 1.8559 27.7336 4.13529V4.13435Z" fill="#0033FF"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Dify</title><clipPath id="lobe-icons-dify-fill"><path d="M1 0h10.286c6.627 0 12 5.373 12 12s-5.373 12-12 12H1V0z"></path></clipPath><foreignObject clip-path="url(#lobe-icons-dify-fill)" height="24" style="background:conic-gradient(from 180deg at 50% 50%, #0222C3, #8FB1F4, #FFFFFF)" width="24"></foreignObject></svg>
|
||||
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 480 B |
@@ -197,26 +197,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown {
|
||||
.ant-dropdown-menu {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-dropdown-menu-sub {
|
||||
max-height: 50vh;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
.ant-dropdown-arrow + .ant-dropdown-menu {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
border: 0.5px solid var(--color-border);
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 350px;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/compon
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { getExtensionByLanguage, isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import { isValidPlantUML } from '@renderer/utils/markdown'
|
||||
import dayjs from 'dayjs'
|
||||
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -67,21 +67,23 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
|
||||
}, [children, t])
|
||||
|
||||
const handleDownloadSource = useCallback(async () => {
|
||||
const handleDownloadSource = useCallback(() => {
|
||||
let fileName = ''
|
||||
|
||||
// 尝试提取 HTML 标题
|
||||
// 尝试提取标题
|
||||
if (language === 'html' && children.includes('</html>')) {
|
||||
fileName = extractTitle(children) || ''
|
||||
const title = extractTitle(children)
|
||||
if (title) {
|
||||
fileName = `${title}.html`
|
||||
}
|
||||
}
|
||||
|
||||
// 默认使用日期格式命名
|
||||
if (!fileName) {
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}`
|
||||
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
|
||||
}
|
||||
|
||||
const ext = await getExtensionByLanguage(language)
|
||||
window.api.file.save(`${fileName}${ext}`, children)
|
||||
window.api.file.save(fileName, children)
|
||||
}, [children, language])
|
||||
|
||||
const handleRunScript = useCallback(() => {
|
||||
|
||||
@@ -41,10 +41,11 @@ const MarkdownEditor: FC<MarkdownEditorProps> = ({
|
||||
return (
|
||||
<EditorContainer style={{ height }}>
|
||||
<InputArea value={inputValue} onChange={handleChange} placeholder={placeholder} autoFocus={autoFocus} />
|
||||
<PreviewArea className="markdown">
|
||||
<PreviewArea>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkCjkFriendly, remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}>
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
||||
className="markdown">
|
||||
{inputValue || t('settings.provider.notes.markdown_editor_default_value')}
|
||||
</ReactMarkdown>
|
||||
</PreviewArea>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { backupToWebdav } from '@renderer/services/BackupService'
|
||||
import { Input, Modal } from 'antd'
|
||||
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Input, Modal, Select, Spin } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface WebdavModalProps {
|
||||
isModalVisible: boolean
|
||||
handleBackup: () => void
|
||||
@@ -80,3 +87,156 @@ export function WebdavBackupModal({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface WebdavRestoreModalProps {
|
||||
isRestoreModalVisible: boolean
|
||||
handleRestore: () => void
|
||||
handleCancel: () => void
|
||||
restoring: boolean
|
||||
selectedFile: string | null
|
||||
setSelectedFile: (value: string | null) => void
|
||||
loadingFiles: boolean
|
||||
backupFiles: BackupFile[]
|
||||
}
|
||||
|
||||
interface UseWebdavRestoreModalProps {
|
||||
webdavHost: string | undefined
|
||||
webdavUser: string | undefined
|
||||
webdavPass: string | undefined
|
||||
webdavPath: string | undefined
|
||||
restoreMethod?: typeof restoreFromWebdav
|
||||
}
|
||||
|
||||
export function useWebdavRestoreModal({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath,
|
||||
restoreMethod
|
||||
}: UseWebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = useState(false)
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
|
||||
const showRestoreModal = useCallback(async () => {
|
||||
if (!webdavHost) {
|
||||
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsRestoreModalVisible(true)
|
||||
setLoadingFiles(true)
|
||||
try {
|
||||
const files = await window.api.backup.listWebdavFiles({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
})
|
||||
setBackupFiles(files)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'list-files-error' })
|
||||
} finally {
|
||||
setLoadingFiles(false)
|
||||
}
|
||||
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!selectedFile || !webdavHost) {
|
||||
window.message.error({
|
||||
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
|
||||
key: 'restore-error'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.webdav.restore.confirm.title'),
|
||||
content: t('settings.data.webdav.restore.confirm.content'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod ?? restoreFromWebdav)(selectedFile)
|
||||
setIsRestoreModalVisible(false)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'restore-error' })
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [selectedFile, webdavHost, t, restoreMethod])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRestoreModalVisible(false)
|
||||
}
|
||||
|
||||
return {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
}
|
||||
}
|
||||
|
||||
export function WebdavRestoreModal({
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles
|
||||
}: WebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.webdav.restore.modal.title')}
|
||||
open={isRestoreModalVisible}
|
||||
onOk={handleRestore}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{ loading: restoring }}
|
||||
width={600}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
|
||||
value={selectedFile}
|
||||
onChange={setSelectedFile}
|
||||
options={backupFiles.map(formatFileOption)}
|
||||
loading={loadingFiles}
|
||||
showSearch
|
||||
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
{loadingFiles && (
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileOption(file: BackupFile) {
|
||||
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
|
||||
const size = formatFileSize(file.size)
|
||||
return {
|
||||
label: `${file.fileName} (${date}, ${size})`,
|
||||
value: file.fileName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ const visionAllowedModels = [
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest',
|
||||
'gemma-3(?:-[\\w-]+)',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)'
|
||||
'doubao-1.6-seed(?:-[\\w-]+)'
|
||||
]
|
||||
|
||||
const visionExcludedModels = [
|
||||
@@ -238,8 +238,7 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'glm-4(?:-[\\w-]+)?',
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||
'grok-3(?:-[\\w-]+)?',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
'grok-3(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
@@ -1352,6 +1351,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'DeepSeek-V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3-250324',
|
||||
provider: 'doubao',
|
||||
name: 'DeepSeek-V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'doubao-pro-32k-241215',
|
||||
provider: 'doubao',
|
||||
@@ -2283,8 +2288,6 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
]
|
||||
|
||||
export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -2304,7 +2307,21 @@ export const GENERATE_IMAGE_MODELS = [
|
||||
...SUPPORTED_DISABLE_GENERATION_MODELS
|
||||
]
|
||||
|
||||
export const GEMINI_SEARCH_REGEX = new RegExp('gemini-2\\..*', 'i')
|
||||
export const GEMINI_SEARCH_MODELS = [
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-pro-exp-02-05',
|
||||
'gemini-2.0-pro-exp',
|
||||
'gemini-2.5-pro-exp',
|
||||
'gemini-2.5-pro-exp-03-25',
|
||||
'gemini-2.5-pro-preview',
|
||||
'gemini-2.5-pro-preview-03-25',
|
||||
'gemini-2.5-pro-preview-05-06',
|
||||
'gemini-2.5-flash-preview',
|
||||
'gemini-2.5-flash-preview-04-17'
|
||||
]
|
||||
|
||||
export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini']
|
||||
|
||||
@@ -2348,7 +2365,7 @@ export function isVisionModel(model: Model): boolean {
|
||||
// }
|
||||
|
||||
if (model.provider === 'doubao') {
|
||||
return VISION_REGEX.test(model.name) || VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
return VISION_REGEX.test(model.name) || model.type?.includes('vision') || false
|
||||
}
|
||||
|
||||
return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
|
||||
@@ -2637,13 +2654,13 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (GEMINI_SEARCH_REGEX.test(baseName) || isOpenAIWebSearchModel(model)) {
|
||||
if (GEMINI_SEARCH_MODELS.includes(baseName) || isOpenAIWebSearchModel(model)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.id === 'gemini' || provider?.type === 'gemini') {
|
||||
return GEMINI_SEARCH_REGEX.test(baseName)
|
||||
return GEMINI_SEARCH_MODELS.includes(baseName)
|
||||
}
|
||||
|
||||
if (provider.id === 'hunyuan') {
|
||||
@@ -2682,7 +2699,7 @@ export function isOpenRouterBuiltInWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return isOpenAIWebSearchChatCompletionOnlyModel(model) || model.id.includes('sonar')
|
||||
return isOpenAIWebSearchModel(model) || model.id.includes('sonar')
|
||||
}
|
||||
|
||||
export function isGenerateImageModel(model: Model): boolean {
|
||||
@@ -2714,7 +2731,7 @@ export function isSupportedDisableGenerationModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(getBaseModelName(model.id))
|
||||
return SUPPORTED_DISABLE_GENERATION_MODELS.includes(model.id)
|
||||
}
|
||||
|
||||
export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record<string, any> {
|
||||
@@ -2820,7 +2837,6 @@ export function groupQwenModels(models: Model[]): Record<string, Model[]> {
|
||||
|
||||
export const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
|
||||
// Gemini models
|
||||
'gemini-2\\.5-flash-lite.*$': { min: 512, max: 24576 },
|
||||
'gemini-.*-flash.*$': { min: 0, max: 24576 },
|
||||
'gemini-.*-pro.*$': { min: 128, max: 32768 },
|
||||
|
||||
|
||||
@@ -30,14 +30,6 @@ export function useAppInit() {
|
||||
console.timeEnd('init')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getDataPathFromArgs().then((dataPath) => {
|
||||
if (dataPath) {
|
||||
window.navigate('/settings/data', { replace: true })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useUpdateHandler()
|
||||
useFullScreenNotice()
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "Clear Context {{Command}}",
|
||||
"input.new_topic": "New Topic {{Command}}",
|
||||
"input.pause": "Pause",
|
||||
"input.placeholder": "Type your message here, press {{key}} to send...",
|
||||
"input.placeholder": "Type your message here...",
|
||||
"input.send": "Send",
|
||||
"input.settings": "Settings",
|
||||
"input.topics": " Topics ",
|
||||
@@ -755,8 +755,7 @@
|
||||
"backspace_clear": "Backspace to clear",
|
||||
"esc": "ESC to {{action}}",
|
||||
"esc_back": "return",
|
||||
"esc_close": "close",
|
||||
"esc_pause": "pause"
|
||||
"esc_close": "close"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -787,18 +786,6 @@
|
||||
"string": "Text"
|
||||
},
|
||||
"pinned": "Pinned",
|
||||
"price": {
|
||||
"cost": "Cost",
|
||||
"currency": "Currency",
|
||||
"custom": "Custom",
|
||||
"custom_currency": "Custom Currency",
|
||||
"custom_currency_placeholder": "Enter Custom Currency",
|
||||
"input": "Input Price",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "Output Price",
|
||||
"price": "Price"
|
||||
},
|
||||
"reasoning": "Reasoning",
|
||||
"rerank_model": "Reranker",
|
||||
"rerank_model_support_provider": "Currently, the reranker model only supports some providers ({{provider}})",
|
||||
"rerank_model_not_support_provider": "Currently, the reranker model does not support this provider ({{provider}})",
|
||||
@@ -1085,27 +1072,6 @@
|
||||
"assistant.title": "Default Assistant",
|
||||
"data": {
|
||||
"app_data": "App Data",
|
||||
"app_data.select": "Modify Directory",
|
||||
"app_data.select_title": "Change App Data Directory",
|
||||
"app_data.restart_notice": "The app may need to restart multiple times to apply the changes",
|
||||
"app_data.copy_data_option": "Copy data, will automatically restart after copying the original directory data to the new directory",
|
||||
"app_data.copy_time_notice": "Copying data may take a while, do not force quit app",
|
||||
"app_data.path_changed_without_copy": "Path changed successfully",
|
||||
"app_data.copying_warning": "Data copying, do not force quit app",
|
||||
"app_data.copying": "Copying data to new location...",
|
||||
"app_data.copy_success": "Successfully copied data to new location",
|
||||
"app_data.copy_failed": "Failed to copy data",
|
||||
"app_data.select_success": "Data directory changed, the app will restart to apply changes",
|
||||
"app_data.select_error": "Failed to change data directory",
|
||||
"app_data.migration_title": "Data Migration",
|
||||
"app_data.original_path": "Original Path",
|
||||
"app_data.new_path": "New Path",
|
||||
"app_data.select_error_root_path": "New path cannot be the root path",
|
||||
"app_data.select_error_write_permission": "New path does not have write permission",
|
||||
"app_data.stop_quit_app_reason": "The app is currently migrating data and cannot be exited",
|
||||
"app_data.select_not_empty_dir": "New path is not empty",
|
||||
"app_data.select_not_empty_dir_content": "New path is not empty, if you select copy, it will overwrite the data in the new path, there is a risk of data loss, continue?",
|
||||
"app_data.select_error_same_path": "New path is the same as the old path, please select another path",
|
||||
"app_knowledge": "Knowledge Base Files",
|
||||
"app_knowledge.button.delete": "Delete File",
|
||||
"app_knowledge.remove_all": "Remove Knowledge Base Files",
|
||||
@@ -1166,7 +1132,7 @@
|
||||
"markdown_export.select": "Select",
|
||||
"markdown_export.title": "Markdown Export",
|
||||
"markdown_export.show_model_name.title": "Use Model Name on Export",
|
||||
"markdown_export.show_model_name.help": "When enabled, the model name will be displayed when exporting to Markdown. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
|
||||
"markdown_export.show_model_name.help": "When enabled, the topic-naming model will be used to create titles for exported messages. Note: This option also affects all export methods through Markdown, such as Notion, Yuque, etc.",
|
||||
"markdown_export.show_model_provider.title": "Show Model Provider",
|
||||
"markdown_export.show_model_provider.help": "Display the model provider (e.g., OpenAI, Gemini) when exporting to Markdown",
|
||||
"minute_interval_one": "{{count}} minute",
|
||||
@@ -1230,6 +1196,8 @@
|
||||
"restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?",
|
||||
"restore.confirm.title": "Confirm Restore",
|
||||
"restore.content": "Restore from WebDAV will overwrite the current data, continue?",
|
||||
"restore.modal.select.placeholder": "Please select a backup file to restore",
|
||||
"restore.modal.title": "Restore from WebDAV",
|
||||
"restore.title": "Restore from WebDAV",
|
||||
"syncError": "Backup Error",
|
||||
"syncStatus": "Backup Status",
|
||||
@@ -1917,8 +1885,7 @@
|
||||
"model_desc": "Model used for translation service",
|
||||
"bidirectional": "Bidirectional Translation Settings",
|
||||
"bidirectional_tip": "When enabled, only bidirectional translation between source and target languages is supported",
|
||||
"scroll_sync": "Scroll Sync Settings",
|
||||
"preview": "Markdown Preview"
|
||||
"scroll_sync": "Scroll Sync Settings"
|
||||
},
|
||||
"title": "Translation",
|
||||
"tooltip.newline": "Newline",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "コンテキストをクリア {{Command}}",
|
||||
"input.new_topic": "新しいトピック {{Command}}",
|
||||
"input.pause": "一時停止",
|
||||
"input.placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
|
||||
"input.placeholder": "ここにメッセージを入力...",
|
||||
"input.send": "送信",
|
||||
"input.settings": "設定",
|
||||
"input.topics": " トピック ",
|
||||
@@ -752,11 +752,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "C キーを押してコピー",
|
||||
"backspace_clear": "バックスペースを押してクリアします",
|
||||
"esc": "ESC キーを押して{{action}}",
|
||||
"esc_back": "戻る",
|
||||
"esc_close": "ウィンドウを閉じる",
|
||||
"esc_pause": "一時停止"
|
||||
"backspace_clear": "バックスペースを押してクリアします"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -804,19 +803,7 @@
|
||||
"vision": "画像",
|
||||
"websearch": "ウェブ検索"
|
||||
},
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。",
|
||||
"price": {
|
||||
"cost": "コスト",
|
||||
"currency": "通貨",
|
||||
"custom": "カスタム",
|
||||
"custom_currency": "カスタム通貨",
|
||||
"custom_currency_placeholder": "カスタム通貨を入力してください",
|
||||
"input": "入力価格",
|
||||
"million_tokens": "百万トークン",
|
||||
"output": "出力価格",
|
||||
"price": "価格"
|
||||
},
|
||||
"reasoning": "思考"
|
||||
"rerank_model_not_support_provider": "現在、並べ替えモデルはこのプロバイダー ({{provider}}) をサポートしていません。"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "ダイアログを展開",
|
||||
@@ -1083,28 +1070,7 @@
|
||||
"assistant.title": "デフォルトアシスタント",
|
||||
"data": {
|
||||
"app_data": "アプリデータ",
|
||||
"app_data.select": "ディレクトリを変更",
|
||||
"app_data.select_title": "アプリデータディレクトリの変更",
|
||||
"app_data.restart_notice": "変更を適用するには、アプリを再起動する必要があります。",
|
||||
"app_data.copy_data_option": "データをコピーする, 開くと元のディレクトリのデータが新しいディレクトリにコピーされます。",
|
||||
"app_data.copy_time_notice": "データコピーには時間がかかります。アプリを強制終了しないでください。",
|
||||
"app_data.path_changed_without_copy": "パスが変更されました。",
|
||||
"app_data.copying_warning": "データコピー中、アプリを強制終了しないでください",
|
||||
"app_data.copying": "新しい場所にデータをコピーしています...",
|
||||
"app_data.copy_success": "データを新しい場所に正常にコピーしました",
|
||||
"app_data.copy_failed": "データのコピーに失敗しました",
|
||||
"app_data.select_success": "データディレクトリが変更されました。変更を適用するためにアプリが再起動します",
|
||||
"app_data.select_error": "データディレクトリの変更に失敗しました",
|
||||
"app_data.migration_title": "データ移行",
|
||||
"app_data.original_path": "元のパス",
|
||||
"app_data.new_path": "新しいパス",
|
||||
"app_data.select_error_root_path": "新しいパスはルートパスにできません",
|
||||
"app_data.select_error_write_permission": "新しいパスに書き込み権限がありません",
|
||||
"app_data.stop_quit_app_reason": "アプリは現在データを移行しているため、終了できません",
|
||||
"app_data.select_not_empty_dir": "新しいパスは空ではありません",
|
||||
"app_data.select_not_empty_dir_content": "新しいパスは空ではありません。コピーを選択すると、新しいパスのデータが上書きされます。データが失われるリスクがあります。続行しますか?",
|
||||
"app_data.select_error_same_path": "新しいパスは元のパスと同じです。別のパスを選択してください",
|
||||
"app_knowledge": "知識ベースファイル",
|
||||
"app_knowledge": "ナレッジベースファイル",
|
||||
"app_knowledge.button.delete": "ファイルを削除",
|
||||
"app_knowledge.remove_all": "ナレッジベースファイルを削除",
|
||||
"app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?",
|
||||
@@ -1164,7 +1130,7 @@
|
||||
"markdown_export.select": "選択",
|
||||
"markdown_export.title": "Markdown エクスポート",
|
||||
"markdown_export.show_model_name.title": "エクスポート時にモデル名を使用",
|
||||
"markdown_export.show_model_name.help": "有効にすると、Markdownエクスポート時にモデル名を表示します。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
|
||||
"markdown_export.show_model_name.help": "有効にすると、トピック命名モデルがエクスポートされたメッセージのタイトル作成に使用されます。注意:この設定はNotion、Yuqueなど、Markdownを通じたすべてのエクスポート方法にも影響します。",
|
||||
"markdown_export.show_model_provider.title": "モデルプロバイダーを表示",
|
||||
"markdown_export.show_model_provider.help": "Markdownエクスポート時にモデルプロバイダー(例:OpenAI、Geminiなど)を表示します。",
|
||||
"minute_interval_one": "{{count}} 分",
|
||||
@@ -1210,6 +1176,8 @@
|
||||
"restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.confirm.title": "復元を確認",
|
||||
"restore.content": "WebDAVから復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
|
||||
"restore.modal.title": "WebDAV から復元",
|
||||
"restore.title": "WebDAVから復元",
|
||||
"syncError": "バックアップエラー",
|
||||
"syncStatus": "バックアップ状態",
|
||||
@@ -1916,8 +1884,7 @@
|
||||
"model_desc": "翻訳サービスで使用されるモデル",
|
||||
"bidirectional": "双方向翻訳設定",
|
||||
"bidirectional_tip": "有効にすると、ソース言語と目標言語間の双方向翻訳のみがサポートされます",
|
||||
"scroll_sync": "スクロール同期設定",
|
||||
"preview": "Markdown プレビュー"
|
||||
"scroll_sync": "スクロール同期設定"
|
||||
},
|
||||
"title": "翻訳",
|
||||
"tooltip.newline": "改行",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "Очистить контекст {{Command}}",
|
||||
"input.new_topic": "Новый топик {{Command}}",
|
||||
"input.pause": "Остановить",
|
||||
"input.placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
|
||||
"input.placeholder": "Введите ваше сообщение здесь...",
|
||||
"input.send": "Отправить",
|
||||
"input.settings": "Настройки",
|
||||
"input.topics": " Топики ",
|
||||
@@ -752,11 +752,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "Нажмите C для копирования",
|
||||
"backspace_clear": "Нажмите Backspace, чтобы очистить",
|
||||
"esc": "Нажмите ESC {{action}}",
|
||||
"esc_back": "возвращения",
|
||||
"esc_close": "закрытия окна",
|
||||
"esc_pause": "пауза"
|
||||
"backspace_clear": "Нажмите Backspace, чтобы очистить"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -804,19 +803,7 @@
|
||||
"vision": "Визуальные",
|
||||
"websearch": "Веб-поисковые"
|
||||
},
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})",
|
||||
"price": {
|
||||
"cost": "Стоимость",
|
||||
"currency": "Валюта",
|
||||
"custom": "Пользовательский",
|
||||
"custom_currency": "Пользовательская валюта",
|
||||
"custom_currency_placeholder": "Введите пользовательскую валюту",
|
||||
"input": "Цена ввода",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "Цена вывода",
|
||||
"price": "Цена"
|
||||
},
|
||||
"reasoning": "Рассуждение"
|
||||
"rerank_model_not_support_provider": "В настоящее время модель переупорядочивания не поддерживает этого провайдера ({{provider}})"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "Развернуть диалоговое окно",
|
||||
@@ -974,8 +961,7 @@
|
||||
"per_image": "за изображение",
|
||||
"per_images": "за изображения",
|
||||
"required_field": "Обязательное поле",
|
||||
"uploaded_input": "Загруженный ввод",
|
||||
"prompt_placeholder_en": "[to be translated]:Enter your image description, currently Imagen only supports English prompts"
|
||||
"uploaded_input": "Загруженный ввод"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
@@ -1083,28 +1069,7 @@
|
||||
"assistant.title": "Ассистент по умолчанию",
|
||||
"data": {
|
||||
"app_data": "Данные приложения",
|
||||
"app_data.select": "Изменить директорию",
|
||||
"app_data.select_title": "Изменить директорию данных приложения",
|
||||
"app_data.restart_notice": "Для применения изменений может потребоваться несколько перезапусков приложения",
|
||||
"app_data.copy_data_option": "Копировать данные, будет автоматически перезапущено после копирования данных из исходной директории в новую директорию",
|
||||
"app_data.copy_time_notice": "Копирование данных из исходной директории займет некоторое время, пожалуйста, будьте терпеливы",
|
||||
"app_data.path_changed_without_copy": "Путь изменен успешно",
|
||||
"app_data.copying_warning": "Копирование данных, нельзя взаимодействовать с приложением, не закрывайте приложение",
|
||||
"app_data.copying": "Копирование данных в новое место...",
|
||||
"app_data.copy_success": "Данные успешно скопированы в новое место",
|
||||
"app_data.copy_failed": "Не удалось скопировать данные",
|
||||
"app_data.select_success": "Директория данных изменена, приложение будет перезапущено для применения изменений",
|
||||
"app_data.select_error": "Не удалось изменить директорию данных",
|
||||
"app_data.migration_title": "Миграция данных",
|
||||
"app_data.original_path": "Исходный путь",
|
||||
"app_data.new_path": "Новый путь",
|
||||
"app_data.select_error_root_path": "Новый путь не может быть корневым",
|
||||
"app_data.select_error_write_permission": "Новый путь не имеет разрешения на запись",
|
||||
"app_data.stop_quit_app_reason": "Приложение в настоящее время перемещает данные и не может быть закрыто",
|
||||
"app_data.select_not_empty_dir": "Новый путь не пуст",
|
||||
"app_data.select_not_empty_dir_content": "Новый путь не пуст, если вы выбираете копирование, он перезапишет данные в новом пути, есть риск потери данных, продолжить?",
|
||||
"app_data.select_error_same_path": "Новый путь совпадает с исходным путем, пожалуйста, выберите другой путь",
|
||||
"app_knowledge": "Файлы базы знаний",
|
||||
"app_knowledge": "База знаний",
|
||||
"app_knowledge.button.delete": "Удалить файл",
|
||||
"app_knowledge.remove_all": "Удалить файлы базы знаний",
|
||||
"app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?",
|
||||
@@ -1164,7 +1129,7 @@
|
||||
"markdown_export.select": "Выбрать",
|
||||
"markdown_export.title": "Экспорт в Markdown",
|
||||
"markdown_export.show_model_name.title": "Использовать имя модели при экспорте",
|
||||
"markdown_export.show_model_name.help": "Если включено, при экспорте в Markdown будет отображаться имя модели. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
|
||||
"markdown_export.show_model_name.help": "Если включено, для создания заголовков экспортируемых сообщений будет использоваться модель именования темы. Примечание: Эта опция также влияет на все методы экспорта через Markdown, такие как Notion, Yuque и т.д.",
|
||||
"markdown_export.show_model_provider.title": "Показать поставщика модели",
|
||||
"markdown_export.show_model_provider.help": "Показывать поставщика модели (например, OpenAI, Gemini) при экспорте в Markdown",
|
||||
"minute_interval_one": "{{count}} минута",
|
||||
@@ -1228,6 +1193,8 @@
|
||||
"restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.confirm.title": "Подтверждение восстановления",
|
||||
"restore.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления",
|
||||
"restore.modal.title": "Восстановление с WebDAV",
|
||||
"restore.title": "Восстановление с WebDAV",
|
||||
"syncError": "Ошибка резервного копирования",
|
||||
"syncStatus": "Статус резервного копирования",
|
||||
@@ -1916,8 +1883,7 @@
|
||||
"model_desc": "Модель, используемая для службы перевода",
|
||||
"bidirectional": "Настройки двунаправленного перевода",
|
||||
"scroll_sync": "Настройки синхронизации прокрутки",
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот.",
|
||||
"preview": "Markdown предпросмотр"
|
||||
"bidirectional_tip": "Если включено, перевод будет выполняться в обоих направлениях, исходный текст будет переведен на целевой язык и наоборот."
|
||||
},
|
||||
"title": "Перевод",
|
||||
"tooltip.newline": "Перевести",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "清除上下文 {{Command}}",
|
||||
"input.new_topic": "新话题 {{Command}}",
|
||||
"input.pause": "暂停",
|
||||
"input.placeholder": "在这里输入消息,按 {{key}} 发送...",
|
||||
"input.placeholder": "在这里输入消息...",
|
||||
"input.translating": "翻译中...",
|
||||
"input.send": "发送",
|
||||
"input.settings": "设置",
|
||||
@@ -755,8 +755,7 @@
|
||||
"backspace_clear": "按 Backspace 清空",
|
||||
"esc": "按 ESC {{action}}",
|
||||
"esc_back": "返回",
|
||||
"esc_close": "关闭",
|
||||
"esc_pause": "暂停"
|
||||
"esc_close": "关闭"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -787,18 +786,6 @@
|
||||
"string": "文本"
|
||||
},
|
||||
"pinned": "已固定",
|
||||
"price": {
|
||||
"cost": "花费",
|
||||
"currency": "币种",
|
||||
"custom": "自定义",
|
||||
"custom_currency": "自定义币种",
|
||||
"custom_currency_placeholder": "请输入自定义币种",
|
||||
"input": "输入价格",
|
||||
"million_tokens": "百万 Token",
|
||||
"output": "输出价格",
|
||||
"price": "价格"
|
||||
},
|
||||
"reasoning": "推理",
|
||||
"rerank_model": "重排模型",
|
||||
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
|
||||
"rerank_model_not_support_provider": "目前重排序模型不支持该服务商 ({{provider}})",
|
||||
@@ -1085,27 +1072,6 @@
|
||||
"assistant.title": "默认助手",
|
||||
"data": {
|
||||
"app_data": "应用数据",
|
||||
"app_data.select": "修改目录",
|
||||
"app_data.select_title": "更改应用数据目录",
|
||||
"app_data.restart_notice": "应用可能会重启多次以应用更改",
|
||||
"app_data.copy_data_option": "复制数据,会自动重启后将原始目录数据复制到新目录",
|
||||
"app_data.copy_time_notice": "复制数据将需要一些时间,复制期间不要关闭应用",
|
||||
"app_data.path_changed_without_copy": "路径已更改成功",
|
||||
"app_data.copying_warning": "数据复制中,不要强制退出app",
|
||||
"app_data.copying": "正在将数据复制到新位置...",
|
||||
"app_data.copy_success": "已成功复制数据到新位置",
|
||||
"app_data.copy_failed": "复制数据失败",
|
||||
"app_data.select_success": "数据目录已更改,应用将重启以应用更改",
|
||||
"app_data.select_error": "更改数据目录失败",
|
||||
"app_data.migration_title": "数据迁移",
|
||||
"app_data.original_path": "原始路径",
|
||||
"app_data.new_path": "新路径",
|
||||
"app_data.select_error_root_path": "新路径不能是根路径",
|
||||
"app_data.select_error_write_permission": "新路径没有写入权限",
|
||||
"app_data.stop_quit_app_reason": "应用目前在迁移数据, 不能退出",
|
||||
"app_data.select_not_empty_dir": "新路径不为空",
|
||||
"app_data.select_not_empty_dir_content": "新路径不为空,选择复制将覆盖新路径中的数据, 有数据丢失的风险,是否继续?",
|
||||
"app_data.select_error_same_path": "新路径与旧路径相同,请选择其他路径",
|
||||
"app_knowledge": "知识库文件",
|
||||
"app_knowledge.button.delete": "删除文件",
|
||||
"app_knowledge.remove_all": "删除知识库文件",
|
||||
@@ -1166,7 +1132,7 @@
|
||||
"markdown_export.select": "选择",
|
||||
"markdown_export.title": "Markdown 导出",
|
||||
"markdown_export.show_model_name.title": "导出时使用模型名称",
|
||||
"markdown_export.show_model_name.help": "开启后,导出Markdown时会显示模型名称。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。",
|
||||
"markdown_export.show_model_name.help": "开启后,使用话题命名模型为导出的消息创建标题。注意:该项也会影响所有通过Markdown导出的方式,如Notion、语雀等。",
|
||||
"markdown_export.show_model_provider.title": "显示模型供应商",
|
||||
"markdown_export.show_model_provider.help": "在导出Markdown时显示模型供应商,如OpenAI、Gemini等",
|
||||
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
|
||||
@@ -1232,6 +1198,8 @@
|
||||
"restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?",
|
||||
"restore.confirm.title": "确认恢复",
|
||||
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
|
||||
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
|
||||
"restore.modal.title": "从 WebDAV 恢复",
|
||||
"restore.title": "从 WebDAV 恢复",
|
||||
"syncError": "备份错误",
|
||||
"syncStatus": "备份状态",
|
||||
@@ -1919,8 +1887,7 @@
|
||||
"model_desc": "翻译服务使用的模型",
|
||||
"bidirectional": "双向翻译设置",
|
||||
"bidirectional_tip": "开启后,仅支持在源语言和目标语言之间进行双向翻译",
|
||||
"scroll_sync": "滚动同步设置",
|
||||
"preview": "Markdown 预览"
|
||||
"scroll_sync": "滚动同步设置"
|
||||
},
|
||||
"title": "翻译",
|
||||
"tooltip.newline": "换行",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"input.new.context": "清除上下文 {{Command}}",
|
||||
"input.new_topic": "新話題 {{Command}}",
|
||||
"input.pause": "暫停",
|
||||
"input.placeholder": "在此輸入您的訊息,按 {{key}} 傳送...",
|
||||
"input.placeholder": "在此輸入您的訊息...",
|
||||
"input.send": "傳送",
|
||||
"input.settings": "設定",
|
||||
"input.topics": " 話題 ",
|
||||
@@ -752,11 +752,10 @@
|
||||
},
|
||||
"footer": {
|
||||
"copy_last_message": "按 C 鍵複製",
|
||||
"backspace_clear": "按 Backspace 清空",
|
||||
"esc": "按 ESC {{action}}",
|
||||
"esc_back": "返回",
|
||||
"esc_close": "關閉視窗",
|
||||
"esc_pause": "暫停"
|
||||
"backspace_clear": "按 Backspace 清空"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": {
|
||||
@@ -804,19 +803,7 @@
|
||||
"vision": "視覺",
|
||||
"websearch": "網路搜尋"
|
||||
},
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})",
|
||||
"price": {
|
||||
"cost": "花費",
|
||||
"currency": "幣種",
|
||||
"custom": "自訂",
|
||||
"custom_currency": "自訂幣種",
|
||||
"custom_currency_placeholder": "請輸入自訂幣種",
|
||||
"input": "輸入價格",
|
||||
"million_tokens": "M Tokens",
|
||||
"output": "輸出價格",
|
||||
"price": "價格"
|
||||
},
|
||||
"reasoning": "推理"
|
||||
"rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})"
|
||||
},
|
||||
"navbar": {
|
||||
"expand": "伸縮對話框",
|
||||
@@ -1084,28 +1071,7 @@
|
||||
"assistant.icon.type.none": "不顯示",
|
||||
"assistant.title": "預設助手",
|
||||
"data": {
|
||||
"app_data": "應用數據",
|
||||
"app_data.select": "修改目錄",
|
||||
"app_data.select_title": "變更應用數據目錄",
|
||||
"app_data.restart_notice": "變更數據目錄後可能需要重啟應用才能生效",
|
||||
"app_data.copy_data_option": "複製數據, 會自動重啟後將原始目錄數據複製到新目錄",
|
||||
"app_data.copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用",
|
||||
"app_data.path_changed_without_copy": "路徑已變更成功",
|
||||
"app_data.copying_warning": "數據複製中,不要強制退出應用",
|
||||
"app_data.copying": "正在複製數據到新位置...",
|
||||
"app_data.copy_success": "成功複製數據到新位置",
|
||||
"app_data.copy_failed": "複製數據失敗",
|
||||
"app_data.select_success": "數據目錄已變更,應用將重啟以應用變更",
|
||||
"app_data.select_error": "變更數據目錄失敗",
|
||||
"app_data.migration_title": "數據遷移",
|
||||
"app_data.original_path": "原始路徑",
|
||||
"app_data.new_path": "新路徑",
|
||||
"app_data.select_error_root_path": "新路徑不能是根路徑",
|
||||
"app_data.select_error_write_permission": "新路徑沒有寫入權限",
|
||||
"app_data.stop_quit_app_reason": "應用目前正在遷移數據,不能退出",
|
||||
"app_data.select_not_empty_dir": "新路徑不為空",
|
||||
"app_data.select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據, 有數據丟失的風險,是否繼續?",
|
||||
"app_data.select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑",
|
||||
"app_data": "應用程式資料",
|
||||
"app_knowledge": "知識庫文件",
|
||||
"app_knowledge.button.delete": "刪除檔案",
|
||||
"app_knowledge.remove_all": "刪除知識庫檔案",
|
||||
@@ -1166,7 +1132,7 @@
|
||||
"markdown_export.select": "選擇",
|
||||
"markdown_export.title": "Markdown 匯出",
|
||||
"markdown_export.show_model_name.title": "匯出時使用模型名稱",
|
||||
"markdown_export.show_model_name.help": "啟用後,匯出Markdown時會顯示模型名稱。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。",
|
||||
"markdown_export.show_model_name.help": "啟用後,將以主題命名模型為匯出的訊息建立標題。注意:該項也會影響所有透過Markdown匯出的方式,如Notion、語雀等。",
|
||||
"markdown_export.show_model_provider.title": "顯示模型供應商",
|
||||
"markdown_export.show_model_provider.help": "在匯出Markdown時顯示模型供應商,如OpenAI、Gemini等",
|
||||
"minute_interval_one": "{{count}} 分鐘",
|
||||
@@ -1230,6 +1196,8 @@
|
||||
"restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
|
||||
"restore.confirm.title": "復元確認",
|
||||
"restore.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?",
|
||||
"restore.modal.select.placeholder": "請選擇要恢復的備份文件",
|
||||
"restore.modal.title": "從 WebDAV 恢復",
|
||||
"restore.title": "從 WebDAV 恢復",
|
||||
"syncError": "備份錯誤",
|
||||
"syncStatus": "備份狀態",
|
||||
@@ -1916,8 +1884,7 @@
|
||||
"model_desc": "翻譯服務使用的模型",
|
||||
"bidirectional": "雙向翻譯設定",
|
||||
"bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯",
|
||||
"scroll_sync": "滾動同步設定",
|
||||
"preview": "Markdown 預覽"
|
||||
"scroll_sync": "滾動同步設定"
|
||||
},
|
||||
"title": "翻譯",
|
||||
"tooltip.newline": "換行",
|
||||
|
||||
@@ -75,8 +75,8 @@ const AgentsPage: FC = () => {
|
||||
{agent.description && <AgentDescription>{agent.description}</AgentDescription>}
|
||||
|
||||
{agent.prompt && (
|
||||
<AgentPrompt className="markdown">
|
||||
<ReactMarkdown>{agent.prompt}</ReactMarkdown>
|
||||
<AgentPrompt>
|
||||
<ReactMarkdown className="markdown">{agent.prompt}</ReactMarkdown>{' '}
|
||||
</AgentPrompt>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { groupTranslations } from '@renderer/pages/agents/agentGroupTranslations'
|
||||
import { DynamicIcon, IconName } from 'lucide-react/dynamic'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
groupName: string
|
||||
@@ -10,25 +8,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth = 1.2 }) => {
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language as keyof (typeof groupTranslations)[string]
|
||||
|
||||
const findOriginalKey = (name: string): string => {
|
||||
if (groupTranslations[name]) {
|
||||
return name
|
||||
}
|
||||
|
||||
for (const key in groupTranslations) {
|
||||
if (groupTranslations[key][currentLanguage] === name) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
const originalKey = findOriginalKey(groupName)
|
||||
|
||||
const iconMap: { [key: string]: IconName } = {
|
||||
我的: 'user-check',
|
||||
精选: 'star',
|
||||
@@ -67,5 +46,5 @@ export const AgentGroupIcon: FC<Props> = ({ groupName, size = 20, strokeWidth =
|
||||
搜索: 'search'
|
||||
} as const
|
||||
|
||||
return <DynamicIcon name={iconMap[originalKey] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
|
||||
return <DynamicIcon name={iconMap[groupName] || 'bot-message-square'} size={size} strokeWidth={strokeWidth} />
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import store from '@renderer/store'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
let _agents: Agent[] = []
|
||||
|
||||
@@ -23,8 +22,6 @@ export function useSystemAgents() {
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const { resourcesPath } = useRuntime()
|
||||
const { agentssubscribeUrl } = store.getState().settings
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language
|
||||
|
||||
useEffect(() => {
|
||||
const loadAgents = async () => {
|
||||
@@ -47,21 +44,9 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
// 如果没有远程配置或获取失败,加载本地代理
|
||||
if (resourcesPath) {
|
||||
try {
|
||||
let fileName = 'agents.json'
|
||||
if (currentLanguage === 'zh-CN') {
|
||||
fileName = 'agents-zh.json'
|
||||
} else {
|
||||
fileName = 'agents-en.json'
|
||||
}
|
||||
|
||||
const localAgentsData = await window.api.fs.read(`${resourcesPath}/data/${fileName}`, 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
} catch (error) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
}
|
||||
if (resourcesPath && _agents.length === 0) {
|
||||
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
|
||||
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||
}
|
||||
|
||||
setAgents(_agents)
|
||||
@@ -73,7 +58,7 @@ export function useSystemAgents() {
|
||||
}
|
||||
|
||||
loadAgents()
|
||||
}, [defaultAgent, resourcesPath, agentssubscribeUrl, currentLanguage])
|
||||
}, [defaultAgent, resourcesPath, agentssubscribeUrl])
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
@@ -35,7 +36,6 @@ import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
@@ -87,6 +87,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef(null)
|
||||
const { searching } = useRuntime()
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { pauseMessages } = useMessageOperations(topic)
|
||||
const loading = useTopicLoading(topic)
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -103,6 +104,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const currentMessageId = useRef<string>('')
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
const { bases: knowledgeBases } = useKnowledgeBases()
|
||||
const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode)
|
||||
|
||||
@@ -173,11 +175,22 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
if (uploadedFiles) {
|
||||
baseUserMessage.files = uploadedFiles
|
||||
}
|
||||
const knowledgeBaseIds = selectedKnowledgeBases?.map((base) => base.id)
|
||||
|
||||
if (knowledgeBaseIds) {
|
||||
baseUserMessage.knowledgeBaseIds = knowledgeBaseIds
|
||||
}
|
||||
|
||||
if (mentionModels) {
|
||||
baseUserMessage.mentions = mentionModels
|
||||
}
|
||||
|
||||
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
|
||||
baseUserMessage.enabledMCPs = activedMcpServers.filter((server) =>
|
||||
assistant.mcpServers?.some((s) => s.id === server.id)
|
||||
)
|
||||
}
|
||||
|
||||
const assistantWithTopicPrompt = topic.prompt
|
||||
? { ...assistant, prompt: `${assistant.prompt}\n${topic.prompt}` }
|
||||
: assistant
|
||||
@@ -198,7 +211,19 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
}
|
||||
}, [assistant, dispatch, files, inputEmpty, loading, mentionModels, resizeTextArea, text, topic])
|
||||
}, [
|
||||
activedMcpServers,
|
||||
assistant,
|
||||
dispatch,
|
||||
files,
|
||||
inputEmpty,
|
||||
loading,
|
||||
mentionModels,
|
||||
resizeTextArea,
|
||||
selectedKnowledgeBases,
|
||||
text,
|
||||
topic
|
||||
])
|
||||
|
||||
const translate = useCallback(async () => {
|
||||
if (isTranslating) {
|
||||
@@ -284,6 +309,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
|
||||
// 按下Tab键,自动选中${xxx}
|
||||
if (event.key === 'Tab' && inputFocus) {
|
||||
event.preventDefault()
|
||||
@@ -339,37 +366,32 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}
|
||||
|
||||
//to check if the SendMessage key is pressed
|
||||
//other keys should be ignored
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
} else {
|
||||
//shift+enter's default behavior is to add a new line, ignore it
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
// update text by setState, not directly modify textarea.value
|
||||
setText(newText)
|
||||
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
// set cursor position in the next render cycle
|
||||
setTimeout(() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
onInput() // trigger resizeTextArea
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
|
||||
if (quickPanel.isVisible) return event.preventDefault()
|
||||
|
||||
sendMessage()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) {
|
||||
@@ -672,6 +694,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
|
||||
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
|
||||
|
||||
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
|
||||
|
||||
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ ...assistant, knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
@@ -774,16 +798,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isTranslating
|
||||
? t('chat.input.translating')
|
||||
: t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
|
||||
}
|
||||
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
variant="borderless"
|
||||
spellCheck={false}
|
||||
rows={2}
|
||||
rows={textareaRows}
|
||||
ref={textareaRef}
|
||||
style={{
|
||||
fontSize,
|
||||
@@ -930,7 +950,7 @@ const Textarea = styled(TextArea)`
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: none !important;
|
||||
transition: height 0.2s ease;
|
||||
&.ant-input {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import remarkMath from 'remark-math'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import Link from './Link'
|
||||
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
|
||||
import Table from './Table'
|
||||
|
||||
const ALLOWED_ELEMENTS =
|
||||
@@ -41,7 +40,7 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
const { mathEngine } = useSettings()
|
||||
|
||||
const remarkPlugins = useMemo(() => {
|
||||
const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])]
|
||||
const plugins = [remarkGfm, remarkCjkFriendly]
|
||||
if (mathEngine !== 'none') {
|
||||
plugins.push(remarkMath)
|
||||
}
|
||||
@@ -106,21 +105,20 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
disallowedElements={DISALLOWED_ELEMENTS}
|
||||
urlTransform={urlTransform}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
footnoteBackContent: ' '
|
||||
}}>
|
||||
{messageContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
className="markdown"
|
||||
components={components}
|
||||
disallowedElements={DISALLOWED_ELEMENTS}
|
||||
urlTransform={urlTransform}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
footnoteBackContent: ' '
|
||||
}}>
|
||||
{messageContent}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -103,12 +103,6 @@ vi.mock('rehype-katex', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-mathjax', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('rehype-raw', () => ({ __esModule: true, default: vi.fn() }))
|
||||
|
||||
// Mock custom plugins
|
||||
vi.mock('../plugins/remarkDisableConstructs', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock ReactMarkdown with realistic rendering
|
||||
vi.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
@@ -168,16 +162,12 @@ describe('Markdown', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render markdown content with correct structure', () => {
|
||||
const block = createMainTextBlock({ content: 'Test content' })
|
||||
const { container } = render(<Markdown block={block} />)
|
||||
render(<Markdown block={block} />)
|
||||
|
||||
// Check that the outer container has the markdown class
|
||||
const markdownContainer = container.querySelector('.markdown')
|
||||
expect(markdownContainer).toBeInTheDocument()
|
||||
|
||||
// Check that the markdown content is rendered inside
|
||||
const markdownContent = screen.getByTestId('markdown-content')
|
||||
expect(markdownContent).toBeInTheDocument()
|
||||
expect(markdownContent).toHaveTextContent('Test content')
|
||||
const markdown = screen.getByTestId('markdown-content')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
expect(markdown).toHaveClass('markdown')
|
||||
expect(markdown).toHaveTextContent('Test content')
|
||||
})
|
||||
|
||||
it('should handle empty content gracefully', () => {
|
||||
|
||||
@@ -3,58 +3,55 @@
|
||||
exports[`Markdown > rendering > should match snapshot 1`] = `
|
||||
<div
|
||||
class="markdown"
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
<div
|
||||
data-testid="markdown-content"
|
||||
>
|
||||
# Test Markdown
|
||||
# Test Markdown
|
||||
|
||||
This is **bold** text.
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<span
|
||||
data-testid="has-link-component"
|
||||
>
|
||||
link
|
||||
</span>
|
||||
<div
|
||||
data-testid="has-code-component"
|
||||
>
|
||||
<div
|
||||
data-testid="has-code-component"
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
>
|
||||
<div
|
||||
data-id="code-block-1"
|
||||
data-testid="code-block"
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
<code>
|
||||
test code
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
data-testid="has-table-component"
|
||||
>
|
||||
<div
|
||||
data-block-id="test-block-1"
|
||||
data-testid="table-component"
|
||||
>
|
||||
<table>
|
||||
test table
|
||||
</table>
|
||||
<button
|
||||
data-testid="copy-table-button"
|
||||
type="button"
|
||||
>
|
||||
Copy Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-testid="has-table-component"
|
||||
>
|
||||
<div
|
||||
data-block-id="test-block-1"
|
||||
data-testid="table-component"
|
||||
>
|
||||
<table>
|
||||
test table
|
||||
</table>
|
||||
<button
|
||||
data-testid="copy-table-button"
|
||||
type="button"
|
||||
>
|
||||
Copy Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-testid="has-img-component"
|
||||
>
|
||||
img
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import remarkDisableConstructs from '../remarkDisableConstructs'
|
||||
|
||||
describe('disableIndentedCode', () => {
|
||||
const renderMarkdown = (markdown: string, constructs: string[] = ['codeIndented']) => {
|
||||
return render(<ReactMarkdown remarkPlugins={[remarkDisableConstructs(constructs)]}>{markdown}</ReactMarkdown>)
|
||||
}
|
||||
|
||||
describe('normal path', () => {
|
||||
it('should disable indented code blocks while preserving other code types', () => {
|
||||
const markdown = `
|
||||
# Test Document
|
||||
|
||||
Regular paragraph.
|
||||
|
||||
This should be treated as a regular paragraph, not code
|
||||
|
||||
\`inline code\` should work
|
||||
|
||||
\`\`\`javascript
|
||||
// This fenced code should work
|
||||
console.log('hello')
|
||||
\`\`\`
|
||||
|
||||
Another paragraph.
|
||||
`
|
||||
|
||||
const { container } = renderMarkdown(markdown)
|
||||
|
||||
// Verify only fenced code (pre element)
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(1)
|
||||
|
||||
// Verify inline code
|
||||
const inlineCode = container.querySelector('code:not(pre code)')
|
||||
expect(inlineCode?.textContent).toBe('inline code')
|
||||
|
||||
// Verify fenced code
|
||||
const fencedCode = container.querySelector('pre code')
|
||||
expect(fencedCode?.textContent).toContain('console.log')
|
||||
|
||||
// Verify indented content becomes paragraph
|
||||
const paragraphs = container.querySelectorAll('p')
|
||||
const indentedParagraph = Array.from(paragraphs).find((p) =>
|
||||
p.textContent?.includes('This should be treated as a regular paragraph')
|
||||
)
|
||||
expect(indentedParagraph).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should handle indented code in nested structures', () => {
|
||||
const markdown = `
|
||||
> Blockquote with \`inline code\`
|
||||
>
|
||||
> This indented code in blockquote should become text
|
||||
|
||||
1. List item
|
||||
|
||||
This indented code in list should become text
|
||||
|
||||
* Bullet list
|
||||
* Nested item
|
||||
|
||||
More indented code to convert
|
||||
`
|
||||
|
||||
const { container } = renderMarkdown(markdown)
|
||||
|
||||
// Verify no indented code blocks
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(0)
|
||||
|
||||
// Verify blockquote exists and contains converted text
|
||||
const blockquote = container.querySelector('blockquote')
|
||||
expect(blockquote?.textContent).toContain('This indented code in blockquote should become text')
|
||||
|
||||
// Verify lists exist
|
||||
const lists = container.querySelectorAll('ul, ol')
|
||||
expect(lists.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should preserve other markdown elements when disabling constructs', () => {
|
||||
const markdown = `
|
||||
# Heading
|
||||
|
||||
Paragraph text.
|
||||
|
||||
Indented code to disable
|
||||
|
||||
[Link text](https://example.com)
|
||||
|
||||
\`\`\`
|
||||
Fenced code to keep
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
const { container } = renderMarkdown(markdown)
|
||||
|
||||
// Verify heading
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Heading')
|
||||
|
||||
// Verify link
|
||||
const link = container.querySelector('a')
|
||||
expect(link?.textContent).toBe('Link text')
|
||||
expect(link?.getAttribute('href')).toBe('https://example.com')
|
||||
|
||||
// Verify only fenced code
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should not affect markdown when no constructs are disabled', () => {
|
||||
const markdown = `
|
||||
This is indented code
|
||||
|
||||
\`inline code\`
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('fenced')
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
const { container } = renderMarkdown(markdown, [])
|
||||
|
||||
// Should have indented code and fenced code
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle markdown with only inline and fenced code', () => {
|
||||
const markdown = `
|
||||
Regular paragraph with \`inline code\`.
|
||||
|
||||
\`\`\`typescript
|
||||
function test(): string {
|
||||
return "hello";
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
const { container } = renderMarkdown(markdown)
|
||||
|
||||
// Should have only fenced code
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(1)
|
||||
|
||||
// Verify fenced code content
|
||||
const fencedCode = container.querySelector('pre code')
|
||||
expect(fencedCode?.textContent).toContain('function test()')
|
||||
|
||||
// Verify inline code
|
||||
const inlineCode = container.querySelector('code:not(pre code)')
|
||||
expect(inlineCode?.textContent).toBe('inline code')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import remarkDisableConstructs from '../remarkDisableConstructs'
|
||||
|
||||
describe('remarkDisableConstructs', () => {
|
||||
let mockData: any
|
||||
let mockThis: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockData = {}
|
||||
mockThis = {
|
||||
data: vi.fn().mockReturnValue(mockData)
|
||||
}
|
||||
})
|
||||
|
||||
describe('plugin creation', () => {
|
||||
it('should return a function when called', () => {
|
||||
const plugin = remarkDisableConstructs(['codeIndented'])
|
||||
|
||||
expect(typeof plugin).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normal path', () => {
|
||||
it('should add micromarkExtensions for single construct', () => {
|
||||
const plugin = remarkDisableConstructs(['codeIndented'])
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData).toHaveProperty('micromarkExtensions')
|
||||
expect(Array.isArray(mockData.micromarkExtensions)).toBe(true)
|
||||
expect(mockData.micromarkExtensions).toHaveLength(1)
|
||||
expect(mockData.micromarkExtensions[0]).toEqual({
|
||||
disable: {
|
||||
null: ['codeIndented']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple constructs', () => {
|
||||
const constructs = ['codeIndented', 'autolink', 'htmlFlow']
|
||||
const plugin = remarkDisableConstructs(constructs)
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData.micromarkExtensions[0]).toEqual({
|
||||
disable: {
|
||||
null: constructs
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should not add extensions when empty array is provided', () => {
|
||||
const plugin = remarkDisableConstructs([])
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData).not.toHaveProperty('micromarkExtensions')
|
||||
})
|
||||
|
||||
it('should not add extensions when undefined is passed', () => {
|
||||
const plugin = remarkDisableConstructs()
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData).not.toHaveProperty('micromarkExtensions')
|
||||
})
|
||||
|
||||
it('should handle empty construct names', () => {
|
||||
const plugin = remarkDisableConstructs(['', ' '])
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData.micromarkExtensions[0]).toEqual({
|
||||
disable: {
|
||||
null: ['', ' ']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle mixed valid and empty construct names', () => {
|
||||
const plugin = remarkDisableConstructs(['codeIndented', '', 'autolink'])
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData.micromarkExtensions[0]).toEqual({
|
||||
disable: {
|
||||
null: ['codeIndented', '', 'autolink']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction with existing data', () => {
|
||||
it('should append to existing micromarkExtensions', () => {
|
||||
const existingExtension = { some: 'extension' }
|
||||
mockData.micromarkExtensions = [existingExtension]
|
||||
|
||||
const plugin = remarkDisableConstructs(['codeIndented'])
|
||||
plugin.call(mockThis as any)
|
||||
|
||||
expect(mockData.micromarkExtensions).toHaveLength(2)
|
||||
expect(mockData.micromarkExtensions[0]).toBe(existingExtension)
|
||||
expect(mockData.micromarkExtensions[1]).toEqual({
|
||||
disable: {
|
||||
null: ['codeIndented']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Plugin } from 'unified'
|
||||
|
||||
/**
|
||||
* Custom remark plugin to disable specific markdown constructs
|
||||
*
|
||||
* This plugin allows you to disable specific markdown constructs by passing
|
||||
* them as micromark extensions to the underlying parser.
|
||||
*
|
||||
* @see https://github.com/micromark/micromark
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Disable indented code blocks
|
||||
* remarkDisableConstructs(['codeIndented'])
|
||||
*
|
||||
* // Disable multiple constructs
|
||||
* remarkDisableConstructs(['codeIndented', 'autolink', 'htmlFlow'])
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper function to add values to plugin data
|
||||
* @param data - The plugin data object
|
||||
* @param field - The field name to add to
|
||||
* @param value - The value to add
|
||||
*/
|
||||
function add(data: any, field: string, value: unknown): void {
|
||||
const list = data[field] ? data[field] : (data[field] = [])
|
||||
list.push(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remark plugin to disable specific markdown constructs
|
||||
* @param constructs - Array of construct names to disable (e.g., ['codeIndented', 'autolink'])
|
||||
* @returns A remark plugin function
|
||||
*/
|
||||
function remarkDisableConstructs(constructs: string[] = []): Plugin<[], any, any> {
|
||||
return function () {
|
||||
const data = this.data()
|
||||
|
||||
if (constructs.length > 0) {
|
||||
const disableExtension = {
|
||||
disable: {
|
||||
null: constructs
|
||||
}
|
||||
}
|
||||
|
||||
add(data, 'micromarkExtensions', disableExtension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default remarkDisableConstructs
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DownOutlined } from '@ant-design/icons'
|
||||
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
|
||||
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
@@ -13,7 +14,6 @@ import type { Message } from '@renderer/types/newMessage'
|
||||
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { Avatar } from 'antd'
|
||||
import { CircleChevronDown } from 'lucide-react'
|
||||
import { type FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -104,18 +104,14 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
if (groupMessages.length > 1) {
|
||||
for (const m of groupMessages) {
|
||||
dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId: m.topicId,
|
||||
messageId: m.id,
|
||||
updates: { foldSelected: m.id === message.id }
|
||||
})
|
||||
newMessagesActions.updateMessage({ topicId: m.topicId, messageId: m.id, updates: { foldSelected: true } })
|
||||
)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'auto', block: 'start' })
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
@@ -187,9 +183,16 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6
|
||||
}}
|
||||
onClick={scrollToBottom}>
|
||||
<CircleChevronDown
|
||||
<MessageItemContainer
|
||||
style={{ transform: `scale(${1 + calculateValueByDistance('bottom-anchor', 1)})` }}></MessageItemContainer>
|
||||
<Avatar
|
||||
icon={<DownOutlined style={{ color: theme === 'dark' ? 'var(--color-text)' : 'var(--color-primary)' }} />}
|
||||
size={10 + calculateValueByDistance('bottom-anchor', 20)}
|
||||
style={{ color: theme === 'dark' ? 'var(--color-text)' : 'var(--color-primary)' }}
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? 'var(--color-background-soft)' : 'var(--color-primary-light)',
|
||||
border: `1px solid ${theme === 'dark' ? 'var(--color-border-soft)' : 'var(--color-primary-soft)'}`,
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
</MessageItem>
|
||||
{messages.map((message, index) => {
|
||||
@@ -200,8 +203,6 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
const username = removeLeadingEmoji(getUserName(message))
|
||||
const content = getMainTextContent(message)
|
||||
|
||||
if (message.type === 'clear') return null
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
@@ -261,6 +262,7 @@ const MessageItemContainer = styled.div`
|
||||
justify-content: space-between;
|
||||
text-align: right;
|
||||
gap: 4px;
|
||||
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
opacity: 0;
|
||||
transform-origin: right center;
|
||||
`
|
||||
|
||||
@@ -8,7 +8,7 @@ import PasteService from '@renderer/services/PasteService'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { classNames, getFileExtension } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { findAllBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
@@ -169,39 +169,31 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
onResend(updatedBlocks)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>, blockId: string) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (message.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
// keep the same enter behavior as inputbar
|
||||
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
|
||||
if (isEnterPressed) {
|
||||
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
} else {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
const start = textArea.selectionStart
|
||||
const end = textArea.selectionEnd
|
||||
const text = textArea.value
|
||||
const newText = text.substring(0, start) + '\n' + text.substring(end)
|
||||
if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
//same with onChange()
|
||||
handleTextChange(blockId, newText)
|
||||
if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
// set cursor position in the next render cycle
|
||||
setTimeout(() => {
|
||||
textArea.selectionStart = textArea.selectionEnd = start + 1
|
||||
resizeTextArea() // trigger resizeTextArea
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
|
||||
if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) {
|
||||
handleResend()
|
||||
return event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +212,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
|
||||
handleTextChange(block.id, e.target.value)
|
||||
resizeTextArea()
|
||||
}}
|
||||
onKeyDown={(e) => handleKeyDown(e, block.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
contextMenu="true"
|
||||
spellCheck={false}
|
||||
|
||||
@@ -18,29 +18,6 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, false)
|
||||
}
|
||||
|
||||
const getPrice = () => {
|
||||
const inputTokens = message?.usage?.prompt_tokens ?? 0
|
||||
const outputTokens = message?.usage?.completion_tokens ?? 0
|
||||
const model = message.model
|
||||
if (!model || model.pricing?.input_per_million_tokens === 0 || model.pricing?.output_per_million_tokens === 0) {
|
||||
return 0
|
||||
}
|
||||
return (
|
||||
(inputTokens * (model.pricing?.input_per_million_tokens ?? 0) +
|
||||
outputTokens * (model.pricing?.output_per_million_tokens ?? 0)) /
|
||||
1000000
|
||||
)
|
||||
}
|
||||
|
||||
const getPriceString = () => {
|
||||
const price = getPrice()
|
||||
if (price === 0) {
|
||||
return ''
|
||||
}
|
||||
const currencySymbol = message.model?.pricing?.currencySymbol || '$'
|
||||
return `| ${t('models.price.cost')}: ${currencySymbol}${price}`
|
||||
}
|
||||
|
||||
if (!message.usage) {
|
||||
return <div />
|
||||
}
|
||||
@@ -72,7 +49,6 @@ const MessgeTokens: React.FC<MessageTokensProps> = ({ message }) => {
|
||||
<span>{message?.usage?.total_tokens}</span>
|
||||
<span>↑{message?.usage?.prompt_tokens}</span>
|
||||
<span>↓{message?.usage?.completion_tokens}</span>
|
||||
<span>{getPriceString()}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { CheckOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
|
||||
import {
|
||||
DEFAULT_CONTEXTCOUNT,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_TEMPERATURE,
|
||||
isMac,
|
||||
isWindows
|
||||
} from '@renderer/config/constant'
|
||||
import {
|
||||
isOpenAIModel,
|
||||
isSupportedFlexServiceTier,
|
||||
@@ -53,7 +59,6 @@ import {
|
||||
TranslateLanguageVarious
|
||||
} from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp, Settings2 } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -665,11 +670,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
value={sendMessageShortcut}
|
||||
menuItemSelectedIcon={<CheckOutlined />}
|
||||
options={[
|
||||
{ value: 'Enter', label: getSendMessageShortcutLabel('Enter') },
|
||||
{ value: 'Ctrl+Enter', label: getSendMessageShortcutLabel('Ctrl+Enter') },
|
||||
{ value: 'Alt+Enter', label: getSendMessageShortcutLabel('Alt+Enter') },
|
||||
{ value: 'Command+Enter', label: getSendMessageShortcutLabel('Command+Enter') },
|
||||
{ value: 'Shift+Enter', label: getSendMessageShortcutLabel('Shift+Enter') }
|
||||
{ value: 'Enter', label: 'Enter' },
|
||||
{ value: 'Shift+Enter', label: 'Shift + Enter' },
|
||||
{ value: 'Ctrl+Enter', label: 'Ctrl + Enter' },
|
||||
{ value: 'Command+Enter', label: `${isMac ? '⌘' : isWindows ? 'Win' : 'Super'} + Enter` }
|
||||
]}
|
||||
onChange={(value) => setSendMessageShortcut(value as SendMessageShortcut)}
|
||||
style={{ width: 135 }}
|
||||
|
||||
@@ -157,7 +157,7 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
max-width: var(--assistants-width);
|
||||
min-width: var(--assistants-width);
|
||||
background-color: var(--color-background);
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
.collapsed {
|
||||
width: 0;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -46,10 +45,9 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
<ButtonContent>
|
||||
<ModelAvatar model={model} size={20} />
|
||||
<ModelName>
|
||||
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
|
||||
{model ? model.name : t('button.select_model')} {providerName ? '| ' + providerName : ''}
|
||||
</ModelName>
|
||||
</ButtonContent>
|
||||
<ChevronsUpDown size={14} color="var(--color-icon)" />
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
@@ -57,23 +55,21 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
const DropdownButton = styled(Button)`
|
||||
font-size: 11px;
|
||||
border-radius: 15px;
|
||||
padding: 13px 5px;
|
||||
padding: 12px 8px 12px 3px;
|
||||
-webkit-app-region: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
margin-top: 1px;
|
||||
`
|
||||
|
||||
const ButtonContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const ModelName = styled.span`
|
||||
font-weight: 500;
|
||||
margin-right: -2px;
|
||||
`
|
||||
|
||||
export default SelectModelButton
|
||||
|
||||
@@ -116,6 +116,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const aiProvider = new AiProvider(provider)
|
||||
values.dimensions = await aiProvider.getEmbeddingDimensions(selectedEmbeddingModel)
|
||||
} catch (error) {
|
||||
console.error('Error getting embedding dimensions:', error)
|
||||
window.message.error(t('message.error.get_embedding_dimensions') + '\n' + getErrorMessage(error))
|
||||
setLoading(false)
|
||||
return
|
||||
|
||||
@@ -103,8 +103,8 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
</HStack>
|
||||
<TextAreaContainer>
|
||||
{showMarkdown ? (
|
||||
<MarkdownContainer className="markdown" onClick={() => setShowMarkdown(false)}>
|
||||
<ReactMarkdown>{prompt}</ReactMarkdown>
|
||||
<MarkdownContainer onClick={() => setShowMarkdown(false)}>
|
||||
<ReactMarkdown className="markdown">{prompt}</ReactMarkdown>
|
||||
<div style={{ height: '30px' }} />
|
||||
</MarkdownContainer>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
CloudSyncOutlined,
|
||||
FileSearchOutlined,
|
||||
FolderOpenOutlined,
|
||||
LoadingOutlined,
|
||||
SaveOutlined,
|
||||
YuqueOutlined
|
||||
} from '@ant-design/icons'
|
||||
@@ -19,7 +18,7 @@ import store, { useAppDispatch } from '@renderer/store'
|
||||
import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/settings'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Progress, Switch, Typography } from 'antd'
|
||||
import { Button, Switch, Typography } from 'antd'
|
||||
import { FileText, FolderCog, FolderInput, Sparkle } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -180,354 +179,6 @@ const DataSettings: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAppDataPath = async () => {
|
||||
if (!appInfo || !appInfo.appDataPath) {
|
||||
return
|
||||
}
|
||||
|
||||
const newAppDataPath = await window.api.select({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: t('settings.data.app_data.select_title')
|
||||
})
|
||||
|
||||
if (!newAppDataPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// check new app data path is root path
|
||||
// if is root path, show error
|
||||
const pathParts = newAppDataPath.split(/[/\\]/).filter((part: string) => part !== '')
|
||||
if (pathParts.length <= 1) {
|
||||
window.message.error(t('settings.data.app_data.select_error_root_path'))
|
||||
return
|
||||
}
|
||||
|
||||
// check new app data path is same as old app data path
|
||||
if (newAppDataPath.startsWith(appInfo!.appDataPath)) {
|
||||
window.message.error(t('settings.data.app_data.select_error_same_path'))
|
||||
return
|
||||
}
|
||||
|
||||
// check new app data path has write permission
|
||||
const hasWritePermission = await window.api.hasWritePermission(newAppDataPath)
|
||||
if (!hasWritePermission) {
|
||||
window.message.error(t('settings.data.app_data.select_error_write_permission'))
|
||||
return
|
||||
}
|
||||
|
||||
const migrationTitle = (
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
|
||||
)
|
||||
const migrationClassName = 'migration-modal'
|
||||
|
||||
if (await window.api.isNotEmptyDir(newAppDataPath)) {
|
||||
const modal = window.modal.confirm({
|
||||
title: t('settings.data.app_data.select_not_empty_dir'),
|
||||
content: t('settings.data.app_data.select_not_empty_dir_content'),
|
||||
centered: true,
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
modal.destroy()
|
||||
// 显示确认对话框
|
||||
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
showMigrationConfirmModal(appInfo.appDataPath, newAppDataPath, migrationTitle, migrationClassName)
|
||||
}
|
||||
|
||||
// 显示确认迁移的对话框
|
||||
const showMigrationConfirmModal = async (
|
||||
originalPath: string,
|
||||
newPath: string,
|
||||
title: React.ReactNode,
|
||||
className: string
|
||||
) => {
|
||||
// 复制数据选项状态
|
||||
let shouldCopyData = !(await window.api.isNotEmptyDir(newPath))
|
||||
|
||||
// 创建路径内容组件
|
||||
const PathsContent = () => (
|
||||
<div>
|
||||
<MigrationPathRow>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.original_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{originalPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
<MigrationPathRow style={{ marginTop: '16px' }}>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.new_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{newPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
const CopyDataContent = () => (
|
||||
<div>
|
||||
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Switch
|
||||
defaultChecked={shouldCopyData}
|
||||
onChange={(checked) => {
|
||||
shouldCopyData = checked
|
||||
}}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
|
||||
{t('settings.data.app_data.copy_data_option')}
|
||||
</MigrationPathLabel>
|
||||
</MigrationPathRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 显示确认模态框
|
||||
const modal = window.modal.confirm({
|
||||
title,
|
||||
className,
|
||||
width: 'min(600px, 90vw)',
|
||||
style: { minHeight: '400px' },
|
||||
content: (
|
||||
<MigrationModalContent>
|
||||
<PathsContent />
|
||||
<CopyDataContent />
|
||||
<MigrationNotice>
|
||||
<p style={{ color: 'var(--color-warning)' }}>{t('settings.data.app_data.restart_notice')}</p>
|
||||
<p style={{ color: 'var(--color-text-3)', marginTop: '8px' }}>
|
||||
{t('settings.data.app_data.copy_time_notice')}
|
||||
</p>
|
||||
</MigrationNotice>
|
||||
</MigrationModalContent>
|
||||
),
|
||||
centered: true,
|
||||
okButtonProps: {
|
||||
danger: true
|
||||
},
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 立即关闭确认对话框
|
||||
modal.destroy()
|
||||
|
||||
if (shouldCopyData) {
|
||||
// 如果选择复制数据,显示进度模态框并执行迁移
|
||||
window.message.info({
|
||||
content: t('settings.data.app_data.restart_notice'),
|
||||
duration: 3
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.api.relaunchApp({
|
||||
args: ['--new-data-path=' + newPath]
|
||||
})
|
||||
}, 300)
|
||||
} else {
|
||||
// 如果不复制数据,直接设置新的应用数据路径
|
||||
await window.api.setAppDataPath(newPath)
|
||||
window.message.success(t('settings.data.app_data.path_changed_without_copy'))
|
||||
}
|
||||
|
||||
// 更新应用数据路径
|
||||
setAppInfo(await window.api.getAppInfo())
|
||||
|
||||
// 通知用户并重启应用
|
||||
setTimeout(() => {
|
||||
window.message.success(t('settings.data.app_data.select_success'))
|
||||
window.api.setStopQuitApp(false, '')
|
||||
window.api.relaunchApp()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
window.api.setStopQuitApp(false, '')
|
||||
window.message.error({
|
||||
content: t('settings.data.app_data.path_change_failed') + ': ' + error,
|
||||
duration: 5
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataMigration = async () => {
|
||||
const newDataPath = await window.api.getDataPathFromArgs()
|
||||
if (!newDataPath) return
|
||||
|
||||
const originalPath = (await window.api.getAppInfo())?.appDataPath
|
||||
if (!originalPath) return
|
||||
|
||||
const title = (
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>{t('settings.data.app_data.migration_title')}</div>
|
||||
)
|
||||
const className = 'migration-modal'
|
||||
const messageKey = 'data-migration'
|
||||
|
||||
// Create PathsContent component for this specific migration
|
||||
const PathsContent = () => (
|
||||
<div>
|
||||
<MigrationPathRow>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.original_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{originalPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
<MigrationPathRow style={{ marginTop: '16px' }}>
|
||||
<MigrationPathLabel>{t('settings.data.app_data.new_path')}:</MigrationPathLabel>
|
||||
<MigrationPathValue>{newDataPath}</MigrationPathValue>
|
||||
</MigrationPathRow>
|
||||
</div>
|
||||
)
|
||||
|
||||
const { loadingModal, progressInterval, updateProgress } = showProgressModal(title, className, PathsContent)
|
||||
try {
|
||||
window.api.setStopQuitApp(true, t('settings.data.app_data.stop_quit_app_reason'))
|
||||
await startMigration(originalPath, newDataPath, progressInterval, updateProgress, loadingModal, messageKey)
|
||||
|
||||
// 更新应用数据路径
|
||||
setAppInfo(await window.api.getAppInfo())
|
||||
|
||||
// 通知用户并重启应用
|
||||
setTimeout(() => {
|
||||
window.message.success(t('settings.data.app_data.select_success'))
|
||||
window.api.setStopQuitApp(false, '')
|
||||
window.api.relaunchApp({
|
||||
args: ['--user-data-dir=' + newDataPath]
|
||||
})
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
window.api.setStopQuitApp(false, '')
|
||||
window.message.error({
|
||||
content: t('settings.data.app_data.copy_failed') + ': ' + error,
|
||||
key: messageKey,
|
||||
duration: 5
|
||||
})
|
||||
} finally {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
loadingModal.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
handleDataMigration()
|
||||
}, [])
|
||||
|
||||
// 显示进度模态框
|
||||
const showProgressModal = (title: React.ReactNode, className: string, PathsContent: React.FC) => {
|
||||
let currentProgress = 0
|
||||
let progressInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// 创建进度更新模态框
|
||||
const loadingModal = window.modal.info({
|
||||
title,
|
||||
className,
|
||||
width: 'min(600px, 90vw)',
|
||||
style: { minHeight: '400px' },
|
||||
icon: <LoadingOutlined style={{ fontSize: 18 }} />,
|
||||
content: (
|
||||
<MigrationModalContent>
|
||||
<PathsContent />
|
||||
<MigrationNotice>
|
||||
<p>{t('settings.data.app_data.copying')}</p>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<Progress percent={currentProgress} status="active" strokeWidth={8} />
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-warning)', marginTop: '12px', fontSize: '13px' }}>
|
||||
{t('settings.data.app_data.copying_warning')}
|
||||
</p>
|
||||
</MigrationNotice>
|
||||
</MigrationModalContent>
|
||||
),
|
||||
centered: true,
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
okButtonProps: { style: { display: 'none' } }
|
||||
})
|
||||
|
||||
// 更新进度的函数
|
||||
const updateProgress = (progress: number, status: 'active' | 'success' = 'active') => {
|
||||
loadingModal.update({
|
||||
title,
|
||||
content: (
|
||||
<MigrationModalContent>
|
||||
<PathsContent />
|
||||
<MigrationNotice>
|
||||
<p>{t('settings.data.app_data.copying')}</p>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<Progress percent={Math.round(progress)} status={status} strokeWidth={8} />
|
||||
</div>
|
||||
<p style={{ color: 'var(--color-warning)', marginTop: '12px', fontSize: '13px' }}>
|
||||
{t('settings.data.app_data.copying_warning')}
|
||||
</p>
|
||||
</MigrationNotice>
|
||||
</MigrationModalContent>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 开始模拟进度更新
|
||||
progressInterval = setInterval(() => {
|
||||
if (currentProgress < 95) {
|
||||
currentProgress += Math.random() * 5 + 1
|
||||
if (currentProgress > 95) currentProgress = 95
|
||||
updateProgress(currentProgress)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return { loadingModal, progressInterval, updateProgress }
|
||||
}
|
||||
|
||||
// 开始迁移数据
|
||||
const startMigration = async (
|
||||
originalPath: string,
|
||||
newPath: string,
|
||||
progressInterval: NodeJS.Timeout | null,
|
||||
updateProgress: (progress: number, status?: 'active' | 'success') => void,
|
||||
loadingModal: { destroy: () => void },
|
||||
messageKey: string
|
||||
): Promise<void> => {
|
||||
// flush app data
|
||||
await window.api.flushAppData()
|
||||
|
||||
// 开始复制过程
|
||||
const copyResult = await window.api.copy(originalPath, newPath)
|
||||
|
||||
// 停止进度更新
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
}
|
||||
|
||||
// 显示100%完成
|
||||
updateProgress(100, 'success')
|
||||
|
||||
if (!copyResult.success) {
|
||||
// 延迟关闭加载模态框
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
loadingModal.destroy()
|
||||
window.message.error({
|
||||
content: t('settings.data.app_data.copy_failed') + ': ' + copyResult.error,
|
||||
key: messageKey,
|
||||
duration: 5
|
||||
})
|
||||
resolve()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
throw new Error(copyResult.error || 'Unknown error during copy')
|
||||
}
|
||||
|
||||
// 在复制成功后设置新的AppDataPath
|
||||
await window.api.setAppDataPath(newPath)
|
||||
|
||||
// 短暂延迟以显示100%完成
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// 关闭加载模态框
|
||||
loadingModal.destroy()
|
||||
|
||||
window.message.success({
|
||||
content: t('settings.data.app_data.copy_success'),
|
||||
key: messageKey,
|
||||
duration: 2
|
||||
})
|
||||
}
|
||||
|
||||
const onSkipBackupFilesChange = (value: boolean) => {
|
||||
setSkipBackupFile(value)
|
||||
dispatch(_setSkipBackupFile(value))
|
||||
@@ -594,9 +245,6 @@ const DataSettings: FC = () => {
|
||||
<PathRow>
|
||||
<PathText style={{ color: 'var(--color-text-3)' }}>{appInfo?.appDataPath}</PathText>
|
||||
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
|
||||
<HStack gap="5px" style={{ marginLeft: '8px' }}>
|
||||
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
|
||||
</HStack>
|
||||
</PathRow>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
@@ -704,38 +352,4 @@ const PathRow = styled(HStack)`
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
// Add styled components for migration modal
|
||||
const MigrationModalContent = styled.div`
|
||||
padding: 20px 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const MigrationNotice = styled.div`
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const MigrationPathRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
`
|
||||
|
||||
const MigrationPathLabel = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const MigrationPathValue = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
border: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default DataSettings
|
||||
|
||||
@@ -170,7 +170,7 @@ const ModelSettings: FC = () => {
|
||||
<HStack alignItems="center" gap={0}>
|
||||
<StyledButton
|
||||
type={!quickAssistantId ? 'primary' : 'default'}
|
||||
onClick={() => dispatch(setQuickAssistantId(''))}
|
||||
onClick={() => dispatch(setQuickAssistantId(null))}
|
||||
selected={!quickAssistantId}>
|
||||
{t('settings.models.use_model')}
|
||||
</StyledButton>
|
||||
@@ -188,29 +188,22 @@ const ModelSettings: FC = () => {
|
||||
{!quickAssistantId ? null : (
|
||||
<HStack alignItems="center" style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
value={quickAssistantId || defaultAssistant.id}
|
||||
value={quickAssistantId}
|
||||
style={{ width: 360 }}
|
||||
onChange={(value) => dispatch(setQuickAssistantId(value))}
|
||||
placeholder={t('settings.models.quick_assistant_selection')}>
|
||||
<Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
|
||||
<AssistantName>{defaultAssistant.name}</AssistantName>
|
||||
<Spacer />
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
{assistants
|
||||
.filter((a) => a.id !== defaultAssistant.id)
|
||||
.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
{assistants.map((a) => (
|
||||
<Select.Option key={a.id} value={a.id}>
|
||||
<AssistantItem>
|
||||
<ModelAvatar model={a.model || defaultModel} size={18} />
|
||||
<AssistantName>{a.name}</AssistantName>
|
||||
<Spacer />
|
||||
{a.id === defaultAssistant.id && (
|
||||
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
|
||||
)}
|
||||
</AssistantItem>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@renderer/config/models'
|
||||
import { Model, ModelType } from '@renderer/types'
|
||||
import { getDefaultGroupName } from '@renderer/utils'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, InputNumber, message, Modal, Select } from 'antd'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -20,42 +20,25 @@ interface ModelEditContentProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const symbols = ['$', '¥', '€', '£']
|
||||
const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, open, onClose }) => {
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const [showMoreSettings, setShowMoreSettings] = useState(false)
|
||||
const [currencySymbol, setCurrencySymbol] = useState(model.pricing?.currencySymbol || '$')
|
||||
const [isCustomCurrency, setIsCustomCurrency] = useState(!symbols.includes(model.pricing?.currencySymbol || '$'))
|
||||
|
||||
const [showModelTypes, setShowModelTypes] = useState(false)
|
||||
const onFinish = (values: any) => {
|
||||
const finalCurrencySymbol = isCustomCurrency ? values.customCurrencySymbol : values.currencySymbol
|
||||
const updatedModel = {
|
||||
...model,
|
||||
id: values.id || model.id,
|
||||
name: values.name || model.name,
|
||||
group: values.group || model.group,
|
||||
pricing: {
|
||||
input_per_million_tokens: Number(values.input_per_million_tokens) || 0,
|
||||
output_per_million_tokens: Number(values.output_per_million_tokens) || 0,
|
||||
currencySymbol: finalCurrencySymbol || '$'
|
||||
}
|
||||
group: values.group || model.group
|
||||
}
|
||||
onUpdateModel(updatedModel)
|
||||
setShowMoreSettings(false)
|
||||
setShowModelTypes(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setShowMoreSettings(false)
|
||||
setShowModelTypes(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const currencyOptions = [
|
||||
...symbols.map((symbol) => ({ label: symbol, value: symbol })),
|
||||
{ label: t('models.price.custom'), value: 'custom' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('models.edit')}
|
||||
@@ -69,7 +52,7 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
if (visible) {
|
||||
form.getFieldInstance('id')?.focus()
|
||||
} else {
|
||||
setShowMoreSettings(false)
|
||||
setShowModelTypes(false)
|
||||
}
|
||||
}}>
|
||||
<Form
|
||||
@@ -81,15 +64,7 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
initialValues={{
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
group: model.group,
|
||||
input_per_million_tokens: model.pricing?.input_per_million_tokens ?? 0,
|
||||
output_per_million_tokens: model.pricing?.output_per_million_tokens ?? 0,
|
||||
currencySymbol: symbols.includes(model.pricing?.currencySymbol || '$')
|
||||
? model.pricing?.currencySymbol || '$'
|
||||
: 'custom',
|
||||
customCurrencySymbol: symbols.includes(model.pricing?.currencySymbol || '$')
|
||||
? ''
|
||||
: model.pricing?.currencySymbol || ''
|
||||
group: model.group
|
||||
}}
|
||||
onFinish={onFinish}>
|
||||
<Form.Item
|
||||
@@ -134,22 +109,20 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 15, textAlign: 'center' }}>
|
||||
<Flex justify="center" align="center" style={{ position: 'relative' }}>
|
||||
<MoreSettingsRow
|
||||
onClick={() => setShowMoreSettings(!showMoreSettings)}
|
||||
style={{ position: 'absolute', right: 0 }}>
|
||||
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
|
||||
<MoreSettingsRow onClick={() => setShowModelTypes(!showModelTypes)}>
|
||||
{t('settings.moresetting')}
|
||||
<ExpandIcon>{showMoreSettings ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
|
||||
<ExpandIcon>{showModelTypes ? <UpOutlined /> : <DownOutlined />}</ExpandIcon>
|
||||
</MoreSettingsRow>
|
||||
<Button type="primary" htmlType="submit" size="middle">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
{showMoreSettings && (
|
||||
{showModelTypes && (
|
||||
<div>
|
||||
<Divider style={{ margin: '0 0 15px 0' }} />
|
||||
<TypeTitle>{t('models.type.select')}</TypeTitle>
|
||||
<TypeTitle>{t('models.type.select')}:</TypeTitle>
|
||||
{(() => {
|
||||
const defaultTypes = [
|
||||
...(isVisionModel(model) ? ['vision'] : []),
|
||||
@@ -220,59 +193,6 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<TypeTitle>{t('models.price.price')}</TypeTitle>
|
||||
<Form.Item name="currencySymbol" label={t('models.price.currency')} style={{ marginBottom: 10 }}>
|
||||
<Select
|
||||
style={{ width: '100px' }}
|
||||
options={currencyOptions}
|
||||
onChange={(value) => {
|
||||
if (value === 'custom') {
|
||||
setIsCustomCurrency(true)
|
||||
setCurrencySymbol(form.getFieldValue('customCurrencySymbol') || '')
|
||||
} else {
|
||||
setIsCustomCurrency(false)
|
||||
setCurrencySymbol(value)
|
||||
}
|
||||
}}
|
||||
dropdownMatchSelectWidth={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{isCustomCurrency && (
|
||||
<Form.Item
|
||||
name="customCurrencySymbol"
|
||||
label={t('models.price.custom_currency')}
|
||||
style={{ marginBottom: 10 }}
|
||||
rules={[{ required: isCustomCurrency }]}>
|
||||
<Input
|
||||
style={{ width: '100px' }}
|
||||
placeholder={t('models.price.custom_currency_placeholder')}
|
||||
maxLength={5}
|
||||
onChange={(e) => setCurrencySymbol(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('models.price.input')} name="input_per_million_tokens">
|
||||
<InputNumber
|
||||
placeholder="0.00"
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '240px' }}
|
||||
addonAfter={`${currencySymbol} / ${t('models.price.million_tokens')}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('models.price.output')} name="output_per_million_tokens">
|
||||
<InputNumber
|
||||
placeholder="0.00"
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
style={{ width: '240px' }}
|
||||
addonAfter={`${currencySymbol} / ${t('models.price.million_tokens')}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
@@ -281,7 +201,6 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
}
|
||||
|
||||
const TypeTitle = styled.div`
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -26,7 +26,6 @@ import { find, isEmpty, sortBy } from 'lodash'
|
||||
import { HelpCircle, Settings2, TriangleAlert } from 'lucide-react'
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
let _text = ''
|
||||
@@ -40,8 +39,6 @@ const TranslateSettings: FC<{
|
||||
setIsScrollSyncEnabled: (value: boolean) => void
|
||||
isBidirectional: boolean
|
||||
setIsBidirectional: (value: boolean) => void
|
||||
enableMarkdown: boolean
|
||||
setEnableMarkdown: (value: boolean) => void
|
||||
bidirectionalPair: [string, string]
|
||||
setBidirectionalPair: (value: [string, string]) => void
|
||||
translateModel: Model | undefined
|
||||
@@ -55,8 +52,6 @@ const TranslateSettings: FC<{
|
||||
setIsScrollSyncEnabled,
|
||||
isBidirectional,
|
||||
setIsBidirectional,
|
||||
enableMarkdown,
|
||||
setEnableMarkdown,
|
||||
bidirectionalPair,
|
||||
setBidirectionalPair,
|
||||
translateModel,
|
||||
@@ -87,7 +82,6 @@ const TranslateSettings: FC<{
|
||||
setBidirectionalPair(localPair)
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: localPair })
|
||||
db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled })
|
||||
db.settings.put({ id: 'translate:markdown:enabled', value: enableMarkdown })
|
||||
window.message.success({
|
||||
content: t('message.save.success.title'),
|
||||
key: 'translate-settings-save'
|
||||
@@ -141,13 +135,6 @@ const TranslateSettings: FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div style={{ fontWeight: 500 }}>{t('translate.settings.preview')}</div>
|
||||
<Switch checked={enableMarkdown} onChange={setEnableMarkdown} />
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div style={{ fontWeight: 500 }}>{t('translate.settings.scroll_sync')}</div>
|
||||
@@ -225,11 +212,10 @@ const TranslatePage: FC = () => {
|
||||
const [historyDrawerVisible, setHistoryDrawerVisible] = useState(false)
|
||||
const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false)
|
||||
const [isBidirectional, setIsBidirectional] = useState(false)
|
||||
const [enableMarkdown, setEnableMarkdown] = useState(false)
|
||||
const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese'])
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const [detectedLanguage, setDetectedLanguage] = useState<string | null>(null)
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>('auto')
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>('auto') // 添加用户选择的源语言状态
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textAreaRef = useRef<TextAreaRef>(null)
|
||||
const outputTextRef = useRef<HTMLDivElement>(null)
|
||||
@@ -307,7 +293,8 @@ const TranslatePage: FC = () => {
|
||||
let actualSourceLanguage: string
|
||||
if (sourceLanguage === 'auto') {
|
||||
actualSourceLanguage = await detectLanguage(text)
|
||||
setDetectedLanguage(actualSourceLanguage)
|
||||
console.log('检测到的语言:', actualSourceLanguage)
|
||||
setDetectedLanguage(actualSourceLanguage) // 更新检测到的语言
|
||||
} else {
|
||||
actualSourceLanguage = sourceLanguage
|
||||
}
|
||||
@@ -384,9 +371,6 @@ const TranslatePage: FC = () => {
|
||||
const targetLang = await db.settings.get({ id: 'translate:target:language' })
|
||||
targetLang && setTargetLanguage(targetLang.value)
|
||||
|
||||
const sourceLang = await db.settings.get({ id: 'translate:source:language' })
|
||||
sourceLang && setSourceLanguage(sourceLang.value)
|
||||
|
||||
const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' })
|
||||
if (bidirectionalPairSetting) {
|
||||
const langPair = bidirectionalPairSetting.value
|
||||
@@ -404,9 +388,6 @@ const TranslatePage: FC = () => {
|
||||
|
||||
const scrollSyncSetting = await db.settings.get({ id: 'translate:scroll:sync' })
|
||||
setIsScrollSyncEnabled(scrollSyncSetting ? scrollSyncSetting.value : false)
|
||||
|
||||
const markdownSetting = await db.settings.get({ id: 'translate:markdown:enabled' })
|
||||
setEnableMarkdown(markdownSetting ? markdownSetting.value : false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -528,15 +509,12 @@ const TranslatePage: FC = () => {
|
||||
value={sourceLanguage}
|
||||
style={{ width: 180 }}
|
||||
optionFilterProp="label"
|
||||
onChange={(value) => {
|
||||
setSourceLanguage(value)
|
||||
db.settings.put({ id: 'translate:source:language', value })
|
||||
}}
|
||||
onChange={(value) => setSourceLanguage(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'auto',
|
||||
label: detectedLanguage
|
||||
? `${t('translate.detected.language')} (${t(`languages.${detectedLanguage.toLowerCase()}`)})`
|
||||
? `${t('translate.detected.language')}(${t(`languages.${detectedLanguage.toLowerCase()}`)})`
|
||||
: t('translate.detected.language')
|
||||
},
|
||||
...translateLanguageOptions().map((lang) => ({
|
||||
@@ -608,13 +586,7 @@ const TranslatePage: FC = () => {
|
||||
</OperationBar>
|
||||
|
||||
<OutputText ref={outputTextRef} onScroll={handleOutputScroll} className="selectable">
|
||||
{!result ? (
|
||||
t('translate.output.placeholder')
|
||||
) : enableMarkdown ? (
|
||||
<ReactMarkdown>{result}</ReactMarkdown>
|
||||
) : (
|
||||
result
|
||||
)}
|
||||
{result || t('translate.output.placeholder')}
|
||||
</OutputText>
|
||||
</OutputContainer>
|
||||
</ContentContainer>
|
||||
@@ -626,8 +598,6 @@ const TranslatePage: FC = () => {
|
||||
setIsScrollSyncEnabled={setIsScrollSyncEnabled}
|
||||
isBidirectional={isBidirectional}
|
||||
setIsBidirectional={toggleBidirectional}
|
||||
enableMarkdown={enableMarkdown}
|
||||
setEnableMarkdown={setEnableMarkdown}
|
||||
bidirectionalPair={bidirectionalPair}
|
||||
setBidirectionalPair={setBidirectionalPair}
|
||||
translateModel={translateModel}
|
||||
|
||||
@@ -33,11 +33,10 @@ import { SdkModel } from '@renderer/types/sdk'
|
||||
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
import { extractInfoFromXML, ExtractResults } from '@renderer/utils/extract'
|
||||
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { findFileBlocks, getKnowledgeBaseIds, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { findLast, isEmpty, takeRight } from 'lodash'
|
||||
|
||||
import AiProvider from '../aiCore'
|
||||
import store from '../store'
|
||||
import {
|
||||
getAssistantProvider,
|
||||
getAssistantSettings,
|
||||
@@ -64,7 +63,7 @@ async function fetchExternalTool(
|
||||
lastAnswer?: Message
|
||||
): Promise<ExternalToolResult> {
|
||||
// 可能会有重复?
|
||||
const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id)
|
||||
const knowledgeBaseIds = getKnowledgeBaseIds(lastUserMessage)
|
||||
const hasKnowledgeBase = !isEmpty(knowledgeBaseIds)
|
||||
const knowledgeRecognition = assistant.knowledgeRecognition || 'on'
|
||||
const webSearchProvider = WebSearchService.getWebSearchProvider(assistant.webSearchProviderId)
|
||||
@@ -252,28 +251,15 @@ async function fetchExternalTool(
|
||||
|
||||
// Get MCP tools (Fix duplicate declaration)
|
||||
let mcpTools: MCPTool[] = [] // Initialize as empty array
|
||||
const allMcpServers = store.getState().mcp.servers || []
|
||||
const activedMcpServers = allMcpServers.filter((s) => s.isActive)
|
||||
const assistantMcpServers = assistant.mcpServers || []
|
||||
|
||||
const enabledMCPs = activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id))
|
||||
|
||||
const enabledMCPs = assistant.mcpServers
|
||||
if (enabledMCPs && enabledMCPs.length > 0) {
|
||||
try {
|
||||
const toolPromises = enabledMCPs.map<Promise<MCPTool[]>>(async (mcpServer) => {
|
||||
try {
|
||||
const tools = await window.api.mcp.listTools(mcpServer)
|
||||
return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
|
||||
} catch (error) {
|
||||
console.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error)
|
||||
return []
|
||||
}
|
||||
const toolPromises = enabledMCPs.map(async (mcpServer) => {
|
||||
const tools = await window.api.mcp.listTools(mcpServer)
|
||||
return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
|
||||
})
|
||||
const results = await Promise.allSettled(toolPromises)
|
||||
mcpTools = results
|
||||
.filter((result): result is PromiseFulfilledResult<MCPTool[]> => result.status === 'fulfilled')
|
||||
.map((result) => result.value)
|
||||
.flat()
|
||||
const results = await Promise.all(toolPromises)
|
||||
mcpTools = results.flat() // Flatten the array of arrays
|
||||
} catch (toolError) {
|
||||
console.error('Error fetching MCP tools:', toolError)
|
||||
}
|
||||
@@ -584,7 +570,10 @@ export async function checkApi(provider: Provider, model: Model): Promise<void>
|
||||
assistant.model = model
|
||||
try {
|
||||
if (isEmbeddingModel(model)) {
|
||||
await ai.getEmbeddingDimensions(model)
|
||||
const result = await ai.getEmbeddingDimensions(model)
|
||||
if (result === 0) {
|
||||
throw new Error(i18n.t('message.error.enter.model'))
|
||||
}
|
||||
} else {
|
||||
const params: CompletionsParams = {
|
||||
callType: 'check',
|
||||
@@ -595,9 +584,6 @@ export async function checkApi(provider: Provider, model: Model): Promise<void>
|
||||
|
||||
// Try streaming check first
|
||||
const result = await ai.completions(params)
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
if (!result.getText()) {
|
||||
throw new Error('No response received')
|
||||
}
|
||||
@@ -611,9 +597,6 @@ export async function checkApi(provider: Provider, model: Model): Promise<void>
|
||||
streamOutput: false
|
||||
}
|
||||
const result = await ai.completions(params)
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
if (!result.getText()) {
|
||||
throw new Error('No response received')
|
||||
}
|
||||
|
||||
@@ -14,17 +14,7 @@ export function getDefaultAssistant(): Assistant {
|
||||
topics: [getDefaultTopic('default')],
|
||||
messages: [],
|
||||
type: 'assistant',
|
||||
regularPhrases: [], // Added regularPhrases
|
||||
settings: {
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
contextCount: DEFAULT_CONTEXTCOUNT,
|
||||
enableMaxTokens: false,
|
||||
maxTokens: 0,
|
||||
streamOutput: true,
|
||||
topP: 1,
|
||||
toolUseMode: 'prompt',
|
||||
customParameters: []
|
||||
}
|
||||
regularPhrases: [] // Added regularPhrases
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,17 +127,7 @@ export async function createAssistantFromAgent(agent: Agent) {
|
||||
topics: [topic],
|
||||
model: agent.defaultModel,
|
||||
type: 'assistant',
|
||||
regularPhrases: agent.regularPhrases || [], // Ensured regularPhrases
|
||||
settings: agent.settings || {
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
contextCount: DEFAULT_CONTEXTCOUNT,
|
||||
enableMaxTokens: false,
|
||||
maxTokens: 0,
|
||||
streamOutput: true,
|
||||
topP: 1,
|
||||
toolUseMode: 'prompt',
|
||||
customParameters: []
|
||||
}
|
||||
regularPhrases: agent.regularPhrases || [] // Ensured regularPhrases
|
||||
}
|
||||
|
||||
store.dispatch(addAssistant(assistant))
|
||||
|
||||
@@ -101,7 +101,7 @@ export const searchKnowledgeBase = async (
|
||||
|
||||
// 执行搜索
|
||||
const searchResults = await window.api.knowledgeBase.search({
|
||||
search: rewrite || query,
|
||||
search: query,
|
||||
base: baseParams
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
||||
import store from '@renderer/store'
|
||||
import { messageBlocksSelectors, removeManyBlocks } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import type { Assistant, FileType, Model, Topic, Usage } from '@renderer/types'
|
||||
import type { Assistant, FileType, MCPServer, Model, Topic, Usage } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
@@ -108,7 +108,9 @@ export function getUserMessage({
|
||||
content,
|
||||
files,
|
||||
// Keep other potential params if needed by createMessage
|
||||
knowledgeBaseIds,
|
||||
mentions,
|
||||
enabledMCPs,
|
||||
usage
|
||||
}: {
|
||||
assistant: Assistant
|
||||
@@ -118,6 +120,7 @@ export function getUserMessage({
|
||||
files?: FileType[]
|
||||
knowledgeBaseIds?: string[]
|
||||
mentions?: Model[]
|
||||
enabledMCPs?: MCPServer[]
|
||||
usage?: Usage
|
||||
}): { message: Message; blocks: MessageBlock[] } {
|
||||
const defaultModel = getDefaultModel()
|
||||
@@ -130,7 +133,8 @@ export function getUserMessage({
|
||||
if (content !== undefined) {
|
||||
// Pass messageId when creating blocks
|
||||
const textBlock = createMainTextBlock(messageId, content, {
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
knowledgeBaseIds
|
||||
})
|
||||
blocks.push(textBlock)
|
||||
blockIds.push(textBlock.id)
|
||||
@@ -161,7 +165,7 @@ export function getUserMessage({
|
||||
blocks: blockIds,
|
||||
// 移除knowledgeBaseIds
|
||||
mentions,
|
||||
// 移除mcp
|
||||
enabledMCPs,
|
||||
type,
|
||||
usage
|
||||
}
|
||||
@@ -199,6 +203,7 @@ export function resetAssistantMessage(message: Message, model?: Model): Message
|
||||
useful: undefined,
|
||||
askId: undefined,
|
||||
mentions: undefined,
|
||||
enabledMCPs: undefined,
|
||||
blocks: [],
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 114,
|
||||
version: 112,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface LlmState {
|
||||
defaultModel: Model
|
||||
topicNamingModel: Model
|
||||
translateModel: Model
|
||||
quickAssistantId: string
|
||||
quickAssistantId: string | null
|
||||
settings: LlmSettings
|
||||
}
|
||||
|
||||
@@ -237,15 +237,14 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
isVertex: false
|
||||
},
|
||||
{
|
||||
id: 'vertexai',
|
||||
name: 'VertexAI',
|
||||
type: 'vertexai',
|
||||
id: 'zhipu',
|
||||
name: 'ZhiPu',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://aiplatform.googleapis.com',
|
||||
models: [],
|
||||
apiHost: 'https://open.bigmodel.cn/api/paas/v4/',
|
||||
models: SYSTEM_MODELS.zhipu,
|
||||
isSystem: true,
|
||||
enabled: false,
|
||||
isVertex: true
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
@@ -268,16 +267,6 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
enabled: false,
|
||||
isAuthed: false
|
||||
},
|
||||
{
|
||||
id: 'zhipu',
|
||||
name: 'ZhiPu',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://open.bigmodel.cn/api/paas/v4/',
|
||||
models: SYSTEM_MODELS.zhipu,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'yi',
|
||||
name: 'Yi',
|
||||
@@ -388,6 +377,26 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'zhinao',
|
||||
name: 'zhinao',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.360.cn',
|
||||
models: SYSTEM_MODELS.zhinao,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'hunyuan',
|
||||
name: 'hunyuan',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.hunyuan.cloud.tencent.com',
|
||||
models: SYSTEM_MODELS.hunyuan,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'nvidia',
|
||||
name: 'nvidia',
|
||||
@@ -468,16 +477,6 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'hunyuan',
|
||||
name: 'hunyuan',
|
||||
type: 'openai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.hunyuan.cloud.tencent.com',
|
||||
models: SYSTEM_MODELS.hunyuan,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'tencent-cloud-ti',
|
||||
name: 'Tencent Cloud TI',
|
||||
@@ -517,6 +516,17 @@ export const INITIAL_PROVIDERS: Provider[] = [
|
||||
models: SYSTEM_MODELS.voyageai,
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'vertexai',
|
||||
name: 'VertexAI',
|
||||
type: 'vertexai',
|
||||
apiKey: '',
|
||||
apiHost: 'https://aiplatform.googleapis.com',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false,
|
||||
isVertex: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -524,7 +534,7 @@ export const initialState: LlmState = {
|
||||
defaultModel: SYSTEM_MODELS.defaultModel[0],
|
||||
topicNamingModel: SYSTEM_MODELS.defaultModel[1],
|
||||
translateModel: SYSTEM_MODELS.defaultModel[2],
|
||||
quickAssistantId: '',
|
||||
quickAssistantId: null,
|
||||
providers: INITIAL_PROVIDERS,
|
||||
settings: {
|
||||
ollama: {
|
||||
@@ -640,7 +650,7 @@ const llmSlice = createSlice({
|
||||
state.translateModel = action.payload.model
|
||||
},
|
||||
|
||||
setQuickAssistantId: (state, action: PayloadAction<string>) => {
|
||||
setQuickAssistantId: (state, action: PayloadAction<string | null>) => {
|
||||
state.quickAssistantId = action.payload
|
||||
},
|
||||
setOllamaKeepAliveTime: (state, action: PayloadAction<number>) => {
|
||||
|
||||
@@ -1582,6 +1582,7 @@ const migrateConfig = {
|
||||
'113': (state: RootState) => {
|
||||
try {
|
||||
addProvider(state, 'vertexai')
|
||||
state.llm.providers = moveProvider(state.llm.providers, 'vertexai', 10)
|
||||
if (!state.llm.settings.vertexai) {
|
||||
state.llm.settings.vertexai = llmInitialState.settings.vertexai
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
import { WebDAVSyncState } from './backup'
|
||||
|
||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter'
|
||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
|
||||
|
||||
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
|
||||
|
||||
|
||||
@@ -174,12 +174,6 @@ export type ProviderType =
|
||||
|
||||
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search'
|
||||
|
||||
export type ModelPricing = {
|
||||
input_per_million_tokens: number
|
||||
output_per_million_tokens: number
|
||||
currencySymbol?: string
|
||||
}
|
||||
|
||||
export type Model = {
|
||||
id: string
|
||||
provider: string
|
||||
@@ -188,7 +182,6 @@ export type Model = {
|
||||
owned_by?: string
|
||||
description?: string
|
||||
type?: ModelType[]
|
||||
pricing?: ModelPricing
|
||||
}
|
||||
|
||||
export type Suggestion = {
|
||||
|
||||
222
src/renderer/src/types/model.ts
Normal file
222
src/renderer/src/types/model.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const InputType = z.enum(['text', 'image', 'audio', 'video', 'document'])
|
||||
export type InputType = z.infer<typeof InputType>
|
||||
|
||||
export const OutputType = z.enum(['text', 'image', 'audio', 'video', 'vector'])
|
||||
export type OutputType = z.infer<typeof OutputType>
|
||||
|
||||
export const OutputMode = z.enum(['sync', 'streaming'])
|
||||
export type OutputMode = z.infer<typeof OutputMode>
|
||||
|
||||
export const ModelCapability = z.enum([
|
||||
'audioGeneration',
|
||||
'cache',
|
||||
'codeExecution',
|
||||
'embedding',
|
||||
'fineTuning',
|
||||
'imageGeneration',
|
||||
'OCR',
|
||||
'realTime',
|
||||
'rerank',
|
||||
'reasoning',
|
||||
'streaming',
|
||||
'structuredOutput',
|
||||
'textGeneration',
|
||||
'translation',
|
||||
'transcription',
|
||||
'toolUse',
|
||||
'videoGeneration',
|
||||
'webSearch'
|
||||
])
|
||||
export type ModelCapability = z.infer<typeof ModelCapability>
|
||||
|
||||
export const ModelSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
modelId: z.string(),
|
||||
providerId: z.string(),
|
||||
name: z.string(),
|
||||
group: z.string(),
|
||||
description: z.string().optional(),
|
||||
owned_by: z.string().optional(),
|
||||
|
||||
supportedInputs: z.array(InputType),
|
||||
supportedOutputs: z.array(OutputType),
|
||||
supportedOutputModes: z.array(OutputMode),
|
||||
|
||||
limits: z
|
||||
.object({
|
||||
inputTokenLimit: z.number().optional(),
|
||||
outputTokenLimit: z.number().optional(),
|
||||
contextWindow: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
price: z
|
||||
.object({
|
||||
inputTokenPrice: z.number().optional(),
|
||||
outputTokenPrice: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
capabilities: z.array(ModelCapability)
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// 如果模型支持streaming,则必须支持streamingOutputMode
|
||||
if (data.capabilities.includes('streaming') && !data.supportedOutputModes.includes('streaming')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有OCR能力,则必须支持图像输入类型或者文件输入类型
|
||||
if (
|
||||
data.capabilities.includes('OCR') &&
|
||||
!data.supportedInputs.includes('image') &&
|
||||
!data.supportedInputs.includes('document')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有图像生成能力,则必须支持图像输出
|
||||
if (data.capabilities.includes('imageGeneration') && !data.supportedOutputs.includes('image')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有音频生成能力,则必须支持音频输出类型
|
||||
if (data.capabilities.includes('audioGeneration') && !data.supportedOutputs.includes('audio')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有音频识别能力,则必须支持音频输入类型
|
||||
if (
|
||||
(data.capabilities.includes('transcription') || data.capabilities.includes('translation')) &&
|
||||
!data.supportedInputs.includes('audio')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有视频生成能力,则必须支持视频输出类型
|
||||
if (data.capabilities.includes('videoGeneration') && !data.supportedOutputs.includes('video')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有embedding能力,则必须支持向量输出类型
|
||||
if (data.capabilities.includes('embedding') && !data.supportedOutputs.includes('vector')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有toolUse, Reasoning, streaming, cache, codeExecution, imageGeneration, audioGeneration, videoGeneration, webSearch能力,则必须支持文字的输入
|
||||
if (
|
||||
(data.capabilities.includes('toolUse') ||
|
||||
data.capabilities.includes('reasoning') ||
|
||||
data.capabilities.includes('streaming') ||
|
||||
data.capabilities.includes('cache') ||
|
||||
data.capabilities.includes('codeExecution') ||
|
||||
data.capabilities.includes('imageGeneration') ||
|
||||
data.capabilities.includes('audioGeneration') ||
|
||||
data.capabilities.includes('videoGeneration') ||
|
||||
data.capabilities.includes('webSearch')) &&
|
||||
!data.supportedInputs.includes('text')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果模型有toolUse, Reasoning, streaming, cache, codeExecution, OCR, textGeneration, translation, transcription, webSearch, structuredOutput能力,则必须支持文字的输出
|
||||
if (
|
||||
(data.capabilities.includes('toolUse') ||
|
||||
data.capabilities.includes('reasoning') ||
|
||||
data.capabilities.includes('streaming') ||
|
||||
data.capabilities.includes('cache') ||
|
||||
data.capabilities.includes('codeExecution') ||
|
||||
data.capabilities.includes('OCR') ||
|
||||
data.capabilities.includes('textGeneration') ||
|
||||
data.capabilities.includes('translation') ||
|
||||
data.capabilities.includes('transcription') ||
|
||||
data.capabilities.includes('webSearch') ||
|
||||
data.capabilities.includes('structuredOutput')) &&
|
||||
!data.supportedOutputs.includes('text')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'ModelCard has inconsistent capabilities and supported input/output type'
|
||||
}
|
||||
)
|
||||
|
||||
export type ModelCard = z.infer<typeof ModelSchema>
|
||||
|
||||
export function createModelCard(model: ModelCard): ModelCard {
|
||||
return ModelSchema.parse(model)
|
||||
}
|
||||
|
||||
export function supportesInputType(model: ModelCard, inputType: InputType) {
|
||||
return model.supportedInputs.includes(inputType)
|
||||
}
|
||||
|
||||
export function supportesOutputType(model: ModelCard, outputType: OutputType) {
|
||||
return model.supportedOutputs.includes(outputType)
|
||||
}
|
||||
|
||||
export function supportesOutputMode(model: ModelCard, outputMode: OutputMode) {
|
||||
return model.supportedOutputModes.includes(outputMode)
|
||||
}
|
||||
|
||||
export function supportesCapability(model: ModelCard, capability: ModelCapability) {
|
||||
return model.capabilities.includes(capability)
|
||||
}
|
||||
|
||||
export function isVisionModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'image')
|
||||
}
|
||||
|
||||
export function isImageGenerationModel(model: ModelCard) {
|
||||
return isVisionModel(model) && supportesCapability(model, 'imageGeneration')
|
||||
}
|
||||
|
||||
export function isAudioModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'audio')
|
||||
}
|
||||
|
||||
export function isAudioGenerationModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'audioGeneration')
|
||||
}
|
||||
|
||||
export function isVideoModel(model: ModelCard) {
|
||||
return supportesInputType(model, 'video')
|
||||
}
|
||||
|
||||
export function isEmbedModel(model: ModelCard) {
|
||||
return supportesOutputType(model, 'vector') && supportesCapability(model, 'embedding')
|
||||
}
|
||||
|
||||
export function isTextEmbeddingModel(model: ModelCard) {
|
||||
return isEmbedModel(model) && supportesInputType(model, 'text') && model.supportedInputs.length === 1
|
||||
}
|
||||
|
||||
export function isMultiModalEmbeddingModel(model: ModelCard) {
|
||||
return isEmbedModel(model) && model.supportedInputs.length > 1
|
||||
}
|
||||
|
||||
export function isRerankModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'rerank')
|
||||
}
|
||||
|
||||
export function isReasoningModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'reasoning')
|
||||
}
|
||||
|
||||
export function isToolUseModel(model: ModelCard) {
|
||||
return supportesCapability(model, 'toolUse')
|
||||
}
|
||||
|
||||
export function isOnlyStreamingModel(model: ModelCard) {
|
||||
return (
|
||||
supportesCapability(model, 'streaming') &&
|
||||
supportesOutputMode(model, 'streaming') &&
|
||||
model.supportedOutputModes.length === 1
|
||||
)
|
||||
}
|
||||
@@ -173,9 +173,6 @@ export type Message = {
|
||||
useful?: boolean
|
||||
askId?: string // 关联的问题消息ID
|
||||
mentions?: Model[]
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
enabledMCPs?: MCPServer[]
|
||||
|
||||
usage?: Usage
|
||||
@@ -207,14 +204,8 @@ export interface MessageInputBaseParams {
|
||||
topic: Topic
|
||||
content?: string
|
||||
files?: FileType[]
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
knowledgeBaseIds?: string[]
|
||||
mentions?: Model[]
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
enabledMCPs?: MCPServer[]
|
||||
usage?: CompletionUsage
|
||||
}
|
||||
|
||||
@@ -102,6 +102,6 @@ export type GeminiSdkToolCall = FunctionCall
|
||||
|
||||
export type GeminiOptions = {
|
||||
streamOutput: boolean
|
||||
signal?: AbortSignal
|
||||
abortSignal?: AbortSignal
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
convertMathFormula,
|
||||
findCitationInChildren,
|
||||
getCodeBlockId,
|
||||
getExtensionByLanguage,
|
||||
markdownToPlainText,
|
||||
removeTrailingDoubleSpaces,
|
||||
updateCodeBlock
|
||||
@@ -144,67 +143,6 @@ describe('markdown', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExtensionByLanguage', () => {
|
||||
// 批量测试语言名称到扩展名的映射
|
||||
const testLanguageExtensions = (testCases: Record<string, string>) => {
|
||||
for (const [language, expectedExtension] of Object.entries(testCases)) {
|
||||
const result = getExtensionByLanguage(language)
|
||||
expect(result).toBe(expectedExtension)
|
||||
}
|
||||
}
|
||||
|
||||
it('should return extension for exact language name match', () => {
|
||||
testLanguageExtensions({
|
||||
'4D': '.4dm',
|
||||
'C#': '.cs',
|
||||
JavaScript: '.js',
|
||||
TypeScript: '.ts',
|
||||
'Objective-C++': '.mm',
|
||||
Python: '.py',
|
||||
SVG: '.svg',
|
||||
'Visual Basic .NET': '.vb'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return extension for case-insensitive language name match', () => {
|
||||
testLanguageExtensions({
|
||||
'4d': '.4dm',
|
||||
'c#': '.cs',
|
||||
javascript: '.js',
|
||||
typescript: '.ts',
|
||||
'objective-c++': '.mm',
|
||||
python: '.py',
|
||||
svg: '.svg',
|
||||
'visual basic .net': '.vb'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return extension for language aliases', () => {
|
||||
testLanguageExtensions({
|
||||
js: '.js',
|
||||
node: '.js',
|
||||
'obj-c++': '.mm',
|
||||
'objc++': '.mm',
|
||||
'objectivec++': '.mm',
|
||||
py: '.py',
|
||||
'visual basic': '.vb'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return fallback extension for unknown languages', () => {
|
||||
testLanguageExtensions({
|
||||
'unknown-language': '.unknown-language',
|
||||
custom: '.custom'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty string input', () => {
|
||||
testLanguageExtensions({
|
||||
'': '.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCodeBlockId', () => {
|
||||
it('should generate ID from position information', () => {
|
||||
// 从位置信息生成ID
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import type { SendMessageShortcut } from '@renderer/store/settings'
|
||||
import { FileType } from '@renderer/types'
|
||||
|
||||
export const getFilesFromDropEvent = async (e: React.DragEvent<HTMLDivElement>): Promise<FileType[]> => {
|
||||
@@ -60,47 +58,3 @@ export const getFilesFromDropEvent = async (e: React.DragEvent<HTMLDivElement>):
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// convert send message shortcut to human readable label
|
||||
export const getSendMessageShortcutLabel = (shortcut: SendMessageShortcut) => {
|
||||
switch (shortcut) {
|
||||
case 'Enter':
|
||||
return 'Enter'
|
||||
case 'Ctrl+Enter':
|
||||
return 'Ctrl + Enter'
|
||||
case 'Alt+Enter':
|
||||
return `${isMac ? '⌥' : 'Alt'} + Enter`
|
||||
case 'Command+Enter':
|
||||
return `${isMac ? '⌘' : isWindows ? 'Win' : 'Super'} + Enter`
|
||||
case 'Shift+Enter':
|
||||
return 'Shift + Enter'
|
||||
default:
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
|
||||
// check if the send message key is pressed in textarea
|
||||
export const isSendMessageKeyPressed = (
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
shortcut: SendMessageShortcut
|
||||
) => {
|
||||
let isSendMessageKeyPressed = false
|
||||
switch (shortcut) {
|
||||
case 'Enter':
|
||||
if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Ctrl+Enter':
|
||||
if (event.ctrlKey && !event.shiftKey && !event.metaKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Command+Enter':
|
||||
if (event.metaKey && !event.shiftKey && !event.ctrlKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Alt+Enter':
|
||||
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
case 'Shift+Enter':
|
||||
if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) isSendMessageKeyPressed = true
|
||||
break
|
||||
}
|
||||
return isSendMessageKeyPressed
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { languages } from '@shared/config/languages'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import removeMarkdown from 'remove-markdown'
|
||||
@@ -55,40 +54,6 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
|
||||
return markdown.replace(/ {2}$/gm, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据语言名称获取文件扩展名
|
||||
* - 先精确匹配,再忽略大小写,最后匹配别名
|
||||
* - 返回第一个扩展名
|
||||
* @param language 语言名称
|
||||
* @returns 文件扩展名
|
||||
*/
|
||||
export function getExtensionByLanguage(language: string): string {
|
||||
const lowerLanguage = language.toLowerCase()
|
||||
|
||||
// 精确匹配语言名称
|
||||
const directMatch = languages[language]
|
||||
if (directMatch?.extensions?.[0]) {
|
||||
return directMatch.extensions[0]
|
||||
}
|
||||
|
||||
// 大小写不敏感的语言名称匹配
|
||||
for (const [langName, data] of Object.entries(languages)) {
|
||||
if (langName.toLowerCase() === lowerLanguage && data.extensions?.[0]) {
|
||||
return data.extensions[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 通过别名匹配
|
||||
for (const [, data] of Object.entries(languages)) {
|
||||
if (data.aliases?.some((alias) => alias.toLowerCase() === lowerLanguage)) {
|
||||
return data.extensions?.[0] || `.${language}`
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到语言名称
|
||||
return `.${language}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据代码块节点的起始位置生成 ID
|
||||
* @param start 代码块节点的起始位置
|
||||
|
||||
@@ -140,6 +140,17 @@ export const getCitationContent = (message: Message): string => {
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the knowledgeBaseIds array from the *first* MainTextMessageBlock of a message.
|
||||
* Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed.
|
||||
* @param message - The message object.
|
||||
* @returns The knowledgeBaseIds array or undefined if not found.
|
||||
*/
|
||||
export const getKnowledgeBaseIds = (message: Message): string[] | undefined => {
|
||||
const firstTextBlock = findMainTextBlocks(message)
|
||||
return firstTextBlock?.flatMap((block) => block.knowledgeBaseIds).filter((id): id is string => Boolean(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file content from all FileMessageBlocks and ImageMessageBlocks of a message.
|
||||
* @param message - The message object.
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Messages from './components/Messages'
|
||||
interface Props {
|
||||
route: string
|
||||
assistant: Assistant | null
|
||||
topic: Topic | null
|
||||
isOutputted: boolean
|
||||
assistant: Assistant
|
||||
}
|
||||
|
||||
const ChatWindow: FC<Props> = ({ route, assistant, topic, isOutputted }) => {
|
||||
if (!assistant || !topic) return null
|
||||
|
||||
const ChatWindow: FC<Props> = ({ route, assistant }) => {
|
||||
return (
|
||||
<Main className="bubble">
|
||||
<Messages assistant={assistant} topic={topic} route={route} isOutputted={isOutputted} />
|
||||
<Messages assistant={{ ...assistant }} route={route} />
|
||||
</Main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { last } from 'lodash'
|
||||
import { FC, useRef } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageItem from './Message'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
topic: Topic
|
||||
route: string
|
||||
isOutputted: boolean
|
||||
}
|
||||
|
||||
interface ContainerProps {
|
||||
right?: boolean
|
||||
}
|
||||
|
||||
const Messages: FC<Props> = ({ assistant, topic, route, isOutputted }) => {
|
||||
const messages = useTopicMessages(topic.id)
|
||||
const Messages: FC<Props> = ({ assistant, route }) => {
|
||||
// const [messages, setMessages] = useState<Message[]>([])
|
||||
const messages = useTopicMessages(assistant.topics[0].id)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const messagesRef = useRef(messages)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
messagesRef.current = messages
|
||||
|
||||
// const onSendMessage = useCallback(
|
||||
// async (message: Message) => {
|
||||
// setMessages((prev) => {
|
||||
// const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
|
||||
// store.dispatch(newMessagesActions.addMessage({ topicId: assistant.topics[0].id, message: assistantMessage }))
|
||||
// const messages = prev.concat([message, assistantMessage])
|
||||
// return messages
|
||||
// })
|
||||
// },
|
||||
// [assistant]
|
||||
// )
|
||||
|
||||
// useEffect(() => {
|
||||
// const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage)]
|
||||
// return () => unsubscribes.forEach((unsub) => unsub())
|
||||
// }, [assistant.id])
|
||||
|
||||
useHotkeys('c', () => {
|
||||
const lastMessage = last(messages)
|
||||
if (lastMessage) {
|
||||
const content = getMainTextContent(lastMessage)
|
||||
navigator.clipboard.writeText(content)
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Container id="messages" key={assistant.id}>
|
||||
{!isOutputted && <LoadingOutlined style={{ fontSize: 16 }} spin />}
|
||||
<Container id="messages" key={assistant.id} ref={containerRef}>
|
||||
{[...messages].reverse().map((message, index) => (
|
||||
<MessageItem key={message.id} message={message} index={index} total={messages.length} route={route} />
|
||||
))}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { ThemeMode, Topic } from '@renderer/types'
|
||||
import { upsertManyBlocks } from '@renderer/store/messageBlock'
|
||||
import { updateOneBlock, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
import { Assistant, ThemeMode } from '@renderer/types'
|
||||
import { Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { isAbortError } from '@renderer/utils/error'
|
||||
import { createMainTextBlock, createThinkingBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { AssistantMessageStatus } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { createMainTextBlock } from '@renderer/utils/messageUtils/create'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Divider } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { last } from 'lodash'
|
||||
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -33,111 +33,63 @@ import Footer from './components/Footer'
|
||||
import InputBar from './components/InputBar'
|
||||
|
||||
const HomeWindow: FC = () => {
|
||||
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
||||
const [clipboardText, setClipboardText] = useState('')
|
||||
const [selectedText, setSelectedText] = useState('')
|
||||
const [currentAssistant, setCurrentAssistant] = useState<Assistant>({} as Assistant)
|
||||
const [text, setText] = useState('')
|
||||
const [lastClipboardText, setLastClipboardText] = useState<string | null>(null)
|
||||
const textChange = useState(() => {})[1]
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const topic = defaultAssistant.topics[0]
|
||||
const { defaultModel } = useDefaultModel()
|
||||
const model = currentAssistant.model || defaultModel
|
||||
const { language, readClipboardAtStartup, windowStyle } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
|
||||
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
||||
|
||||
const [userInputText, setUserInputText] = useState('')
|
||||
|
||||
const [clipboardText, setClipboardText] = useState('')
|
||||
const lastClipboardTextRef = useRef<string | null>(null)
|
||||
|
||||
const [isPinned, setIsPinned] = useState(false)
|
||||
|
||||
// Indicator for loading(thinking/streaming)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
// Indicator for whether the first message is outputted
|
||||
const [isOutputted, setIsOutputted] = useState(false)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
const { assistant: currentAssistant } = useAssistant(quickAssistantId)
|
||||
|
||||
const currentTopic = useRef<Topic>(getDefaultTopic(currentAssistant.id))
|
||||
const currentAskId = useRef('')
|
||||
|
||||
const inputBarRef = useRef<HTMLDivElement>(null)
|
||||
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
||||
const referenceText = selectedText || clipboardText || text
|
||||
|
||||
const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
|
||||
const content = isFirstMessage ? (referenceText === text ? text : `${referenceText}\n\n${text}`).trim() : text.trim()
|
||||
|
||||
const userContent = useMemo(() => {
|
||||
if (isFirstMessage) {
|
||||
return referenceText === userInputText ? userInputText : `${referenceText}\n\n${userInputText}`.trim()
|
||||
const { quickAssistantId } = useAppSelector((state) => state.llm)
|
||||
|
||||
const readClipboard = useCallback(async () => {
|
||||
if (!readClipboardAtStartup) return
|
||||
|
||||
const text = await navigator.clipboard.readText().catch(() => null)
|
||||
if (text && text !== lastClipboardText) {
|
||||
setLastClipboardText(text)
|
||||
setClipboardText(text.trim())
|
||||
}
|
||||
return userInputText.trim()
|
||||
}, [isFirstMessage, referenceText, userInputText])
|
||||
}, [readClipboardAtStartup, lastClipboardText])
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
// Reset state when switching to home route
|
||||
useEffect(() => {
|
||||
if (route === 'home') {
|
||||
setIsFirstMessage(true)
|
||||
setError(null)
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
const focusInput = () => {
|
||||
if (inputBarRef.current) {
|
||||
const input = inputBarRef.current.querySelector('input')
|
||||
if (input) {
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Use useCallback with stable dependencies to avoid infinite loops
|
||||
const readClipboard = useCallback(async () => {
|
||||
if (!readClipboardAtStartup || !document.hasFocus()) return
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text && text !== lastClipboardTextRef.current) {
|
||||
lastClipboardTextRef.current = text
|
||||
setClipboardText(text.trim())
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle clipboard read errors (common in some environments)
|
||||
console.warn('Failed to read clipboard:', error)
|
||||
}
|
||||
}, [readClipboardAtStartup])
|
||||
|
||||
const clearClipboard = useCallback(async () => {
|
||||
setClipboardText('')
|
||||
lastClipboardTextRef.current = null
|
||||
focusInput()
|
||||
}, [focusInput])
|
||||
}
|
||||
|
||||
const onWindowShow = useCallback(async () => {
|
||||
featureMenusRef.current?.resetSelectedIndex()
|
||||
await readClipboard()
|
||||
readClipboard().then()
|
||||
focusInput()
|
||||
}, [readClipboard, focusInput])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.miniWindow.setPin(isPinned)
|
||||
}, [isPinned])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
|
||||
}
|
||||
}, [onWindowShow])
|
||||
}, [readClipboard])
|
||||
|
||||
useEffect(() => {
|
||||
readClipboard()
|
||||
}, [readClipboard])
|
||||
|
||||
const handleCloseWindow = useCallback(() => window.api.miniWindow.hide(), [])
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
const onCloseWindow = () => window.api.miniWindow.hide()
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
|
||||
@@ -145,7 +97,10 @@ const HomeWindow: FC = () => {
|
||||
// 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名
|
||||
// 输入法可以`Esc`终止候选词过程
|
||||
// 这两个例子的`Enter`和`Esc`快捷助手都不应该响应
|
||||
if (e.nativeEvent.isComposing || e.key === 'Process') {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Process') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,16 +108,14 @@ const HomeWindow: FC = () => {
|
||||
case 'Enter':
|
||||
case 'NumpadEnter':
|
||||
{
|
||||
if (isLoading) return
|
||||
|
||||
e.preventDefault()
|
||||
if (userContent) {
|
||||
if (content) {
|
||||
if (route === 'home') {
|
||||
featureMenusRef.current?.useFeature()
|
||||
} else {
|
||||
// Currently text input is only available in 'chat' mode
|
||||
// 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat'
|
||||
setRoute('chat')
|
||||
handleSendMessage()
|
||||
onSendMessage().then()
|
||||
focusInput()
|
||||
}
|
||||
}
|
||||
@@ -170,9 +123,11 @@ const HomeWindow: FC = () => {
|
||||
break
|
||||
case 'Backspace':
|
||||
{
|
||||
if (userInputText.length === 0) {
|
||||
clearClipboard()
|
||||
}
|
||||
textChange(() => {
|
||||
if (text.length === 0) {
|
||||
clearClipboard()
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
@@ -193,345 +148,226 @@ const HomeWindow: FC = () => {
|
||||
break
|
||||
case 'Escape':
|
||||
{
|
||||
handleEsc()
|
||||
setText('')
|
||||
setRoute('home')
|
||||
route === 'home' && onCloseWindow()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUserInputText(e.target.value)
|
||||
setText(e.target.value)
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
setIsLoading(false)
|
||||
setError(error.message)
|
||||
}
|
||||
useEffect(() => {
|
||||
const defaultCurrentAssistant = {
|
||||
...defaultAssistant,
|
||||
model: defaultModel
|
||||
}
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
if (quickAssistantId) {
|
||||
// 獲取指定助手,如果不存在則使用默認助手
|
||||
const assistantFromId = getAssistantById(quickAssistantId)
|
||||
const currentAssistant = assistantFromId || defaultCurrentAssistant
|
||||
// 如果助手本身沒有設定模型,則使用預設模型
|
||||
if (!currentAssistant.model) {
|
||||
currentAssistant.model = defaultModel
|
||||
}
|
||||
setCurrentAssistant(currentAssistant)
|
||||
} else {
|
||||
setCurrentAssistant(defaultCurrentAssistant)
|
||||
}
|
||||
}, [quickAssistantId, defaultAssistant, defaultModel])
|
||||
|
||||
const onSendMessage = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (isEmpty(userContent) || !currentTopic.current) {
|
||||
if (isEmpty(content)) {
|
||||
return
|
||||
}
|
||||
const topic = currentAssistant.topics[0]
|
||||
const messageParams = {
|
||||
role: 'user',
|
||||
content: [prompt, content].filter(Boolean).join('\n\n'),
|
||||
assistant: currentAssistant,
|
||||
topic,
|
||||
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
status: 'success'
|
||||
}
|
||||
const topicId = topic.id
|
||||
const { message: userMessage, blocks } = getUserMessage(messageParams)
|
||||
|
||||
try {
|
||||
const topicId = currentTopic.current.id
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(blocks))
|
||||
|
||||
const { message: userMessage, blocks } = getUserMessage({
|
||||
content: [prompt, userContent].filter(Boolean).join('\n\n'),
|
||||
assistant: currentAssistant,
|
||||
topic: currentTopic.current
|
||||
})
|
||||
const assistant = currentAssistant
|
||||
let blockId: string | null = null
|
||||
let blockContent: string = ''
|
||||
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||
store.dispatch(upsertManyBlocks(blocks))
|
||||
const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
const assistantMessage = getAssistantMessage({
|
||||
assistant: currentAssistant,
|
||||
topic: currentTopic.current
|
||||
})
|
||||
assistantMessage.askId = userMessage.id
|
||||
currentAskId.current = userMessage.id
|
||||
|
||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
const allMessagesForTopic = selectMessagesForTopic(store.getState(), topicId)
|
||||
const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessage.id)
|
||||
|
||||
const messagesForContext = allMessagesForTopic
|
||||
.slice(0, userMessageIndex + 1)
|
||||
.filter((m) => m && !m.status?.includes('ing'))
|
||||
|
||||
let blockId: string | null = null
|
||||
let blockContent: string = ''
|
||||
let thinkingBlockId: string | null = null
|
||||
let thinkingBlockContent: string = ''
|
||||
|
||||
setIsLoading(true)
|
||||
setIsOutputted(false)
|
||||
setError(null)
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setUserInputText('')
|
||||
|
||||
await fetchChatCompletion({
|
||||
messages: messagesForContext,
|
||||
assistant: { ...currentAssistant, settings: { streamOutput: true } },
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
switch (chunk.type) {
|
||||
case ChunkType.THINKING_DELTA:
|
||||
{
|
||||
thinkingBlockContent += chunk.text
|
||||
setIsOutputted(true)
|
||||
if (!thinkingBlockId) {
|
||||
const block = createThinkingBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING,
|
||||
thinking_millsec: chunk.thinking_millsec
|
||||
})
|
||||
thinkingBlockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { content: thinkingBlockContent, thinking_millsec: chunk.thinking_millsec }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ChunkType.THINKING_COMPLETE:
|
||||
{
|
||||
if (thinkingBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
case ChunkType.TEXT_DELTA:
|
||||
{
|
||||
blockContent += chunk.text
|
||||
setIsOutputted(true)
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case ChunkType.TEXT_COMPLETE:
|
||||
{
|
||||
blockId &&
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
case ChunkType.ERROR: {
|
||||
//stop the thinking timer
|
||||
const isAborted = isAbortError(chunk.error)
|
||||
const possibleBlockId = thinkingBlockId || blockId
|
||||
if (possibleBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: possibleBlockId,
|
||||
changes: {
|
||||
status: isAborted ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
if (!isAborted) {
|
||||
throw new Error(chunk.error.message)
|
||||
}
|
||||
}
|
||||
//fall through
|
||||
case ChunkType.BLOCK_COMPLETE:
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
break
|
||||
fetchChatCompletion({
|
||||
messages: [userMessage],
|
||||
assistant: { ...assistant, settings: { streamOutput: true } },
|
||||
onChunkReceived: (chunk: Chunk) => {
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
blockContent += chunk.text
|
||||
if (!blockId) {
|
||||
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
|
||||
status: MessageBlockStatus.STREAMING
|
||||
})
|
||||
blockId = block.id
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { blockInstruction: { id: block.id } }
|
||||
})
|
||||
)
|
||||
store.dispatch(upsertOneBlock(block))
|
||||
} else {
|
||||
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
handleError(err instanceof Error ? err : new Error('An error occurred'))
|
||||
console.error('Error fetching result:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
}
|
||||
if (chunk.type === ChunkType.TEXT_COMPLETE) {
|
||||
blockId && store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
|
||||
store.dispatch(
|
||||
newMessagesActions.updateMessage({
|
||||
topicId,
|
||||
messageId: assistantMessage.id,
|
||||
updates: { status: AssistantMessageStatus.SUCCESS }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setIsFirstMessage(false)
|
||||
setText('') // ✅ 清除输入框内容
|
||||
},
|
||||
[userContent, currentAssistant]
|
||||
[content, currentAssistant, topic]
|
||||
)
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
if (currentAskId.current) {
|
||||
abortCompletion(currentAskId.current)
|
||||
setIsLoading(false)
|
||||
setIsOutputted(true)
|
||||
currentAskId.current = ''
|
||||
}
|
||||
}, [])
|
||||
const clearClipboard = () => {
|
||||
setClipboardText('')
|
||||
setSelectedText('')
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handleEsc = useCallback(() => {
|
||||
if (isLoading) {
|
||||
handlePause()
|
||||
// If the input is focused, the `Esc` callback will not be triggered here.
|
||||
useHotkeys('esc', () => {
|
||||
if (route === 'home') {
|
||||
onCloseWindow()
|
||||
} else {
|
||||
if (route === 'home') {
|
||||
handleCloseWindow()
|
||||
} else {
|
||||
// Clear the topic messages to reduce memory usage
|
||||
if (currentTopic.current) {
|
||||
store.dispatch(newMessagesActions.clearTopicMessages(currentTopic.current.id))
|
||||
}
|
||||
|
||||
// Reset the topic
|
||||
currentTopic.current = getDefaultTopic(currentAssistant.id)
|
||||
|
||||
setError(null)
|
||||
setRoute('home')
|
||||
setUserInputText('')
|
||||
}
|
||||
setRoute('home')
|
||||
setText('')
|
||||
}
|
||||
}, [isLoading, route, handleCloseWindow, currentAssistant.id, handlePause])
|
||||
})
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!currentTopic.current) return
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
|
||||
|
||||
const messages = selectMessagesForTopic(store.getState(), currentTopic.current.id)
|
||||
const lastMessage = last(messages)
|
||||
|
||||
if (lastMessage) {
|
||||
const content = getMainTextContent(lastMessage)
|
||||
navigator.clipboard.writeText(content)
|
||||
window.message.success(t('message.copy.success'))
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
|
||||
}
|
||||
}, [currentTopic, t])
|
||||
}, [onWindowShow, onSendMessage, setRoute])
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
// 当路由为home时,初始化isFirstMessage为true
|
||||
useEffect(() => {
|
||||
if (route === 'home') {
|
||||
setIsFirstMessage(true)
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const backgroundColor = () => {
|
||||
// ONLY MAC: when transparent style + light theme: use vibrancy effect
|
||||
// because the dark style under mac's vibrancy effect has not been implemented
|
||||
if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) {
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
return 'var(--color-background)'
|
||||
}, [windowStyle, theme])
|
||||
|
||||
// Memoize placeholder text
|
||||
const inputPlaceholder = useMemo(() => {
|
||||
if (referenceText && route === 'home') {
|
||||
return t('miniwindow.input.placeholder.title')
|
||||
}
|
||||
return t('miniwindow.input.placeholder.empty', {
|
||||
model: quickAssistantId ? currentAssistant.name : currentAssistant.model.name
|
||||
})
|
||||
}, [referenceText, route, t, quickAssistantId, currentAssistant])
|
||||
|
||||
// Memoize footer props
|
||||
const baseFooterProps = useMemo(
|
||||
() => ({
|
||||
route,
|
||||
loading: isLoading,
|
||||
onEsc: handleEsc,
|
||||
setIsPinned,
|
||||
isPinned
|
||||
}),
|
||||
[route, isLoading, handleEsc, isPinned]
|
||||
)
|
||||
|
||||
switch (route) {
|
||||
case 'chat':
|
||||
case 'summary':
|
||||
case 'explanation':
|
||||
return (
|
||||
<Container style={{ backgroundColor }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
text={userInputText}
|
||||
assistant={currentAssistant}
|
||||
referenceText={referenceText}
|
||||
placeholder={inputPlaceholder}
|
||||
loading={isLoading}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
)}
|
||||
{['summary', 'explanation'].includes(route) && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
</div>
|
||||
)}
|
||||
<ChatWindow
|
||||
route={route}
|
||||
assistant={currentAssistant}
|
||||
topic={currentTopic.current}
|
||||
isOutputted={isOutputted}
|
||||
/>
|
||||
{error && <ErrorMsg>{error}</ErrorMsg>}
|
||||
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer key="footer" {...baseFooterProps} onCopy={handleCopy} />
|
||||
</Container>
|
||||
)
|
||||
|
||||
case 'translate':
|
||||
return (
|
||||
<Container style={{ backgroundColor }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer key="footer" {...baseFooterProps} />
|
||||
</Container>
|
||||
)
|
||||
|
||||
// Home
|
||||
default:
|
||||
return (
|
||||
<Container style={{ backgroundColor }}>
|
||||
<InputBar
|
||||
text={userInputText}
|
||||
assistant={currentAssistant}
|
||||
referenceText={referenceText}
|
||||
placeholder={inputPlaceholder}
|
||||
loading={isLoading}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<Main>
|
||||
<FeatureMenus
|
||||
setRoute={setRoute}
|
||||
onSendMessage={handleSendMessage}
|
||||
text={userContent}
|
||||
ref={featureMenusRef}
|
||||
/>
|
||||
</Main>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer
|
||||
key="footer"
|
||||
{...baseFooterProps}
|
||||
canUseBackspace={userInputText.length > 0 || clipboardText.length === 0}
|
||||
clearClipboard={clearClipboard}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (['chat', 'summary', 'explanation'].includes(route)) {
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
referenceText={referenceText}
|
||||
placeholder={
|
||||
quickAssistantId
|
||||
? t('miniwindow.input.placeholder.empty', { model: currentAssistant.name })
|
||||
: t('miniwindow.input.placeholder.empty', { model: model.name })
|
||||
}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
)}
|
||||
{['summary', 'explanation'].includes(route) && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
</div>
|
||||
)}
|
||||
<ChatWindow route={route} assistant={currentAssistant ?? defaultAssistant} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
if (route === 'translate') {
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
referenceText={referenceText}
|
||||
placeholder={
|
||||
referenceText && route === 'home'
|
||||
? t('miniwindow.input.placeholder.title')
|
||||
: quickAssistantId
|
||||
? t('miniwindow.input.placeholder.empty', { model: currentAssistant.name })
|
||||
: t('miniwindow.input.placeholder.empty', { model: model.name })
|
||||
}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleChange={handleChange}
|
||||
ref={inputBarRef}
|
||||
/>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||
<Main>
|
||||
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} ref={featureMenusRef} />
|
||||
</Main>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer
|
||||
route={route}
|
||||
canUseBackspace={text.length > 0 || clipboardText.length == 0}
|
||||
clearClipboard={clearClipboard}
|
||||
onExit={() => {
|
||||
setRoute('home')
|
||||
setText('')
|
||||
onCloseWindow()
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
@@ -552,15 +388,4 @@ const Main = styled.main`
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const ErrorMsg = styled.div`
|
||||
color: var(--color-error);
|
||||
background: rgba(255, 0, 0, 0.15);
|
||||
border: 1px solid var(--color-error);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
export default HomeWindow
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
import { ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Tag as AntdTag, Tooltip } from 'antd'
|
||||
import { CircleArrowLeft, Copy, Pin } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface FooterProps {
|
||||
route: string
|
||||
canUseBackspace?: boolean
|
||||
loading?: boolean
|
||||
setIsPinned: (isPinned: boolean) => void
|
||||
isPinned: boolean
|
||||
clearClipboard?: () => void
|
||||
onEsc: () => void
|
||||
onCopy?: () => void
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = ({
|
||||
route,
|
||||
canUseBackspace,
|
||||
loading,
|
||||
clearClipboard,
|
||||
onEsc,
|
||||
setIsPinned,
|
||||
isPinned,
|
||||
onCopy
|
||||
}) => {
|
||||
const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isPinned, setIsPinned] = useState(false)
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
onEsc()
|
||||
})
|
||||
|
||||
useHotkeys('c', () => {
|
||||
handleCopy()
|
||||
})
|
||||
|
||||
const handleCopy = () => {
|
||||
if (loading || !onCopy) return
|
||||
onCopy()
|
||||
const onClickPin = () => {
|
||||
window.api.miniWindow.setPin(!isPinned).then(() => {
|
||||
setIsPinned(!isPinned)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,21 +27,11 @@ const Footer: FC<FooterProps> = ({
|
||||
<FooterText>
|
||||
<Tag
|
||||
bordered={false}
|
||||
icon={
|
||||
loading ? (
|
||||
<LoadingOutlined style={{ fontSize: 12, color: 'var(--color-error)', padding: 0 }} spin />
|
||||
) : (
|
||||
<CircleArrowLeft size={14} color="var(--color-text)" />
|
||||
)
|
||||
}
|
||||
icon={<CircleArrowLeft size={14} color="var(--color-text)" />}
|
||||
className="nodrag"
|
||||
onClick={onEsc}>
|
||||
onClick={() => onExit()}>
|
||||
{t('miniwindow.footer.esc', {
|
||||
action: loading
|
||||
? t('miniwindow.footer.esc_pause')
|
||||
: route === 'home'
|
||||
? t('miniwindow.footer.esc_close')
|
||||
: t('miniwindow.footer.esc_back')
|
||||
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
|
||||
})}
|
||||
</Tag>
|
||||
{route === 'home' && !canUseBackspace && (
|
||||
@@ -74,27 +44,19 @@ const Footer: FC<FooterProps> = ({
|
||||
{t('miniwindow.footer.backspace_clear')}
|
||||
</Tag>
|
||||
)}
|
||||
{route !== 'home' && !loading && (
|
||||
{route !== 'home' && (
|
||||
<Tag
|
||||
bordered={false}
|
||||
icon={<Copy size={14} color="var(--color-text)" />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className="nodrag"
|
||||
onClick={handleCopy}>
|
||||
className="nodrag">
|
||||
{t('miniwindow.footer.copy_last_message')}
|
||||
</Tag>
|
||||
)}
|
||||
</FooterText>
|
||||
<PinButtonArea onClick={() => setIsPinned(!isPinned)} className="nodrag">
|
||||
<PinButtonArea onClick={() => onClickPin()} className="nodrag">
|
||||
<Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left">
|
||||
<Pin
|
||||
size={14}
|
||||
stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'}
|
||||
style={{
|
||||
transform: isPinned ? 'rotate(40deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
<Pin size={14} stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'} />
|
||||
</Tooltip>
|
||||
</PinButtonArea>
|
||||
</WindowFooter>
|
||||
@@ -122,7 +84,6 @@ const PinButtonArea = styled.div`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 5px;
|
||||
`
|
||||
|
||||
const Tag = styled(AntdTag)`
|
||||
@@ -130,12 +91,6 @@ const Tag = styled(AntdTag)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
export default Footer
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { Input as AntdInput } from 'antd'
|
||||
import { InputRef } from 'rc-input/lib/interface'
|
||||
import React, { useRef } from 'react'
|
||||
@@ -7,10 +7,9 @@ import styled from 'styled-components'
|
||||
|
||||
interface InputBarProps {
|
||||
text: string
|
||||
assistant: Assistant
|
||||
model: any
|
||||
referenceText: string
|
||||
placeholder: string
|
||||
loading: boolean
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
@@ -18,19 +17,19 @@ interface InputBarProps {
|
||||
const InputBar = ({
|
||||
ref,
|
||||
text,
|
||||
assistant,
|
||||
model,
|
||||
placeholder,
|
||||
loading,
|
||||
handleKeyDown,
|
||||
handleChange
|
||||
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const { generating } = useRuntime()
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
if (!loading) {
|
||||
if (!generating) {
|
||||
setTimeout(() => inputRef.current?.input?.focus(), 0)
|
||||
}
|
||||
return (
|
||||
<InputWrapper ref={ref}>
|
||||
{assistant.model && <ModelAvatar model={assistant.model} size={30} />}
|
||||
<ModelAvatar model={model} size={30} />
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
@@ -38,6 +37,7 @@ const InputBar = ({
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
disabled={generating}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
||||
48
yarn.lock
48
yarn.lock
@@ -5712,7 +5712,7 @@ __metadata:
|
||||
react-hotkeys-hook: "npm:^4.6.1"
|
||||
react-i18next: "npm:^14.1.2"
|
||||
react-infinite-scroll-component: "npm:^6.1.0"
|
||||
react-markdown: "npm:^10.1.0"
|
||||
react-markdown: "npm:^9.0.1"
|
||||
react-redux: "npm:^9.1.2"
|
||||
react-router: "npm:6"
|
||||
react-router-dom: "npm:6"
|
||||
@@ -5721,10 +5721,10 @@ __metadata:
|
||||
redux: "npm:^5.0.1"
|
||||
redux-persist: "npm:^6.0.0"
|
||||
rehype-katex: "npm:^7.0.1"
|
||||
rehype-mathjax: "npm:^7.1.0"
|
||||
rehype-mathjax: "npm:^7.0.0"
|
||||
rehype-raw: "npm:^7.0.0"
|
||||
remark-cjk-friendly: "npm:^1.2.0"
|
||||
remark-gfm: "npm:^4.0.1"
|
||||
remark-cjk-friendly: "npm:^1.1.0"
|
||||
remark-gfm: "npm:^4.0.0"
|
||||
remark-math: "npm:^6.0.0"
|
||||
remove-markdown: "npm:^0.6.2"
|
||||
rollup-plugin-visualizer: "npm:^5.12.0"
|
||||
@@ -12737,9 +12737,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-cjk-friendly-util@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "micromark-extension-cjk-friendly-util@npm:2.0.0"
|
||||
"micromark-extension-cjk-friendly-util@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "micromark-extension-cjk-friendly-util@npm:1.1.0"
|
||||
dependencies:
|
||||
get-east-asian-width: "npm:^1.3.0"
|
||||
micromark-util-character: "npm:^2.0.0"
|
||||
@@ -12747,16 +12747,16 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
micromark-util-types:
|
||||
optional: true
|
||||
checksum: 10c0/194c799d88982ebf785e65a1c29cbded17d5dd3510a1769123ec30ddb7e256502b97753f63e8994d91ebafa1e9b96aa2dc2a90aa4e2f2072269b05652a412886
|
||||
checksum: 10c0/3ae1d4fd92f03a6c8e34e314c14a42b35cdd1bcbe043fceb1d2d45cd1a7b364e77643a3ca181910666cb11cc3606a1595fae9a15e87b0a4988fc57d5e4f65f67
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"micromark-extension-cjk-friendly@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "micromark-extension-cjk-friendly@npm:1.2.0"
|
||||
"micromark-extension-cjk-friendly@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "micromark-extension-cjk-friendly@npm:1.1.0"
|
||||
dependencies:
|
||||
devlop: "npm:^1.0.0"
|
||||
micromark-extension-cjk-friendly-util: "npm:^2.0.0"
|
||||
micromark-extension-cjk-friendly-util: "npm:^1.1.0"
|
||||
micromark-util-chunked: "npm:^2.0.0"
|
||||
micromark-util-resolve-all: "npm:^2.0.0"
|
||||
micromark-util-symbol: "npm:^2.0.0"
|
||||
@@ -12766,7 +12766,7 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
micromark-util-types:
|
||||
optional: true
|
||||
checksum: 10c0/5be1841629310e21c803b64feb00453fb8ac939be80c2ff473d8b4486d8eca973347520912a6e4abeda5bea4ed8ef39d3db48c4bad8285dd380d9ed34417dd0d
|
||||
checksum: 10c0/95be6d8b4164b9b3b5281d77ed4f9337d95b2041ad4f7a775baa0d7f8ec495818101881eea2c7cc0ee4ee11738716899f20b3fbfbc2e6b80106544065d2ec04d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15611,9 +15611,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-markdown@npm:^10.1.0":
|
||||
version: 10.1.0
|
||||
resolution: "react-markdown@npm:10.1.0"
|
||||
"react-markdown@npm:^9.0.1":
|
||||
version: 9.1.0
|
||||
resolution: "react-markdown@npm:9.1.0"
|
||||
dependencies:
|
||||
"@types/hast": "npm:^3.0.0"
|
||||
"@types/mdast": "npm:^4.0.0"
|
||||
@@ -15629,7 +15629,7 @@ __metadata:
|
||||
peerDependencies:
|
||||
"@types/react": ">=18"
|
||||
react: ">=18"
|
||||
checksum: 10c0/4a5dc7d15ca6d05e9ee95318c1904f83b111a76f7588c44f50f1d54d4c97193b84e4f64c4b592057c989228238a2590306cedd0c4d398e75da49262b2b5ae1bf
|
||||
checksum: 10c0/5bd645d39379f776d64588105f4060c390c3c8e4ff048552c9fa0ad31b756bb3ff7c11081542dc58d840ccf183a6dd4fd4d4edab44d8c24dee8b66551a5fd30d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15915,7 +15915,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rehype-mathjax@npm:^7.1.0":
|
||||
"rehype-mathjax@npm:^7.0.0":
|
||||
version: 7.1.0
|
||||
resolution: "rehype-mathjax@npm:7.1.0"
|
||||
dependencies:
|
||||
@@ -15942,18 +15942,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-cjk-friendly@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "remark-cjk-friendly@npm:1.2.0"
|
||||
"remark-cjk-friendly@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "remark-cjk-friendly@npm:1.1.0"
|
||||
dependencies:
|
||||
micromark-extension-cjk-friendly: "npm:^1.2.0"
|
||||
micromark-extension-cjk-friendly: "npm:^1.1.0"
|
||||
peerDependencies:
|
||||
"@types/mdast": ^4.0.0
|
||||
unified: ^11.0.0
|
||||
peerDependenciesMeta:
|
||||
"@types/mdast":
|
||||
optional: true
|
||||
checksum: 10c0/ca7dc4fd50491693c4a84164650b30c3ae027cc7aa11b7a2e3811ab07ad0bf0c73484e37f9aed710bb68f95ca03cc540effe64cbe94bbc055b40e1aa951e2013
|
||||
checksum: 10c0/ef43a4c404baaaa3e3d888ea68db8ffa101746faadb96d19d6b7ee8d00f0a025613c2e508527236961b226e41d8fb34f6cc6ac217ae8770fcbf47b9f496ab32a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15967,7 +15967,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-gfm@npm:^4.0.1":
|
||||
"remark-gfm@npm:^4.0.0":
|
||||
version: 4.0.1
|
||||
resolution: "remark-gfm@npm:4.0.1"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user