Compare commits
22 Commits
v1.6.0-bet
...
feat/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd6d6bd56e | ||
|
|
7a23386de4 | ||
|
|
a227f6dcb9 | ||
|
|
9ff4acf092 | ||
|
|
128b1fe9bc | ||
|
|
9a92372c3e | ||
|
|
0a36869b3c | ||
|
|
a9a38f88bb | ||
|
|
aca1fcad18 | ||
|
|
24bc878c27 | ||
|
|
b1a9fbc6fd | ||
|
|
8a4c635c97 | ||
|
|
16d5f5c299 | ||
|
|
69a5a0434a | ||
|
|
6d1f3a5729 | ||
|
|
b725400428 | ||
|
|
9f7d2be463 | ||
|
|
fdee510c8c | ||
|
|
76ac1bd8f7 | ||
|
|
362658339a | ||
|
|
925d7e2a25 | ||
|
|
089477eb1e |
@@ -4,6 +4,8 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
import pkg from './package.json' assert { type: 'json' }
|
||||
|
||||
const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
|
||||
}
|
||||
@@ -21,25 +23,15 @@ export default defineConfig({
|
||||
'@shared': resolve('packages/shared'),
|
||||
'@logger': resolve('src/main/services/LoggerService'),
|
||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node')
|
||||
'@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node'),
|
||||
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
|
||||
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
|
||||
'@cherrystudio/ai-core': resolve('packages/aiCore/src')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@libsql/client',
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
'jsdom',
|
||||
'electron',
|
||||
'graceful-fs',
|
||||
'selection-hook',
|
||||
'@napi-rs/system-ocr',
|
||||
'@strongtz/win32-arm64-msvc',
|
||||
'os-proxy-config',
|
||||
'sharp',
|
||||
'turndown'
|
||||
],
|
||||
external: ['bufferutil', 'utf-8-validate', 'electron', ...Object.keys(pkg.dependencies)],
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
|
||||
12
package.json
12
package.json
@@ -74,8 +74,9 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"ai-sdk-provider-claude-code": "^1.1.3",
|
||||
"express": "^5.1.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"htmlparser2": "^10.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"officeparser": "^4.2.0",
|
||||
@@ -170,6 +171,7 @@
|
||||
"@truto/turndown-plugin-gfm": "^1.0.2",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/cli-progress": "^3",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/fs-extra": "^11",
|
||||
"@types/he": "^1",
|
||||
"@types/lodash": "^4.17.5",
|
||||
@@ -336,7 +338,13 @@
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@img/sharp-darwin-arm64": "0.34.3",
|
||||
"@img/sharp-darwin-x64": "0.34.3",
|
||||
"@img/sharp-linux-arm": "0.34.3",
|
||||
"@img/sharp-linux-arm64": "0.34.3",
|
||||
"@img/sharp-linux-x64": "0.34.3",
|
||||
"@img/sharp-win32-x64": "0.34.3"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -250,6 +250,7 @@ export enum IpcChannel {
|
||||
|
||||
// Provider
|
||||
Provider_AddKey = 'provider:add-key',
|
||||
Provider_GetClaudeCodePort = 'provider:get-claude-code-port',
|
||||
|
||||
//Selection Assistant
|
||||
Selection_TextSelected = 'selection:text-selected',
|
||||
|
||||
@@ -2089,7 +2089,7 @@
|
||||
"Design",
|
||||
"Education"
|
||||
],
|
||||
"prompt": "I want you to act as a Graphviz DOT generator, an expert to create meaningful diagrams. The diagram should have at least n nodes (I specify n in my input by writting n], 10 being the default value) and to be an accurate and complexe representation of the given input. Each node is indexed by a number to reduce the size of the output, should not include any styling, and with layout=neato, overlap=false, node shape=rectangle] as parameters. The code should be valid, bugless and returned on a single line, without any explanation. Provide a clear and organized diagram, the relationships between the nodes have to make sense for an expert of that input. My first diagram is: \"The water cycle 8]\".\n\n",
|
||||
"prompt": "I want you to act as a Graphviz DOT generator, an expert to create meaningful diagrams. The diagram should have at least n nodes (I specify n in my input by writing n], 10 being the default value) and to be an accurate and complex representation of the given input. Each node is indexed by a number to reduce the size of the output, should not include any styling, and with layout=neato, overlap=false, node shape=rectangle] as parameters. The code should be valid, bugless and returned on a single line, without any explanation. Provide a clear and organized diagram, the relationships between the nodes have to make sense for an expert of that input. My first diagram is: \"The water cycle 8]\".\n\n",
|
||||
"description": "Generate meaningful charts."
|
||||
},
|
||||
{
|
||||
@@ -2148,7 +2148,7 @@
|
||||
"Career",
|
||||
"Business"
|
||||
],
|
||||
"prompt": "Please acknowledge my following request. Please respond to me as a product manager. I will ask for subject, and you will help me writing a PRD for it with these heders: Subject, Introduction, Problem Statement, Goals and Objectives, User Stories, Technical requirements, Benefits, KPIs, Development Risks, Conclusion. Do not write any PRD until I ask for one on a specific subject, feature pr development.\n\n",
|
||||
"prompt": "Please acknowledge my following request. Please respond to me as a product manager. I will ask for subject, and you will help me writing a PRD for it with these headers: Subject, Introduction, Problem Statement, Goals and Objectives, User Stories, Technical requirements, Benefits, KPIs, Development Risks, Conclusion. Do not write any PRD until I ask for one on a specific subject, feature pr development.\n\n",
|
||||
"description": "Help draft the Product Requirements Document."
|
||||
},
|
||||
{
|
||||
@@ -2159,7 +2159,7 @@
|
||||
"Entertainment",
|
||||
"General"
|
||||
],
|
||||
"prompt": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkeness I mentionned. Do not write explanations on replies. My first sentence is \"how are you?",
|
||||
"prompt": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkenness I mentioned. Do not write explanations on replies. My first sentence is \"how are you?",
|
||||
"description": "Mimic the speech pattern of a drunk person."
|
||||
},
|
||||
{
|
||||
@@ -3517,7 +3517,7 @@
|
||||
"Tools",
|
||||
"Copywriting"
|
||||
],
|
||||
"prompt": "I want you to act as a scientific manuscript matcher. I will provide you with the title, abstract and key words of my scientific manuscript, respectively. Your task is analyzing my title, abstract and key words synthetically to find the most related, reputable journals for potential publication of my research based on an analysis of tens of millions of citation connections in database, such as Web of Science, Pubmed, Scopus, ScienceDirect and so on. You only need to provide me with the 15 most suitable journals. Your reply should include the name of journal, the cooresponding match score (The full score is ten). I want you to reply in text-based excel sheet and sort by matching scores in reverse order.\nMy title is \"XXX\" My abstract is \"XXX\" My key words are \"XXX\"\n\n",
|
||||
"prompt": "I want you to act as a scientific manuscript matcher. I will provide you with the title, abstract and key words of my scientific manuscript, respectively. Your task is analyzing my title, abstract and key words synthetically to find the most related, reputable journals for potential publication of my research based on an analysis of tens of millions of citation connections in database, such as Web of Science, Pubmed, Scopus, ScienceDirect and so on. You only need to provide me with the 15 most suitable journals. Your reply should include the name of journal, the corresponding match score (The full score is ten). I want you to reply in text-based excel sheet and sort by matching scores in reverse order.\nMy title is \"XXX\" My abstract is \"XXX\" My key words are \"XXX\"\n\n",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electro
|
||||
|
||||
import { isDev, isLinux, isWin } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { claudeCodeService } from './services/ClaudeCodeService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
import { nodeTraceService } from './services/NodeTraceService'
|
||||
@@ -119,6 +120,14 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
nodeTraceService.init()
|
||||
|
||||
// Start Claude-code HTTP service
|
||||
try {
|
||||
await claudeCodeService.start()
|
||||
logger.info('Claude-code HTTP service started successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Claude-code HTTP service:', error as Error)
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
@@ -193,6 +202,15 @@ if (!app.requestSingleInstanceLock()) {
|
||||
} catch (error) {
|
||||
logger.warn('Error cleaning up MCP service:', error as Error)
|
||||
}
|
||||
|
||||
// Stop Claude-code HTTP service
|
||||
try {
|
||||
await claudeCodeService.stop()
|
||||
logger.info('Claude-code HTTP service stopped')
|
||||
} catch (error) {
|
||||
logger.warn('Error stopping Claude-code HTTP service:', error as Error)
|
||||
}
|
||||
|
||||
// finish the logger
|
||||
logger.finish()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { claudeCodeService } from './services/ClaudeCodeService'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@@ -755,4 +756,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// CherryIN
|
||||
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
// Provider
|
||||
ipcMain.handle(IpcChannel.Provider_GetClaudeCodePort, () => {
|
||||
return claudeCodeService.getPort()
|
||||
})
|
||||
}
|
||||
|
||||
158
src/main/services/ClaudeCodeService.ts
Normal file
158
src/main/services/ClaudeCodeService.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createExecutor } from '@cherrystudio/ai-core'
|
||||
import { loggerService } from '@logger'
|
||||
import { createClaudeCode } from 'ai-sdk-provider-claude-code'
|
||||
import express, { Request, Response } from 'express'
|
||||
import { Server } from 'http'
|
||||
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
|
||||
export class ClaudeCodeService {
|
||||
private app: express.Application
|
||||
private server: Server | null = null
|
||||
private port: number = 0
|
||||
private claudeCodeProvider: any = null
|
||||
|
||||
constructor() {
|
||||
this.app = express()
|
||||
this.setupMiddleware()
|
||||
this.setupRoutes()
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
this.app.use(express.json())
|
||||
this.app.use(express.text())
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
// Health check endpoint
|
||||
this.app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
// Initialize claude-code provider
|
||||
this.app.post('/init', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const config = req.body
|
||||
logger.info('Initializing claude-code provider with config', config)
|
||||
|
||||
this.claudeCodeProvider = createClaudeCode()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Claude-code provider initialized successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize claude-code provider', error as Error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Stream text completion endpoint
|
||||
this.app.post('/completions', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!this.claudeCodeProvider) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Claude-code provider not initialized. Call /init first.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { modelId, params, options } = req.body
|
||||
logger.info('Processing completions request', { modelId, hasParams: !!params })
|
||||
|
||||
// 创建执行器
|
||||
const executor = createExecutor('claude-code', options || {}, [])
|
||||
const model = this.claudeCodeProvider.languageModel('opus')
|
||||
|
||||
// 执行流式文本生成
|
||||
const result = await executor.streamText({
|
||||
...params,
|
||||
model,
|
||||
abortSignal: new AbortController().signal
|
||||
})
|
||||
console.log('result', result)
|
||||
// 使用 AI SDK 提供的便捷函数处理流式响应
|
||||
result.pipeUIMessageStreamToResponse(res)
|
||||
|
||||
logger.info('Completions request completed successfully')
|
||||
} catch (error) {
|
||||
logger.error('Error in completions endpoint', error as Error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async start(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 尝试使用固定端口,如果失败则使用系统分配端口
|
||||
const preferredPort = 23456
|
||||
|
||||
this.server = this.app.listen(preferredPort, 'localhost', () => {
|
||||
if (this.server?.address()) {
|
||||
this.port = (this.server.address() as any)?.port || 0
|
||||
logger.info(`Claude-code HTTP service started on port ${this.port}`)
|
||||
resolve(this.port)
|
||||
} else {
|
||||
reject(new Error('Failed to start server'))
|
||||
}
|
||||
})
|
||||
|
||||
this.server.on('error', (error: any) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.warn(`Port ${preferredPort} is in use, trying with dynamic port`)
|
||||
// 如果固定端口被占用,使用动态端口
|
||||
this.server = this.app.listen(0, 'localhost', () => {
|
||||
if (this.server?.address()) {
|
||||
this.port = (this.server.address() as any)?.port || 0
|
||||
logger.info(`Claude-code HTTP service started on dynamic port ${this.port}`)
|
||||
resolve(this.port)
|
||||
} else {
|
||||
reject(new Error('Failed to start server'))
|
||||
}
|
||||
})
|
||||
|
||||
this.server.on('error', (dynamicError) => {
|
||||
logger.error('Server error on dynamic port', dynamicError)
|
||||
reject(dynamicError)
|
||||
})
|
||||
} else {
|
||||
logger.error('Server error', error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => {
|
||||
logger.info('Claude-code HTTP service stopped')
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.server !== null && this.server.listening
|
||||
}
|
||||
}
|
||||
|
||||
// 单例实例
|
||||
export const claudeCodeService = new ClaudeCodeService()
|
||||
@@ -32,7 +32,8 @@ class ObsidianVaultService {
|
||||
)
|
||||
} else {
|
||||
// Linux
|
||||
this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json')
|
||||
this.obsidianConfigPath = this.resolveLinuxObsidianConfigPath()
|
||||
logger.debug(`Resolved Obsidian config path (linux): ${this.obsidianConfigPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +165,57 @@ class ObsidianVaultService {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Linux 下解析 Obsidian 配置文件路径,兼容多种安装方式。
|
||||
* 优先返回第一个存在的路径;若均不存在,则返回 XDG 默认路径。
|
||||
*/
|
||||
private resolveLinuxObsidianConfigPath(): string {
|
||||
const home = app.getPath('home')
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(home, '.config')
|
||||
|
||||
// 常见目录名与文件名大小写差异做兼容
|
||||
const configDirs = ['obsidian', 'Obsidian']
|
||||
const fileNames = ['obsidian.json', 'Obsidian.json']
|
||||
|
||||
const candidates: string[] = []
|
||||
|
||||
// 1) AppImage/DEB(XDG 标准路径)
|
||||
for (const dir of configDirs) {
|
||||
for (const file of fileNames) {
|
||||
candidates.push(path.join(xdgConfigHome, dir, file))
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Snap 安装:
|
||||
// - 常见:~/snap/obsidian/current/.config/obsidian/obsidian.json
|
||||
// - 兼容:~/snap/obsidian/common/.config/obsidian/obsidian.json
|
||||
for (const dir of configDirs) {
|
||||
for (const file of fileNames) {
|
||||
candidates.push(path.join(home, 'snap', 'obsidian', 'current', '.config', dir, file))
|
||||
candidates.push(path.join(home, 'snap', 'obsidian', 'common', '.config', dir, file))
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Flatpak 安装:~/.var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json
|
||||
for (const dir of configDirs) {
|
||||
for (const file of fileNames) {
|
||||
candidates.push(path.join(home, '.var', 'app', 'md.obsidian.Obsidian', 'config', dir, file))
|
||||
}
|
||||
}
|
||||
|
||||
const existing = candidates.find((p) => {
|
||||
try {
|
||||
return fs.existsSync(p)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
return path.join(xdgConfigHome, 'obsidian', 'obsidian.json')
|
||||
}
|
||||
}
|
||||
|
||||
export default ObsidianVaultService
|
||||
|
||||
@@ -437,6 +437,9 @@ const api = {
|
||||
cherryin: {
|
||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
|
||||
},
|
||||
provider: {
|
||||
getClaudeCodePort: () => ipcRenderer.invoke(IpcChannel.Provider_GetClaudeCodePort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,54 @@ export class AiSdkToChunkAdapter {
|
||||
return await aiSdkResult.text
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接处理单个 chunk 数据
|
||||
* @param chunk AI SDK 的 chunk 数据
|
||||
*/
|
||||
async processChunk(response: ReadableStream<TextStreamPart<any>>): Promise<void> {
|
||||
const reader = response.getReader()
|
||||
const final = {
|
||||
text: '',
|
||||
reasoningContent: '',
|
||||
webSearchResults: [],
|
||||
reasoningId: ''
|
||||
}
|
||||
try {
|
||||
let buffer = ''
|
||||
const decoder = new TextDecoder()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
// 按行处理 SSE 数据
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || '' // 保留最后一行(可能不完整)
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const dataStr = line.slice(6) // 移除 "data: " 前缀
|
||||
|
||||
if (dataStr === '[DONE]') {
|
||||
break
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(dataStr)
|
||||
this.convertAndEmitChunk(data, final)
|
||||
} catch (parseError) {
|
||||
// 忽略无法解析的数据
|
||||
// logger.debug('Failed to parse streamed data:', parseError as Error, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 fullStream 并转换为 Cherry Studio chunks
|
||||
* @param fullStream AI SDK 的 fullStream (ReadableStream)
|
||||
@@ -90,6 +138,7 @@ export class AiSdkToChunkAdapter {
|
||||
final: { text: string; reasoningContent: string; webSearchResults: any[]; reasoningId: string }
|
||||
) {
|
||||
logger.info(`AI SDK chunk type: ${chunk.type}`, chunk)
|
||||
console.log('final', final)
|
||||
switch (chunk.type) {
|
||||
// === 文本相关事件 ===
|
||||
case 'text-start':
|
||||
@@ -99,7 +148,7 @@ export class AiSdkToChunkAdapter {
|
||||
break
|
||||
case 'text-delta':
|
||||
if (this.accumulate) {
|
||||
final.text += chunk.text || ''
|
||||
final.text += chunk.delta || ''
|
||||
} else {
|
||||
final.text = chunk.text || ''
|
||||
}
|
||||
@@ -232,13 +281,13 @@ export class AiSdkToChunkAdapter {
|
||||
text: final.text || '',
|
||||
reasoning_content: final.reasoningContent || '',
|
||||
usage: {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
prompt_tokens: chunk?.totalUsage?.inputTokens || 0,
|
||||
total_tokens: chunk?.totalUsage?.totalTokens || 0
|
||||
},
|
||||
metrics: chunk.totalUsage
|
||||
metrics: chunk?.totalUsage
|
||||
? {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
time_completion_millsec: 0
|
||||
}
|
||||
: undefined
|
||||
@@ -250,13 +299,13 @@ export class AiSdkToChunkAdapter {
|
||||
text: final.text || '',
|
||||
reasoning_content: final.reasoningContent || '',
|
||||
usage: {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
prompt_tokens: chunk.totalUsage.inputTokens || 0,
|
||||
total_tokens: chunk.totalUsage.totalTokens || 0
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
prompt_tokens: chunk?.totalUsage?.inputTokens || 0,
|
||||
total_tokens: chunk?.totalUsage?.totalTokens || 0
|
||||
},
|
||||
metrics: chunk.totalUsage
|
||||
metrics: chunk?.totalUsage
|
||||
? {
|
||||
completion_tokens: chunk.totalUsage.outputTokens || 0,
|
||||
completion_tokens: chunk?.totalUsage?.outputTokens || 0,
|
||||
time_completion_millsec: 0
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -9,18 +9,16 @@
|
||||
|
||||
import { createExecutor } from '@cherrystudio/ai-core'
|
||||
import { loggerService } from '@logger'
|
||||
import { isNotSupportedImageSizeModel } from '@renderer/config/models'
|
||||
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
|
||||
|
||||
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
|
||||
import LegacyAiProvider from './legacy/index'
|
||||
import { CompletionsResult } from './legacy/middleware/schemas'
|
||||
import { CompletionsParams, CompletionsResult } from './legacy/middleware/schemas'
|
||||
import { AiSdkMiddlewareConfig, buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
||||
import { buildPlugins } from './plugins/PluginBuilder'
|
||||
import { createAiSdkProvider } from './provider/factory'
|
||||
@@ -92,6 +90,11 @@ export default class ModernAiProvider {
|
||||
// 准备特殊配置
|
||||
await prepareSpecialProviderConfig(this.actualProvider, this.config)
|
||||
|
||||
// 特殊处理 claude-code provider,通过本地 HTTP 服务器
|
||||
// if (this.config.providerId === 'claude-code') {
|
||||
return await this._completionsViaHttpService(modelId, params, config)
|
||||
// }
|
||||
|
||||
// 提前创建本地 provider 实例
|
||||
if (!this.localProvider) {
|
||||
this.localProvider = await createAiSdkProvider(this.config)
|
||||
@@ -140,7 +143,24 @@ export default class ModernAiProvider {
|
||||
config: ModernAiProviderConfig
|
||||
): Promise<CompletionsResult> {
|
||||
if (config.isImageGenerationEndpoint) {
|
||||
return await this.modernImageGeneration(model as ImageModel, params, config)
|
||||
// 使用 legacy 实现处理图像生成(支持图片编辑等高级功能)
|
||||
if (!config.uiMessages) {
|
||||
throw new Error('uiMessages is required for image generation endpoint')
|
||||
}
|
||||
|
||||
const legacyParams: CompletionsParams = {
|
||||
callType: 'chat',
|
||||
messages: config.uiMessages, // 使用原始的 UI 消息格式
|
||||
assistant: config.assistant,
|
||||
streamOutput: config.streamOutput ?? true,
|
||||
onChunk: config.onChunk,
|
||||
topicId: config.topicId,
|
||||
mcpTools: config.mcpTools,
|
||||
enableWebSearch: config.enableWebSearch
|
||||
}
|
||||
|
||||
// 调用 legacy 的 completions,会自动使用 ImageGenerationMiddleware
|
||||
return await this.legacyProvider.completions(legacyParams)
|
||||
}
|
||||
|
||||
return await this.modernCompletions(model as LanguageModel, params, config)
|
||||
@@ -231,6 +251,79 @@ export default class ModernAiProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过本地 HTTP 服务器处理 claude-code completions
|
||||
*/
|
||||
private async _completionsViaHttpService(
|
||||
modelId: string,
|
||||
params: StreamTextParams,
|
||||
config: ModernAiProviderConfig
|
||||
): Promise<CompletionsResult> {
|
||||
logger.info('Starting claude-code completions via HTTP service', {
|
||||
modelId,
|
||||
providerId: this.config!.providerId,
|
||||
topicId: config.topicId,
|
||||
hasOnChunk: !!config.onChunk
|
||||
})
|
||||
|
||||
try {
|
||||
// 初始化 claude-code provider
|
||||
const initResponse = await fetch('http://localhost:' + (await this.getClaudeCodePort()) + '/init', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.config!.options)
|
||||
})
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Failed to initialize claude-code provider: ${initResponse.statusText}`)
|
||||
}
|
||||
|
||||
// 发送 completions 请求
|
||||
const completionsResponse = await fetch('http://localhost:' + (await this.getClaudeCodePort()) + '/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
modelId,
|
||||
params,
|
||||
options: this.config!.options
|
||||
})
|
||||
})
|
||||
|
||||
if (!completionsResponse.ok) {
|
||||
throw new Error(`Failed to get completions: ${completionsResponse.statusText}`)
|
||||
}
|
||||
|
||||
let finalText = ''
|
||||
|
||||
if (config.onChunk && completionsResponse.body) {
|
||||
// 创建 adapter 来处理 chunk 数据
|
||||
const accumulate = this.model!.supported_text_delta !== false
|
||||
const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate)
|
||||
await adapter.processChunk(completionsResponse.body)
|
||||
} else {
|
||||
finalText = await completionsResponse.text()
|
||||
}
|
||||
|
||||
return {
|
||||
getText: () => finalText
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in claude-code HTTP service completions', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Claude-code HTTP 服务端口
|
||||
*/
|
||||
private async getClaudeCodePort(): Promise<number> {
|
||||
return await window.api.provider.getClaudeCodePort()
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用现代化AI SDK的completions实现
|
||||
*/
|
||||
@@ -290,7 +383,9 @@ export default class ModernAiProvider {
|
||||
|
||||
/**
|
||||
* 使用现代化 AI SDK 的图像生成实现,支持流式输出
|
||||
* @deprecated 已改为使用 legacy 实现以支持图片编辑等高级功能
|
||||
*/
|
||||
/*
|
||||
private async modernImageGeneration(
|
||||
model: ImageModel,
|
||||
params: StreamTextParams,
|
||||
@@ -407,6 +502,7 @@ export default class ModernAiProvider {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// 代理其他方法到原有实现
|
||||
public async models() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { MCPTool, Model, Provider } from '@renderer/types'
|
||||
import type { MCPTool, Message, Model, Provider } from '@renderer/types'
|
||||
import type { Chunk } from '@renderer/types/chunk'
|
||||
import { extractReasoningMiddleware, LanguageModelMiddleware, simulateStreamingMiddleware } from 'ai'
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AiSdkMiddlewareConfig {
|
||||
enableWebSearch: boolean
|
||||
enableGenerateImage: boolean
|
||||
mcpTools?: MCPTool[]
|
||||
uiMessages?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import type { Message, Model } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import {
|
||||
findFileBlocks,
|
||||
@@ -154,11 +153,8 @@ async function convertMessageToAssistantModelMessage(
|
||||
/**
|
||||
* 转换 Cherry Studio 消息数组为 AI SDK 消息数组
|
||||
*/
|
||||
export async function convertMessagesToSdkMessages(
|
||||
messages: Message[],
|
||||
model: Model
|
||||
): Promise<StreamTextParams['messages']> {
|
||||
const sdkMessages: StreamTextParams['messages'] = []
|
||||
export async function convertMessagesToSdkMessages(messages: Message[], model: Model): Promise<ModelMessage[]> {
|
||||
const sdkMessages: ModelMessage[] = []
|
||||
const isVision = isVisionModel(model)
|
||||
|
||||
for (const message of messages) {
|
||||
|
||||
@@ -76,6 +76,17 @@ export function getAiSdkProviderId(provider: Provider): ProviderId | 'openai-com
|
||||
export async function createAiSdkProvider(config) {
|
||||
let localProvider: Awaited<AiSdkProvider> | null = null
|
||||
try {
|
||||
// 特殊处理 claude-code provider,通过 IPC 在主线程中创建
|
||||
// if (config.providerId === 'claude-code') {
|
||||
localProvider = await window.api.provider.createClaudeCode()
|
||||
logger.debug('Claude-code provider created via IPC', {
|
||||
providerId: config.providerId,
|
||||
hasOptions: !!config.options
|
||||
})
|
||||
console.log('localProvider', localProvider)
|
||||
return localProvider
|
||||
// }
|
||||
|
||||
if (config.providerId === 'openai' && config.options?.mode === 'chat') {
|
||||
config.providerId = `${config.providerId}-chat`
|
||||
} else if (config.providerId === 'azure' && config.options?.mode === 'responses') {
|
||||
|
||||
@@ -92,7 +92,6 @@ function formatProviderApiHost(provider: Provider): Provider {
|
||||
*/
|
||||
export function getActualProvider(model: Model): Provider {
|
||||
const baseProvider = getProviderByModel(model)
|
||||
|
||||
// 按顺序处理各种转换
|
||||
let actualProvider = cloneDeep(baseProvider)
|
||||
actualProvider = handleSpecialProviders(model, actualProvider)
|
||||
|
||||
@@ -257,12 +257,13 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
) : (
|
||||
<CodeViewer
|
||||
className="source-view"
|
||||
value={children}
|
||||
language={language}
|
||||
onHeightChange={handleHeightChange}
|
||||
expanded={shouldExpand}
|
||||
wrapped={shouldWrap}
|
||||
onHeightChange={handleHeightChange}>
|
||||
{children}
|
||||
</CodeViewer>
|
||||
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
||||
/>
|
||||
),
|
||||
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
|
||||
)
|
||||
|
||||
@@ -48,8 +48,6 @@ export interface CodeEditorProps {
|
||||
maxHeight?: string
|
||||
/** Minimum editor height. */
|
||||
minHeight?: string
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: string
|
||||
/** Editor options that extend BasicSetupOptions. */
|
||||
options?: {
|
||||
/**
|
||||
@@ -70,6 +68,8 @@ export interface CodeEditorProps {
|
||||
} & BasicSetupOptions
|
||||
/** Additional extensions for CodeMirror. */
|
||||
extensions?: Extension[]
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: number
|
||||
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
|
||||
style?: React.CSSProperties
|
||||
/** CSS class name appended to the default `code-editor` class. */
|
||||
@@ -108,9 +108,9 @@ const CodeEditor = ({
|
||||
height,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
fontSize,
|
||||
options,
|
||||
extensions,
|
||||
fontSize: customFontSize,
|
||||
style,
|
||||
className,
|
||||
editable = true,
|
||||
@@ -121,7 +121,7 @@ const CodeEditor = ({
|
||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||
|
||||
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
||||
const customBasicSetup = useMemo(() => {
|
||||
const basicSetup = useMemo(() => {
|
||||
return {
|
||||
lineNumbers: _lineNumbers,
|
||||
...(codeEditor as BasicSetupOptions),
|
||||
@@ -129,7 +129,7 @@ const CodeEditor = ({
|
||||
}
|
||||
}, [codeEditor, _lineNumbers, options])
|
||||
|
||||
const customFontSize = useMemo(() => fontSize ?? `${_fontSize - 1}px`, [fontSize, _fontSize])
|
||||
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
|
||||
|
||||
const { activeCmTheme } = useCodeStyle()
|
||||
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||
@@ -214,10 +214,10 @@ const CodeEditor = ({
|
||||
foldKeymap: enableKeymap,
|
||||
completionKeymap: enableKeymap,
|
||||
lintKeymap: enableKeymap,
|
||||
...customBasicSetup // override basicSetup
|
||||
...basicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
fontSize: customFontSize,
|
||||
fontSize,
|
||||
marginTop: 0,
|
||||
borderRadius: 'inherit',
|
||||
...style
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@@ -11,13 +10,48 @@ import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodeViewerProps {
|
||||
/** Code string value. */
|
||||
value: string
|
||||
/**
|
||||
* Code language string.
|
||||
* - Case-insensitive.
|
||||
* - Supports common names: javascript, json, python, etc.
|
||||
* - Supports shiki aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
|
||||
*/
|
||||
language: string
|
||||
children: React.ReactNode
|
||||
expanded?: boolean
|
||||
wrapped?: boolean
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
className?: string
|
||||
/**
|
||||
* Height of the scroll container.
|
||||
* Only works when expanded is false.
|
||||
*/
|
||||
height?: string | number
|
||||
/**
|
||||
* Maximum height of the scroll container.
|
||||
* Only works when expanded is false.
|
||||
*/
|
||||
maxHeight?: string | number
|
||||
/** Viewer options. */
|
||||
options?: {
|
||||
/**
|
||||
* Whether to show line numbers.
|
||||
*/
|
||||
lineNumbers?: boolean
|
||||
}
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: number
|
||||
/** CSS class name appended to the default `code-viewer` class. */
|
||||
className?: string
|
||||
/**
|
||||
* Whether the editor is expanded.
|
||||
* If true, the height and maxHeight props are ignored.
|
||||
* @default true
|
||||
*/
|
||||
expanded?: boolean
|
||||
/**
|
||||
* Whether the code lines are wrapped.
|
||||
* @default true
|
||||
*/
|
||||
wrapped?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,19 +60,33 @@ interface CodeViewerProps {
|
||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||
const CodeViewer = ({
|
||||
value,
|
||||
language,
|
||||
height,
|
||||
maxHeight,
|
||||
onHeightChange,
|
||||
options,
|
||||
fontSize: customFontSize,
|
||||
className,
|
||||
expanded = true,
|
||||
wrapped = true
|
||||
}: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings()
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
|
||||
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
|
||||
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
|
||||
const lineNumbers = useMemo(() => options?.lineNumbers ?? _lineNumbers, [options?.lineNumbers, _lineNumbers])
|
||||
|
||||
const rawLines = useMemo(() => (typeof value === 'string' ? value.trimEnd().split('\n') : []), [value])
|
||||
|
||||
// 计算行号数字位数
|
||||
const gutterDigits = useMemo(
|
||||
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||
[codeShowLineNumbers, rawLines.length]
|
||||
() => (lineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||
[lineNumbers, rawLines.length]
|
||||
)
|
||||
|
||||
// 设置 pre 标签属性
|
||||
@@ -68,7 +116,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
||||
const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId])
|
||||
// `line-height: 1.6` 为全局样式,但是为了避免测量误差在这里取整
|
||||
const estimateSize = useCallback(() => Math.round((fontSize - 1) * 1.6), [fontSize])
|
||||
const estimateSize = useCallback(() => Math.round(fontSize * 1.6), [fontSize])
|
||||
|
||||
// 创建 virtualizer 实例
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -105,20 +153,19 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
}, [rawLines.length, onHeightChange])
|
||||
|
||||
return (
|
||||
<div ref={shikiThemeRef} style={height ? { height } : undefined}>
|
||||
<div ref={shikiThemeRef} style={expanded ? undefined : { height }}>
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={wrapped}
|
||||
$expanded={expanded}
|
||||
$expand={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
$height={height}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
maxHeight: expanded ? undefined : height ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
||||
height: height,
|
||||
fontSize,
|
||||
height: expanded ? undefined : height,
|
||||
maxHeight: expanded ? undefined : maxHeight,
|
||||
overflowY: expanded ? 'hidden' : 'auto'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
@@ -142,7 +189,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
<VirtualizedRow
|
||||
rawLine={rawLines[virtualItem.index]}
|
||||
tokenLine={tokenLines[virtualItem.index]}
|
||||
showLineNumbers={codeShowLineNumbers}
|
||||
showLineNumbers={lineNumbers}
|
||||
index={virtualItem.index}
|
||||
/>
|
||||
</div>
|
||||
@@ -226,9 +273,8 @@ VirtualizedRow.displayName = 'VirtualizedRow'
|
||||
|
||||
const ScrollContainer = styled.div<{
|
||||
$wrap?: boolean
|
||||
$expanded?: boolean
|
||||
$expand?: boolean
|
||||
$lineHeight?: number
|
||||
$height?: string | number
|
||||
}>`
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
@@ -244,7 +290,7 @@ const ScrollContainer = styled.div<{
|
||||
line-height: ${(props) => props.$lineHeight}px;
|
||||
/* contain 优化 wrap 时滚动性能,will-change 优化 unwrap 时滚动性能 */
|
||||
contain: ${(props) => (props.$wrap ? 'content' : 'none')};
|
||||
will-change: ${(props) => (!props.$wrap && !props.$expanded ? 'transform' : 'auto')};
|
||||
will-change: ${(props) => (!props.$wrap && !props.$expand ? 'transform' : 'auto')};
|
||||
|
||||
.line-number {
|
||||
width: var(--gutter-width, 1.2ch);
|
||||
|
||||
@@ -71,8 +71,9 @@ describe('DraggableList', () => {
|
||||
})
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
const emptyList: Array<{ id: string; name: string }> = []
|
||||
render(
|
||||
<DraggableList list={[]} onUpdate={() => {}}>
|
||||
<DraggableList list={emptyList} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList, // 列表未过滤
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
ResponderProvided
|
||||
} from '@hello-pangea/dnd'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { FC, HTMLAttributes } from 'react'
|
||||
import { HTMLAttributes, Key, useCallback } from 'react'
|
||||
|
||||
interface Props<T> {
|
||||
list: T[]
|
||||
@@ -17,23 +17,25 @@ interface Props<T> {
|
||||
listStyle?: React.CSSProperties
|
||||
listProps?: HTMLAttributes<HTMLDivElement>
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
itemKey?: keyof T | ((item: T) => Key)
|
||||
onUpdate: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
onDragEnd?: OnDragEndResponder
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
}
|
||||
|
||||
const DraggableList: FC<Props<any>> = ({
|
||||
function DraggableList<T>({
|
||||
children,
|
||||
list,
|
||||
style,
|
||||
listStyle,
|
||||
listProps,
|
||||
itemKey,
|
||||
droppableProps,
|
||||
onDragStart,
|
||||
onUpdate,
|
||||
onDragEnd
|
||||
}) => {
|
||||
}: Props<T>) {
|
||||
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
onDragEnd?.(result, provided)
|
||||
if (result.destination) {
|
||||
@@ -46,6 +48,17 @@ const DraggableList: FC<Props<any>> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const getId = useCallback(
|
||||
(item: T) => {
|
||||
if (typeof itemKey === 'function') return itemKey(item)
|
||||
if (itemKey) return item[itemKey] as Key
|
||||
if (typeof item === 'string') return item as Key
|
||||
if (item && typeof item === 'object' && 'id' in item) return item.id as Key
|
||||
return undefined
|
||||
},
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
<Droppable droppableId="droppable" {...droppableProps}>
|
||||
@@ -53,9 +66,9 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||
<div {...listProps} className="draggable-list-container">
|
||||
{list.map((item, index) => {
|
||||
const id = item.id || item
|
||||
const draggableId = String(getId(item) ?? index)
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
<Draggable key={`draggable_${draggableId}`} draggableId={draggableId} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface UseDraggableReorderParams<T> {
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,8 +19,16 @@ interface UseDraggableReorderParams<T> {
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey }
|
||||
*/
|
||||
export function useDraggableReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
export function useDraggableReorder<T>({
|
||||
originalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
itemKey
|
||||
}: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
|
||||
@@ -208,7 +208,7 @@ const VirtualRow = memo(
|
||||
const draggableId = String(virtualItem.key)
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable_${draggableId}_${virtualItem.index}`}
|
||||
key={`draggable_${draggableId}`}
|
||||
draggableId={draggableId}
|
||||
isDragDisabled={disabled}
|
||||
index={virtualItem.index}>
|
||||
|
||||
@@ -56,6 +56,7 @@ const MermaidPreview = ({
|
||||
document.body.removeChild(measureEl)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[diagramId, mermaid, forceRenderKey]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||
import DragHandle from '@tiptap/extension-drag-handle-react'
|
||||
import { EditorContent } from '@tiptap/react'
|
||||
@@ -26,6 +27,7 @@ import { ToC } from './TableOfContent'
|
||||
import { Toolbar } from './toolbar'
|
||||
import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
|
||||
import { useRichEditor } from './useRichEditor'
|
||||
const logger = loggerService.withContext('RichEditor')
|
||||
|
||||
const RichEditor = ({
|
||||
ref,
|
||||
@@ -290,6 +292,7 @@ const RichEditor = ({
|
||||
const end = $from.end()
|
||||
editor.chain().focus().setTextSelection({ from: start, to: end }).setEnhancedLink({ href: url }).run()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to set enhanced link:', error as Error)
|
||||
editor.chain().focus().toggleEnhancedLink({ href: '' }).run()
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
@@ -7,11 +10,12 @@ import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
|
||||
import tabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import {
|
||||
ChevronRight,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Hammer,
|
||||
@@ -28,13 +32,11 @@ import {
|
||||
Terminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { TopNavbarOpenedMinappTabs } from '../app/PinnedMinapps'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -81,6 +83,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const { settedTheme, toggleTheme } = useTheme()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
@@ -142,34 +146,83 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
navigate(tab.path)
|
||||
}
|
||||
|
||||
const handleScrollRight = () => {
|
||||
scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current
|
||||
if (!scrollElement) return
|
||||
|
||||
const checkScrollability = () => {
|
||||
setCanScroll(scrollElement.scrollWidth > scrollElement.clientWidth)
|
||||
}
|
||||
|
||||
checkScrollability()
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollability)
|
||||
resizeObserver.observe(scrollElement)
|
||||
|
||||
window.addEventListener('resize', checkScrollability)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', checkScrollability)
|
||||
}
|
||||
}, [tabs])
|
||||
|
||||
const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs])
|
||||
|
||||
const { onSortEnd } = useDndReorder<Tab>({
|
||||
originalList: tabs,
|
||||
filteredList: visibleTabs,
|
||||
onUpdate: (newTabs) => dispatch(setTabs(newTabs)),
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabsBar $isFullscreen={isFullscreen}>
|
||||
{tabs
|
||||
.filter((tab) => !specialTabs.includes(tab.id))
|
||||
.map((tab) => {
|
||||
return (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
)
|
||||
})}
|
||||
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
<TabsArea>
|
||||
<TabsScroll ref={scrollRef}>
|
||||
<Sortable
|
||||
items={visibleTabs}
|
||||
itemKey="id"
|
||||
layout="list"
|
||||
horizontal
|
||||
gap={'6px'}
|
||||
onSortEnd={onSortEnd}
|
||||
className="tabs-sortable"
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
data-no-dnd
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
/>
|
||||
</TabsScroll>
|
||||
{canScroll && (
|
||||
<ScrollButton onClick={handleScrollRight} className="scroll-right-button" shape="circle" size="small">
|
||||
<ChevronRight size={16} />
|
||||
</ScrollButton>
|
||||
)}
|
||||
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
</TabsArea>
|
||||
<RightButtonsContainer>
|
||||
<TopNavbarOpenedMinappTabs />
|
||||
<Tooltip
|
||||
@@ -200,6 +253,7 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
@@ -221,6 +275,34 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const TabsArea = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
padding-right: 2rem;
|
||||
position: relative;
|
||||
|
||||
-webkit-app-region: drag;
|
||||
|
||||
> * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.scroll-right-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TabsScroll = styled(Scrollbar)`
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -228,12 +310,12 @@ const Tab = styled.div<{ active?: boolean }>`
|
||||
padding: 4px 10px;
|
||||
padding-right: 8px;
|
||||
background: ${(props) => (props.active ? 'var(--color-list-item)' : 'transparent')};
|
||||
transition: background 0.2s;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
height: 30px;
|
||||
min-width: 90px;
|
||||
transition: background 0.2s;
|
||||
|
||||
.close-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
@@ -251,12 +333,15 @@ const TabHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const TabIcon = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-2);
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const TabTitle = styled.span`
|
||||
@@ -265,6 +350,8 @@ const TabTitle = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const CloseButton = styled.span`
|
||||
@@ -284,6 +371,7 @@ const AddTabButton = styled.div`
|
||||
cursor: pointer;
|
||||
color: var(--color-text-2);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
flex-shrink: 0;
|
||||
&.active {
|
||||
background: var(--color-list-item);
|
||||
}
|
||||
@@ -292,11 +380,28 @@ const AddTabButton = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const ScrollButton = styled(Button)`
|
||||
position: absolute;
|
||||
right: 4rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
`
|
||||
|
||||
const RightButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const ThemeButton = styled.div`
|
||||
|
||||
20
src/renderer/src/components/TooltipIcons/HelpTooltip.tsx
Normal file
20
src/renderer/src/components/TooltipIcons/HelpTooltip.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Tooltip, TooltipProps } from 'antd'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
|
||||
type InheritedTooltipProps = Omit<TooltipProps, 'children'>
|
||||
|
||||
interface HelpTooltipProps extends InheritedTooltipProps {
|
||||
iconColor?: string
|
||||
iconSize?: string | number
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const HelpTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconStyle, ...rest }: HelpTooltipProps) => {
|
||||
return (
|
||||
<Tooltip {...rest}>
|
||||
<HelpCircle size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Help" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpTooltip
|
||||
@@ -9,7 +9,7 @@ interface InfoTooltipProps extends InheritedTooltipProps {
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const InfoTooltip = ({ iconColor = 'var(--color-text-3)', iconSize = 14, iconStyle, ...rest }: InfoTooltipProps) => {
|
||||
const InfoTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconStyle, ...rest }: InfoTooltipProps) => {
|
||||
return (
|
||||
<Tooltip {...rest}>
|
||||
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
|
||||
3
src/renderer/src/components/TooltipIcons/index.ts
Normal file
3
src/renderer/src/components/TooltipIcons/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as HelpTooltip } from './HelpTooltip'
|
||||
export { default as InfoTooltip } from './InfoTooltip'
|
||||
export { default as WarnTooltip } from './WarnTooltip'
|
||||
@@ -56,6 +56,8 @@ interface SortableProps<T> {
|
||||
listStyle?: React.CSSProperties
|
||||
/** Ghost item style */
|
||||
ghostItemStyle?: React.CSSProperties
|
||||
/** Item gap */
|
||||
gap?: number | string
|
||||
}
|
||||
|
||||
function Sortable<T>({
|
||||
@@ -70,7 +72,8 @@ function Sortable<T>({
|
||||
useDragOverlay = true,
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle
|
||||
listStyle,
|
||||
gap
|
||||
}: SortableProps<T>) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PortalSafePointerSensor, {
|
||||
@@ -150,7 +153,12 @@ function Sortable<T>({
|
||||
onDragCancel={handleDragCancel}
|
||||
modifiers={modifiers}>
|
||||
<SortableContext items={itemIds} strategy={strategy}>
|
||||
<ListWrapper className={className} data-layout={layout} style={listStyle}>
|
||||
<ListWrapper
|
||||
className={className}
|
||||
data-layout={layout}
|
||||
data-direction={horizontal ? 'horizontal' : 'vertical'}
|
||||
$gap={gap}
|
||||
style={listStyle}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={itemIds[index]}
|
||||
@@ -176,17 +184,31 @@ function Sortable<T>({
|
||||
)
|
||||
}
|
||||
|
||||
const ListWrapper = styled.div`
|
||||
const ListWrapper = styled.div<{ $gap?: number | string }>`
|
||||
gap: ${({ $gap }) => $gap};
|
||||
|
||||
&[data-layout='grid'] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-layout='list'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
[data-direction='horizontal'] {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
[data-direction='vertical'] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default Sortable
|
||||
|
||||
@@ -8,7 +8,7 @@ interface UseDndReorderParams<T> {
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,8 +18,11 @@ interface UseDndReorderParams<T> {
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
|
||||
*/
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, itemKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function upgradeToV7(tx: Transaction): Promise<void> {
|
||||
content: mcpTool.response,
|
||||
error:
|
||||
mcpTool.status !== 'done'
|
||||
? { message: 'MCP Tool did not complete', originalStatus: mcpTool.status }
|
||||
? { message: 'MCP Tool did not complete', originalStatus: mcpTool.status, name: null, stack: null }
|
||||
: undefined,
|
||||
createdAt: oldMessage.createdAt,
|
||||
metadata: { rawMcpToolResponse: mcpTool }
|
||||
@@ -263,10 +263,18 @@ export async function upgradeToV7(tx: Transaction): Promise<void> {
|
||||
// 10. Error Block (Status is ERROR)
|
||||
if (oldMessage.error && typeof oldMessage.error === 'object' && Object.keys(oldMessage.error).length > 0) {
|
||||
if (isEmpty(oldMessage.content)) {
|
||||
const block = createErrorBlock(oldMessage.id, oldMessage.error, {
|
||||
createdAt: oldMessage.createdAt,
|
||||
status: MessageBlockStatus.ERROR // Error block status is ERROR
|
||||
})
|
||||
const block = createErrorBlock(
|
||||
oldMessage.id,
|
||||
{
|
||||
message: oldMessage.error?.message ?? null,
|
||||
name: oldMessage.error?.name ?? null,
|
||||
stack: oldMessage.error?.stack ?? null
|
||||
},
|
||||
{
|
||||
createdAt: oldMessage.createdAt,
|
||||
status: MessageBlockStatus.ERROR // Error block status is ERROR
|
||||
}
|
||||
)
|
||||
blocksToCreate.push(block)
|
||||
messageBlockIds.push(block.id)
|
||||
}
|
||||
|
||||
14
src/renderer/src/hooks/useShowWorkspace.ts
Normal file
14
src/renderer/src/hooks/useShowWorkspace.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectNotesSettings, updateNotesSettings } from '@renderer/store/note'
|
||||
|
||||
export function useShowWorkspace() {
|
||||
const dispatch = useAppDispatch()
|
||||
const settings = useAppSelector(selectNotesSettings)
|
||||
const showWorkspace = settings.showWorkspace
|
||||
|
||||
return {
|
||||
showWorkspace,
|
||||
setShowWorkspace: (show: boolean) => dispatch(updateNotesSettings({ showWorkspace: show })),
|
||||
toggleShowWorkspace: () => dispatch(updateNotesSettings({ showWorkspace: !showWorkspace }))
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@ import {
|
||||
setAssistantsTabSortType,
|
||||
setShowAssistants,
|
||||
setShowTopics,
|
||||
setShowWorkspace,
|
||||
toggleShowAssistants,
|
||||
toggleShowTopics,
|
||||
toggleShowWorkspace
|
||||
toggleShowTopics
|
||||
} from '@renderer/store/settings'
|
||||
import { AssistantsSortType } from '@renderer/types'
|
||||
|
||||
@@ -41,14 +39,3 @@ export function useAssistantsTabSortType() {
|
||||
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
|
||||
}
|
||||
}
|
||||
|
||||
export function useShowWorkspace() {
|
||||
const showWorkspace = useAppSelector((state) => state.settings.showWorkspace)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
showWorkspace,
|
||||
setShowWorkspace: (show: boolean) => dispatch(setShowWorkspace(show)),
|
||||
toggleShowWorkspace: () => dispatch(toggleShowWorkspace())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
|
||||
"title": "Code Execution"
|
||||
},
|
||||
"code_image_tools": "Enable preview tools",
|
||||
"code_image_tools": {
|
||||
"label": "Enable preview tools",
|
||||
"tip": "Enable preview tools for images rendered from code blocks such as mermaid"
|
||||
},
|
||||
"code_wrappable": "Code block wrappable",
|
||||
"context_count": {
|
||||
"label": "Context",
|
||||
@@ -828,6 +831,7 @@
|
||||
"invalid": "Invalid MCP server"
|
||||
}
|
||||
},
|
||||
"cause": "Error cause",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Returned an invalid data format"
|
||||
@@ -837,6 +841,7 @@
|
||||
"quota_exceeded": "Your daily {{quota}} free quota has been exhausted. Please go to the <provider>{{provider}}</provider> to obtain an API key and configure the API key to continue using.",
|
||||
"response": "Something went wrong. Please check if you have set your API key in the Settings > Providers"
|
||||
},
|
||||
"data": "data",
|
||||
"detail": "Error Details",
|
||||
"details": "Details",
|
||||
"http": {
|
||||
@@ -856,6 +861,7 @@
|
||||
"exists": "Model already exists",
|
||||
"not_exists": "Model does not exist"
|
||||
},
|
||||
"name": "Error name",
|
||||
"no_api_key": "API key is not configured",
|
||||
"pause_placeholder": "Paused",
|
||||
"provider_disabled": "Model provider is not enabled",
|
||||
@@ -864,9 +870,13 @@
|
||||
"title": "Render Error"
|
||||
},
|
||||
"requestBody": "Request Body",
|
||||
"requestBodyValues": "Request Body Values",
|
||||
"requestUrl": "Request URL",
|
||||
"responseBody": "Response Body",
|
||||
"responseHeaders": "Response Header",
|
||||
"stack": "Stack Trace",
|
||||
"status": "Status Code",
|
||||
"statusCode": "Status code",
|
||||
"unknown": "Unknown error",
|
||||
"user_message_not_found": "Cannot find original user message to resend"
|
||||
},
|
||||
@@ -1560,6 +1570,7 @@
|
||||
"selected": "Selected tags"
|
||||
},
|
||||
"function_calling": "Function Calling",
|
||||
"invalid_model": "Invalid Model",
|
||||
"no_matches": "No models available",
|
||||
"parameter_name": "Parameter Name",
|
||||
"parameter_type": {
|
||||
@@ -1633,6 +1644,7 @@
|
||||
"only_markdown": "Only Markdown files are supported",
|
||||
"only_one_file_allowed": "Only one file can be uploaded",
|
||||
"open_folder": "Open an external folder",
|
||||
"open_outside": "Open from external",
|
||||
"rename": "Rename",
|
||||
"rename_changed": "Due to security policies, the filename has been changed from {{original}} to {{final}}",
|
||||
"save": "Save to Notes",
|
||||
@@ -4152,7 +4164,7 @@
|
||||
"aborted": "Translation aborted"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Text, files, or images (OCR supported) can be pasted or dragged in"
|
||||
"placeholder": "Text, text files, or images (with OCR support) can be pasted or dragged in"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "Source language is different from the set language",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
|
||||
"title": "コード実行"
|
||||
},
|
||||
"code_image_tools": "プレビューツールを有効にする",
|
||||
"code_image_tools": {
|
||||
"label": "プレビューツールを有効にする",
|
||||
"tip": "mermaid などのコードブロックから生成された画像に対してプレビューツールを有効にする"
|
||||
},
|
||||
"code_wrappable": "コードブロック折り返し",
|
||||
"context_count": {
|
||||
"label": "コンテキスト",
|
||||
@@ -828,6 +831,7 @@
|
||||
"invalid": "無効なMCPサーバー"
|
||||
}
|
||||
},
|
||||
"cause": "エラーの原因",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "無効なデータ形式が返されました"
|
||||
@@ -837,6 +841,7 @@
|
||||
"quota_exceeded": "本日の{{quota}}無料クォータが使い果たされました。<provider>{{provider}}</provider>でAPIキーを取得し、APIキーを設定して使用を続けてください。",
|
||||
"response": "エラーが発生しました。APIキーが設定されていない場合は、設定 > プロバイダーでキーを設定してください"
|
||||
},
|
||||
"data": "データ",
|
||||
"detail": "エラーの詳細",
|
||||
"details": "詳細",
|
||||
"http": {
|
||||
@@ -856,6 +861,7 @@
|
||||
"exists": "モデルが既に存在します",
|
||||
"not_exists": "モデルが存在しません"
|
||||
},
|
||||
"name": "エラー名",
|
||||
"no_api_key": "APIキーが設定されていません",
|
||||
"pause_placeholder": "応答を一時停止しました",
|
||||
"provider_disabled": "モデルプロバイダーが有効になっていません",
|
||||
@@ -864,9 +870,13 @@
|
||||
"title": "レンダリングエラー"
|
||||
},
|
||||
"requestBody": "要求されたコンテンツ",
|
||||
"requestBodyValues": "リクエストボディ",
|
||||
"requestUrl": "リクエストパス",
|
||||
"responseBody": "レスポンス内容",
|
||||
"responseHeaders": "レスポンスヘッダー",
|
||||
"stack": "スタック情報",
|
||||
"status": "ステータスコード",
|
||||
"statusCode": "ステータスコード",
|
||||
"unknown": "不明なエラー",
|
||||
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした"
|
||||
},
|
||||
@@ -1560,6 +1570,7 @@
|
||||
"selected": "選択済みのタグ"
|
||||
},
|
||||
"function_calling": "関数呼び出し",
|
||||
"invalid_model": "無効なモデル",
|
||||
"no_matches": "利用可能なモデルがありません",
|
||||
"parameter_name": "パラメータ名",
|
||||
"parameter_type": {
|
||||
@@ -1633,6 +1644,7 @@
|
||||
"only_markdown": "Markdown ファイルのみをアップロードできます",
|
||||
"only_one_file_allowed": "アップロードできるファイルは1つだけです",
|
||||
"open_folder": "外部フォルダーを開きます",
|
||||
"open_outside": "外部から開く",
|
||||
"rename": "名前の変更",
|
||||
"rename_changed": "セキュリティポリシーにより、ファイル名は{{original}}から{{final}}に変更されました",
|
||||
"save": "メモに保存する",
|
||||
@@ -4152,7 +4164,7 @@
|
||||
"aborted": "翻訳中止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "テキスト、ファイル、画像(OCR対応)を貼り付けたりドラッグアンドドロップしたりできます"
|
||||
"placeholder": "テキスト、テキストファイル、画像(OCR対応)を貼り付けたり、ドラッグして挿入したりできます"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "ソース言語が設定された言語と異なります",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
|
||||
"title": "Выполнение кода"
|
||||
},
|
||||
"code_image_tools": "Включить инструменты предпросмотра",
|
||||
"code_image_tools": {
|
||||
"label": "Включить инструменты предпросмотра",
|
||||
"tip": "Включить инструменты предпросмотра для изображений, сгенерированных из блоков кода (например mermaid)"
|
||||
},
|
||||
"code_wrappable": "Блок кода можно переносить",
|
||||
"context_count": {
|
||||
"label": "Контекст",
|
||||
@@ -828,6 +831,7 @@
|
||||
"invalid": "Недействительный сервер MCP"
|
||||
}
|
||||
},
|
||||
"cause": "Ошибка произошла по следующей причине",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Вернулся недопустимый формат данных"
|
||||
@@ -837,6 +841,7 @@
|
||||
"quota_exceeded": "Ваша ежедневная {{quota}} бесплатная квота исчерпана. Пожалуйста, перейдите в <provider>{{provider}}</provider> для получения ключа API и настройте ключ API для продолжения использования.",
|
||||
"response": "Что-то пошло не так. Пожалуйста, проверьте, установлен ли ваш ключ API в Настройки > Провайдеры"
|
||||
},
|
||||
"data": "данные",
|
||||
"detail": "Детали ошибки",
|
||||
"details": "Подробности",
|
||||
"http": {
|
||||
@@ -856,6 +861,7 @@
|
||||
"exists": "Модель уже существует",
|
||||
"not_exists": "Модель не существует"
|
||||
},
|
||||
"name": "错误名称",
|
||||
"no_api_key": "Ключ API не настроен",
|
||||
"pause_placeholder": "Получение ответа приостановлено",
|
||||
"provider_disabled": "Провайдер моделей не включен",
|
||||
@@ -864,9 +870,13 @@
|
||||
"title": "Ошибка рендеринга"
|
||||
},
|
||||
"requestBody": "Запрашиваемый контент",
|
||||
"requestBodyValues": "Тело запроса",
|
||||
"requestUrl": "Путь запроса",
|
||||
"responseBody": "Содержание ответа",
|
||||
"responseHeaders": "Заголовки ответа",
|
||||
"stack": "Информация стека",
|
||||
"status": "Код статуса",
|
||||
"statusCode": "Код состояния",
|
||||
"unknown": "Неизвестная ошибка",
|
||||
"user_message_not_found": "Не удалось найти исходное сообщение пользователя"
|
||||
},
|
||||
@@ -1560,6 +1570,7 @@
|
||||
"selected": "Выбранные теги"
|
||||
},
|
||||
"function_calling": "Вызов функции",
|
||||
"invalid_model": "Недействительная модель",
|
||||
"no_matches": "Нет доступных моделей",
|
||||
"parameter_name": "Имя параметра",
|
||||
"parameter_type": {
|
||||
@@ -1633,6 +1644,7 @@
|
||||
"only_markdown": "Только Markdown",
|
||||
"only_one_file_allowed": "Можно загрузить только один файл",
|
||||
"open_folder": "Откройте внешнюю папку",
|
||||
"open_outside": "открыть снаружи",
|
||||
"rename": "переименовать",
|
||||
"rename_changed": "В связи с политикой безопасности имя файла было изменено с {{Original}} на {{final}}",
|
||||
"save": "Сохранить в заметки",
|
||||
@@ -4152,7 +4164,7 @@
|
||||
"aborted": "Перевод прерван"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Можно вставить или перетащить текст, файлы, изображения (поддержка OCR)"
|
||||
"placeholder": "Можно вставить или перетащить текст, текстовые файлы, изображения (с поддержкой OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "Исходный язык отличается от настроенного",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
|
||||
"title": "代码执行"
|
||||
},
|
||||
"code_image_tools": "启用预览工具",
|
||||
"code_image_tools": {
|
||||
"label": "启用预览工具",
|
||||
"tip": "为 mermaid 等代码块渲染后的图像启用预览工具"
|
||||
},
|
||||
"code_wrappable": "代码块可换行",
|
||||
"context_count": {
|
||||
"label": "上下文数",
|
||||
@@ -828,6 +831,7 @@
|
||||
"invalid": "无效的MCP服务器"
|
||||
}
|
||||
},
|
||||
"cause": "错误原因",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "返回了无效的数据格式"
|
||||
@@ -837,6 +841,7 @@
|
||||
"quota_exceeded": "您今日免费配额已用尽,请前往 <provider>{{provider}}</provider> 获取API密钥,配置API密钥后继续使用",
|
||||
"response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥"
|
||||
},
|
||||
"data": "数据",
|
||||
"detail": "错误详情",
|
||||
"details": "详细信息",
|
||||
"http": {
|
||||
@@ -856,6 +861,7 @@
|
||||
"exists": "模型已存在",
|
||||
"not_exists": "模型不存在"
|
||||
},
|
||||
"name": "错误名称",
|
||||
"no_api_key": "API 密钥未配置",
|
||||
"pause_placeholder": "已中断",
|
||||
"provider_disabled": "模型提供商未启用",
|
||||
@@ -864,9 +870,13 @@
|
||||
"title": "渲染错误"
|
||||
},
|
||||
"requestBody": "请求内容",
|
||||
"requestBodyValues": "请求体",
|
||||
"requestUrl": "请求路径",
|
||||
"responseBody": "响应内容",
|
||||
"responseHeaders": "响应首部",
|
||||
"stack": "堆栈信息",
|
||||
"status": "状态码",
|
||||
"statusCode": "状态码",
|
||||
"unknown": "未知错误",
|
||||
"user_message_not_found": "无法找到原始用户消息"
|
||||
},
|
||||
@@ -1560,6 +1570,7 @@
|
||||
"selected": "已选标签"
|
||||
},
|
||||
"function_calling": "函数调用",
|
||||
"invalid_model": "无效模型",
|
||||
"no_matches": "无可用模型",
|
||||
"parameter_name": "参数名称",
|
||||
"parameter_type": {
|
||||
@@ -1633,6 +1644,7 @@
|
||||
"only_markdown": "仅支持 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上传一个文件",
|
||||
"open_folder": "打开外部文件夹",
|
||||
"open_outside": "从外部打开",
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由于安全策略,文件名已从 {{original}} 更改为 {{final}}",
|
||||
"save": "保存到笔记",
|
||||
@@ -4152,7 +4164,7 @@
|
||||
"aborted": "翻译中止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "可粘贴或拖入文本、文件、图片(支持OCR)"
|
||||
"placeholder": "可粘贴或拖入文本、文本文件、图片(支持OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "源语言与设置的语言不同",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
|
||||
"title": "程式碼執行"
|
||||
},
|
||||
"code_image_tools": "啟用預覽工具",
|
||||
"code_image_tools": {
|
||||
"label": "啟用預覽工具",
|
||||
"tip": "為 mermaid 等程式碼區塊渲染後的圖像啟用預覽工具"
|
||||
},
|
||||
"code_wrappable": "程式碼區塊可自動換行",
|
||||
"context_count": {
|
||||
"label": "上下文",
|
||||
@@ -828,6 +831,7 @@
|
||||
"invalid": "無效的MCP伺服器"
|
||||
}
|
||||
},
|
||||
"cause": "錯誤原因",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "返回了無效的資料格式"
|
||||
@@ -837,6 +841,7 @@
|
||||
"quota_exceeded": "您今日{{quota}}免费配额已用尽,请前往 <provider>{{provider}}</provider> 获取API密钥,配置API密钥后继续使用",
|
||||
"response": "出現錯誤。如果尚未設定 API 金鑰,請前往設定 > 模型提供者中設定金鑰"
|
||||
},
|
||||
"data": "数据",
|
||||
"detail": "錯誤詳情",
|
||||
"details": "詳細信息",
|
||||
"http": {
|
||||
@@ -856,6 +861,7 @@
|
||||
"exists": "模型已存在",
|
||||
"not_exists": "模型不存在"
|
||||
},
|
||||
"name": "錯誤名稱",
|
||||
"no_api_key": "API 金鑰未設定",
|
||||
"pause_placeholder": "回應已暫停",
|
||||
"provider_disabled": "模型供應商未啟用",
|
||||
@@ -864,9 +870,13 @@
|
||||
"title": "渲染錯誤"
|
||||
},
|
||||
"requestBody": "請求內容",
|
||||
"requestBodyValues": "请求体",
|
||||
"requestUrl": "請求路徑",
|
||||
"responseBody": "响应内容",
|
||||
"responseHeaders": "响应首部",
|
||||
"stack": "堆棧信息",
|
||||
"status": "狀態碼",
|
||||
"statusCode": "狀態碼",
|
||||
"unknown": "未知錯誤",
|
||||
"user_message_not_found": "無法找到原始用戶訊息"
|
||||
},
|
||||
@@ -1560,6 +1570,7 @@
|
||||
"selected": "已選標籤"
|
||||
},
|
||||
"function_calling": "函數調用",
|
||||
"invalid_model": "無效模型",
|
||||
"no_matches": "無可用模型",
|
||||
"parameter_name": "參數名稱",
|
||||
"parameter_type": {
|
||||
@@ -1633,6 +1644,7 @@
|
||||
"only_markdown": "僅支援 Markdown 格式",
|
||||
"only_one_file_allowed": "只能上傳一個文件",
|
||||
"open_folder": "打開外部文件夾",
|
||||
"open_outside": "從外部打開",
|
||||
"rename": "重命名",
|
||||
"rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}",
|
||||
"save": "儲存到筆記",
|
||||
@@ -4152,7 +4164,7 @@
|
||||
"aborted": "翻譯中止"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "可粘貼或拖入文字、檔案、圖片(支援OCR)"
|
||||
"placeholder": "可粘貼或拖入文字、文字檔案、圖片(支援OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "源語言與設定的語言不同",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "Στη γραμμή εργαλείων των εκτελέσιμων blocks κώδικα θα εμφανίζεται το κουμπί εκτέλεσης· προσέξτε να μην εκτελέσετε επικίνδυνο κώδικα!",
|
||||
"title": "Εκτέλεση Κώδικα"
|
||||
},
|
||||
"code_image_tools": "Ενεργοποίηση εργαλείου προεπισκόπησης",
|
||||
"code_image_tools": {
|
||||
"label": "Ενεργοποίηση εργαλείου προεπισκόπησης",
|
||||
"tip": "Ενεργοποίηση εργαλείου προεπισκόπησης για εικόνες που αποδίδονται από blocks κώδικα όπως το mermaid"
|
||||
},
|
||||
"code_wrappable": "Οι κώδικες μπορούν να γράφονται σε διαφορετική γραμμή",
|
||||
"context_count": {
|
||||
"label": "Πλήθος ενδιάμεσων",
|
||||
@@ -677,6 +680,7 @@
|
||||
"model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε",
|
||||
"model_required": "Επιλέξτε μοντέλο",
|
||||
"select_folder": "Επιλογή φακέλου",
|
||||
"supported_providers": "υποστηριζόμενοι πάροχοι",
|
||||
"title": "Εργαλεία κώδικα",
|
||||
"update_options": "Ενημέρωση επιλογών",
|
||||
"working_directory": "κατάλογος εργασίας"
|
||||
@@ -743,6 +747,7 @@
|
||||
"delete": "Διαγραφή",
|
||||
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
||||
"description": "Περιγραφή",
|
||||
"detail": "Λεπτομέρειες",
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
"docs": "Έγγραφα",
|
||||
"download": "Λήψη",
|
||||
@@ -826,6 +831,7 @@
|
||||
"invalid": "Μη έγκυρος διακομιστής MCP"
|
||||
}
|
||||
},
|
||||
"cause": "Αιτία σφάλματος",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Επέστρεψε μη έγκυρη μορφή δεδομένων"
|
||||
@@ -835,6 +841,9 @@
|
||||
"quota_exceeded": "Η ημερήσια δωρεάν ποσόστωση {{quota}} tokens σας έχει εξαντληθεί. Παρακαλώ μεταβείτε στο <provider>{{provider}}</provider> για να λάβετε ένα κλειδί API και να ρυθμίσετε το κλειδί API για να συνεχίσετε τη χρήση.",
|
||||
"response": "Σφάλμα. Εάν δεν έχετε ρυθμίσει το κλειδί API, πηγαίνετε στο ρυθμισμένα > παρέχοντας το πρόσωπο του μοντέλου"
|
||||
},
|
||||
"data": "δεδομένα",
|
||||
"detail": "Λεπτομέρειες σφάλματος",
|
||||
"details": "Λεπτομέρειες",
|
||||
"http": {
|
||||
"400": "Σφάλμα ζητήματος, παρακαλώ ελέγξτε αν τα παράμετρα του ζητήματος είναι σωστά. Εάν έχετε αλλάξει τις ρυθμίσεις του μοντέλου, επαναφέρετε τις προεπιλεγμένες ρυθμίσεις.",
|
||||
"401": "Αποτυχία επιβεβαίωσης ταυτότητας, παρακαλώ ελέγξτε αν η κλειδί API είναι σωστή",
|
||||
@@ -846,11 +855,13 @@
|
||||
"503": "Η υπηρεσία δεν είναι διαθέσιμη, παρακαλώ δοκιμάστε ξανά",
|
||||
"504": "Υπερχρονισμός φάρων, παρακαλώ δοκιμάστε ξανά"
|
||||
},
|
||||
"message": "Μήνυμα σφάλματος",
|
||||
"missing_user_message": "Αδυναμία εναλλαγής απάντησης μοντέλου: το αρχικό μήνυμα χρήστη έχει διαγραφεί. Παρακαλούμε στείλτε ένα νέο μήνυμα για να λάβετε απάντηση από αυτό το μοντέλο",
|
||||
"model": {
|
||||
"exists": "Το μοντέλο υπάρχει ήδη",
|
||||
"not_exists": "Το μοντέλο δεν υπάρχει"
|
||||
},
|
||||
"name": "Λάθος όνομα",
|
||||
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
|
||||
"pause_placeholder": "Διακόπηκε",
|
||||
"provider_disabled": "Ο παρεχόμενος παροχός του μοντέλου δεν είναι ενεργοποιημένος",
|
||||
@@ -858,6 +869,14 @@
|
||||
"description": "Απέτυχε η ώθηση της εξίσωσης, παρακαλώ ελέγξτε το σωστό μορφάτι της",
|
||||
"title": "Σφάλμα Παρασκήνιου"
|
||||
},
|
||||
"requestBody": "Περιεχόμενο αιτήματος",
|
||||
"requestBodyValues": "Σώμα αιτήματος",
|
||||
"requestUrl": "Μονοπάτι αιτήματος",
|
||||
"responseBody": "απάντηση περιεχομένου",
|
||||
"responseHeaders": "Επικεφαλίδες απόκρισης",
|
||||
"stack": "Πληροφορίες στοίβας",
|
||||
"status": "Κωδικός κατάστασης",
|
||||
"statusCode": "Κωδικός κατάστασης",
|
||||
"unknown": "Άγνωστο σφάλμα",
|
||||
"user_message_not_found": "Αδυναμία εύρεσης της αρχικής μηνύματος χρήστη"
|
||||
},
|
||||
@@ -1319,7 +1338,8 @@
|
||||
"delete": {
|
||||
"content": "Η διαγραφή της ομάδας θα διαγράψει τις ερωτήσεις των χρηστών και όλες τις απαντήσεις του αστρόναυτη",
|
||||
"title": "Διαγραφή ομάδας"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Αποτυχημένο μήνυμα επανάληψης"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@@ -1550,6 +1570,7 @@
|
||||
"selected": "Επιλεγμένη ετικέτα"
|
||||
},
|
||||
"function_calling": "Ξεχωριστική Κλήση Συναρτήσεων",
|
||||
"invalid_model": "Μη έγκυρο μοντέλο",
|
||||
"no_matches": "Δεν υπάρχουν διαθέσιμα μοντέλα",
|
||||
"parameter_name": "Όνομα παραμέτρου",
|
||||
"parameter_type": {
|
||||
@@ -1619,9 +1640,13 @@
|
||||
"new_folder": "Νέος φάκελος",
|
||||
"new_note": "Δημιουργία νέας σημείωσης",
|
||||
"no_content_to_copy": "Δεν υπάρχει περιεχόμενο προς αντιγραφή",
|
||||
"no_file_selected": "Επιλέξτε το αρχείο για μεταφόρτωση",
|
||||
"only_markdown": "Υποστηρίζεται μόνο η μορφή Markdown",
|
||||
"only_one_file_allowed": "Μπορείτε να ανεβάσετε μόνο ένα αρχείο",
|
||||
"open_folder": "Άνοιγμα εξωτερικού φακέλου",
|
||||
"open_outside": "Από το εξωτερικό",
|
||||
"rename": "μετονομασία",
|
||||
"rename_changed": "Λόγω πολιτικής ασφάλειας, το όνομα του αρχείου έχει αλλάξει από {{original}} σε {{final}}",
|
||||
"save": "αποθήκευση στις σημειώσεις",
|
||||
"settings": {
|
||||
"data": {
|
||||
@@ -3343,6 +3368,8 @@
|
||||
"label": "Καταγραφή στοιχείων στο grid"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Επιβεβαίωση πριν τη διαγραφή μηνύματος",
|
||||
"confirm_regenerate_message": "Επιβεβαίωση πριν από την επαναδημιουργία του μηνύματος",
|
||||
"enable_quick_triggers": "Ενεργοποίηση των '/' και '@' για γρήγορη πρόσβαση σε μενού",
|
||||
"paste_long_text_as_file": "Επικόλληση μεγάλου κειμένου ως αρχείο",
|
||||
"paste_long_text_threshold": "Όριο μεγάλου κειμένου",
|
||||
@@ -4137,7 +4164,7 @@
|
||||
"aborted": "Η μετάφραση διακόπηκε"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Μπορείτε να επικολλήσετε ή να σύρετε κείμενο, αρχεία, εικόνες (με υποστήριξη OCR)"
|
||||
"placeholder": "Μπορείτε να επικολλήσετε ή να σύρετε κείμενο, αρχεία κειμένου, εικόνες (υποστηρίζεται η OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "Η γλώσσα πηγής διαφέρει από την οριζόμενη γλώσσα",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "En la barra de herramientas de bloques de código ejecutables se mostrará un botón de ejecución. ¡Tenga cuidado en no ejecutar código peligroso!",
|
||||
"title": "Ejecución de Código"
|
||||
},
|
||||
"code_image_tools": "Activar herramientas de vista previa",
|
||||
"code_image_tools": {
|
||||
"label": "Habilitar herramienta de vista previa",
|
||||
"tip": "Habilitar herramientas de vista previa para imágenes renderizadas de bloques de código como mermaid"
|
||||
},
|
||||
"code_wrappable": "Bloques de código reemplazables",
|
||||
"context_count": {
|
||||
"label": "Número de contextos",
|
||||
@@ -677,6 +680,7 @@
|
||||
"model_placeholder": "Seleccionar el modelo que se va a utilizar",
|
||||
"model_required": "Seleccione el modelo",
|
||||
"select_folder": "Seleccionar carpeta",
|
||||
"supported_providers": "Proveedores de servicios compatibles",
|
||||
"title": "Herramientas de código",
|
||||
"update_options": "Opciones de actualización",
|
||||
"working_directory": "directorio de trabajo"
|
||||
@@ -743,6 +747,7 @@
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
|
||||
"description": "Descripción",
|
||||
"detail": "Detalles",
|
||||
"disabled": "Desactivado",
|
||||
"docs": "Documentos",
|
||||
"download": "Descargar",
|
||||
@@ -826,6 +831,7 @@
|
||||
"invalid": "Servidor MCP no válido"
|
||||
}
|
||||
},
|
||||
"cause": "Error原因",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Devuelve un formato de datos no válido"
|
||||
@@ -835,6 +841,9 @@
|
||||
"quota_exceeded": "Su cuota gratuita diaria de {{quota}} tokens se ha agotado. Por favor, vaya a <provider>{{provider}}</provider> para obtener una clave API y configurar la clave API para continuar usando.",
|
||||
"response": "Ha ocurrido un error, si no ha configurado la clave API, vaya a Configuración > Proveedor de modelos para configurar la clave"
|
||||
},
|
||||
"data": "datos",
|
||||
"detail": "Detalles del error",
|
||||
"details": "Detalles",
|
||||
"http": {
|
||||
"400": "Error en la solicitud, revise si los parámetros de la solicitud son correctos. Si modificó la configuración del modelo, restablezca a la configuración predeterminada",
|
||||
"401": "Fallo en la autenticación, revise si la clave API es correcta",
|
||||
@@ -846,11 +855,13 @@
|
||||
"503": "Servicio no disponible, inténtelo de nuevo más tarde",
|
||||
"504": "Tiempo de espera de la puerta de enlace, inténtelo de nuevo más tarde"
|
||||
},
|
||||
"message": "错误信息",
|
||||
"missing_user_message": "No se puede cambiar la respuesta del modelo: el mensaje original del usuario ha sido eliminado. Envíe un nuevo mensaje para obtener la respuesta de este modelo",
|
||||
"model": {
|
||||
"exists": "El modelo ya existe",
|
||||
"not_exists": "El modelo no existe"
|
||||
},
|
||||
"name": "Nombre de error",
|
||||
"no_api_key": "La clave API no está configurada",
|
||||
"pause_placeholder": "Interrumpido",
|
||||
"provider_disabled": "El proveedor de modelos no está habilitado",
|
||||
@@ -858,6 +869,14 @@
|
||||
"description": "Error al renderizar la fórmula, por favor, compruebe si el formato de la fórmula es correcto",
|
||||
"title": "Error de renderizado"
|
||||
},
|
||||
"requestBody": "Contenido de la solicitud",
|
||||
"requestBodyValues": "Cuerpo de la solicitud",
|
||||
"requestUrl": "Ruta de solicitud",
|
||||
"responseBody": "Contenido de la respuesta",
|
||||
"responseHeaders": "Encabezados de respuesta",
|
||||
"stack": "Información de la pila",
|
||||
"status": "código de estado",
|
||||
"statusCode": "código de estado",
|
||||
"unknown": "Error desconocido",
|
||||
"user_message_not_found": "No se pudo encontrar el mensaje original del usuario"
|
||||
},
|
||||
@@ -1319,7 +1338,8 @@
|
||||
"delete": {
|
||||
"content": "Eliminar el mensaje del grupo eliminará la pregunta del usuario y todas las respuestas del asistente",
|
||||
"title": "Eliminar mensaje del grupo"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Reintentar el mensaje con error"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@@ -1550,6 +1570,7 @@
|
||||
"selected": "Etiquetas seleccionadas"
|
||||
},
|
||||
"function_calling": "Llamada a función",
|
||||
"invalid_model": "Modelo inválido",
|
||||
"no_matches": "No hay modelos disponibles",
|
||||
"parameter_name": "Nombre del parámetro",
|
||||
"parameter_type": {
|
||||
@@ -1619,9 +1640,13 @@
|
||||
"new_folder": "Nueva carpeta",
|
||||
"new_note": "Crear nota nueva",
|
||||
"no_content_to_copy": "No hay contenido para copiar",
|
||||
"no_file_selected": "Por favor, seleccione el archivo a subir",
|
||||
"only_markdown": "Solo se admite el formato Markdown",
|
||||
"only_one_file_allowed": "solo se puede subir un archivo",
|
||||
"open_folder": "abrir carpeta externa",
|
||||
"open_outside": "Abrir desde el exterior",
|
||||
"rename": "renombrar",
|
||||
"rename_changed": "Debido a políticas de seguridad, el nombre del archivo ha cambiado de {{original}} a {{final}}",
|
||||
"save": "Guardar en notas",
|
||||
"settings": {
|
||||
"data": {
|
||||
@@ -3343,6 +3368,8 @@
|
||||
"label": "Desencadenante de detalles de cuadrícula"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Confirmar antes de eliminar mensaje",
|
||||
"confirm_regenerate_message": "confirmar antes de regenerar el mensaje",
|
||||
"enable_quick_triggers": "Habilitar menú rápido con '/' y '@'",
|
||||
"paste_long_text_as_file": "Pegar texto largo como archivo",
|
||||
"paste_long_text_threshold": "Límite de longitud de texto largo",
|
||||
@@ -4137,7 +4164,7 @@
|
||||
"aborted": "Traducción cancelada"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Se puede pegar o arrastrar texto, archivos e imágenes (compatible con OCR)"
|
||||
"placeholder": "Puede pegar o arrastrar texto, archivos de texto o imágenes (compatible con OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "El idioma de origen es diferente al idioma configurado",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "Une bouton d'exécution s'affichera dans la barre d'outils des blocs de code exécutables. Attention à ne pas exécuter de code dangereux !",
|
||||
"title": "Exécution de code"
|
||||
},
|
||||
"code_image_tools": "Activer l'outil d'aperçu",
|
||||
"code_image_tools": {
|
||||
"label": "Activer l'outil d'aperçu",
|
||||
"tip": "Activer les outils de prévisualisation pour les images rendues des blocs de code tels que mermaid"
|
||||
},
|
||||
"code_wrappable": "Blocs de code avec retours à la ligne",
|
||||
"context_count": {
|
||||
"label": "Nombre de contextes",
|
||||
@@ -677,6 +680,7 @@
|
||||
"model_placeholder": "Sélectionnez le modèle à utiliser",
|
||||
"model_required": "Veuillez sélectionner le modèle",
|
||||
"select_folder": "Sélectionner le dossier",
|
||||
"supported_providers": "fournisseurs pris en charge",
|
||||
"title": "Outils de code",
|
||||
"update_options": "Options de mise à jour",
|
||||
"working_directory": "répertoire de travail"
|
||||
@@ -743,6 +747,7 @@
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
|
||||
"description": "Description",
|
||||
"detail": "détails",
|
||||
"disabled": "Désactivé",
|
||||
"docs": "Documents",
|
||||
"download": "Télécharger",
|
||||
@@ -826,6 +831,7 @@
|
||||
"invalid": "Serveur MCP invalide"
|
||||
}
|
||||
},
|
||||
"cause": "Erreur causée par",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "a renvoyé un format de données invalide"
|
||||
@@ -835,6 +841,9 @@
|
||||
"quota_exceeded": "Votre quota gratuit quotidien de {{quota}} tokens a été épuisé. Veuillez vous rendre sur <provider>{{provider}}</provider> pour obtenir une clé API et configurer la clé API pour continuer à utiliser.",
|
||||
"response": "Une erreur s'est produite, si l'API n'est pas configurée, veuillez aller dans Paramètres > Fournisseurs de modèles pour configurer la clé"
|
||||
},
|
||||
"data": "données",
|
||||
"detail": "Détails de l'erreur",
|
||||
"details": "Informations détaillées",
|
||||
"http": {
|
||||
"400": "Erreur de requête, veuillez vérifier si les paramètres de la requête sont corrects. Si vous avez modifié les paramètres du modèle, réinitialisez-les aux paramètres par défaut.",
|
||||
"401": "Échec de l'authentification, veuillez vérifier que votre clé API est correcte.",
|
||||
@@ -846,11 +855,13 @@
|
||||
"503": "Service indisponible, veuillez réessayer plus tard.",
|
||||
"504": "Délai d'expiration de la passerelle, veuillez réessayer plus tard."
|
||||
},
|
||||
"message": "Erreur message",
|
||||
"missing_user_message": "Impossible de changer de modèle de réponse : le message utilisateur d'origine a été supprimé. Veuillez envoyer un nouveau message pour obtenir une réponse de ce modèle.",
|
||||
"model": {
|
||||
"exists": "Le modèle existe déjà",
|
||||
"not_exists": "Le modèle n'existe pas"
|
||||
},
|
||||
"name": "Nom d'erreur",
|
||||
"no_api_key": "La clé API n'est pas configurée",
|
||||
"pause_placeholder": "Прервано",
|
||||
"provider_disabled": "Le fournisseur de modèles n'est pas activé",
|
||||
@@ -858,6 +869,14 @@
|
||||
"description": "La formule n'a pas été rendue avec succès, veuillez vérifier si le format de la formule est correct",
|
||||
"title": "Erreur de rendu"
|
||||
},
|
||||
"requestBody": "Contenu de la demande",
|
||||
"requestBodyValues": "Corps de la requête",
|
||||
"requestUrl": "Chemin de la requête",
|
||||
"responseBody": "Contenu de la réponse",
|
||||
"responseHeaders": "En-têtes de réponse",
|
||||
"stack": "Informations de la pile",
|
||||
"status": "Code d'état",
|
||||
"statusCode": "Code d'état",
|
||||
"unknown": "Неизвестная ошибка",
|
||||
"user_message_not_found": "Impossible de trouver le message d'utilisateur original"
|
||||
},
|
||||
@@ -1319,7 +1338,8 @@
|
||||
"delete": {
|
||||
"content": "La suppression du groupe de messages supprimera les questions des utilisateurs et toutes les réponses des assistants",
|
||||
"title": "Supprimer le groupe de messages"
|
||||
}
|
||||
},
|
||||
"retry_failed": "message d'erreur de nouvelle tentative"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@@ -1550,6 +1570,7 @@
|
||||
"selected": "Étiquette sélectionnée"
|
||||
},
|
||||
"function_calling": "Appel de fonction",
|
||||
"invalid_model": "Modèle invalide",
|
||||
"no_matches": "Aucun modèle disponible",
|
||||
"parameter_name": "Nom du paramètre",
|
||||
"parameter_type": {
|
||||
@@ -1619,9 +1640,13 @@
|
||||
"new_folder": "Nouveau dossier",
|
||||
"new_note": "Nouvelle note",
|
||||
"no_content_to_copy": "Aucun contenu à copier",
|
||||
"no_file_selected": "Veuillez sélectionner le fichier à télécharger",
|
||||
"only_markdown": "uniquement le format Markdown est pris en charge",
|
||||
"only_one_file_allowed": "On ne peut télécharger qu'un seul fichier",
|
||||
"open_folder": "ouvrir le dossier externe",
|
||||
"open_outside": "Ouvrir depuis l'extérieur",
|
||||
"rename": "renommer",
|
||||
"rename_changed": "En raison de la politique de sécurité, le nom du fichier a été changé de {{original}} à {{final}}",
|
||||
"save": "sauvegarder dans les notes",
|
||||
"settings": {
|
||||
"data": {
|
||||
@@ -3343,6 +3368,8 @@
|
||||
"label": "Déclencheur de popover de la grille"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "Confirmer avant de supprimer le message",
|
||||
"confirm_regenerate_message": "Confirmer avant de régénérer le message",
|
||||
"enable_quick_triggers": "Activer les menus rapides avec '/' et '@'",
|
||||
"paste_long_text_as_file": "Coller le texte long sous forme de fichier",
|
||||
"paste_long_text_threshold": "Seuil de longueur de texte",
|
||||
@@ -4137,7 +4164,7 @@
|
||||
"aborted": "Traduction annulée"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Peut coller ou glisser du texte, des fichiers, des images (avec reconnaissance optique de caractères)"
|
||||
"placeholder": "Peut coller ou glisser du texte, des fichiers texte ou des images (avec prise en charge de l'OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "La langue source est différente de la langue définie",
|
||||
|
||||
@@ -538,7 +538,10 @@
|
||||
"tip": "A barra de ferramentas de blocos de código executáveis exibirá um botão de execução; atenção para não executar códigos perigosos!",
|
||||
"title": "Execução de Código"
|
||||
},
|
||||
"code_image_tools": "Ativar ferramenta de pré-visualização",
|
||||
"code_image_tools": {
|
||||
"label": "Habilitar ferramenta de visualização",
|
||||
"tip": "Ativar ferramentas de visualização para imagens renderizadas de blocos de código como mermaid"
|
||||
},
|
||||
"code_wrappable": "Bloco de código com quebra de linha",
|
||||
"context_count": {
|
||||
"label": "Número de contexto",
|
||||
@@ -677,6 +680,7 @@
|
||||
"model_placeholder": "Selecione o modelo a ser utilizado",
|
||||
"model_required": "Selecione o modelo",
|
||||
"select_folder": "Selecionar pasta",
|
||||
"supported_providers": "Provedores de serviço suportados",
|
||||
"title": "Ferramenta de código",
|
||||
"update_options": "Opções de atualização",
|
||||
"working_directory": "diretório de trabalho"
|
||||
@@ -743,6 +747,7 @@
|
||||
"delete": "Excluir",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir?",
|
||||
"description": "Descrição",
|
||||
"detail": "detalhes",
|
||||
"disabled": "Desativado",
|
||||
"docs": "Documentos",
|
||||
"download": "Baixar",
|
||||
@@ -826,6 +831,7 @@
|
||||
"invalid": "Servidor MCP inválido"
|
||||
}
|
||||
},
|
||||
"cause": "Causa do erro",
|
||||
"chat": {
|
||||
"chunk": {
|
||||
"non_json": "Devolveu um formato de dados inválido"
|
||||
@@ -835,6 +841,9 @@
|
||||
"quota_exceeded": "Sua cota gratuita diária de {{quota}} tokens foi esgotada. Por favor, vá para <provider>{{provider}}</provider> para obter uma chave API e configurar a chave API para continuar usando.",
|
||||
"response": "Ocorreu um erro, se a chave da API não foi configurada, por favor vá para Configurações > Provedores de Modelo para configurar a chave"
|
||||
},
|
||||
"data": "dados",
|
||||
"detail": "Detalhes do erro",
|
||||
"details": "Detalhes",
|
||||
"http": {
|
||||
"400": "Erro na solicitação, por favor verifique se os parâmetros da solicitação estão corretos. Se você alterou as configurações do modelo, redefina para as configurações padrão",
|
||||
"401": "Falha na autenticação, por favor verifique se a chave da API está correta",
|
||||
@@ -846,11 +855,13 @@
|
||||
"503": "Serviço indisponível, por favor tente novamente mais tarde",
|
||||
"504": "Tempo de espera do gateway excedido, por favor tente novamente mais tarde"
|
||||
},
|
||||
"message": "Mensagem de erro",
|
||||
"missing_user_message": "Não é possível alternar a resposta do modelo: a mensagem original do usuário foi excluída. Envie uma nova mensagem para obter a resposta deste modelo",
|
||||
"model": {
|
||||
"exists": "O modelo já existe",
|
||||
"not_exists": "O modelo não existe"
|
||||
},
|
||||
"name": "Nome do erro",
|
||||
"no_api_key": "A chave da API não foi configurada",
|
||||
"pause_placeholder": "Interrompido",
|
||||
"provider_disabled": "O provedor de modelos está desativado",
|
||||
@@ -858,6 +869,14 @@
|
||||
"description": "Falha ao renderizar a fórmula, por favor verifique se o formato da fórmula está correto",
|
||||
"title": "Erro de Renderização"
|
||||
},
|
||||
"requestBody": "Conteúdo da solicitação",
|
||||
"requestBodyValues": "Corpo da solicitação",
|
||||
"requestUrl": "Caminho da solicitação",
|
||||
"responseBody": "Conteúdo da resposta",
|
||||
"responseHeaders": "Cabeçalho de resposta",
|
||||
"stack": "Informações da pilha",
|
||||
"status": "Código de status",
|
||||
"statusCode": "Código de status",
|
||||
"unknown": "Erro desconhecido",
|
||||
"user_message_not_found": "Não foi possível encontrar a mensagem original do usuário"
|
||||
},
|
||||
@@ -1319,7 +1338,8 @@
|
||||
"delete": {
|
||||
"content": "Excluir mensagens de grupo removerá as perguntas dos usuários e todas as respostas do assistente",
|
||||
"title": "Excluir mensagens de grupo"
|
||||
}
|
||||
},
|
||||
"retry_failed": "Repetir mensagem com erro"
|
||||
},
|
||||
"ignore": {
|
||||
"knowledge": {
|
||||
@@ -1550,6 +1570,7 @@
|
||||
"selected": "Etiqueta selecionada"
|
||||
},
|
||||
"function_calling": "Chamada de função",
|
||||
"invalid_model": "Modelo inválido",
|
||||
"no_matches": "Nenhum modelo disponível",
|
||||
"parameter_name": "Nome do parâmetro",
|
||||
"parameter_type": {
|
||||
@@ -1619,9 +1640,13 @@
|
||||
"new_folder": "Nova pasta",
|
||||
"new_note": "Nova nota",
|
||||
"no_content_to_copy": "Não há conteúdo para copiar",
|
||||
"no_file_selected": "Selecione o arquivo a ser enviado",
|
||||
"only_markdown": "Apenas o formato Markdown é suportado",
|
||||
"only_one_file_allowed": "só é possível enviar um arquivo",
|
||||
"open_folder": "Abrir pasta externa",
|
||||
"open_outside": "Abrir externamente",
|
||||
"rename": "renomear",
|
||||
"rename_changed": "Devido às políticas de segurança, o nome do arquivo foi alterado de {{original}} para {{final}}",
|
||||
"save": "salvar em notas",
|
||||
"settings": {
|
||||
"data": {
|
||||
@@ -3343,6 +3368,8 @@
|
||||
"label": "Disparador de detalhes da grade"
|
||||
},
|
||||
"input": {
|
||||
"confirm_delete_message": "confirmar antes de excluir a mensagem",
|
||||
"confirm_regenerate_message": "Confirmar antes de regenerar a mensagem",
|
||||
"enable_quick_triggers": "Ativar menu rápido com '/' e '@'",
|
||||
"paste_long_text_as_file": "Colar texto longo como arquivo",
|
||||
"paste_long_text_threshold": "Limite de texto longo",
|
||||
@@ -4137,7 +4164,7 @@
|
||||
"aborted": "Tradução interrompida"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Pode colar ou arrastar e soltar texto, arquivos e imagens (suporte a OCR)"
|
||||
"placeholder": "Pode colar ou arrastar texto, arquivos de texto ou imagens (com suporte a OCR)"
|
||||
},
|
||||
"language": {
|
||||
"not_pair": "O idioma de origem é diferente do idioma definido",
|
||||
|
||||
@@ -139,7 +139,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
} as Partial<Components>
|
||||
}, [block.id])
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
if (/<style\b[^>]*>/i.test(messageContent)) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,16 @@ import { getHttpMessageLabel, getProviderLabel } from '@renderer/i18n/label'
|
||||
import { getProviderById } from '@renderer/services/ProviderService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { removeBlocksThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import {
|
||||
isSerializedAiSdkAPICallError,
|
||||
isSerializedAiSdkError,
|
||||
isSerializedError,
|
||||
SerializedAiSdkAPICallError,
|
||||
SerializedAiSdkError,
|
||||
SerializedError
|
||||
} from '@renderer/types/error'
|
||||
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
|
||||
import { Alert as AntdAlert, Button, Modal } from 'antd'
|
||||
import React, { useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
@@ -25,13 +34,16 @@ const ErrorBlock: React.FC<Props> = ({ block, message }) => {
|
||||
const ErrorMessage: React.FC<{ block: ErrorMessageBlock }> = ({ block }) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const i18nKey = `error.${block.error?.i18nKey}`
|
||||
const i18nKey = block.error && 'i18nKey' in block.error ? `error.${block.error?.i18nKey}` : ''
|
||||
const errorKey = `error.${block.error?.message}`
|
||||
const errorStatus = block.error?.status
|
||||
const errorStatus =
|
||||
block.error && ('status' in block.error || 'statusCode' in block.error)
|
||||
? block.error?.status || block.error?.statusCode
|
||||
: undefined
|
||||
|
||||
if (i18n.exists(i18nKey)) {
|
||||
const providerId = block.error?.providerId
|
||||
if (providerId) {
|
||||
const providerId = block.error && 'providerId' in block.error ? block.error?.providerId : undefined
|
||||
if (providerId && typeof providerId === 'string') {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey={i18nKey}
|
||||
@@ -54,10 +66,10 @@ const ErrorMessage: React.FC<{ block: ErrorMessageBlock }> = ({ block }) => {
|
||||
return t(errorKey)
|
||||
}
|
||||
|
||||
if (HTTP_ERROR_CODES.includes(errorStatus)) {
|
||||
if (typeof errorStatus === 'number' && HTTP_ERROR_CODES.includes(errorStatus)) {
|
||||
return (
|
||||
<h5>
|
||||
{getHttpMessageLabel(errorStatus)} {block.error?.message}
|
||||
{getHttpMessageLabel(errorStatus.toString())} {block.error?.message}
|
||||
</h5>
|
||||
)
|
||||
}
|
||||
@@ -80,15 +92,23 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
|
||||
}
|
||||
|
||||
const getAlertMessage = () => {
|
||||
if (block.error && HTTP_ERROR_CODES.includes(block.error?.status)) {
|
||||
const status =
|
||||
block.error && ('status' in block.error || 'statusCode' in block.error)
|
||||
? block.error?.status || block.error?.statusCode
|
||||
: undefined
|
||||
if (block.error && typeof status === 'number' && HTTP_ERROR_CODES.includes(status)) {
|
||||
return block.error.message
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getAlertDescription = () => {
|
||||
if (block.error && HTTP_ERROR_CODES.includes(block.error?.status)) {
|
||||
return getHttpMessageLabel(block.error.status)
|
||||
const status =
|
||||
block.error && ('status' in block.error || 'statusCode' in block.error)
|
||||
? block.error?.status || block.error?.statusCode
|
||||
: undefined
|
||||
if (block.error && typeof status === 'number' && HTTP_ERROR_CODES.includes(status)) {
|
||||
return getHttpMessageLabel(status.toString())
|
||||
}
|
||||
return <ErrorMessage block={block} />
|
||||
}
|
||||
@@ -123,7 +143,7 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
|
||||
interface ErrorDetailModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
error?: Record<string, any>
|
||||
error?: SerializedError
|
||||
}
|
||||
|
||||
const ErrorDetailModal: React.FC<ErrorDetailModalProps> = ({ open, onClose, error }) => {
|
||||
@@ -131,53 +151,31 @@ const ErrorDetailModal: React.FC<ErrorDetailModalProps> = ({ open, onClose, erro
|
||||
|
||||
const copyErrorDetails = () => {
|
||||
if (!error) return
|
||||
|
||||
const errorText = `
|
||||
${t('error.message')}: ${error.message || 'N/A'}
|
||||
${t('error.requestUrl')}: ${error.url || 'N/A'}
|
||||
${t('error.requestBody')}: ${error.requestBody ? JSON.stringify(error.requestBody, null, 2) : 'N/A'}
|
||||
${t('error.stack')}: ${error.stack || 'N/A'}
|
||||
`.trim()
|
||||
let errorText: string
|
||||
if (isSerializedAiSdkError(error)) {
|
||||
errorText = formatAiSdkError(error)
|
||||
} else if (isSerializedError(error)) {
|
||||
errorText = formatError(error)
|
||||
} else {
|
||||
// fallback
|
||||
errorText = safeToString(error)
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(errorText)
|
||||
window.message.success(t('message.copied'))
|
||||
}
|
||||
|
||||
const renderErrorDetails = (error: any) => {
|
||||
const renderErrorDetails = (error?: SerializedError) => {
|
||||
if (!error) return <div>{t('error.unknown')}</div>
|
||||
|
||||
if (isSerializedAiSdkAPICallError(error)) {
|
||||
return <AiApiCallError error={error} />
|
||||
}
|
||||
if (isSerializedAiSdkError(error)) {
|
||||
return <AiSdkError error={error} />
|
||||
}
|
||||
return (
|
||||
<ErrorDetailList>
|
||||
{error.message && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.message')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.message}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{error.url && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.requestUrl')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.url}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{error.requestBody && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.requestBody')}:</ErrorDetailLabel>
|
||||
<CodeViewer className="source-view" language="json" expanded>
|
||||
{JSON.stringify(error.requestBody, null, 2)}
|
||||
</CodeViewer>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{error.stack && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.stack')}:</ErrorDetailLabel>
|
||||
<StackTrace>
|
||||
<pre>{error.stack}</pre>
|
||||
</StackTrace>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
<BuiltinError error={error} />
|
||||
</ErrorDetailList>
|
||||
)
|
||||
}
|
||||
@@ -262,4 +260,110 @@ const Alert = styled(AntdAlert)`
|
||||
}
|
||||
`
|
||||
|
||||
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
|
||||
const BuiltinError = ({ error }: { error: SerializedError }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
{error.name && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.name')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.name}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
{error.message && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.message')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.message}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
{error.stack && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.stack')}:</ErrorDetailLabel>
|
||||
<StackTrace>
|
||||
<pre>{error.stack}</pre>
|
||||
</StackTrace>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
|
||||
const AiSdkError = ({ error }: { error: SerializedAiSdkError }) => {
|
||||
const { t } = useTranslation()
|
||||
const cause = error.cause
|
||||
return (
|
||||
<>
|
||||
<BuiltinError error={error} />
|
||||
{cause && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.cause}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const AiApiCallError = ({ error }: { error: SerializedAiSdkAPICallError }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 这些字段是 unknown 类型,暂且不清楚都可能是什么类型,总之先覆盖下大部分场景
|
||||
const requestBodyValues = safeToString(error.requestBodyValues)
|
||||
const data = safeToString(error.data)
|
||||
|
||||
return (
|
||||
<ErrorDetailList>
|
||||
<AiSdkError error={error} />
|
||||
|
||||
{error.url && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.requestUrl')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.url}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{requestBodyValues && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.requestBodyValues')}:</ErrorDetailLabel>
|
||||
<CodeViewer value={safeToString(error.requestBodyValues)} className="source-view" language="json" expanded />
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{error.statusCode && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.statusCode')}:</ErrorDetailLabel>
|
||||
<ErrorDetailValue>{error.statusCode}</ErrorDetailValue>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
{error.responseHeaders && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.responseHeaders')}:</ErrorDetailLabel>
|
||||
<CodeViewer
|
||||
value={JSON.stringify(error.responseHeaders, null, 2)}
|
||||
className="source-view"
|
||||
language="json"
|
||||
expanded
|
||||
/>
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{error.responseBody && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.responseBody')}:</ErrorDetailLabel>
|
||||
<CodeViewer value={error.responseBody} className="source-view" language="json" expanded />
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.data')}:</ErrorDetailLabel>
|
||||
<CodeViewer value={safeToString(error.data)} className="source-view" language="json" expanded />
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
</ErrorDetailList>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ErrorBlock)
|
||||
|
||||
@@ -42,7 +42,19 @@ import {
|
||||
} from '@renderer/utils/messageUtils/find'
|
||||
import { Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { AtSign, Check, FilePenLine, Languages, ListChecks, Menu, Save, Split, ThumbsUp, Upload } from 'lucide-react'
|
||||
import {
|
||||
AtSign,
|
||||
Check,
|
||||
FilePenLine,
|
||||
Languages,
|
||||
ListChecks,
|
||||
Menu,
|
||||
NotebookPen,
|
||||
Save,
|
||||
Split,
|
||||
ThumbsUp,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -255,15 +267,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
onClick: () => {
|
||||
SaveToKnowledgePopup.showForMessage(message)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.save'),
|
||||
key: 'clipboard',
|
||||
onClick: async () => {
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMessageToNotes(title, markdown, notesPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -382,7 +385,6 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
toggleMultiSelectMode,
|
||||
message,
|
||||
mainTextContent,
|
||||
notesPath,
|
||||
messageContainerRef,
|
||||
topic.name
|
||||
]
|
||||
@@ -620,6 +622,21 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('notes.save')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMessageToNotes(title, markdown, notesPath)
|
||||
}}
|
||||
$softHoverBg={softHoverBg}>
|
||||
<NotebookPen size={15} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{confirmDeleteMessage ? (
|
||||
<Popconfirm
|
||||
title={t('message.message.delete.content')}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { setNarrowMode } from '@renderer/store/settings'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { Menu, MessageSquareDiff, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -83,11 +83,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
<PanelLeftClose size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}>
|
||||
<MessageSquareDiff size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</NavbarLeft>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import EditableNumber from '@renderer/components/EditableNumber'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { HelpTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
|
||||
import { isOpenAIModel } from '@renderer/config/models'
|
||||
import { UNKNOWN } from '@renderer/config/translate'
|
||||
@@ -48,8 +49,8 @@ import {
|
||||
import { Assistant, AssistantSettings, CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp, Settings2 } from 'lucide-react'
|
||||
import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -193,10 +194,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
}>
|
||||
<SettingGroup style={{ marginTop: 5 }}>
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.temperature.label')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.temperature.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.temperature.label')}
|
||||
<HelpTooltip title={t('chat.settings.temperature.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
@@ -224,10 +225,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
)}
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.context_count.label')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.context_count.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.context_count.label')}
|
||||
<HelpTooltip title={t('chat.settings.context_count.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
</Row>
|
||||
<Row align="middle" gutter={10}>
|
||||
<Col span={23}>
|
||||
@@ -256,10 +257,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<Row align="middle">
|
||||
<SettingRowTitleSmall>{t('chat.settings.max_tokens.label')}</SettingRowTitleSmall>
|
||||
<Tooltip title={t('chat.settings.max_tokens.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.max_tokens.label')}
|
||||
<HelpTooltip title={t('chat.settings.max_tokens.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
</Row>
|
||||
<Switch
|
||||
size="small"
|
||||
@@ -327,9 +328,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.thought_auto_collapse.label')}
|
||||
<Tooltip title={t('chat.settings.thought_auto_collapse.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('chat.settings.thought_auto_collapse.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
@@ -426,10 +425,8 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('settings.math.single_dollar.label')}{' '}
|
||||
<Tooltip title={t('settings.math.single_dollar.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
{t('settings.math.single_dollar.label')}
|
||||
<HelpTooltip title={t('settings.math.single_dollar.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
@@ -457,9 +454,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.title')}
|
||||
<Tooltip title={t('chat.settings.code_execution.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('chat.settings.code_execution.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
@@ -473,9 +468,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.timeout_minutes.label')}
|
||||
<Tooltip title={t('chat.settings.code_execution.timeout_minutes.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('chat.settings.code_execution.timeout_minutes.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<EditableNumber
|
||||
size="small"
|
||||
@@ -563,7 +556,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_image_tools')}</SettingRowTitleSmall>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_image_tools.label')}
|
||||
<HelpTooltip title={t('chat.settings.code_image_tools.tip')} />
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeImageTools}
|
||||
@@ -713,6 +709,7 @@ const Container = styled(Scrollbar)`
|
||||
|
||||
const SettingRowTitleSmall = styled(SettingRowTitle)`
|
||||
font-size: 13px;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const SettingGroup = styled.div<{ theme?: ThemeMode }>`
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
FolderOpen,
|
||||
HelpCircle,
|
||||
MenuIcon,
|
||||
NotebookPen,
|
||||
PackagePlus,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
@@ -276,6 +277,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
onPinTopic(topic)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.save'),
|
||||
key: 'notes',
|
||||
icon: <NotebookPen size={14} />,
|
||||
onClick: async () => {
|
||||
exportTopicToNotes(topic, notesPath)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.clear.title'),
|
||||
key: 'clear-messages',
|
||||
@@ -345,13 +354,6 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic,
|
||||
window.message.error(t('chat.save.topic.knowledge.error.save_failed'))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.save'),
|
||||
key: 'notes',
|
||||
onClick: async () => {
|
||||
exportTopicToNotes(topic, notesPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4,8 +4,9 @@ import { isLocalAi } from '@renderer/config/env'
|
||||
import { isEmbeddingModel, isRerankModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { Assistant, Model } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { Button, Tag } from 'antd'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import { FC, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -19,6 +20,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
const { model, updateAssistant } = useAssistant(assistant.id)
|
||||
const { t } = useTranslation()
|
||||
const timerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
const provider = useAppSelector((state) => state.llm.providers.find((p) => p.id === model?.provider))
|
||||
|
||||
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
|
||||
|
||||
@@ -60,6 +62,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
</ModelName>
|
||||
</ButtonContent>
|
||||
<ChevronsUpDown size={14} color="var(--color-icon)" />
|
||||
{!provider && <Tag color="error">{t('models.invalid_model')}</Tag>}
|
||||
</DropdownButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@renderer/components/InfoTooltip', () => ({
|
||||
default: ({ title }: { title: string }) => <div>{mocks.i18n.t(title)}</div>
|
||||
vi.mock('@renderer/components/TooltipIcons', () => ({
|
||||
InfoTooltip: ({ title }: { title: string }) => <div>{mocks.i18n.t(title)}</div>
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
|
||||
@@ -31,8 +31,8 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
// Mock InfoTooltip component
|
||||
vi.mock('@renderer/components/InfoTooltip', () => ({
|
||||
default: ({ title, placement }: { title: string; placement: string }) => (
|
||||
vi.mock('@renderer/components/TooltipIcons', () => ({
|
||||
InfoTooltip: ({ title, placement }: { title: string; placement: string }) => (
|
||||
<span data-testid="info-tooltip" title={title} data-placement={placement}>
|
||||
ℹ️
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Alert, InputNumber } from 'antd'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import { useFiles } from '@renderer/hooks/useFiles'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import StatusIcon from '@renderer/pages/knowledge/components/StatusIcon'
|
||||
@@ -48,6 +49,7 @@ const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap, preprocessMap }) => {
|
||||
const { t } = useTranslation()
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight)
|
||||
const { onSelectFile, selecting } = useFiles({ extensions: fileTypes })
|
||||
|
||||
const { base, fileItems, addFiles, refreshItem, removeItem, getProcessingStatus } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
@@ -71,19 +73,12 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
const handleAddFile = async () => {
|
||||
if (disabled || selecting) {
|
||||
return
|
||||
}
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.accept = fileTypes.join(',')
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
files && handleDrop(Array.from(files))
|
||||
}
|
||||
input.click()
|
||||
const selectedFiles = await onSelectFile({ multipleSelections: true })
|
||||
processFiles(selectedFiles)
|
||||
}
|
||||
|
||||
const handleDrop = async (files: File[]) => {
|
||||
@@ -118,8 +113,14 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
}
|
||||
})
|
||||
.filter(({ ext }) => fileTypes.includes(ext))
|
||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
logger.debug('uploadedFiles', uploadedFiles)
|
||||
processFiles(_files)
|
||||
}
|
||||
}
|
||||
|
||||
const processFiles = async (files: FileMetadata[]) => {
|
||||
logger.debug('processFiles', files)
|
||||
if (files.length > 0) {
|
||||
const uploadedFiles = await FileManager.uploadFiles(files)
|
||||
addFiles(uploadedFiles)
|
||||
}
|
||||
}
|
||||
@@ -150,16 +151,23 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
</ItemHeader>
|
||||
|
||||
<ItemFlexColumn>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddFile()
|
||||
}}>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}
|
||||
openFileDialogOnClick={false}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
{fileItems.length === 0 ? (
|
||||
<KnowledgeEmptyView />
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { loggerService } from '@logger'
|
||||
import AiProvider from '@renderer/aiCore'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/ap
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useActiveNode } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useStore'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { findNodeInTree } from '@renderer/services/NotesTreeService'
|
||||
import { Breadcrumb, BreadcrumbProps, Dropdown, Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useStore'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import { Tooltip } from 'antd'
|
||||
import { PanelLeftClose, PanelRightClose } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hooks/useNotesQuery'
|
||||
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
|
||||
import {
|
||||
createFolder,
|
||||
createNote,
|
||||
@@ -20,6 +20,7 @@ import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType }
|
||||
import { NotesSortType, NotesTreeNode } from '@renderer/types/note'
|
||||
import { FileChangeEvent } from '@shared/config/types'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { debounce } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -34,7 +35,7 @@ const logger = loggerService.withContext('NotesPage')
|
||||
const NotesPage: FC = () => {
|
||||
const editorRef = useRef<RichEditorRef>(null)
|
||||
const { t } = useTranslation()
|
||||
const { showWorkspace } = useSettings()
|
||||
const { showWorkspace } = useShowWorkspace()
|
||||
const dispatch = useAppDispatch()
|
||||
const activeFilePath = useAppSelector(selectActiveFilePath)
|
||||
const sortType = useAppSelector(selectSortType)
|
||||
@@ -51,7 +52,6 @@ const NotesPage: FC = () => {
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
const watcherRef = useRef<(() => void) | null>(null)
|
||||
const isSyncingTreeRef = useRef(false)
|
||||
const isEditorInitialized = useRef(false)
|
||||
const lastContentRef = useRef<string>('')
|
||||
const lastFilePathRef = useRef<string | undefined>(undefined)
|
||||
const isInitialSortApplied = useRef(false)
|
||||
@@ -85,7 +85,7 @@ const NotesPage: FC = () => {
|
||||
const saveCurrentNote = useCallback(
|
||||
async (content: string, filePath?: string) => {
|
||||
const targetPath = filePath || activeFilePath
|
||||
if (!targetPath || content === currentContent) return
|
||||
if (!targetPath || content.trim() === currentContent.trim()) return
|
||||
|
||||
try {
|
||||
await window.api.file.write(targetPath, content)
|
||||
@@ -113,8 +113,7 @@ const NotesPage: FC = () => {
|
||||
lastContentRef.current = newMarkdown
|
||||
lastFilePathRef.current = activeFilePath
|
||||
// 捕获当前文件路径,避免在防抖执行时文件路径已改变的竞态条件
|
||||
const currentFilePath = activeFilePath
|
||||
debouncedSave(newMarkdown, currentFilePath)
|
||||
debouncedSave(newMarkdown, activeFilePath)
|
||||
},
|
||||
[debouncedSave, activeFilePath]
|
||||
)
|
||||
@@ -284,26 +283,35 @@ const NotesPage: FC = () => {
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentContent && editorRef.current) {
|
||||
editorRef.current.setMarkdown(currentContent)
|
||||
// 标记编辑器已初始化
|
||||
isEditorInitialized.current = true
|
||||
const editor = editorRef.current
|
||||
if (!editor || !currentContent) return
|
||||
// 获取编辑器当前内容
|
||||
const editorMarkdown = editor.getMarkdown()
|
||||
|
||||
// 只有当编辑器内容与期望内容不一致时才更新
|
||||
// 这样既能处理初始化,也能处理后续的内容同步,还能避免光标跳动
|
||||
if (editorMarkdown !== currentContent) {
|
||||
editor.setMarkdown(currentContent)
|
||||
}
|
||||
}, [currentContent, activeFilePath])
|
||||
|
||||
// 切换文件时重置编辑器初始化状态并兜底保存
|
||||
// 切换文件时的清理工作
|
||||
useEffect(() => {
|
||||
if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) {
|
||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save before file switch failed:', error as Error)
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
// 保存之前文件的内容
|
||||
if (lastContentRef.current && lastFilePathRef.current) {
|
||||
saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => {
|
||||
logger.error('Emergency save before file switch failed:', error as Error)
|
||||
})
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
isEditorInitialized.current = false
|
||||
lastContentRef.current = ''
|
||||
lastFilePathRef.current = undefined
|
||||
}, [activeFilePath, currentContent, saveCurrentNote])
|
||||
// 取消防抖保存并清理状态
|
||||
debouncedSave.cancel()
|
||||
lastContentRef.current = ''
|
||||
lastFilePathRef.current = undefined
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeFilePath])
|
||||
|
||||
// 获取目标文件夹路径(选中文件夹或根目录)
|
||||
const getTargetFolderPath = useCallback(() => {
|
||||
@@ -593,22 +601,31 @@ const NotesPage: FC = () => {
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('notes.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
{showWorkspace && (
|
||||
<NotesSidebar
|
||||
notesTree={notesTree}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectNode={handleSelectNode}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onRenameNode={handleRenameNode}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMoveNode={handleMoveNode}
|
||||
onSortNodes={handleSortNodes}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
{showWorkspace && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 250, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}>
|
||||
<NotesSidebar
|
||||
notesTree={notesTree}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectNode={handleSelectNode}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNode={handleDeleteNode}
|
||||
onRenameNode={handleRenameNode}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMoveNode={handleMoveNode}
|
||||
onSortNodes={handleSortNodes}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<EditorWrapper>
|
||||
<HeaderNavbar notesTree={notesTree} getCurrentNoteContent={getCurrentNoteContent} />
|
||||
<NotesEditor
|
||||
|
||||
@@ -303,6 +303,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onClick: () => {
|
||||
handleStartEdit(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.open_outside'),
|
||||
key: 'open_outside',
|
||||
icon: <FolderOpen size={14} />,
|
||||
onClick: () => {
|
||||
window.api.openPath(node.externalPath)
|
||||
}
|
||||
}
|
||||
]
|
||||
if (node.type !== 'folder') {
|
||||
@@ -520,6 +528,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
|
||||
const SidebarContainer = styled.div`
|
||||
width: 250px;
|
||||
min-width: 250px;
|
||||
height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CheckOutlined } from '@ant-design/icons'
|
||||
import { NotesSortType } from '@renderer/types/note'
|
||||
import { Dropdown, Input, MenuProps, Tooltip } from 'antd'
|
||||
import { ArrowLeft, ArrowUpNarrowWide, FilePlus, FolderPlus, Search, Star } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowUpNarrowWide, FilePlus2, FolderPlus, Search, Star } from 'lucide-react'
|
||||
import { FC, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -77,7 +77,7 @@ const NotesSidebarHeader: FC<NotesSidebarHeaderProps> = ({
|
||||
|
||||
<Tooltip title={t('notes.new_note')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCreateNote}>
|
||||
<FilePlus size={18} />
|
||||
<FilePlus2 size={18} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PlusOutlined, RedoOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import AiProviderNew from '@renderer/aiCore/index_new'
|
||||
import AiProvider from '@renderer/aiCore'
|
||||
import IcImageUp from '@renderer/assets/images/paintings/ic_ImageUp.svg'
|
||||
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
@@ -203,12 +203,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
try {
|
||||
if (mode === 'aihubmix_image_generate') {
|
||||
if (painting.model.startsWith('imagen-')) {
|
||||
const AI = new AiProviderNew({
|
||||
id: painting.model,
|
||||
provider: 'aihubmix',
|
||||
name: painting.model,
|
||||
group: 'imagen'
|
||||
})
|
||||
const AI = new AiProvider(aihubmixProvider)
|
||||
const base64s = await AI.generateImage({
|
||||
prompt,
|
||||
model: painting.model,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'emoji-picker-element'
|
||||
|
||||
import { CloseCircleFilled } from '@ant-design/icons'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import CodeViewer from '@renderer/components/CodeViewer'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
@@ -14,6 +13,7 @@ import { Button, Input, Popover } from 'antd'
|
||||
import { Edit, HelpCircle, Save } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider } from '..'
|
||||
@@ -122,7 +122,14 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
<TextAreaContainer>
|
||||
<RichEditorContainer>
|
||||
{showPreview ? (
|
||||
<CodeViewer children={processedPrompt} language="markdown" expanded={true} height="100%" />
|
||||
<MarkdownContainer
|
||||
onDoubleClick={() => {
|
||||
const currentScrollTop = editorRef.current?.getScrollTop?.() || 0
|
||||
setShowPreview(false)
|
||||
requestAnimationFrame(() => editorRef.current?.setScrollTop?.(currentScrollTop))
|
||||
}}>
|
||||
<ReactMarkdown>{processedPrompt || prompt}</ReactMarkdown>
|
||||
</MarkdownContainer>
|
||||
) : (
|
||||
<CodeEditor
|
||||
value={prompt}
|
||||
@@ -214,4 +221,10 @@ const RichEditorContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const MarkdownContainer = styled.div.attrs({ className: 'markdown' })`
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default AssistantPromptSettings
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { SuccessTag } from '@renderer/components/Tags/SuccessTag'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// import { loggerService } from '@logger'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import CustomTag from '@renderer/components/Tags/CustomTag'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { TESSERACT_LANG_MAP } from '@renderer/config/ocr'
|
||||
import { useOcrProvider } from '@renderer/hooks/useOcrProvider'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useEnableDeveloperMode, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
|
||||
@@ -55,7 +55,7 @@ const McpServersList: FC = () => {
|
||||
originalList: mcpServers,
|
||||
filteredList: filteredMcpServers,
|
||||
onUpdate: updateMcpServers,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -251,6 +251,7 @@ const McpServersList: FC = () => {
|
||||
itemKey="id"
|
||||
onSortEnd={onSortEnd}
|
||||
layout="grid"
|
||||
gap={'12px'}
|
||||
useDragOverlay
|
||||
showGhost
|
||||
renderItem={(server) => (
|
||||
|
||||
@@ -116,7 +116,7 @@ export const syncModelScopeServers = async (
|
||||
env: {},
|
||||
isActive: true,
|
||||
provider: 'ModelScope',
|
||||
providerUrl: `${MODELSCOPE_HOST}/mcp/servers/@${server.id}`,
|
||||
providerUrl: `${MODELSCOPE_HOST}/mcp/servers/${server.id}`,
|
||||
logoUrl: server.logo_url || '',
|
||||
tags: server.tags || []
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RedoOutlined } from '@ant-design/icons'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ModelSelector from '@renderer/components/ModelSelector'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { Flex, Switch } from 'antd'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
VisionTag,
|
||||
WebSearchTag
|
||||
} from '@renderer/components/Tags/Model'
|
||||
import WarnTooltip from '@renderer/components/WarnTooltip'
|
||||
import { WarnTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
|
||||
import {
|
||||
isEmbeddingModel,
|
||||
|
||||
@@ -321,7 +321,7 @@ const ProviderList: FC = () => {
|
||||
originalList: providers,
|
||||
filteredList: filteredProviders,
|
||||
onUpdate: updateProviders,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import InfoTooltip from '@renderer/components/InfoTooltip'
|
||||
import { InfoTooltip } from '@renderer/components/TooltipIcons'
|
||||
import useTranslate from '@renderer/hooks/useTranslate'
|
||||
import { addCustomLanguage, updateCustomLanguage } from '@renderer/services/TranslateService'
|
||||
import { CustomTranslateLanguage } from '@renderer/types'
|
||||
|
||||
@@ -71,15 +71,27 @@ const BlacklistSettings: FC = () => {
|
||||
|
||||
function updateManualBlacklist(blacklist: string) {
|
||||
const blacklistDomains = blacklist.split('\n').filter((url) => url.trim() !== '')
|
||||
|
||||
const validDomains: string[] = []
|
||||
const hasError = blacklistDomains.some((domain) => {
|
||||
const parsed = parseMatchPattern(domain.trim())
|
||||
if (parsed === null) {
|
||||
return true // 有错误
|
||||
const trimmedDomain = domain.trim()
|
||||
// 正则表达式
|
||||
if (trimmedDomain.startsWith('/') && trimmedDomain.endsWith('/')) {
|
||||
try {
|
||||
const regexPattern = trimmedDomain.slice(1, -1)
|
||||
new RegExp(regexPattern, 'i')
|
||||
validDomains.push(trimmedDomain)
|
||||
return false
|
||||
} catch (error) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const parsed = parseMatchPattern(trimmedDomain)
|
||||
if (parsed === null) {
|
||||
return true
|
||||
}
|
||||
validDomains.push(trimmedDomain)
|
||||
return false
|
||||
}
|
||||
validDomains.push(domain.trim())
|
||||
return false
|
||||
})
|
||||
|
||||
setErrFormat(hasError)
|
||||
@@ -237,7 +249,9 @@ const BlacklistSettings: FC = () => {
|
||||
<Button onClick={() => updateManualBlacklist(blacklistInput)} style={{ marginTop: 10 }}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{errFormat && <Alert message={t('settings.tool.websearch.blacklist_tooltip')} type="error" />}
|
||||
{errFormat && (
|
||||
<Alert style={{ marginTop: 10 }} message={t('settings.tool.websearch.blacklist_tooltip')} type="error" />
|
||||
)}
|
||||
</SettingGroup>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
|
||||
@@ -277,7 +277,7 @@ const TranslatePage: FC = () => {
|
||||
// 控制复制按钮
|
||||
const onCopy = () => {
|
||||
navigator.clipboard.writeText(translatedContent)
|
||||
setCopied(false)
|
||||
setCopied(true)
|
||||
}
|
||||
|
||||
// 控制历史记录点击
|
||||
|
||||
@@ -83,7 +83,8 @@ export async function fetchChatCompletion({
|
||||
assistant,
|
||||
options,
|
||||
onChunkReceived,
|
||||
topicId
|
||||
topicId,
|
||||
uiMessages
|
||||
}: FetchChatCompletionParams) {
|
||||
logger.info('fetchChatCompletion called with detailed context', {
|
||||
messageCount: messages?.length || 0,
|
||||
@@ -132,7 +133,8 @@ export async function fetchChatCompletion({
|
||||
isImageGenerationEndpoint: isDedicatedImageGenerationModel(assistant.model || getDefaultModel()),
|
||||
enableWebSearch: capabilities.enableWebSearch,
|
||||
enableGenerateImage: capabilities.enableGenerateImage,
|
||||
mcpTools
|
||||
mcpTools,
|
||||
uiMessages
|
||||
}
|
||||
|
||||
// --- Call AI Completions ---
|
||||
@@ -141,7 +143,8 @@ export async function fetchChatCompletion({
|
||||
...middlewareConfig,
|
||||
assistant,
|
||||
topicId,
|
||||
callType: 'chat'
|
||||
callType: 'chat',
|
||||
uiMessages
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -134,8 +134,15 @@ export function getAssistantProvider(assistant: Assistant): Provider {
|
||||
|
||||
export function getProviderByModel(model?: Model): Provider {
|
||||
const providers = store.getState().llm.providers
|
||||
const providerId = model ? model.provider : getDefaultProvider().id
|
||||
return providers.find((p) => p.id === providerId) as Provider
|
||||
const provider = providers.find((p) => p.id === model?.provider)
|
||||
|
||||
if (!provider) {
|
||||
const defaultProvider = providers.find((p) => p.id === getDefaultModel()?.provider)
|
||||
const cherryinProvider = providers.find((p) => p.id === 'cherryin')
|
||||
return defaultProvider || cherryinProvider || providers[0]
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
export function getProviderByModelId(modelId?: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { convertMessagesToSdkMessages } from '@renderer/aiCore/prepareParams'
|
||||
import { Assistant, Message } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { filterAdjacentUserMessaegs, filterLastAssistantMessage } from '@renderer/utils/messageUtils/filters'
|
||||
import { ModelMessage } from 'ai'
|
||||
import { findLast, isEmpty, takeRight } from 'lodash'
|
||||
|
||||
import { getAssistantSettings, getDefaultModel } from './AssistantService'
|
||||
@@ -16,13 +16,16 @@ export class ConversationService {
|
||||
static async prepareMessagesForModel(
|
||||
messages: Message[],
|
||||
assistant: Assistant
|
||||
): Promise<StreamTextParams['messages']> {
|
||||
): Promise<{ modelMessages: ModelMessage[]; uiMessages: Message[] }> {
|
||||
const { contextCount } = getAssistantSettings(assistant)
|
||||
// This logic is extracted from the original ApiService.fetchChatCompletion
|
||||
// const contextMessages = filterContextMessages(messages)
|
||||
const lastUserMessage = findLast(messages, (m) => m.role === 'user')
|
||||
if (!lastUserMessage) {
|
||||
return
|
||||
return {
|
||||
modelMessages: [],
|
||||
uiMessages: []
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMessages1 = filterAfterContextClearMessages(messages)
|
||||
@@ -33,16 +36,19 @@ export class ConversationService {
|
||||
|
||||
const filteredMessages4 = filterAdjacentUserMessaegs(filteredMessages3)
|
||||
|
||||
let _messages = filterUserRoleStartMessages(
|
||||
let uiMessages = filterUserRoleStartMessages(
|
||||
filterEmptyMessages(filterAfterContextClearMessages(takeRight(filteredMessages4, contextCount + 2))) // 取原来几个provider的最大值
|
||||
)
|
||||
|
||||
// Fallback: ensure at least the last user message is present to avoid empty payloads
|
||||
if ((!_messages || _messages.length === 0) && lastUserMessage) {
|
||||
_messages = [lastUserMessage]
|
||||
if ((!uiMessages || uiMessages.length === 0) && lastUserMessage) {
|
||||
uiMessages = [lastUserMessage]
|
||||
}
|
||||
|
||||
return await convertMessagesToSdkMessages(_messages, assistant.model || getDefaultModel())
|
||||
return {
|
||||
modelMessages: await convertMessagesToSdkMessages(uiMessages, assistant.model || getDefaultModel()),
|
||||
uiMessages
|
||||
}
|
||||
}
|
||||
|
||||
static needsWebSearch(assistant: Assistant): boolean {
|
||||
|
||||
@@ -42,14 +42,15 @@ export class OrchestrationService {
|
||||
const { messages, assistant } = request
|
||||
|
||||
try {
|
||||
const llmMessages = await ConversationService.prepareMessagesForModel(messages, assistant)
|
||||
const { modelMessages, uiMessages } = await ConversationService.prepareMessagesForModel(messages, assistant)
|
||||
|
||||
await fetchChatCompletion({
|
||||
messages: llmMessages,
|
||||
messages: modelMessages,
|
||||
assistant: assistant,
|
||||
options: request.options,
|
||||
onChunkReceived,
|
||||
topicId: request.topicId
|
||||
topicId: request.topicId,
|
||||
uiMessages: uiMessages
|
||||
})
|
||||
} catch (error: any) {
|
||||
onChunkReceived({ type: ChunkType.ERROR, error })
|
||||
@@ -70,17 +71,18 @@ export async function transformMessagesAndFetch(
|
||||
const { messages, assistant } = request
|
||||
|
||||
try {
|
||||
const llmMessages = await ConversationService.prepareMessagesForModel(messages, assistant)
|
||||
const { modelMessages, uiMessages } = await ConversationService.prepareMessagesForModel(messages, assistant)
|
||||
|
||||
// replace prompt variables
|
||||
assistant.prompt = await replacePromptVariables(assistant.prompt, assistant.model?.name)
|
||||
|
||||
await fetchChatCompletion({
|
||||
messages: llmMessages,
|
||||
messages: modelMessages,
|
||||
assistant: assistant,
|
||||
options: request.options,
|
||||
onChunkReceived,
|
||||
topicId: request.topicId
|
||||
topicId: request.topicId,
|
||||
uiMessages
|
||||
})
|
||||
} catch (error: any) {
|
||||
onChunkReceived({ type: ChunkType.ERROR, error })
|
||||
|
||||
@@ -34,12 +34,18 @@ class TabsService {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
const lastTab = remainingTabs[remainingTabs.length - 1]
|
||||
|
||||
store.dispatch(setActiveTab(lastTab.id))
|
||||
|
||||
// 使用 NavigationService 导航到新的标签页
|
||||
if (NavigationService.navigate) {
|
||||
NavigationService.navigate(lastTab.path)
|
||||
} else {
|
||||
logger.error('Navigation service is not initialized')
|
||||
return false
|
||||
logger.warn('Navigation service not ready, will navigate on next render')
|
||||
setTimeout(() => {
|
||||
if (NavigationService.navigate) {
|
||||
NavigationService.navigate(lastTab.path)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => {
|
||||
id: uuid(),
|
||||
type: 'error',
|
||||
title: i18n.t('notification.assistant'),
|
||||
message: serializableError.message,
|
||||
message: serializableError.message ?? '',
|
||||
silent: false,
|
||||
timestamp: Date.now(),
|
||||
source: 'assistant'
|
||||
|
||||
@@ -97,7 +97,12 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
|
||||
}
|
||||
|
||||
if (finalStatus === MessageBlockStatus.ERROR) {
|
||||
changes.error = { message: `Tool execution failed/error`, details: toolResponse.response }
|
||||
changes.error = {
|
||||
message: `Tool execution failed/error`,
|
||||
details: toolResponse.response,
|
||||
name: null,
|
||||
stack: null
|
||||
}
|
||||
}
|
||||
|
||||
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL, true)
|
||||
|
||||
@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 145,
|
||||
version: 147,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -2348,6 +2348,41 @@ const migrateConfig = {
|
||||
logger.error('migrate 145 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'146': (state: RootState) => {
|
||||
try {
|
||||
// Migrate showWorkspace from settings to note store
|
||||
if (state.settings && state.note) {
|
||||
const showWorkspaceValue = (state.settings as any)?.showWorkspace
|
||||
if (showWorkspaceValue !== undefined) {
|
||||
state.note.settings.showWorkspace = showWorkspaceValue
|
||||
// Remove from settings
|
||||
delete (state.settings as any).showWorkspace
|
||||
} else if (state.note.settings.showWorkspace === undefined) {
|
||||
// Set default value if not exists
|
||||
state.note.settings.showWorkspace = true
|
||||
}
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 146 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'147': (state: RootState) => {
|
||||
try {
|
||||
state.llm.providers.forEach((provider) => {
|
||||
if (provider.id === SystemProviderIds.anthropic) {
|
||||
if (provider.apiHost.endsWith('/')) {
|
||||
provider.apiHost = provider.apiHost.slice(0, -1)
|
||||
}
|
||||
}
|
||||
})
|
||||
return state
|
||||
} catch (error) {
|
||||
logger.error('migrate 147 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface NotesSettings {
|
||||
defaultViewMode: 'edit' | 'read'
|
||||
defaultEditMode: Omit<EditorView, 'read'>
|
||||
showTabStatus: boolean
|
||||
showWorkspace: boolean
|
||||
}
|
||||
|
||||
export interface NoteState {
|
||||
@@ -27,7 +28,8 @@ export const initialState: NoteState = {
|
||||
fontFamily: 'default',
|
||||
defaultViewMode: 'edit',
|
||||
defaultEditMode: 'preview',
|
||||
showTabStatus: true
|
||||
showTabStatus: true,
|
||||
showWorkspace: true
|
||||
},
|
||||
notesPath: '',
|
||||
sortType: 'sort_a2z'
|
||||
|
||||
@@ -215,8 +215,6 @@ export interface SettingsState {
|
||||
// API Server
|
||||
apiServer: ApiServerConfig
|
||||
showMessageOutline: boolean
|
||||
// Notes Related
|
||||
showWorkspace: boolean
|
||||
}
|
||||
|
||||
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
|
||||
@@ -409,9 +407,7 @@ export const initialState: SettingsState = {
|
||||
port: 23333,
|
||||
apiKey: `cs-sk-${uuid()}`
|
||||
},
|
||||
showMessageOutline: false,
|
||||
// Notes Related
|
||||
showWorkspace: true
|
||||
showMessageOutline: false
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
@@ -846,12 +842,6 @@ const settingsSlice = createSlice({
|
||||
},
|
||||
setShowMessageOutline: (state, action: PayloadAction<boolean>) => {
|
||||
state.showMessageOutline = action.payload
|
||||
},
|
||||
setShowWorkspace: (state, action: PayloadAction<boolean>) => {
|
||||
state.showWorkspace = action.payload
|
||||
},
|
||||
toggleShowWorkspace: (state) => {
|
||||
state.showWorkspace = !state.showWorkspace
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -982,9 +972,7 @@ export const {
|
||||
// API Server actions
|
||||
setApiServerEnabled,
|
||||
setApiServerPort,
|
||||
setApiServerApiKey,
|
||||
setShowWorkspace,
|
||||
toggleShowWorkspace
|
||||
setApiServerApiKey
|
||||
} = settingsSlice.actions
|
||||
|
||||
export default settingsSlice.reducer
|
||||
|
||||
@@ -24,6 +24,9 @@ const tabsSlice = createSlice({
|
||||
name: 'tabs',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTabs: (state, action: PayloadAction<Tab[]>) => {
|
||||
state.tabs = action.payload
|
||||
},
|
||||
addTab: (state, action: PayloadAction<Tab>) => {
|
||||
const existingTab = state.tabs.find((tab) => tab.path === action.payload.path)
|
||||
if (!existingTab) {
|
||||
@@ -53,5 +56,5 @@ const tabsSlice = createSlice({
|
||||
}
|
||||
})
|
||||
|
||||
export const { addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions
|
||||
export const { setTabs, addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions
|
||||
export default tabsSlice.reducer
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ImageModel, LanguageModel } from 'ai'
|
||||
import type { AISDKError, APICallError, ImageModel, LanguageModel } from 'ai'
|
||||
import { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
|
||||
|
||||
export type StreamTextParams = Omit<Parameters<typeof streamText>[0], 'model' | 'messages'> &
|
||||
@@ -27,3 +27,6 @@ export type StreamObjectParams = Omit<Parameters<typeof streamObject>[0], 'model
|
||||
export type GenerateObjectParams = Omit<Parameters<typeof generateObject>[0], 'model'>
|
||||
|
||||
export type AiSdkModel = LanguageModel | ImageModel
|
||||
|
||||
// 该类型用于格式化错误信息,目前只处理 APICallError,待扩展
|
||||
export type AiSdkErrorUnion = AISDKError | APICallError
|
||||
|
||||
32
src/renderer/src/types/error.ts
Normal file
32
src/renderer/src/types/error.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Serializable } from './serialize'
|
||||
|
||||
export interface SerializedError {
|
||||
name: string | null
|
||||
message: string | null
|
||||
stack: string | null
|
||||
[key: string]: Serializable
|
||||
}
|
||||
export const isSerializedError = (error: Record<string, unknown>): error is SerializedAiSdkError => {
|
||||
return 'name' in error && 'message' in error && 'stack' in error
|
||||
}
|
||||
export interface SerializedAiSdkError extends SerializedError {
|
||||
readonly cause: string | null
|
||||
}
|
||||
|
||||
export const isSerializedAiSdkError = (error: SerializedError): error is SerializedAiSdkError => {
|
||||
return 'cause' in error
|
||||
}
|
||||
|
||||
export interface SerializedAiSdkAPICallError extends SerializedAiSdkError {
|
||||
readonly url: string
|
||||
readonly requestBodyValues: Serializable
|
||||
readonly statusCode: number | null
|
||||
readonly responseHeaders: Record<string, string> | null
|
||||
readonly responseBody: string | null
|
||||
readonly isRetryable: boolean
|
||||
readonly data: Serializable | null
|
||||
}
|
||||
|
||||
export const isSerializedAiSdkAPICallError = (error: SerializedError): error is SerializedAiSdkAPICallError => {
|
||||
return isSerializedAiSdkError(error) && 'url' in error && 'requestBodyValues' in error && 'isRetryable' in error
|
||||
}
|
||||
@@ -1307,6 +1307,7 @@ type BaseParams = {
|
||||
options?: FetchChatCompletionOptions
|
||||
onChunkReceived: (chunk: Chunk) => void
|
||||
topicId?: string // 添加 topicId 参数
|
||||
uiMessages?: Message[]
|
||||
}
|
||||
|
||||
type MessagesParams = BaseParams & {
|
||||
@@ -1316,7 +1317,8 @@ type MessagesParams = BaseParams & {
|
||||
|
||||
type PromptParams = BaseParams & {
|
||||
messages?: never
|
||||
// prompt: StreamTextParams['prompt']
|
||||
// prompt: Just use string for convinience. Native prompt type unite more types, including messages type.
|
||||
// we craete a non-intersecting prompt type to discriminate them.
|
||||
// see https://github.com/vercel/ai/issues/8363
|
||||
prompt: string
|
||||
}
|
||||
|
||||
@@ -16,8 +16,18 @@ export type MCPConfigSample = z.infer<typeof MCPConfigSampleSchema>
|
||||
* 允许 inMemory 作为合法字段,需要额外校验 name 是否 builtin
|
||||
*/
|
||||
export const McpServerTypeSchema = z
|
||||
.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')])
|
||||
.default('stdio') // 大多数情况下默认使用 stdio
|
||||
.string()
|
||||
.transform((type) => {
|
||||
if (type.includes('http')) {
|
||||
return 'streamableHttp'
|
||||
} else {
|
||||
return type
|
||||
}
|
||||
})
|
||||
.pipe(
|
||||
z.union([z.literal('stdio'), z.literal('sse'), z.literal('streamableHttp'), z.literal('inMemory')]).default('stdio') // 大多数情况下默认使用 stdio
|
||||
)
|
||||
|
||||
/**
|
||||
* 定义单个 MCP 服务器的配置。
|
||||
* FIXME: 为了兼容性,暂时允许用户编辑任意字段,这可能会导致问题。
|
||||
@@ -174,6 +184,26 @@ export const McpServerConfigSchema = z
|
||||
message: 'Server type is inMemory but this is not a builtin MCP server, which is not allowed'
|
||||
}
|
||||
)
|
||||
.transform((schema) => {
|
||||
// 显式传入的type会覆盖掉从url推断的逻辑
|
||||
if (!schema.type) {
|
||||
const url = schema.baseUrl ?? schema.url ?? null
|
||||
if (url !== null) {
|
||||
if (url.endsWith('/mcp')) {
|
||||
return {
|
||||
...schema,
|
||||
type: 'streamableHttp'
|
||||
} as const
|
||||
} else if (url.endsWith('/sse')) {
|
||||
return {
|
||||
...schema,
|
||||
type: 'sse'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
}
|
||||
return schema
|
||||
})
|
||||
/**
|
||||
* 将服务器别名(字符串ID)映射到其配置的对象。
|
||||
* 例如: { "my-tools": { command: "...", args: [...] }, "github": { ... } }
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
WebSearchResponse,
|
||||
WebSearchSource
|
||||
} from '.'
|
||||
import { SerializedError } from './error'
|
||||
|
||||
// MessageBlock 类型枚举 - 根据实际API返回特性优化
|
||||
export enum MessageBlockType {
|
||||
@@ -50,7 +51,7 @@ export interface BaseMessageBlock {
|
||||
status: MessageBlockStatus // 块状态
|
||||
model?: Model // 使用的模型
|
||||
metadata?: Record<string, any> // 通用元数据
|
||||
error?: Record<string, any> // Serializable error object instead of AISDKError
|
||||
error?: SerializedError // Serializable error object instead of AISDKError
|
||||
}
|
||||
|
||||
export interface PlaceholderMessageBlock extends BaseMessageBlock {
|
||||
|
||||
@@ -103,7 +103,7 @@ export const isBuiltinOcrProvider = (p: OcrProvider): p is BuiltinOcrProvider =>
|
||||
return isBuiltinOcrProviderId(p.id)
|
||||
}
|
||||
|
||||
// Not sure compatiable api endpoint exists. May not support custom ocr provider
|
||||
// Not sure compatible api endpoint exists. May not support custom ocr provider
|
||||
export type CustomOcrProvider = OcrProvider & {
|
||||
id: Exclude<string, BuiltinOcrProviderId>
|
||||
}
|
||||
|
||||
68
src/renderer/src/types/serialize.ts
Normal file
68
src/renderer/src/types/serialize.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export type Serializable = null | boolean | number | string | { [key: string]: SerializableValue } | SerializableValue[]
|
||||
|
||||
// FIXME: any 不是可安全序列化的类型,但是递归定义会报ts2589
|
||||
type SerializableValue = null | boolean | number | string | { [key: string]: any } | any[]
|
||||
|
||||
/**
|
||||
* 判断一个值是否可序列化(适合用于 Redux 状态)
|
||||
* 支持嵌套对象、数组的深度检测
|
||||
*/
|
||||
|
||||
export function isSerializable(value: unknown): boolean {
|
||||
const seen = new Set() // 用于防止循环引用
|
||||
|
||||
function _isSerializable(val: unknown): boolean {
|
||||
if (val === null || val === undefined) {
|
||||
return val !== undefined // null ✅, undefined ❌
|
||||
}
|
||||
|
||||
const type = typeof val
|
||||
|
||||
if (type === 'string' || type === 'number' || type === 'boolean') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (type === 'object') {
|
||||
// 检查循环引用
|
||||
if (seen.has(val)) {
|
||||
return true // 避免无限递归,假设循环引用对象本身结构合法(但实际 JSON.stringify 会报错)
|
||||
}
|
||||
seen.add(val)
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
return val.every((item) => _isSerializable(item))
|
||||
}
|
||||
|
||||
// 检查是否为纯对象(plain object)
|
||||
const proto = Object.getPrototypeOf(val)
|
||||
if (proto !== null && proto !== Object.prototype && proto !== Array.prototype) {
|
||||
return false // 不是 plain object,比如 class 实例
|
||||
}
|
||||
|
||||
// 检查内置对象(如 Date、RegExp、Map、Set 等)
|
||||
if (
|
||||
val instanceof Date ||
|
||||
val instanceof RegExp ||
|
||||
val instanceof Map ||
|
||||
val instanceof Set ||
|
||||
val instanceof Error ||
|
||||
val instanceof File ||
|
||||
val instanceof Blob
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 递归检查所有属性值
|
||||
return Object.values(val).every((v) => _isSerializable(v))
|
||||
}
|
||||
|
||||
// function、symbol 不可序列化
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return _isSerializable(value)
|
||||
} catch {
|
||||
return false // 如出现循环引用错误等
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ describe('markdownConverter', () => {
|
||||
|
||||
it('should convert task list HTML back to Markdown with label', () => {
|
||||
const html =
|
||||
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox"> abcd</label></li><li data-type="taskItem" class="task-list-item" data-checked="true"><label><input type="checkbox" checked> efgh</lable></li></ul>'
|
||||
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox"> abcd</label></li><li data-type="taskItem" class="task-list-item" data-checked="true"><label><input type="checkbox" checked> efgh</label></li></ul>'
|
||||
const result = htmlToMarkdown(html)
|
||||
expect(result).toBe('- [ ] abcd\n\n- [x] efgh')
|
||||
})
|
||||
@@ -313,6 +313,26 @@ describe('markdownConverter', () => {
|
||||
expect(backToMarkdown).toBe(originalMarkdown)
|
||||
})
|
||||
|
||||
it('should maintain task list structure through html → markdown → html conversion', () => {
|
||||
const originalHtml =
|
||||
'<ul data-type="taskList" class="task-list"><li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li></ul>'
|
||||
const markdown = htmlToMarkdown(originalHtml)
|
||||
const html = markdownToHtml(markdown)
|
||||
|
||||
expect(html).toBe(
|
||||
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false"><label><input type="checkbox" disabled></label><div><p></p></div></li>\n</ul>\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('should maintain task list structure through html → markdown → html conversion2', () => {
|
||||
const originalHtml =
|
||||
'<ul data-type="taskList" class="task-list">\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p>123</p></div>\n</li>\n<li data-type="taskItem" class="task-list-item" data-checked="false">\n<label><input type="checkbox" disabled></label><div><p></p></div>\n</li>\n</ul>\n'
|
||||
const markdown = htmlToMarkdown(originalHtml)
|
||||
const html = markdownToHtml(markdown)
|
||||
|
||||
expect(html).toBe(originalHtml)
|
||||
})
|
||||
|
||||
it('should handle complex task lists with multiple items', () => {
|
||||
const originalMarkdown =
|
||||
'- [ ] First unchecked task\n\n- [x] First checked task\n\n- [ ] Second unchecked task\n\n- [x] Second checked task'
|
||||
@@ -361,7 +381,7 @@ describe('markdownConverter', () => {
|
||||
})
|
||||
|
||||
describe('markdown image', () => {
|
||||
it('should convert markdown iamge to HTML img tag', () => {
|
||||
it('should convert markdown image to HTML img tag', () => {
|
||||
const markdown = ''
|
||||
const result = markdownToHtml(markdown)
|
||||
expect(result).toBe('<p><img src="train.jpg" alt="foo" /></p>\n')
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { loggerService } from '@logger'
|
||||
import {
|
||||
isSerializedAiSdkAPICallError,
|
||||
SerializedAiSdkAPICallError,
|
||||
SerializedAiSdkError,
|
||||
SerializedError
|
||||
} from '@renderer/types/error'
|
||||
import { AISDKError, APICallError } from 'ai'
|
||||
import { t } from 'i18next'
|
||||
import z from 'zod'
|
||||
|
||||
import { safeSerialize } from './serialize'
|
||||
|
||||
const logger = loggerService.withContext('Utils:error')
|
||||
|
||||
export function getErrorDetails(err: any, seen = new WeakSet()): any {
|
||||
@@ -87,12 +95,12 @@ export const formatMcpError = (error: any) => {
|
||||
return error.message
|
||||
}
|
||||
|
||||
export const serializeError = (error: AISDKError) => {
|
||||
export const serializeError = (error: AISDKError): SerializedError => {
|
||||
const baseError = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause: error.cause ? String(error.cause) : undefined
|
||||
stack: error.stack ?? null,
|
||||
cause: error.cause ? String(error.cause) : null
|
||||
}
|
||||
if (APICallError.isInstance(error)) {
|
||||
let content = error.message === '' ? error.responseBody || 'Unknown error' : error.message
|
||||
@@ -104,11 +112,14 @@ export const serializeError = (error: AISDKError) => {
|
||||
}
|
||||
return {
|
||||
...baseError,
|
||||
status: error.statusCode,
|
||||
url: error.url,
|
||||
message: content,
|
||||
requestBody: error.requestBodyValues
|
||||
}
|
||||
requestBodyValues: safeSerialize(error.requestBodyValues),
|
||||
statusCode: error.statusCode ?? null,
|
||||
responseBody: content,
|
||||
isRetryable: error.isRetryable,
|
||||
data: safeSerialize(error.data),
|
||||
responseHeaders: error.responseHeaders ?? null
|
||||
} satisfies SerializedAiSdkAPICallError
|
||||
}
|
||||
return baseError
|
||||
}
|
||||
@@ -123,3 +134,102 @@ export const formatZodError = (error: z.ZodError, title?: string) => {
|
||||
const errorMessage = readableErrors.join('\n')
|
||||
return title ? `${title}: \n${errorMessage}` : errorMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意值安全地转换为字符串
|
||||
* @param value - 需要转换的值,unknown 类型
|
||||
* @returns 转换后的字符串
|
||||
*
|
||||
* @description
|
||||
* 该函数可以安全地处理以下情况:
|
||||
* - null 和 undefined 会被转换为 'null'
|
||||
* - 字符串直接返回
|
||||
* - 原始类型(数字、布尔值、bigint等)使用 String() 转换
|
||||
* - 对象和数组会尝试使用 JSON.stringify 序列化,并处理循环引用
|
||||
* - 如果序列化失败,返回错误信息
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* safeToString(null) // 'null'
|
||||
* safeToString('test') // 'test'
|
||||
* safeToString(123) // '123'
|
||||
* safeToString({a: 1}) // '{"a":1}'
|
||||
* ```
|
||||
*/
|
||||
export function safeToString(value: unknown): string {
|
||||
// 处理 null 和 undefined
|
||||
if (value == null) {
|
||||
return 'null'
|
||||
}
|
||||
|
||||
// 字符串直接返回
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
// 数字、布尔值、bigint 等原始类型,安全用 String()
|
||||
if (typeof value !== 'object' && typeof value !== 'function') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 处理对象(包括数组)
|
||||
if (typeof value === 'object') {
|
||||
// 处理函数
|
||||
if (typeof value === 'function') {
|
||||
return value.toString()
|
||||
}
|
||||
// 其他对象
|
||||
try {
|
||||
return JSON.stringify(value, getCircularReplacer())
|
||||
} catch (err) {
|
||||
return '[Unserializable: ' + err + ']'
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 防止循环引用导致的 JSON.stringify 崩溃
|
||||
function getCircularReplacer() {
|
||||
const seen = new WeakSet()
|
||||
return (_key: string, value: unknown) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]'
|
||||
}
|
||||
seen.add(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function formatError(error: SerializedError): string {
|
||||
return `${t('error.name')}: ${error.name}\n${t('error.message')}: ${error.message}\n${t('error.stack')}: ${error.stack}`
|
||||
}
|
||||
|
||||
export function formatAiSdkError(error: SerializedAiSdkError): string {
|
||||
let text = formatError(error) + '\n'
|
||||
if (error.cause) {
|
||||
text += `${t('error.cause')}: ${error.cause}\n`
|
||||
}
|
||||
if (isSerializedAiSdkAPICallError(error)) {
|
||||
if (error.statusCode) {
|
||||
text += `${t('error.statusCode')}: ${error.statusCode}\n`
|
||||
}
|
||||
text += `${t('error.requestUrl')}: ${error.url}\n`
|
||||
const requestBodyValues = safeToString(error.requestBodyValues)
|
||||
text += `${t('error.requestBodyValues')}: ${requestBodyValues}\n`
|
||||
if (error.responseHeaders) {
|
||||
text += `${t('error.responseHeaders')}: ${JSON.stringify(error.responseHeaders, null, 2)}\n`
|
||||
}
|
||||
if (error.responseBody) {
|
||||
text += `${t('error.responseBody')}: ${error.responseBody}\n`
|
||||
}
|
||||
if (error.data) {
|
||||
const data = safeToString(error.data)
|
||||
text += `${t('error.data')}: ${data}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
@@ -182,59 +182,200 @@ export async function captureScrollableIframe(
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>
|
||||
): Promise<HTMLCanvasElement | undefined> {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return Promise.resolve(undefined)
|
||||
if (!iframe?.contentDocument?.defaultView) return undefined
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
const win = doc?.defaultView
|
||||
if (!doc || !win) return Promise.resolve(undefined)
|
||||
const win = iframe.contentWindow!
|
||||
|
||||
// 等待两帧渲染稳定
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
||||
// 禁用动画以确保捕获静态状态
|
||||
const disableAnimations = () => {
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
// transform: none !important;
|
||||
}`
|
||||
doc.head.appendChild(style)
|
||||
return style
|
||||
}
|
||||
|
||||
// 触发懒加载资源尽快加载
|
||||
doc.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'))
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
// 内联字体以避免跨域问题
|
||||
const inlineFonts = async () => {
|
||||
const fontFaceRegex = /@font-face[\s\S]*?\}/g
|
||||
const fontUrlRegex = /url\((['"]?)([^)"']+)\1\)/g
|
||||
const fontExtRegex = /\.(woff2?|ttf|otf)(\?|#|$)/i
|
||||
|
||||
const de = doc.documentElement
|
||||
const b = doc.body
|
||||
const fetchAsDataUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
const res = await fetch(url, { mode: 'cors', credentials: 'omit' })
|
||||
if (!res.ok) return url
|
||||
const blob = await res.blob()
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.onerror = () => resolve(url)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// 计算完整尺寸
|
||||
const totalWidth = Math.max(b.scrollWidth, de.scrollWidth, b.clientWidth, de.clientWidth)
|
||||
const totalHeight = Math.max(b.scrollHeight, de.scrollHeight, b.clientHeight, de.clientHeight)
|
||||
const processCss = async (cssText: string, baseUrl: string): Promise<string[]> => {
|
||||
const fontBlocks: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
logger.verbose('The iframe to be captured has size:', { totalWidth, totalHeight })
|
||||
while ((match = fontFaceRegex.exec(cssText)) !== null) {
|
||||
let block = match[0]
|
||||
const fontUrls: Array<[string, string]> = []
|
||||
|
||||
// 按比例缩放以不超过上限
|
||||
const MAX = 32767
|
||||
const maxSide = Math.max(totalWidth, totalHeight)
|
||||
const scale = maxSide > MAX ? MAX / maxSide : 1
|
||||
const pixelRatio = (win.devicePixelRatio || 1) * scale
|
||||
let urlMatch: RegExpExecArray | null
|
||||
fontUrlRegex.lastIndex = 0
|
||||
while ((urlMatch = fontUrlRegex.exec(block)) !== null) {
|
||||
const url = urlMatch[2]
|
||||
if (!url.startsWith('data:') && fontExtRegex.test(url)) {
|
||||
try {
|
||||
const absoluteUrl = new URL(url, baseUrl).href
|
||||
fontUrls.push([urlMatch[0], absoluteUrl])
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bg = win.getComputedStyle(b).backgroundColor || '#ffffff'
|
||||
const fg = win.getComputedStyle(b).color || '#000000'
|
||||
// 并行处理所有字体URL
|
||||
const dataUrls = await Promise.all(
|
||||
fontUrls.map(async ([original, url]) => {
|
||||
const dataUrl = await fetchAsDataUrl(url)
|
||||
return [original, `url(${dataUrl})`] as const
|
||||
})
|
||||
)
|
||||
|
||||
dataUrls.forEach(([original, replacement]) => {
|
||||
block = block.replace(original, replacement)
|
||||
})
|
||||
|
||||
fontBlocks.push(block)
|
||||
}
|
||||
|
||||
return fontBlocks
|
||||
}
|
||||
|
||||
const allFontBlocks: string[] = []
|
||||
|
||||
// 处理外部样式表
|
||||
const externalSheets = doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')
|
||||
await Promise.all(
|
||||
Array.from(externalSheets).map(async (link) => {
|
||||
if (!link.href) return
|
||||
try {
|
||||
const res = await fetch(link.href, { mode: 'cors', credentials: 'omit' })
|
||||
if (res.ok) {
|
||||
const cssText = await res.text()
|
||||
const blocks = await processCss(cssText, link.href)
|
||||
allFontBlocks.push(...blocks)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 处理内联样式
|
||||
const inlineStyles = doc.querySelectorAll('style')
|
||||
await Promise.all(
|
||||
Array.from(inlineStyles).map(async (style) => {
|
||||
const cssText = style.textContent || ''
|
||||
const blocks = await processCss(cssText, doc.baseURI)
|
||||
allFontBlocks.push(...blocks)
|
||||
})
|
||||
)
|
||||
|
||||
return allFontBlocks.join('\n')
|
||||
}
|
||||
|
||||
const animationStyle = disableAnimations()
|
||||
let injectedFontStyle: HTMLStyleElement | null = null
|
||||
|
||||
const ensureFontStyle = (css: string): HTMLStyleElement => {
|
||||
const EXISTING = doc.head.querySelector('style[data-cs-inline-fonts="true"]') as HTMLStyleElement | null
|
||||
if (EXISTING) {
|
||||
if (css && css.trim()) {
|
||||
EXISTING.textContent = `${EXISTING.textContent || ''}\n${css}`
|
||||
}
|
||||
return EXISTING
|
||||
}
|
||||
const style = doc.createElement('style')
|
||||
style.setAttribute('data-cs-inline-fonts', 'true')
|
||||
style.textContent = css
|
||||
doc.head.appendChild(style)
|
||||
return style
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = await htmlToImage.toCanvas(de, {
|
||||
backgroundColor: bg,
|
||||
// 等待渲染稳定
|
||||
await new Promise((r) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => r(null))))
|
||||
|
||||
// 强制加载懒加载图片
|
||||
doc.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'))
|
||||
|
||||
// 获取字体CSS
|
||||
const fontEmbedCSS = await inlineFonts()
|
||||
|
||||
// 将字体 CSS 注入到 iframe 文档中,确保注册到 FontFaceSet
|
||||
if (fontEmbedCSS && fontEmbedCSS.trim().length > 0) {
|
||||
injectedFontStyle = ensureFontStyle(fontEmbedCSS)
|
||||
// 访问一次以避免被标记为未使用
|
||||
if (injectedFontStyle.parentNode == null) {
|
||||
doc.head.appendChild(injectedFontStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// 等待字体就绪,避免序列化时回退到系统字体
|
||||
await Promise.race([
|
||||
(doc as any).fonts?.ready ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
])
|
||||
|
||||
// 计算尺寸
|
||||
const { documentElement: de, body: b } = doc
|
||||
const totalWidth = Math.max(b.scrollWidth, de.scrollWidth, b.clientWidth, de.clientWidth)
|
||||
const totalHeight = Math.max(b.scrollHeight, de.scrollHeight, b.clientHeight, de.clientHeight)
|
||||
|
||||
logger.verbose('Capturing iframe:', { totalWidth, totalHeight })
|
||||
|
||||
// 限制最大尺寸,按比例缩放
|
||||
const MAX_SIZE = 32767
|
||||
const scale = Math.min(1, MAX_SIZE / Math.max(totalWidth, totalHeight))
|
||||
const pixelRatio = (win.devicePixelRatio || 1) * scale
|
||||
|
||||
const styles = win.getComputedStyle(b)
|
||||
const backgroundColor = styles.backgroundColor || '#ffffff'
|
||||
const color = styles.color || '#000000'
|
||||
|
||||
return await htmlToImage.toCanvas(de, {
|
||||
fontEmbedCSS,
|
||||
backgroundColor,
|
||||
cacheBust: true,
|
||||
pixelRatio,
|
||||
skipAutoScale: true,
|
||||
width: Math.floor(totalWidth),
|
||||
height: Math.floor(totalHeight),
|
||||
style: {
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
backgroundColor,
|
||||
color,
|
||||
width: `${totalWidth}px`,
|
||||
height: `${totalHeight}px`,
|
||||
overflow: 'visible',
|
||||
display: 'block'
|
||||
}
|
||||
})
|
||||
|
||||
return canvas
|
||||
} catch (error) {
|
||||
logger.error('Error capturing iframe full snapshot:', error as Error)
|
||||
return Promise.resolve(undefined)
|
||||
logger.error('Error capturing iframe:', error as Error)
|
||||
return undefined
|
||||
} finally {
|
||||
// 恢复动画
|
||||
animationStyle.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user