mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 02:09:03 +08:00
修改1亿点点
This commit is contained in:
parent
7a82a61bbe
commit
adac7659a8
37
package.json
37
package.json
@ -62,16 +62,44 @@
|
||||
"@cherrystudio/embedjs-loader-web": "^0.1.28",
|
||||
"@cherrystudio/embedjs-loader-xml": "^0.1.28",
|
||||
"@cherrystudio/embedjs-openai": "^0.1.28",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/closebrackets": "^0.19.2",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/comment": "^0.19.1",
|
||||
"@codemirror/fold": "^0.19.4",
|
||||
"@codemirror/lang-cpp": "^6.0.2",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-java": "^6.0.1",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-php": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.1.7",
|
||||
"@codemirror/lang-rust": "^6.0.1",
|
||||
"@codemirror/lang-sql": "^6.8.0",
|
||||
"@codemirror/lang-vue": "^0.1.3",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/lint": "^6.8.5",
|
||||
"@codemirror/matchbrackets": "^0.19.4",
|
||||
"@codemirror/search": "^6.5.10",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.36.5",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@langchain/core": "^0.3.44",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"adm-zip": "^0.5.16",
|
||||
"async-mutex": "^0.5.0",
|
||||
@ -80,6 +108,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"edge-tts-node": "^1.5.7",
|
||||
"electron-chrome-extensions": "^4.7.0",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
@ -93,9 +122,13 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"node-edge-tts": "^1.2.8",
|
||||
"officeparser": "^4.1.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.1.91",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@ -126,7 +159,7 @@
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/adm-zip": "^0",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/d3": "^7",
|
||||
"@types/diff": "^7",
|
||||
"@types/fs-extra": "^11",
|
||||
@ -158,7 +191,7 @@
|
||||
"electron-vite": "^2.3.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
|
||||
@ -176,5 +176,14 @@ export enum IpcChannel {
|
||||
|
||||
// Long-term Memory File Storage
|
||||
LongTermMemory_LoadData = 'long-term-memory:load-data',
|
||||
LongTermMemory_SaveData = 'long-term-memory:save-data'
|
||||
LongTermMemory_SaveData = 'long-term-memory:save-data',
|
||||
|
||||
// Code Executor
|
||||
CodeExecutor_ExecuteJS = 'code-executor:execute-js',
|
||||
CodeExecutor_ExecutePython = 'code-executor:execute-python',
|
||||
CodeExecutor_GetSupportedLanguages = 'code-executor:get-supported-languages',
|
||||
|
||||
// PDF
|
||||
PDF_SplitPDF = 'pdf:split-pdf',
|
||||
PDF_GetPageCount = 'pdf:get-page-count'
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import { asrServerService } from './services/ASRServerService'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { codeExecutorService } from './services/CodeExecutorService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@ -26,6 +27,7 @@ import { memoryFileService } from './services/MemoryFileService'
|
||||
import * as MsTTSService from './services/MsTTSService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { PDFService } from './services/PDFService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
@ -342,4 +344,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.MsTTS_Synthesize, (_, text: string, voice: string, outputFormat: string) =>
|
||||
MsTTSService.synthesize(text, voice, outputFormat)
|
||||
)
|
||||
|
||||
// 注册代码执行器IPC处理程序
|
||||
ipcMain.handle(IpcChannel.CodeExecutor_GetSupportedLanguages, async () => {
|
||||
return await codeExecutorService.getSupportedLanguages()
|
||||
})
|
||||
ipcMain.handle(IpcChannel.CodeExecutor_ExecuteJS, async (_, code: string) => {
|
||||
return await codeExecutorService.executeJavaScript(code)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.CodeExecutor_ExecutePython, async (_, code: string) => {
|
||||
return await codeExecutorService.executePython(code)
|
||||
})
|
||||
|
||||
// PDF服务
|
||||
ipcMain.handle(IpcChannel.PDF_SplitPDF, PDFService.splitPDF.bind(PDFService))
|
||||
ipcMain.handle(IpcChannel.PDF_GetPageCount, PDFService.getPDFPageCount.bind(PDFService))
|
||||
}
|
||||
|
||||
257
src/main/services/CodeExecutorService.ts
Normal file
257
src/main/services/CodeExecutorService.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// 如果将来需要使用这些工具函数,可以取消注释
|
||||
// import { getBinaryPath, isBinaryExists } from '@main/utils/process'
|
||||
import log from 'electron-log'
|
||||
|
||||
// 支持的语言类型
|
||||
export enum CodeLanguage {
|
||||
JavaScript = 'javascript',
|
||||
Python = 'python'
|
||||
}
|
||||
|
||||
// 执行结果接口
|
||||
export interface ExecutionResult {
|
||||
success: boolean
|
||||
output: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码执行器服务
|
||||
* 提供安全的代码执行环境,支持 JavaScript 和 Python
|
||||
*/
|
||||
export class CodeExecutorService {
|
||||
private readonly tempDir: string
|
||||
|
||||
constructor() {
|
||||
// 创建临时目录用于存放执行的代码文件
|
||||
this.tempDir = path.join(os.tmpdir(), 'cherry-code-executor')
|
||||
this.ensureTempDir()
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保临时目录存在
|
||||
*/
|
||||
private ensureTempDir(): void {
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的编程语言列表
|
||||
*/
|
||||
public async getSupportedLanguages(): Promise<string[]> {
|
||||
const languages = [CodeLanguage.JavaScript]
|
||||
|
||||
// 检查是否安装了 Python
|
||||
if (await this.isPythonAvailable()) {
|
||||
languages.push(CodeLanguage.Python)
|
||||
}
|
||||
|
||||
return languages
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Python 是否可用
|
||||
*/
|
||||
private async isPythonAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const pythonProcess = spawn('python', ['--version'])
|
||||
return new Promise<boolean>((resolve) => {
|
||||
pythonProcess.on('close', (code) => {
|
||||
resolve(code === 0)
|
||||
})
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => resolve(false), 1000)
|
||||
})
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 JavaScript 代码
|
||||
* @param code JavaScript 代码
|
||||
* @returns 执行结果
|
||||
*/
|
||||
public async executeJavaScript(code: string): Promise<ExecutionResult> {
|
||||
const fileId = uuidv4()
|
||||
const tempFilePath = path.join(this.tempDir, `${fileId}.js`)
|
||||
|
||||
try {
|
||||
// 写入临时文件
|
||||
fs.writeFileSync(tempFilePath, code)
|
||||
|
||||
// 使用 Node.js 执行代码
|
||||
return await this.runNodeScript(tempFilePath)
|
||||
} catch (error) {
|
||||
log.error('[CodeExecutor] Error executing JavaScript:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
this.cleanupTempFile(tempFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Python 代码
|
||||
* @param code Python 代码
|
||||
* @returns 执行结果
|
||||
*/
|
||||
public async executePython(code: string): Promise<ExecutionResult> {
|
||||
const fileId = uuidv4()
|
||||
const tempFilePath = path.join(this.tempDir, `${fileId}.py`)
|
||||
|
||||
try {
|
||||
// 写入临时文件
|
||||
fs.writeFileSync(tempFilePath, code)
|
||||
|
||||
// 执行 Python 代码
|
||||
return await this.runPythonScript(tempFilePath)
|
||||
} catch (error) {
|
||||
log.error('[CodeExecutor] Error executing Python:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
this.cleanupTempFile(tempFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 Node.js 脚本
|
||||
* @param scriptPath 脚本路径
|
||||
* @returns 执行结果
|
||||
*/
|
||||
private async runNodeScript(scriptPath: string): Promise<ExecutionResult> {
|
||||
return new Promise<ExecutionResult>((resolve) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
// 使用 Node.js 执行脚本
|
||||
const nodeProcess = spawn(process.execPath, [scriptPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
// 设置为 Node.js 模式,确保在 Electron 环境中正确执行
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
// 限制访问权限
|
||||
NODE_OPTIONS: '--no-warnings --experimental-permission --allow-fs-read=* --allow-fs-write=' + this.tempDir
|
||||
}
|
||||
})
|
||||
|
||||
nodeProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
nodeProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
nodeProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
output: stdout
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
output: stdout,
|
||||
error: stderr
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置超时(10秒)
|
||||
setTimeout(() => {
|
||||
nodeProcess.kill()
|
||||
resolve({
|
||||
success: false,
|
||||
output: stdout,
|
||||
error: 'Execution timed out after 10 seconds'
|
||||
})
|
||||
}, 10000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 Python 脚本
|
||||
* @param scriptPath 脚本路径
|
||||
* @returns 执行结果
|
||||
*/
|
||||
private async runPythonScript(scriptPath: string): Promise<ExecutionResult> {
|
||||
return new Promise<ExecutionResult>((resolve) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
// 使用 Python 执行脚本
|
||||
const pythonProcess = spawn('python', [scriptPath], {
|
||||
env: { ...process.env }
|
||||
})
|
||||
|
||||
pythonProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
pythonProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
pythonProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
output: stdout
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
output: stdout,
|
||||
error: stderr
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 设置超时(10秒)
|
||||
setTimeout(() => {
|
||||
pythonProcess.kill()
|
||||
resolve({
|
||||
success: false,
|
||||
output: stdout,
|
||||
error: 'Execution timed out after 10 seconds'
|
||||
})
|
||||
}, 10000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时文件
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
private cleanupTempFile(filePath: string): void {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[CodeExecutor] Error cleaning up temp file:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例
|
||||
export const codeExecutorService = new CodeExecutorService()
|
||||
204
src/main/services/PDFService.ts
Normal file
204
src/main/services/PDFService.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { FileType } from '@types'
|
||||
import { app } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export class PDFService {
|
||||
// 使用方法而不是静态属性来获取目录路径
|
||||
private static getTempDir(): string {
|
||||
return path.join(app.getPath('temp'), 'CherryStudio')
|
||||
}
|
||||
|
||||
private static getStorageDir(): string {
|
||||
return path.join(app.getPath('userData'), 'files')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取PDF文件的页数
|
||||
* @param _ Electron IPC事件
|
||||
* @param filePath PDF文件路径
|
||||
* @returns PDF文件的页数
|
||||
*/
|
||||
static async getPDFPageCount(_: Electron.IpcMainInvokeEvent, filePath: string): Promise<number> {
|
||||
try {
|
||||
logger.info(`[PDFService] Getting page count for PDF: ${filePath}`)
|
||||
const pdfBytes = fs.readFileSync(filePath)
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes)
|
||||
const pageCount = pdfDoc.getPageCount()
|
||||
logger.info(`[PDFService] PDF page count: ${pageCount}`)
|
||||
return pageCount
|
||||
} catch (error) {
|
||||
logger.error('[PDFService] Error getting PDF page count:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分割PDF文件
|
||||
* @param _ Electron IPC事件
|
||||
* @param file 原始PDF文件
|
||||
* @param pageRange 页码范围,例如:1-5,8,10-15
|
||||
* @returns 分割后的PDF文件信息
|
||||
*/
|
||||
static async splitPDF(_: Electron.IpcMainInvokeEvent, file: FileType, pageRange: string): Promise<FileType> {
|
||||
try {
|
||||
logger.info(`[PDFService] Splitting PDF: ${file.path}, page range: ${pageRange}`)
|
||||
logger.info(`[PDFService] File details:`, JSON.stringify(file))
|
||||
|
||||
// 确保临时目录存在
|
||||
const tempDir = PDFService.getTempDir()
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
logger.info(`[PDFService] Creating temp directory: ${tempDir}`)
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 确保存储目录存在
|
||||
const storageDir = PDFService.getStorageDir()
|
||||
if (!fs.existsSync(storageDir)) {
|
||||
logger.info(`[PDFService] Creating storage directory: ${storageDir}`)
|
||||
fs.mkdirSync(storageDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 读取原始PDF文件
|
||||
logger.info(`[PDFService] Reading PDF file: ${file.path}`)
|
||||
const pdfBytes = fs.readFileSync(file.path)
|
||||
logger.info(`[PDFService] PDF file read, size: ${pdfBytes.length} bytes`)
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes)
|
||||
logger.info(`[PDFService] PDF document loaded, page count: ${pdfDoc.getPageCount()}`)
|
||||
|
||||
// 创建新的PDF文档
|
||||
const newPdfDoc = await PDFDocument.create()
|
||||
logger.info(`[PDFService] New PDF document created`)
|
||||
|
||||
// 解析页码范围
|
||||
const pageIndexes = this.parsePageRange(pageRange, pdfDoc.getPageCount())
|
||||
logger.info(`[PDFService] Page range parsed, indexes: ${pageIndexes.join(', ')}`)
|
||||
|
||||
// 复制指定页面到新文档
|
||||
const copiedPages = await newPdfDoc.copyPages(pdfDoc, pageIndexes)
|
||||
logger.info(`[PDFService] Pages copied, count: ${copiedPages.length}`)
|
||||
copiedPages.forEach((page, index) => {
|
||||
logger.info(`[PDFService] Adding page ${index + 1} to new document`)
|
||||
newPdfDoc.addPage(page)
|
||||
})
|
||||
|
||||
// 保存新文档
|
||||
logger.info(`[PDFService] Saving new PDF document`)
|
||||
const newPdfBytes = await newPdfDoc.save()
|
||||
logger.info(`[PDFService] New PDF document saved, size: ${newPdfBytes.length} bytes`)
|
||||
|
||||
// 生成新文件ID和路径
|
||||
const uuid = uuidv4()
|
||||
const ext = '.pdf'
|
||||
// 使用之前已经声明的storageDir变量
|
||||
const destPath = path.join(storageDir, uuid + ext)
|
||||
logger.info(`[PDFService] Destination path: ${destPath}`)
|
||||
|
||||
// 写入新文件
|
||||
logger.info(`[PDFService] Writing new PDF file`)
|
||||
fs.writeFileSync(destPath, newPdfBytes)
|
||||
logger.info(`[PDFService] New PDF file written`)
|
||||
|
||||
// 获取文件状态
|
||||
const stats = fs.statSync(destPath)
|
||||
logger.info(`[PDFService] File stats: size=${stats.size}, created=${stats.birthtime}`)
|
||||
|
||||
// 创建新文件信息
|
||||
const newFile: FileType = {
|
||||
id: uuid,
|
||||
origin_name: `${path.basename(file.origin_name, '.pdf')}_pages_${pageRange}.pdf`,
|
||||
name: uuid + ext,
|
||||
path: destPath,
|
||||
created_at: stats.birthtime.toISOString(),
|
||||
size: stats.size,
|
||||
ext: ext,
|
||||
type: getFileType(ext),
|
||||
count: 1,
|
||||
pdf_page_range: pageRange
|
||||
}
|
||||
|
||||
logger.info(`[PDFService] PDF split successful: ${newFile.path}`)
|
||||
logger.info(`[PDFService] New file details:`, JSON.stringify(newFile))
|
||||
return newFile
|
||||
} catch (error) {
|
||||
logger.error('[PDFService] Error splitting PDF:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析页码范围字符串为页码索引数组
|
||||
* @param pageRange 页码范围字符串,例如:1-5,8,10-15
|
||||
* @param totalPages PDF文档总页数
|
||||
* @returns 页码索引数组(从0开始)
|
||||
*/
|
||||
private static parsePageRange(pageRange: string, totalPages: number): number[] {
|
||||
logger.info(`[PDFService] Parsing page range: ${pageRange}, total pages: ${totalPages}`)
|
||||
const pageIndexes: number[] = []
|
||||
const parts = pageRange.split(',')
|
||||
logger.info(`[PDFService] Page range parts: ${JSON.stringify(parts)}`)
|
||||
|
||||
try {
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (!trimmed) {
|
||||
logger.info(`[PDFService] Empty part, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info(`[PDFService] Processing part: ${trimmed}`)
|
||||
|
||||
if (trimmed.includes('-')) {
|
||||
const [startStr, endStr] = trimmed.split('-')
|
||||
const start = parseInt(startStr.trim())
|
||||
const end = parseInt(endStr.trim())
|
||||
logger.info(`[PDFService] Range part: ${trimmed}, start: ${start}, end: ${end}`)
|
||||
|
||||
if (isNaN(start) || isNaN(end)) {
|
||||
logger.error(`[PDFService] Invalid range part (NaN): ${trimmed}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (start < 1 || end > totalPages || start > end) {
|
||||
logger.warn(`[PDFService] Invalid range: ${start}-${end}, totalPages: ${totalPages}`)
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pageIndexes.push(i - 1) // PDF页码从0开始,但用户输入从1开始
|
||||
logger.info(`[PDFService] Added page index: ${i - 1} (page ${i})`)
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(trimmed)
|
||||
logger.info(`[PDFService] Single page: ${page}`)
|
||||
|
||||
if (isNaN(page)) {
|
||||
logger.error(`[PDFService] Invalid page number (NaN): ${trimmed}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (page < 1 || page > totalPages) {
|
||||
logger.warn(`[PDFService] Page ${page} out of range, totalPages: ${totalPages}`)
|
||||
continue
|
||||
}
|
||||
|
||||
pageIndexes.push(page - 1) // PDF页码从0开始,但用户输入从1开始
|
||||
logger.info(`[PDFService] Added page index: ${page - 1} (page ${page})`)
|
||||
}
|
||||
}
|
||||
|
||||
// 去重并排序
|
||||
const result = [...new Set(pageIndexes)].sort((a, b) => a - b)
|
||||
logger.info(`[PDFService] Final page indexes: ${result.join(', ')}`)
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error(`[PDFService] Error parsing page range: ${error}`)
|
||||
// 如果解析出错,返回空数组
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/preload/index.d.ts
vendored
7
src/preload/index.d.ts
vendored
@ -207,6 +207,13 @@ declare global {
|
||||
deleteShortMemoryById: (id: string) => Promise<boolean>
|
||||
loadLongTermData: () => Promise<any>
|
||||
saveLongTermData: (data: any, forceOverwrite?: boolean) => Promise<boolean>
|
||||
},
|
||||
asrServer: {
|
||||
startServer: () => Promise<{ success: boolean; pid?: number; port?: number; error?: string }>
|
||||
stopServer: (pid: number) => Promise<{ success: boolean; error?: string }>
|
||||
},
|
||||
pdf: {
|
||||
splitPDF: (file: FileType, pageRange: string) => Promise<FileType>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,6 +199,15 @@ const api = {
|
||||
asrServer: {
|
||||
startServer: () => ipcRenderer.invoke(IpcChannel.Asr_StartServer),
|
||||
stopServer: (pid: number) => ipcRenderer.invoke(IpcChannel.Asr_StopServer, pid)
|
||||
},
|
||||
pdf: {
|
||||
splitPDF: (file: FileType, pageRange: string) => ipcRenderer.invoke(IpcChannel.PDF_SplitPDF, file, pageRange),
|
||||
getPageCount: (filePath: string) => ipcRenderer.invoke(IpcChannel.PDF_GetPageCount, filePath)
|
||||
},
|
||||
codeExecutor: {
|
||||
getSupportedLanguages: () => ipcRenderer.invoke(IpcChannel.CodeExecutor_GetSupportedLanguages),
|
||||
executeJS: (code: string) => ipcRenderer.invoke(IpcChannel.CodeExecutor_ExecuteJS, code),
|
||||
executePython: (code: string) => ipcRenderer.invoke(IpcChannel.CodeExecutor_ExecutePython, code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import { PersistGate } from 'redux-persist/integration/react'
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import DeepClaudeProvider from './components/DeepClaudeProvider'
|
||||
import MemoryProvider from './components/MemoryProvider'
|
||||
import PDFSettingsInitializer from './components/PDFSettingsInitializer'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
@ -33,6 +34,7 @@ function App(): React.ReactElement {
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<MemoryProvider>
|
||||
<DeepClaudeProvider />
|
||||
<PDFSettingsInitializer />
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
|
||||
@ -0,0 +1,106 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
|
||||
export interface ExecutionResultProps {
|
||||
success: boolean
|
||||
output: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ExecutionResult: React.FC<ExecutionResultProps> = ({ success, output, error }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ResultContainer>
|
||||
<ResultHeader success={success}>
|
||||
{success ? (
|
||||
<>
|
||||
<CheckCircleOutlined /> {t('code.execution.success')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloseCircleOutlined /> {t('code.execution.error')}
|
||||
</>
|
||||
)}
|
||||
</ResultHeader>
|
||||
<ResultContent>
|
||||
{output && (
|
||||
<OutputSection>
|
||||
<OutputTitle>{t('code.execution.output')}</OutputTitle>
|
||||
<OutputText>{output}</OutputText>
|
||||
</OutputSection>
|
||||
)}
|
||||
{error && (
|
||||
<ErrorSection>
|
||||
<ErrorTitle>{t('code.execution.error')}</ErrorTitle>
|
||||
<ErrorText>{error}</ErrorText>
|
||||
</ErrorSection>
|
||||
)}
|
||||
</ResultContent>
|
||||
</ResultContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ResultContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const ResultHeader = styled.div<{ success: boolean }>`
|
||||
padding: 8px 12px;
|
||||
background-color: ${(props) => (props.success ? 'rgba(82, 196, 26, 0.1)' : 'rgba(255, 77, 79, 0.1)')};
|
||||
color: ${(props) => (props.success ? 'var(--color-success)' : 'var(--color-error)')};
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const ResultContent = styled.div`
|
||||
padding: 12px;
|
||||
background-color: var(--color-code-background);
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
const OutputSection = styled.div`
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const OutputTitle = styled.div`
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const OutputText = styled.pre`
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const ErrorSection = styled.div`
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
const ErrorTitle = styled.div`
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-error);
|
||||
`
|
||||
|
||||
const ErrorText = styled.pre`
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-error);
|
||||
`
|
||||
|
||||
export default ExecutionResult
|
||||
65
src/renderer/src/components/CodeExecutorButton/index.tsx
Normal file
65
src/renderer/src/components/CodeExecutorButton/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { LoadingOutlined, PlayCircleOutlined } from '@ant-design/icons'
|
||||
import { Tooltip } from 'antd'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodeExecutorButtonProps {
|
||||
language: string
|
||||
code: string
|
||||
onClick: () => void
|
||||
isLoading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const CodeExecutorButton: React.FC<CodeExecutorButtonProps> = ({
|
||||
language,
|
||||
// code 参数在组件内部未使用,但在接口中保留以便将来可能的扩展
|
||||
// code,
|
||||
onClick,
|
||||
isLoading = false,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const supportedLanguages = ['javascript', 'js', 'python', 'py']
|
||||
|
||||
// 检查语言是否支持
|
||||
const isSupported = supportedLanguages.includes(language.toLowerCase())
|
||||
|
||||
if (!isSupported) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={isLoading ? t('code.executing') : t('code.execute')} placement="top">
|
||||
<StyledButton onClick={onClick} disabled={disabled || isLoading} aria-label={t('code.execute')}>
|
||||
{isLoading ? <LoadingOutlined /> : <PlayCircleOutlined />}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
`
|
||||
|
||||
export default CodeExecutorButton
|
||||
@ -0,0 +1,134 @@
|
||||
/* 中文搜索面板样式 */
|
||||
.cm-panel.cm-search {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* 修改按钮文本 */
|
||||
.cm-panel.cm-search button[name="find"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search button[name="find"]::after {
|
||||
content: '查找';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="next"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search button[name="next"]::after {
|
||||
content: '下一个';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="prev"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search button[name="prev"]::after {
|
||||
content: '上一个';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="replace"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search button[name="replace"]::after {
|
||||
content: '替换';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="replaceAll"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search button[name="replaceAll"]::after {
|
||||
content: '全部替换';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 修改标签文本 */
|
||||
.cm-panel.cm-search label[for="case"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search label[for="case"] input {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.cm-panel.cm-search label[for="case"]::after {
|
||||
content: '区分大小写';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="regexp"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search label[for="regexp"] input {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.cm-panel.cm-search label[for="regexp"]::after {
|
||||
content: '正则表达式';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="word"] {
|
||||
font-size: 0;
|
||||
}
|
||||
.cm-panel.cm-search label[for="word"] input {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.cm-panel.cm-search label[for="word"]::after {
|
||||
content: '全词匹配';
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 修改输入框占位符 */
|
||||
.cm-panel.cm-search input[name="search"]::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search input[name="replace"]::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 修改输入框标签 */
|
||||
.cm-panel.cm-search label[for="search"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="replace"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 添加中文占位符 */
|
||||
.cm-panel.cm-search input[name="search"] {
|
||||
position: relative;
|
||||
}
|
||||
.cm-panel.cm-search input[name="search"]::before {
|
||||
content: '查找';
|
||||
position: absolute;
|
||||
color: #999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search input[name="replace"] {
|
||||
position: relative;
|
||||
}
|
||||
.cm-panel.cm-search input[name="replace"]::before {
|
||||
content: '替换';
|
||||
position: absolute;
|
||||
color: #999;
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { Extension } from '@codemirror/state'
|
||||
import { search, openSearchPanel } from '@codemirror/search'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
// 创建中文搜索面板
|
||||
export function createChineseSearchPanel(): Extension {
|
||||
return search({
|
||||
top: true
|
||||
})
|
||||
}
|
||||
|
||||
// 打开中文搜索面板
|
||||
export function openChineseSearchPanel(view: EditorView) {
|
||||
openSearchPanel(view)
|
||||
}
|
||||
317
src/renderer/src/components/CodeMirrorEditor/index.tsx
Normal file
317
src/renderer/src/components/CodeMirrorEditor/index.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap, undo, redo, indentWithTab } from '@codemirror/commands'
|
||||
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { html } from '@codemirror/lang-html'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cpp } from '@codemirror/lang-cpp'
|
||||
import { java } from '@codemirror/lang-java'
|
||||
import { php } from '@codemirror/lang-php'
|
||||
import { rust } from '@codemirror/lang-rust'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { xml } from '@codemirror/lang-xml'
|
||||
import { vue } from '@codemirror/lang-vue'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import { searchKeymap } from '@codemirror/search'
|
||||
import { createChineseSearchPanel, openChineseSearchPanel } from './ChineseSearchPanel'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { useEffect, useRef, useMemo, forwardRef, useImperativeHandle } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import './styles.css'
|
||||
import './ChineseSearchPanel.css'
|
||||
|
||||
|
||||
|
||||
// 自定义语法高亮样式
|
||||
const lightThemeHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: '#0000ff' },
|
||||
{ tag: tags.comment, color: '#008000', fontStyle: 'italic' },
|
||||
{ tag: tags.string, color: '#a31515' },
|
||||
{ tag: tags.number, color: '#098658' },
|
||||
{ tag: tags.operator, color: '#000000' },
|
||||
{ tag: tags.variableName, color: '#001080' },
|
||||
{ tag: tags.propertyName, color: '#001080' },
|
||||
{ tag: tags.className, color: '#267f99' },
|
||||
{ tag: tags.typeName, color: '#267f99' },
|
||||
{ tag: tags.definition(tags.variableName), color: '#001080' },
|
||||
{ tag: tags.definition(tags.propertyName), color: '#001080' },
|
||||
{ tag: tags.definition(tags.className), color: '#267f99' },
|
||||
{ tag: tags.definition(tags.typeName), color: '#267f99' },
|
||||
{ tag: tags.function(tags.variableName), color: '#795e26' },
|
||||
{ tag: tags.function(tags.propertyName), color: '#795e26' },
|
||||
{ tag: tags.angleBracket, color: '#800000' },
|
||||
{ tag: tags.tagName, color: '#800000' },
|
||||
{ tag: tags.attributeName, color: '#ff0000' },
|
||||
{ tag: tags.attributeValue, color: '#0000ff' },
|
||||
{ tag: tags.heading, color: '#800000', fontWeight: 'bold' },
|
||||
{ tag: tags.link, color: '#0000ff', textDecoration: 'underline' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
])
|
||||
|
||||
// 暗色主题语法高亮样式
|
||||
const darkThemeHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: '#569cd6' },
|
||||
{ tag: tags.comment, color: '#6a9955', fontStyle: 'italic' },
|
||||
{ tag: tags.string, color: '#ce9178' },
|
||||
{ tag: tags.number, color: '#b5cea8' },
|
||||
{ tag: tags.operator, color: '#d4d4d4' },
|
||||
{ tag: tags.variableName, color: '#9cdcfe' },
|
||||
{ tag: tags.propertyName, color: '#9cdcfe' },
|
||||
{ tag: tags.className, color: '#4ec9b0' },
|
||||
{ tag: tags.typeName, color: '#4ec9b0' },
|
||||
{ tag: tags.definition(tags.variableName), color: '#9cdcfe' },
|
||||
{ tag: tags.definition(tags.propertyName), color: '#9cdcfe' },
|
||||
{ tag: tags.definition(tags.className), color: '#4ec9b0' },
|
||||
{ tag: tags.definition(tags.typeName), color: '#4ec9b0' },
|
||||
{ tag: tags.function(tags.variableName), color: '#dcdcaa' },
|
||||
{ tag: tags.function(tags.propertyName), color: '#dcdcaa' },
|
||||
{ tag: tags.angleBracket, color: '#808080' },
|
||||
{ tag: tags.tagName, color: '#569cd6' },
|
||||
{ tag: tags.attributeName, color: '#9cdcfe' },
|
||||
{ tag: tags.attributeValue, color: '#ce9178' },
|
||||
{ tag: tags.heading, color: '#569cd6', fontWeight: 'bold' },
|
||||
{ tag: tags.link, color: '#569cd6', textDecoration: 'underline' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
])
|
||||
|
||||
export interface CodeMirrorEditorRef {
|
||||
undo: () => boolean
|
||||
redo: () => boolean
|
||||
openSearch: () => void
|
||||
getContent: () => string
|
||||
}
|
||||
|
||||
interface CodeMirrorEditorProps {
|
||||
code: string
|
||||
language: string
|
||||
onChange?: (value: string) => void
|
||||
readOnly?: boolean
|
||||
showLineNumbers?: boolean
|
||||
fontSize?: number
|
||||
height?: string
|
||||
}
|
||||
|
||||
const getLanguageExtension = (language: string) => {
|
||||
switch (language.toLowerCase()) {
|
||||
case 'javascript':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'typescript':
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return javascript()
|
||||
case 'python':
|
||||
case 'py':
|
||||
return python()
|
||||
case 'html':
|
||||
return html()
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return css()
|
||||
case 'json':
|
||||
return json()
|
||||
case 'markdown':
|
||||
case 'md':
|
||||
return markdown()
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
case 'c++':
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
return cpp()
|
||||
case 'java':
|
||||
return java()
|
||||
case 'php':
|
||||
return php()
|
||||
case 'rust':
|
||||
case 'rs':
|
||||
return rust()
|
||||
case 'sql':
|
||||
return sql()
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
return xml()
|
||||
case 'vue':
|
||||
return vue()
|
||||
default:
|
||||
return javascript()
|
||||
}
|
||||
}
|
||||
|
||||
const CodeMirrorEditor = forwardRef<CodeMirrorEditorRef, CodeMirrorEditorProps>((
|
||||
{
|
||||
code,
|
||||
language,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
showLineNumbers = true,
|
||||
fontSize = 14,
|
||||
height = 'auto'
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// 根据当前主题选择高亮样式
|
||||
const highlightStyle = useMemo(() => {
|
||||
return theme === ThemeMode.dark ? darkThemeHighlightStyle : lightThemeHighlightStyle
|
||||
}, [theme])
|
||||
|
||||
// 暴露撤销/重做方法和获取内容方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
undo: () => {
|
||||
if (editorViewRef.current) {
|
||||
try {
|
||||
// 使用用户事件标记来触发撤销
|
||||
const success = undo({ state: editorViewRef.current.state, dispatch: editorViewRef.current.dispatch })
|
||||
// 返回是否成功撤销
|
||||
return success
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
redo: () => {
|
||||
if (editorViewRef.current) {
|
||||
try {
|
||||
// 使用用户事件标记来触发重做
|
||||
const success = redo({ state: editorViewRef.current.state, dispatch: editorViewRef.current.dispatch })
|
||||
// 返回是否成功重做
|
||||
return success
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
openSearch: () => {
|
||||
if (editorViewRef.current) {
|
||||
openChineseSearchPanel(editorViewRef.current)
|
||||
}
|
||||
},
|
||||
// 获取当前编辑器内容
|
||||
getContent: () => {
|
||||
if (editorViewRef.current) {
|
||||
return editorViewRef.current.state.doc.toString()
|
||||
}
|
||||
return code
|
||||
}
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
// 清除之前的编辑器实例
|
||||
if (editorViewRef.current) {
|
||||
editorViewRef.current.destroy()
|
||||
}
|
||||
|
||||
const languageExtension = getLanguageExtension(language)
|
||||
|
||||
// 监听编辑器所有更新
|
||||
const updateListener = EditorView.updateListener.of(update => {
|
||||
// 当文档变化时更新内部状态
|
||||
if (update.docChanged) {
|
||||
// 检查是否是撤销/重做操作
|
||||
const isUndoRedo = update.transactions.some(tr =>
|
||||
tr.isUserEvent('undo') || tr.isUserEvent('redo')
|
||||
)
|
||||
|
||||
// 记录所有文档变化,但只在撤销/重做时触发 onChange
|
||||
if (isUndoRedo && onChange) {
|
||||
// 如果是撤销/重做操作,则触发 onChange
|
||||
onChange(update.state.doc.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const extensions = [
|
||||
// 配置历史记录
|
||||
history(),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
indentWithTab,
|
||||
{ key: "Mod-z", run: undo },
|
||||
{ key: "Mod-y", run: redo },
|
||||
{ key: "Mod-Shift-z", run: redo }
|
||||
]),
|
||||
syntaxHighlighting(highlightStyle),
|
||||
languageExtension,
|
||||
EditorView.editable.of(!readOnly),
|
||||
updateListener,
|
||||
EditorState.readOnly.of(readOnly),
|
||||
highlightActiveLine(),
|
||||
autocompletion(),
|
||||
createChineseSearchPanel(),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: `${fontSize}px`,
|
||||
height: height
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: 'monospace'
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
// 添加行号
|
||||
if (showLineNumbers) {
|
||||
extensions.push(lineNumbers())
|
||||
}
|
||||
|
||||
// 添加主题
|
||||
if (theme === ThemeMode.dark) {
|
||||
extensions.push(oneDark)
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: code,
|
||||
extensions
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: editorRef.current
|
||||
})
|
||||
|
||||
editorViewRef.current = view
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
}
|
||||
}, [code, language, onChange, readOnly, showLineNumbers, theme, fontSize, height])
|
||||
|
||||
return <EditorContainer ref={editorRef} />
|
||||
});
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
`
|
||||
|
||||
export default CodeMirrorEditor
|
||||
285
src/renderer/src/components/CodeMirrorEditor/styles.css
Normal file
285
src/renderer/src/components/CodeMirrorEditor/styles.css
Normal file
@ -0,0 +1,285 @@
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-family: monospace;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
padding: 0 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cm-activeLineGutter {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
border-right: 1px solid var(--color-border);
|
||||
background-color: var(--color-code-background);
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.cm-gutterElement {
|
||||
padding: 0 3px 0 5px;
|
||||
}
|
||||
|
||||
/* Dark theme specific styles */
|
||||
.dark-theme .cm-editor {
|
||||
background-color: #282c34;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.dark-theme .cm-gutters {
|
||||
background-color: #21252b;
|
||||
color: #636d83;
|
||||
}
|
||||
|
||||
.dark-theme .cm-activeLineGutter {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 自动补全样式 */
|
||||
.cm-tooltip {
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-autocomplete {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete ul {
|
||||
font-family: monospace;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete li {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete li:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete .cm-completionLabel {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete .cm-completionDetail {
|
||||
color: var(--color-text-3);
|
||||
font-size: 0.9em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete .cm-completionIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete .cm-completionMatchedText {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dark-theme .cm-tooltip {
|
||||
background-color: #282c34;
|
||||
border-color: #3e4451;
|
||||
}
|
||||
|
||||
.dark-theme .cm-tooltip-autocomplete .cm-completionLabel {
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.dark-theme .cm-tooltip-autocomplete .cm-completionDetail {
|
||||
color: #636d83;
|
||||
}
|
||||
|
||||
.dark-theme .cm-tooltip-autocomplete .cm-completionMatchedText {
|
||||
color: #61afef;
|
||||
}
|
||||
|
||||
.dark-theme .cm-tooltip-autocomplete li:hover {
|
||||
background-color: #3e4451;
|
||||
}
|
||||
|
||||
/* 查找和替换面板样式 */
|
||||
.cm-search {
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.cm-search input {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cm-search button {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
margin: 2px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-search button:hover {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
|
||||
.cm-search label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 2px 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cm-search input[type="checkbox"] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.cm-searchMatch {
|
||||
background-color: rgba(250, 166, 26, 0.2);
|
||||
}
|
||||
|
||||
.cm-searchMatch-selected {
|
||||
background-color: rgba(250, 166, 26, 0.5);
|
||||
}
|
||||
|
||||
.dark-theme .cm-search {
|
||||
background-color: #282c34;
|
||||
border-color: #3e4451;
|
||||
}
|
||||
|
||||
.dark-theme .cm-search input {
|
||||
background-color: #21252b;
|
||||
border-color: #3e4451;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.dark-theme .cm-search button {
|
||||
background-color: #21252b;
|
||||
border-color: #3e4451;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.dark-theme .cm-search button:hover {
|
||||
background-color: #3e4451;
|
||||
}
|
||||
|
||||
.dark-theme .cm-search label {
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.dark-theme .cm-searchMatch {
|
||||
background-color: rgba(229, 192, 123, 0.3);
|
||||
}
|
||||
|
||||
.dark-theme .cm-searchMatch-selected {
|
||||
background-color: rgba(229, 192, 123, 0.6);
|
||||
}
|
||||
|
||||
/* 中文搜索面板样式覆盖 */
|
||||
.cm-panel.cm-search {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search input[name="search"] {
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search input[name="replace"] {
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="find"] {
|
||||
content: '查找';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="next"] {
|
||||
content: '下一个';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="prev"] {
|
||||
content: '上一个';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="replace"] {
|
||||
content: '替换';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="replaceAll"] {
|
||||
content: '全部替换';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="case"] {
|
||||
content: '区分大小写';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="regexp"] {
|
||||
content: '正则表达式';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="word"] {
|
||||
content: '全词匹配';
|
||||
}
|
||||
|
||||
/* 修改按钮文本 */
|
||||
.cm-panel.cm-search button[name="find"]::before {
|
||||
content: '查找';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="next"]::before {
|
||||
content: '下一个';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="prev"]::before {
|
||||
content: '上一个';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="replace"]::before {
|
||||
content: '替换';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search button[name="replaceAll"]::before {
|
||||
content: '全部替换';
|
||||
}
|
||||
|
||||
/* 修改标签文本 */
|
||||
.cm-panel.cm-search label[for="case"]::after {
|
||||
content: '区分大小写';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="regexp"]::after {
|
||||
content: '正则表达式';
|
||||
}
|
||||
|
||||
.cm-panel.cm-search label[for="word"]::after {
|
||||
content: '全词匹配';
|
||||
}
|
||||
@ -22,7 +22,7 @@ import {
|
||||
setRecommendationThreshold,
|
||||
setShortMemoryActive
|
||||
} from '@renderer/store/memory'
|
||||
import { FC, ReactNode, useEffect, useRef } from 'react'
|
||||
import { FC, ReactNode, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
interface MemoryProviderProps {
|
||||
children: ReactNode
|
||||
@ -45,12 +45,17 @@ const MemoryProvider: FC<MemoryProviderProps> = ({ children }) => {
|
||||
|
||||
// 获取当前对话
|
||||
const currentTopic = useAppSelector((state) => state.messages?.currentTopic?.id)
|
||||
const messages = useAppSelector((state) => {
|
||||
if (!currentTopic || !state.messages?.messagesByTopic) {
|
||||
|
||||
// 使用 useMemo 记忆化选择器的结果,避免返回新的数组引用
|
||||
const messagesByTopic = useAppSelector((state) => state.messages?.messagesByTopic)
|
||||
|
||||
// 使用 useMemo 记忆化消息数组
|
||||
const messages = useMemo(() => {
|
||||
if (!currentTopic || !messagesByTopic) {
|
||||
return []
|
||||
}
|
||||
return state.messages.messagesByTopic[currentTopic] || []
|
||||
})
|
||||
return messagesByTopic[currentTopic] || []
|
||||
}, [currentTopic, messagesByTopic])
|
||||
|
||||
// 存储上一次的话题ID
|
||||
const previousTopicRef = useRef<string | null>(null)
|
||||
|
||||
57
src/renderer/src/components/PDFSettingsInitializer.tsx
Normal file
57
src/renderer/src/components/PDFSettingsInitializer.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import store from '@renderer/store'
|
||||
import { setPdfSettings } from '@renderer/store/settings'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
|
||||
/**
|
||||
* 用于在应用启动时初始化PDF设置
|
||||
*/
|
||||
const PDFSettingsInitializer = () => {
|
||||
const dispatch = useDispatch()
|
||||
const { pdfSettings } = useSettings()
|
||||
|
||||
// 默认PDF设置
|
||||
const defaultPdfSettings = {
|
||||
enablePdfSplitting: true,
|
||||
defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[PDFSettingsInitializer] Initializing PDF settings')
|
||||
// 强制初始化PDF设置,确保 enablePdfSplitting 存在且为 true
|
||||
console.log('[PDFSettingsInitializer] Current pdfSettings:', pdfSettings)
|
||||
|
||||
// 创建合并的设置
|
||||
const mergedSettings = {
|
||||
...defaultPdfSettings,
|
||||
...pdfSettings,
|
||||
// 强制设置 enablePdfSplitting 为 true
|
||||
enablePdfSplitting: true
|
||||
}
|
||||
|
||||
console.log('[PDFSettingsInitializer] Forcing initialization with settings:', mergedSettings)
|
||||
dispatch(setPdfSettings(mergedSettings))
|
||||
|
||||
// 延迟1秒后再次检查设置,确保它们已经被正确应用
|
||||
const timer = setTimeout(() => {
|
||||
const state = store.getState()
|
||||
console.log('[PDFSettingsInitializer] Checking settings after delay:', state.settings.pdfSettings)
|
||||
|
||||
// 如果设置仍然不正确,再次强制设置
|
||||
if (!state.settings.pdfSettings?.enablePdfSplitting) {
|
||||
console.log('[PDFSettingsInitializer] Settings still incorrect, forcing again')
|
||||
dispatch(setPdfSettings({
|
||||
...state.settings.pdfSettings,
|
||||
enablePdfSplitting: true
|
||||
}))
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default PDFSettingsInitializer
|
||||
265
src/renderer/src/components/PDFSplitter.tsx
Normal file
265
src/renderer/src/components/PDFSplitter.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { reloadTranslations } from '@renderer/i18n'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { Button, Input, message, Modal, Spin } from 'antd'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
// 设置 PDF.js worker 路径
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`
|
||||
|
||||
interface PDFSplitterProps {
|
||||
file: FileType
|
||||
visible: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (file: FileType, pageRange: string) => void
|
||||
}
|
||||
|
||||
const PDFSplitter: FC<PDFSplitterProps> = ({ file, visible, onCancel, onConfirm }) => {
|
||||
const { t } = useTranslation()
|
||||
const { pdfSettings } = useSettings()
|
||||
const [pageRange, setPageRange] = useState<string>(
|
||||
pdfSettings?.defaultPageRangePrompt || t('pdf.page_range_placeholder')
|
||||
)
|
||||
const [totalPages, setTotalPages] = useState<number>(0)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [processing, setProcessing] = useState<boolean>(false)
|
||||
// 页面选择相关状态在 PDFPreview 组件中实现
|
||||
|
||||
// 强制重新加载翻译
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
console.log('[PDFSplitter] Reloading translations')
|
||||
reloadTranslations()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
const loadPdf = async () => {
|
||||
console.log('[PDFSplitter] loadPdf called, visible:', visible, 'file:', file)
|
||||
if (visible && file) {
|
||||
setLoading(true)
|
||||
try {
|
||||
console.log('[PDFSplitter] Loading PDF file:', file.path)
|
||||
|
||||
// 使用文件路径直接加载PDF
|
||||
console.log('[PDFSplitter] Using PDFService to split PDF')
|
||||
|
||||
// 尝试从文件属性中获取页数
|
||||
if (file.pdf_page_count) {
|
||||
console.log('[PDFSplitter] Using page count from file properties:', file.pdf_page_count)
|
||||
setTotalPages(file.pdf_page_count)
|
||||
setLoading(false)
|
||||
} else {
|
||||
// 如果文件属性中没有页数信息,则使用默认值
|
||||
// 注意:如果应用程序没有重新加载,getPageCount方法可能不可用
|
||||
console.log('[PDFSplitter] No page count in file properties, checking if getPageCount is available')
|
||||
|
||||
if (window.api.pdf.getPageCount && typeof window.api.pdf.getPageCount === 'function') {
|
||||
console.log('[PDFSplitter] getPageCount method is available, fetching from server')
|
||||
try {
|
||||
window.api.pdf
|
||||
.getPageCount(file.path)
|
||||
.then((pageCount: number) => {
|
||||
console.log('[PDFSplitter] Got page count from server:', pageCount)
|
||||
setTotalPages(pageCount)
|
||||
// 更新文件属性,以便下次使用
|
||||
file.pdf_page_count = pageCount
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('[PDFSplitter] Error getting page count:', error)
|
||||
// 如果出错,使用默认值
|
||||
const defaultPages = 100
|
||||
console.log('[PDFSplitter] Using default page count:', defaultPages)
|
||||
setTotalPages(defaultPages)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[PDFSplitter] Error calling getPageCount:', error)
|
||||
const defaultPages = 100
|
||||
console.log('[PDFSplitter] Using default page count:', defaultPages)
|
||||
setTotalPages(defaultPages)
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
console.log('[PDFSplitter] getPageCount method is not available, using default value')
|
||||
const defaultPages = 100
|
||||
console.log('[PDFSplitter] Using default page count:', defaultPages)
|
||||
setTotalPages(defaultPages)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
// Loading state will be set in the finally block
|
||||
} catch (error) {
|
||||
console.error('[PDFSplitter] Error loading PDF:', error)
|
||||
message.error(t('error.unknown'))
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadPdf()
|
||||
}, [visible, file, onCancel, t])
|
||||
|
||||
// 处理预览按钮点击
|
||||
const handlePreview = () => {
|
||||
console.log('[PDFSplitter] handlePreview called, file:', file)
|
||||
// 使用系统默认的 PDF 查看器打开 PDF 文件
|
||||
window.api.file
|
||||
.openPath(file.path)
|
||||
.then(() => {
|
||||
console.log('[PDFSplitter] PDF file opened successfully')
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('[PDFSplitter] Error opening PDF file:', error)
|
||||
message.error(t('error.unknown'))
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
console.log('[PDFSplitter] handleConfirm called, pageRange:', pageRange, 'totalPages:', totalPages)
|
||||
if (!validatePageRange(pageRange, totalPages)) {
|
||||
console.log('[PDFSplitter] Invalid page range')
|
||||
message.error(t('settings.pdf.invalid_range'))
|
||||
return
|
||||
}
|
||||
|
||||
setProcessing(true)
|
||||
try {
|
||||
console.log('[PDFSplitter] Processing PDF with page range:', pageRange)
|
||||
console.log('[PDFSplitter] File to process:', file)
|
||||
// 将页码范围传递给父组件,实际的PDF分割将在主进程中完成
|
||||
onConfirm(file, pageRange)
|
||||
console.log('[PDFSplitter] onConfirm called successfully')
|
||||
} catch (error) {
|
||||
console.error('[PDFSplitter] Error processing PDF:', error)
|
||||
message.error(t('error.unknown'))
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证页码范围格式
|
||||
const validatePageRange = (range: string, total: number): boolean => {
|
||||
console.log('[PDFSplitter] Validating page range:', range, 'total pages:', total)
|
||||
if (!range.trim()) {
|
||||
console.log('[PDFSplitter] Empty page range')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 支持的格式: 1,2,3-5,7-9
|
||||
const parts = range.split(',')
|
||||
console.log('[PDFSplitter] Page range parts:', parts)
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (!trimmed) {
|
||||
console.log('[PDFSplitter] Empty part, skipping')
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.includes('-')) {
|
||||
const [start, end] = trimmed.split('-').map((n) => parseInt(n.trim()))
|
||||
console.log('[PDFSplitter] Range part:', trimmed, 'start:', start, 'end:', end)
|
||||
if (isNaN(start) || isNaN(end) || start < 1 || end > total || start > end) {
|
||||
console.log('[PDFSplitter] Invalid range part:', trimmed, 'start:', start, 'end:', end)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
const page = parseInt(trimmed)
|
||||
console.log('[PDFSplitter] Single page:', page)
|
||||
if (isNaN(page) || page < 1 || page > total) {
|
||||
console.log('[PDFSplitter] Invalid page number:', page)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[PDFSplitter] Page range is valid')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[PDFSplitter] Error validating page range:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t('settings.pdf.split')} open={visible} onCancel={onCancel} footer={null} destroyOnClose>
|
||||
{loading ? (
|
||||
<LoadingContainer>
|
||||
<Spin size="large" />
|
||||
</LoadingContainer>
|
||||
) : (
|
||||
<Container>
|
||||
<FileInfo>
|
||||
<div>{file.name}</div>
|
||||
<div>{t('settings.pdf.total_pages', { count: totalPages })}</div>
|
||||
</FileInfo>
|
||||
|
||||
<InputContainer>
|
||||
<label>{t('settings.pdf.page_range')}</label>
|
||||
<Input
|
||||
value={pageRange}
|
||||
onChange={(e) => setPageRange(e.target.value)}
|
||||
placeholder={t('settings.pdf.page_range_placeholder')}
|
||||
/>
|
||||
</InputContainer>
|
||||
|
||||
<ButtonContainer>
|
||||
<div>
|
||||
<Button onClick={handlePreview}>{t('settings.pdf.preview')}</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={onCancel}>{t('settings.pdf.cancel')}</Button>
|
||||
<Button type="primary" onClick={handleConfirm} loading={processing} style={{ marginLeft: '8px' }}>
|
||||
{processing ? t('settings.pdf.processing') : t('settings.pdf.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</ButtonContainer>
|
||||
</Container>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
`
|
||||
|
||||
const FileInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
export default PDFSplitter
|
||||
@ -56,6 +56,11 @@ const NavbarCenterContainer = styled.div`
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
|
||||
/* 确保标题区域的按钮可点击 */
|
||||
& button, & a {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div`
|
||||
@ -65,4 +70,10 @@ const NavbarRightContainer = styled.div`
|
||||
padding: 0 12px;
|
||||
padding-right: ${isWindows ? '140px' : 12};
|
||||
justify-content: flex-end;
|
||||
-webkit-app-region: no-drag; /* 确保按钮可点击 */
|
||||
|
||||
/* 确保所有子元素都可点击 */
|
||||
& > * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`
|
||||
|
||||
@ -672,6 +672,18 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'gemini',
|
||||
name: 'Gemini 2.0 Flash',
|
||||
group: 'Gemini 2.0'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-flash-preview-04-17',
|
||||
provider: 'gemini',
|
||||
name: 'Gemini 2.5 Flash',
|
||||
group: 'Gemini 2.5'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro-preview-03-25',
|
||||
provider: 'gemini',
|
||||
name: 'Gemini 2.5 Pro',
|
||||
group: 'Gemini 2.5'
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
@ -2150,7 +2162,9 @@ export const GEMINI_SEARCH_MODELS = [
|
||||
'gemini-2.0-pro-exp-02-05',
|
||||
'gemini-2.0-pro-exp',
|
||||
'gemini-2.5-pro-exp',
|
||||
'gemini-2.5-pro-exp-03-25'
|
||||
'gemini-2.5-pro-exp-03-25',
|
||||
'gemini-2.5-flash-preview-04-17',
|
||||
'gemini-2.5-pro-preview-03-25'
|
||||
]
|
||||
|
||||
export function isTextToImageModel(model: Model): boolean {
|
||||
@ -2393,6 +2407,20 @@ export function isGemmaModel(model?: Model): boolean {
|
||||
return model.id.includes('gemma-') || model.group === 'Gemma'
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否支持思考预算功能
|
||||
* @param model 模型
|
||||
* @returns 是否支持思考预算
|
||||
*/
|
||||
export function isSupportedThinkingBudgetModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 目前只有Gemini 2.5系列模型支持思考预算
|
||||
return model.id.includes('gemini-2.5')
|
||||
}
|
||||
|
||||
export function isZhipuModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
|
||||
@ -43,4 +43,11 @@ i18n.use(initReactI18next).init({
|
||||
}
|
||||
})
|
||||
|
||||
// 强制重新加载翻译
|
||||
export const reloadTranslations = () => {
|
||||
const currentLng = i18n.language
|
||||
i18n.reloadResources(currentLng)
|
||||
console.log('[i18n] Translations reloaded for language:', currentLng)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
{
|
||||
"translation": {
|
||||
"code": {
|
||||
"execute": "Execute Code",
|
||||
"executing": "Executing...",
|
||||
"execution": {
|
||||
"success": "Execution Successful",
|
||||
"error": "Execution Error",
|
||||
"output": "Output",
|
||||
"unsupported_language": "Unsupported Programming Language"
|
||||
}
|
||||
},
|
||||
"voice_call": {
|
||||
"title": "Voice Call",
|
||||
"start": "Start Voice Call",
|
||||
@ -83,6 +93,8 @@
|
||||
"settings.reasoning_effort.medium": "medium",
|
||||
"settings.reasoning_effort.off": "off",
|
||||
"settings.reasoning_effort.tip": "Only supported by OpenAI o-series, Anthropic, and Grok reasoning models",
|
||||
"settings.thinking_budget": "Thinking Budget",
|
||||
"settings.thinking_budget.tip": "Controls the maximum number of tokens used for thinking before generating the final answer (only supported by Gemini 2.5 series models)",
|
||||
"settings.more": "Assistant Settings"
|
||||
},
|
||||
"auth": {
|
||||
@ -282,7 +294,12 @@
|
||||
"collapse": "Collapse",
|
||||
"disable_wrap": "Unwrap",
|
||||
"enable_wrap": "Wrap",
|
||||
"expand": "Expand"
|
||||
"expand": "Expand",
|
||||
"edit": "Edit",
|
||||
"done_editing": "Done Editing",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"search": "Search"
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
@ -668,6 +685,8 @@
|
||||
"number": "Number",
|
||||
"string": "Text"
|
||||
},
|
||||
"thinking_budget": "Thinking Budget",
|
||||
"thinking_budget.tip": "Controls the maximum number of tokens used for thinking before generating the final answer (only supported by Gemini 2.5 series models)",
|
||||
"pinned": "Pinned",
|
||||
"rerank_model": "Reordering Model",
|
||||
"rerank_model_support_provider": "Currently, the reordering model only supports some providers ({{provider}})",
|
||||
@ -1594,6 +1613,25 @@
|
||||
"title": "Privacy Settings",
|
||||
"enable_privacy_mode": "Anonymous reporting of errors and statistics"
|
||||
},
|
||||
"pdf": {
|
||||
"title": "PDF Settings",
|
||||
"enable_splitting": "Enable PDF Splitting",
|
||||
"default_page_range_prompt": "Default Page Range Prompt",
|
||||
"default_page_range_prompt_placeholder": "Example: 1-5,8,10-15",
|
||||
"description": "PDF splitting allows you to select specific page ranges to send to AI for more precise processing of PDF document content.",
|
||||
"split": "Split PDF",
|
||||
"page_range": "Page Range",
|
||||
"page_range_placeholder": "Enter page range, e.g.: 1-5,8,10-15",
|
||||
"total_pages": "Total {{count}} pages",
|
||||
"preview": "Preview",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"invalid_range": "Invalid page range",
|
||||
"processing": "Processing...",
|
||||
"one_at_a_time": "Only one PDF file can be processed at a time, other PDF files will be ignored",
|
||||
"error_loading": "Failed to load PDF file",
|
||||
"error_splitting": "Failed to split PDF file"
|
||||
},
|
||||
"tts": {
|
||||
"title": "Text-to-Speech Settings",
|
||||
"enable": "Enable Text-to-Speech",
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
{
|
||||
"translation": {
|
||||
"code": {
|
||||
"execute": "执行代码",
|
||||
"executing": "执行中...",
|
||||
"execution": {
|
||||
"success": "执行成功",
|
||||
"error": "执行错误",
|
||||
"output": "输出",
|
||||
"unsupported_language": "不支持的编程语言"
|
||||
}
|
||||
},
|
||||
"voice_call": {
|
||||
"title": "语音通话",
|
||||
"start": "开始语音通话",
|
||||
@ -83,6 +93,8 @@
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "关",
|
||||
"settings.reasoning_effort.tip": "仅支持 OpenAI o-series、Anthropic、Grok 推理模型",
|
||||
"settings.thinking_budget": "思考预算",
|
||||
"settings.thinking_budget.tip": "控制模型在生成最终回答前进行思考所使用的token数量上限(仅支持Gemini 2.5系列模型)",
|
||||
"settings.more": "助手设置"
|
||||
},
|
||||
"auth": {
|
||||
@ -283,7 +295,12 @@
|
||||
"collapse": "收起",
|
||||
"disable_wrap": "取消换行",
|
||||
"enable_wrap": "换行",
|
||||
"expand": "展开"
|
||||
"expand": "展开",
|
||||
"edit": "编辑",
|
||||
"done_editing": "完成编辑",
|
||||
"undo": "撤销",
|
||||
"redo": "重做",
|
||||
"search": "查找"
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
@ -669,6 +686,8 @@
|
||||
"number": "数字",
|
||||
"string": "文本"
|
||||
},
|
||||
"thinking_budget": "思考预算",
|
||||
"thinking_budget.tip": "控制模型在生成最终回答前进行思考所使用的token数量上限(仅支持Gemini 2.5系列模型)",
|
||||
"pinned": "已固定",
|
||||
"rerank_model": "重排模型",
|
||||
"rerank_model_support_provider": "目前重排序模型仅支持部分服务商 ({{provider}})",
|
||||
@ -1683,6 +1702,25 @@
|
||||
"title": "隐私设置",
|
||||
"enable_privacy_mode": "匿名发送错误报告和数据统计"
|
||||
},
|
||||
"pdf": {
|
||||
"title": "PDF设置",
|
||||
"enable_splitting": "启用PDF分割功能",
|
||||
"default_page_range_prompt": "默认页码范围提示文本",
|
||||
"default_page_range_prompt_placeholder": "例如:1-5,8,10-15",
|
||||
"description": "PDF分割功能允许您选择特定页码范围发送给AI,以便更精确地处理PDF文档内容。",
|
||||
"split": "分割PDF",
|
||||
"page_range": "页码范围",
|
||||
"page_range_placeholder": "输入页码范围,例如:1-5,8,10-15",
|
||||
"preview": "预览",
|
||||
"total_pages": "共 {{count}} 页",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"invalid_range": "无效的页码范围",
|
||||
"processing": "处理中...",
|
||||
"one_at_a_time": "一次只能处理一个PDF文件,其他PDF文件将被忽略",
|
||||
"error_loading": "加载PDF文件失败",
|
||||
"error_splitting": "分割PDF文件失败"
|
||||
},
|
||||
"voice": {
|
||||
"title": "语音功能",
|
||||
"help": "语音功能包括文本转语音(TTS)、语音识别(ASR)和语音通话。",
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { setPdfSettings } from '@renderer/store/settings'
|
||||
import { FileType, Model } from '@renderer/types'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Paperclip } from 'lucide-react'
|
||||
import { FC, useCallback, useImperativeHandle, useMemo } from 'react'
|
||||
import { FC, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import PDFSplitter from '@renderer/components/PDFSplitter'
|
||||
|
||||
export interface AttachmentButtonRef {
|
||||
openQuickPanel: () => void
|
||||
handlePdfFile: (file: FileType) => boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -21,13 +26,75 @@ interface Props {
|
||||
|
||||
const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => {
|
||||
const { t } = useTranslation()
|
||||
const { pdfSettings } = useSettings()
|
||||
const dispatch = useDispatch()
|
||||
const [pdfSplitterVisible, setPdfSplitterVisible] = useState(false)
|
||||
const [selectedPdfFile, setSelectedPdfFile] = useState<FileType | null>(null)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => (isVisionModel(model) ? [...imageExts, ...documentExts, ...textExts] : [...documentExts, ...textExts]),
|
||||
[model]
|
||||
)
|
||||
|
||||
// 强制初始化PDF设置
|
||||
const forcePdfSettingsInitialization = useCallback(() => {
|
||||
console.log('[AttachmentButton] Forcing PDF settings initialization')
|
||||
// 如果pdfSettings为undefined或缺少enablePdfSplitting属性,则使用默认值初始化
|
||||
if (!pdfSettings || pdfSettings.enablePdfSplitting === undefined) {
|
||||
const defaultPdfSettings = {
|
||||
enablePdfSplitting: true,
|
||||
defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15'
|
||||
}
|
||||
|
||||
console.log('[AttachmentButton] Dispatching setPdfSettings with:', defaultPdfSettings)
|
||||
dispatch(setPdfSettings(defaultPdfSettings))
|
||||
return defaultPdfSettings
|
||||
}
|
||||
|
||||
return pdfSettings
|
||||
}, [dispatch, pdfSettings])
|
||||
|
||||
const handlePdfFile = useCallback((file: FileType) => {
|
||||
console.log('[AttachmentButton] handlePdfFile called with file:', file)
|
||||
|
||||
// 强制初始化PDF设置
|
||||
const settings = forcePdfSettingsInitialization()
|
||||
console.log('[AttachmentButton] PDF settings after initialization:', settings)
|
||||
|
||||
if (settings.enablePdfSplitting && file.ext.toLowerCase() === '.pdf') {
|
||||
console.log('[AttachmentButton] PDF splitting enabled, showing splitter dialog')
|
||||
setSelectedPdfFile(file)
|
||||
setPdfSplitterVisible(true)
|
||||
return true // 返回true表示我们已经处理了这个文件
|
||||
}
|
||||
console.log('[AttachmentButton] PDF splitting disabled or not a PDF file, returning false')
|
||||
return false // 返回false表示这个文件需要正常处理
|
||||
}, [forcePdfSettingsInitialization])
|
||||
|
||||
const handlePdfSplitterConfirm = useCallback(async (file: FileType, pageRange: string) => {
|
||||
console.log('[AttachmentButton] handlePdfSplitterConfirm called with file:', file, 'pageRange:', pageRange)
|
||||
try {
|
||||
// 调用主进程的PDF分割功能
|
||||
console.log('[AttachmentButton] Calling window.api.pdf.splitPDF')
|
||||
const newFile = await window.api.pdf.splitPDF(file, pageRange)
|
||||
console.log('[AttachmentButton] PDF split successful, new file:', newFile)
|
||||
setFiles([...files, newFile])
|
||||
setPdfSplitterVisible(false)
|
||||
setSelectedPdfFile(null)
|
||||
} catch (error) {
|
||||
console.error('[AttachmentButton] Error splitting PDF:', error)
|
||||
window.message.error({
|
||||
content: t('pdf.error_splitting'),
|
||||
key: 'pdf-error-splitting'
|
||||
})
|
||||
}
|
||||
}, [files, setFiles, t])
|
||||
|
||||
const onSelectFile = useCallback(async () => {
|
||||
// 强制初始化PDF设置
|
||||
const settings = forcePdfSettingsInitialization()
|
||||
console.log('[AttachmentButton] PDF settings before file selection:', settings)
|
||||
|
||||
const _files = await window.api.file.select({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
@ -39,27 +106,76 @@ const AttachmentButton: FC<Props> = ({ ref, model, files, setFiles, ToolbarButto
|
||||
})
|
||||
|
||||
if (_files) {
|
||||
setFiles([...files, ..._files])
|
||||
// 检查是否有PDF文件需要特殊处理
|
||||
const pdfFiles = _files.filter(file => file.ext.toLowerCase() === '.pdf')
|
||||
const nonPdfFiles = _files.filter(file => file.ext.toLowerCase() !== '.pdf')
|
||||
|
||||
// 添加非PDF文件
|
||||
if (nonPdfFiles.length > 0) {
|
||||
setFiles([...files, ...nonPdfFiles])
|
||||
}
|
||||
|
||||
// 处理PDF文件
|
||||
if (pdfFiles.length > 0) {
|
||||
console.log('[AttachmentButton] PDF files selected:', pdfFiles)
|
||||
console.log('[AttachmentButton] PDF settings after initialization:', settings)
|
||||
|
||||
if (settings.enablePdfSplitting === true) {
|
||||
console.log('[AttachmentButton] PDF splitting is enabled')
|
||||
// 如果有多个PDF文件,只处理第一个
|
||||
setSelectedPdfFile(pdfFiles[0])
|
||||
setPdfSplitterVisible(true)
|
||||
console.log('[AttachmentButton] Set PDF splitter visible with file:', pdfFiles[0])
|
||||
|
||||
// 如果有多个PDF文件,提示用户一次只能处理一个PDF文件
|
||||
if (pdfFiles.length > 1) {
|
||||
console.log('[AttachmentButton] Multiple PDF files selected, showing info message')
|
||||
window.message.info({
|
||||
content: t('pdf.one_at_a_time'),
|
||||
key: 'pdf-one-at-a-time'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log('[AttachmentButton] PDF splitting is disabled, adding all PDF files')
|
||||
// 如果未启用PDF分割功能,直接添加所有PDF文件
|
||||
setFiles([...files, ...pdfFiles])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [extensions, files, setFiles])
|
||||
}, [extensions, files, setFiles, forcePdfSettingsInitialization, t])
|
||||
|
||||
const openQuickPanel = useCallback(() => {
|
||||
onSelectFile()
|
||||
}, [onSelectFile])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openQuickPanel
|
||||
openQuickPanel,
|
||||
handlePdfFile
|
||||
}))
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document')}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
|
||||
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document')}
|
||||
arrow>
|
||||
<ToolbarButton type="text" onClick={onSelectFile} disabled={disabled}>
|
||||
<Paperclip size={18} style={{ color: files.length ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
{selectedPdfFile && (
|
||||
<PDFSplitter
|
||||
file={selectedPdfFile}
|
||||
visible={pdfSplitterVisible}
|
||||
onCancel={() => {
|
||||
setPdfSplitterVisible(false)
|
||||
setSelectedPdfFile(null)
|
||||
}}
|
||||
onConfirm={handlePdfSplitterConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,4 @@
|
||||
import {
|
||||
CodeOutlined as _CodeOutlined,
|
||||
FileSearchOutlined as _FileSearchOutlined,
|
||||
HolderOutlined,
|
||||
PaperClipOutlined as _PaperClipOutlined,
|
||||
PauseCircleOutlined as _PauseCircleOutlined,
|
||||
ThunderboltOutlined as _ThunderboltOutlined,
|
||||
TranslationOutlined as _TranslationOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import ASRButton from '@renderer/components/ASRButton'
|
||||
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
@ -33,6 +25,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import store, { useAppDispatch } from '@renderer/store'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/messages'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import { setPdfSettings } from '@renderer/store/settings'
|
||||
import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { getFilesFromDropEvent } from '@renderer/utils/input'
|
||||
@ -375,7 +368,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
selectedKnowledgeBases,
|
||||
text,
|
||||
topic,
|
||||
activedMcpServers
|
||||
activedMcpServers,
|
||||
t
|
||||
])
|
||||
|
||||
const translate = useCallback(async () => {
|
||||
@ -822,9 +816,38 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// 强制初始化PDF设置
|
||||
const forcePdfSettingsInitialization = () => {
|
||||
const { pdfSettings } = store.getState().settings
|
||||
console.log('[Inputbar] Current PDF settings:', pdfSettings)
|
||||
|
||||
// 如果pdfSettings为undefined或缺少enablePdfSplitting属性,则使用默认值初始化
|
||||
if (!pdfSettings || pdfSettings.enablePdfSplitting === undefined) {
|
||||
const defaultPdfSettings = {
|
||||
enablePdfSplitting: true,
|
||||
defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15'
|
||||
}
|
||||
|
||||
// 如果pdfSettings存在,则合并现有设置和默认设置
|
||||
const mergedSettings = {
|
||||
...defaultPdfSettings,
|
||||
...pdfSettings,
|
||||
// 确保 enablePdfSplitting 存在且为 true
|
||||
enablePdfSplitting: true
|
||||
}
|
||||
|
||||
console.log('[Inputbar] Forcing PDF settings initialization with:', mergedSettings)
|
||||
dispatch(setPdfSettings(mergedSettings))
|
||||
return mergedSettings
|
||||
}
|
||||
|
||||
return pdfSettings
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
console.log('[Inputbar] handleDrop called')
|
||||
|
||||
const files = await getFilesFromDropEvent(e).catch((err) => {
|
||||
Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err)
|
||||
@ -832,11 +855,50 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
})
|
||||
|
||||
if (files) {
|
||||
files.forEach((file) => {
|
||||
if (supportExts.includes(getFileExtension(file.path))) {
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
console.log('[Inputbar] Files from drop event:', files)
|
||||
// 获取设置中的PDF设置,并强制初始化
|
||||
const pdfSettings = forcePdfSettingsInitialization()
|
||||
console.log('[Inputbar] PDF settings after initialization:', pdfSettings)
|
||||
|
||||
for (const file of files) {
|
||||
const fileExt = getFileExtension(file.path)
|
||||
console.log(`[Inputbar] Processing file: ${file.path} with extension: ${fileExt}`)
|
||||
|
||||
// 如果是PDF文件
|
||||
if (fileExt === '.pdf') {
|
||||
console.log('[Inputbar] PDF file detected, checking if splitting is enabled')
|
||||
console.log('[Inputbar] pdfSettings?.enablePdfSplitting =', pdfSettings?.enablePdfSplitting)
|
||||
|
||||
// 如果启用了PDF分割功能
|
||||
if (pdfSettings?.enablePdfSplitting === true) {
|
||||
console.log('[Inputbar] PDF splitting is enabled, calling handlePdfFile')
|
||||
// 检查attachmentButtonRef.current是否存在
|
||||
if (attachmentButtonRef.current) {
|
||||
console.log('[Inputbar] attachmentButtonRef.current exists, calling handlePdfFile')
|
||||
const handled = attachmentButtonRef.current.handlePdfFile(file)
|
||||
console.log('[Inputbar] handlePdfFile result:', handled)
|
||||
if (handled) {
|
||||
// 如果文件已经被处理,则跳过后面的处理
|
||||
console.log('[Inputbar] File was handled by PDF splitter, skipping normal processing')
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
console.log('[Inputbar] attachmentButtonRef.current is null or undefined')
|
||||
}
|
||||
} else {
|
||||
console.log('[Inputbar] PDF splitting is disabled, processing as normal file')
|
||||
}
|
||||
}
|
||||
})
|
||||
// 其他支持的文件类型
|
||||
else if (supportExts.includes(fileExt)) {
|
||||
console.log('[Inputbar] Adding file to files state:', file.path)
|
||||
setFiles((prevFiles) => [...prevFiles, file])
|
||||
} else {
|
||||
console.log('[Inputbar] File not supported or PDF splitting disabled:', file.path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[Inputbar] No files from drop event')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
533
src/renderer/src/pages/home/Markdown/EditableCodeBlock.tsx
Normal file
533
src/renderer/src/pages/home/Markdown/EditableCodeBlock.tsx
Normal file
@ -0,0 +1,533 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
RedoOutlined,
|
||||
SearchOutlined,
|
||||
UndoOutlined
|
||||
} from '@ant-design/icons'
|
||||
import ExecutionResult, { ExecutionResultProps } from '@renderer/components/CodeExecutorButton/ExecutionResult'
|
||||
import CodeMirrorEditor, { CodeMirrorEditorRef } from '@renderer/components/CodeMirrorEditor'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import UnWrapIcon from '@renderer/components/Icons/UnWrapIcon'
|
||||
import WrapIcon from '@renderer/components/Icons/WrapIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { message, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Mermaid from './Mermaid'
|
||||
import { isValidPlantUML, PlantUML } from './PlantUML'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
interface EditableCodeBlockProps {
|
||||
children: string
|
||||
className?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const EditableCodeBlock: React.FC<EditableCodeBlockProps> = ({ children, className }) => {
|
||||
const match = /language-(\w+)/.exec(className || '') || children?.includes('\n')
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const language = match?.[1] ?? 'text'
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [code, setCode] = useState(children)
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResultProps | null>(null)
|
||||
const codeContentRef = useRef<HTMLPreElement>(null)
|
||||
const editorRef = useRef<CodeMirrorEditorRef>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showFooterCopyButton = children && children.length > 500 && !codeCollapsible
|
||||
const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language)
|
||||
|
||||
useEffect(() => {
|
||||
setCode(children)
|
||||
}, [children])
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(!codeCollapsible)
|
||||
setShouldShowExpandButton(codeCollapsible && (codeContentRef.current?.scrollHeight ?? 0) > 350)
|
||||
}, [codeCollapsible])
|
||||
|
||||
useEffect(() => {
|
||||
setIsUnwrapped(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
// 当点击编辑按钮时调用
|
||||
const handleEditToggle = useCallback(() => {
|
||||
if (isEditing) {
|
||||
// 如果当前是编辑状态,则保存代码
|
||||
if (editorRef.current) {
|
||||
// 使用 getContent 方法获取编辑器内容
|
||||
const newCode = editorRef.current.getContent()
|
||||
setCode(newCode)
|
||||
}
|
||||
}
|
||||
// 切换编辑状态
|
||||
setIsEditing(!isEditing)
|
||||
}, [isEditing])
|
||||
|
||||
// handleCodeChange 函数,只在撤销/重做操作时才会被调用
|
||||
const handleCodeChange = useCallback((newCode: string) => {
|
||||
// 只在撤销/重做操作时才会被调用,所以可以安全地更新代码
|
||||
// 这不会影响普通的输入操作
|
||||
setCode(newCode)
|
||||
}, [])
|
||||
|
||||
// 执行代码
|
||||
const executeCode = useCallback(async () => {
|
||||
if (!code) return
|
||||
|
||||
setIsExecuting(true)
|
||||
setExecutionResult(null)
|
||||
|
||||
try {
|
||||
let result
|
||||
|
||||
// 根据语言类型选择执行方法
|
||||
if (language === 'javascript' || language === 'js') {
|
||||
result = await window.api.codeExecutor.executeJS(code)
|
||||
} else if (language === 'python' || language === 'py') {
|
||||
result = await window.api.codeExecutor.executePython(code)
|
||||
} else {
|
||||
message.error(t('code.execution.unsupported_language'))
|
||||
setIsExecuting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setExecutionResult(result)
|
||||
} catch (error) {
|
||||
console.error('Code execution error:', error)
|
||||
setExecutionResult({
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
}
|
||||
}, [code, language, t])
|
||||
|
||||
if (language === 'mermaid') {
|
||||
return <Mermaid chart={children} />
|
||||
}
|
||||
|
||||
if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUML diagram={children} />
|
||||
}
|
||||
|
||||
if (language === 'svg') {
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block">
|
||||
<CodeHeader>
|
||||
<CodeLanguage>{'<SVG>'}</CodeLanguage>
|
||||
<CopyButton text={children} />
|
||||
</CodeHeader>
|
||||
<SvgPreview>{children}</SvgPreview>
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return match ? (
|
||||
<CodeBlockWrapper className="code-block">
|
||||
<CodeHeader>
|
||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||
</CodeHeader>
|
||||
<StickyWrapper>
|
||||
<HStack
|
||||
position="absolute"
|
||||
gap={12}
|
||||
alignItems="center"
|
||||
style={{ bottom: '0.2rem', right: '1rem', height: '27px' }}>
|
||||
{showDownloadButton && <DownloadButton language={language} data={code} />}
|
||||
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
||||
{codeCollapsible && shouldShowExpandButton && (
|
||||
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<UndoRedoButton
|
||||
icon={<UndoOutlined />}
|
||||
title={t('code_block.undo')}
|
||||
onClick={() => editorRef.current?.undo()}
|
||||
/>
|
||||
<UndoRedoButton
|
||||
icon={<RedoOutlined />}
|
||||
title={t('code_block.redo')}
|
||||
onClick={() => editorRef.current?.redo()}
|
||||
/>
|
||||
<UndoRedoButton
|
||||
icon={<SearchOutlined />}
|
||||
title={t('code_block.search')}
|
||||
onClick={() => editorRef.current?.openSearch()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(language === 'javascript' || language === 'js' || language === 'python' || language === 'py') && (
|
||||
<ExecuteButton isExecuting={isExecuting} onClick={executeCode} title={t('code.execute')} />
|
||||
)}
|
||||
<EditButton isEditing={isEditing} onClick={handleEditToggle} />
|
||||
<CopyButton text={code} />
|
||||
</HStack>
|
||||
</StickyWrapper>
|
||||
{isEditing ? (
|
||||
<EditorContainer
|
||||
style={{
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
|
||||
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible'
|
||||
}}>
|
||||
<CodeMirrorEditor
|
||||
ref={editorRef}
|
||||
code={code}
|
||||
language={language}
|
||||
onChange={handleCodeChange}
|
||||
showLineNumbers={codeShowLineNumbers}
|
||||
fontSize={fontSize - 1}
|
||||
height={codeCollapsible && !isExpanded ? '350px' : 'auto'}
|
||||
/>
|
||||
</EditorContainer>
|
||||
) : (
|
||||
<CodeContent
|
||||
ref={codeContentRef}
|
||||
isShowLineNumbers={codeShowLineNumbers}
|
||||
isUnwrapped={isUnwrapped}
|
||||
isCodeWrappable={codeWrappable}
|
||||
style={{
|
||||
border: '0.5px solid var(--color-code-background)',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
marginTop: 0,
|
||||
fontSize: fontSize - 1,
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
|
||||
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible',
|
||||
position: 'relative',
|
||||
whiteSpace: isUnwrapped ? 'pre' : 'pre-wrap'
|
||||
}}>
|
||||
{code}
|
||||
</CodeContent>
|
||||
)}
|
||||
{executionResult && (
|
||||
<ExecutionResult
|
||||
success={executionResult.success}
|
||||
output={executionResult.output}
|
||||
error={executionResult.error}
|
||||
/>
|
||||
)}
|
||||
{codeCollapsible && (
|
||||
<ExpandButton
|
||||
isExpanded={isExpanded}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
showButton={shouldShowExpandButton}
|
||||
/>
|
||||
)}
|
||||
{showFooterCopyButton && (
|
||||
<CodeFooter>
|
||||
<CopyButton text={code} style={{ marginTop: -40, marginRight: 10 }} />
|
||||
</CodeFooter>
|
||||
)}
|
||||
</CodeBlockWrapper>
|
||||
) : (
|
||||
<WrappedCode className={className}>{children}</WrappedCode>
|
||||
)
|
||||
}
|
||||
|
||||
const EditButton: React.FC<{ isEditing: boolean; onClick: () => void }> = ({ isEditing, onClick }) => {
|
||||
const { t } = useTranslation()
|
||||
const editLabel = isEditing ? t('code_block.done_editing') : t('code_block.edit')
|
||||
|
||||
return (
|
||||
<Tooltip title={editLabel}>
|
||||
<EditButtonWrapper onClick={onClick} title={editLabel}>
|
||||
{isEditing ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <EditOutlined />}
|
||||
</EditButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ExpandButton: React.FC<{
|
||||
isExpanded: boolean
|
||||
onClick: () => void
|
||||
showButton: boolean
|
||||
}> = ({ isExpanded, onClick, showButton }) => {
|
||||
const { t } = useTranslation()
|
||||
if (!showButton) return null
|
||||
|
||||
return (
|
||||
<ExpandButtonWrapper onClick={onClick}>
|
||||
<div className="button-text">{isExpanded ? t('code_block.collapse') : t('code_block.expand')}</div>
|
||||
</ExpandButtonWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const UnwrapButton: React.FC<{ unwrapped: boolean; onClick: () => void }> = ({ unwrapped, onClick }) => {
|
||||
const { t } = useTranslation()
|
||||
const unwrapLabel = unwrapped ? t('code_block.enable_wrap') : t('code_block.disable_wrap')
|
||||
return (
|
||||
<Tooltip title={unwrapLabel}>
|
||||
<UnwrapButtonWrapper onClick={onClick} title={unwrapLabel}>
|
||||
{unwrapped ? (
|
||||
<UnWrapIcon style={{ width: '100%', height: '100%' }} />
|
||||
) : (
|
||||
<WrapIcon style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</UnwrapButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const copy = t('common.copy')
|
||||
|
||||
const onCopy = () => {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text)
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-code' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={copy}>
|
||||
<CopyButtonWrapper onClick={onCopy} style={style}>
|
||||
{copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyIcon className="copy" />}
|
||||
</CopyButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const DownloadButton = ({ language, data }: { language: string; data: string }) => {
|
||||
const onDownload = () => {
|
||||
const fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
|
||||
window.api.file.save(fileName, data)
|
||||
}
|
||||
|
||||
return (
|
||||
<DownloadWrapper onClick={onDownload}>
|
||||
<DownloadOutlined />
|
||||
</DownloadWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const CodeBlockWrapper = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
margin-top: 0;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const CodeContent = styled.pre<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
|
||||
padding: 1em;
|
||||
background-color: var(--color-code-background);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
white-space: ${(props) => (props.isUnwrapped ? 'pre' : 'pre-wrap')};
|
||||
word-break: break-all;
|
||||
`
|
||||
|
||||
const CodeHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 1em;
|
||||
background-color: var(--color-code-background);
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const CodeLanguage = styled.span`
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
const StickyWrapper = styled.div`
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
`
|
||||
|
||||
const CodeFooter = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5em;
|
||||
`
|
||||
|
||||
const ExpandButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0.5em;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-code-background);
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
|
||||
.button-text {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-code-background-hover);
|
||||
}
|
||||
`
|
||||
|
||||
const UnwrapButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const CopyButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.copy {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
const EditButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const UndoRedoButton: React.FC<{ icon: React.ReactNode; title: string; onClick: () => void }> = ({
|
||||
icon,
|
||||
title,
|
||||
onClick
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<UndoRedoButtonWrapper onClick={onClick} title={title}>
|
||||
{icon}
|
||||
</UndoRedoButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const UndoRedoButtonWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const DownloadWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const ExecuteButton: React.FC<{ isExecuting: boolean; onClick: () => void; title: string }> = ({
|
||||
isExecuting,
|
||||
onClick,
|
||||
title
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<ExecuteButtonWrapper onClick={onClick} disabled={isExecuting}>
|
||||
{isExecuting ? <LoadingOutlined /> : <PlayCircleOutlined />}
|
||||
</ExecuteButtonWrapper>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ExecuteButtonWrapper = styled.div<{ disabled: boolean }>`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')};
|
||||
color: var(--color-text-3);
|
||||
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-primary)')};
|
||||
}
|
||||
`
|
||||
|
||||
const CollapseIcon = styled(({ expanded, ...props }: { expanded: boolean; onClick: () => void }) => (
|
||||
<div {...props}>{expanded ? <DownOutlined /> : <DownOutlined style={{ transform: 'rotate(180deg)' }} />}</div>
|
||||
))`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const WrappedCode = styled.code`
|
||||
text-wrap: wrap;
|
||||
`
|
||||
|
||||
export default EditableCodeBlock
|
||||
@ -20,7 +20,7 @@ import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import EditableCodeBlock from './EditableCodeBlock'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import Link from './Link'
|
||||
|
||||
@ -54,7 +54,7 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
const components = useMemo(() => {
|
||||
const baseComponents = {
|
||||
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
|
||||
code: CodeBlock,
|
||||
code: EditableCodeBlock,
|
||||
img: ImagePreview,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
|
||||
// 自定义处理think标签
|
||||
|
||||
@ -6,7 +6,7 @@ import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import type { Message, Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Popover } from 'antd'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
||||
@ -145,8 +145,9 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
}
|
||||
}, [messages, setSelectedMessage])
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(message: Message & { index: number }, index: number) => {
|
||||
// 使用useMemo缓存消息渲染结果,减少重复计算
|
||||
const renderedMessages = useMemo(() => {
|
||||
return messages.map((message, index) => {
|
||||
const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped
|
||||
const messageProps = {
|
||||
isGrouped,
|
||||
@ -197,19 +198,19 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
}
|
||||
|
||||
return messageWrapper
|
||||
},
|
||||
[
|
||||
isGrid,
|
||||
isGrouped,
|
||||
isHorizontal,
|
||||
multiModelMessageStyle,
|
||||
selectedIndex,
|
||||
topic,
|
||||
hidePresetMessages,
|
||||
gridPopoverTrigger,
|
||||
getSelectedMessageId
|
||||
]
|
||||
)
|
||||
})
|
||||
}, [
|
||||
messages,
|
||||
isGrid,
|
||||
isGrouped,
|
||||
isHorizontal,
|
||||
multiModelMessageStyle,
|
||||
selectedIndex,
|
||||
topic,
|
||||
hidePresetMessages,
|
||||
gridPopoverTrigger,
|
||||
getSelectedMessageId
|
||||
])
|
||||
|
||||
return (
|
||||
<GroupContainer
|
||||
@ -222,7 +223,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
$layout={multiModelMessageStyle}
|
||||
$gridColumns={gridColumns}
|
||||
className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
|
||||
{messages.map((message, index) => renderMessage(message, index))}
|
||||
{renderedMessages}
|
||||
</GridContainer>
|
||||
{isGrouped && (
|
||||
<MessageGroupMenuBar
|
||||
@ -349,4 +350,29 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
||||
}}
|
||||
`
|
||||
|
||||
export default memo(MessageGroup)
|
||||
// 使用自定义比较函数的memo包装组件,只在关键属性变化时重新渲染
|
||||
export default memo(MessageGroup, (prevProps, nextProps) => {
|
||||
// 如果消息数组长度不同,需要重新渲染
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查消息内容是否变化
|
||||
const messagesChanged = prevProps.messages.some((prevMsg, index) => {
|
||||
const nextMsg = nextProps.messages[index]
|
||||
return (
|
||||
prevMsg.id !== nextMsg.id ||
|
||||
prevMsg.content !== nextMsg.content ||
|
||||
prevMsg.status !== nextMsg.status ||
|
||||
prevMsg.foldSelected !== nextMsg.foldSelected ||
|
||||
prevMsg.multiModelMessageStyle !== nextMsg.multiModelMessageStyle
|
||||
)
|
||||
})
|
||||
|
||||
if (messagesChanged) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查其他关键属性
|
||||
return prevProps.topic.id === nextProps.topic.id && prevProps.hidePresetMessages === nextProps.hidePresetMessages
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectStreamMessage } from '@renderer/store/messages'
|
||||
import { Assistant, Message, Topic } from '@renderer/types'
|
||||
import { memo } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageItem from './Message'
|
||||
@ -31,9 +31,10 @@ const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
isGrouped,
|
||||
style
|
||||
}) => {
|
||||
// 获取流式消息
|
||||
// 获取流式消息,使用选择器减少不必要的重新渲染
|
||||
const streamMessage = useAppSelector((state) => selectStreamMessage(state, _message.topicId, _message.id))
|
||||
// 获取常规消息
|
||||
|
||||
// 获取常规消息,使用选择器减少不必要的重新渲染
|
||||
const regularMessage = useAppSelector((state) => {
|
||||
// 如果是用户消息,直接使用传入的_message
|
||||
if (_message.role === 'user') {
|
||||
@ -41,15 +42,18 @@ const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
}
|
||||
|
||||
// 对于助手消息,从store中查找最新状态
|
||||
const topicMessages = state.messages.messagesByTopic[_message.topicId]
|
||||
const topicMessages = state.messages?.messagesByTopic?.[_message.topicId]
|
||||
if (!topicMessages) return _message
|
||||
|
||||
return topicMessages.find((m) => m.id === _message.id) || _message
|
||||
})
|
||||
|
||||
// 在hooks调用后进行条件判断
|
||||
const isStreaming = !!(streamMessage && streamMessage.id === _message.id)
|
||||
const message = isStreaming ? streamMessage : regularMessage
|
||||
// 使用useMemo缓存计算结果
|
||||
const { isStreaming, message } = useMemo(() => {
|
||||
const isStreaming = !!(streamMessage && streamMessage.id === _message.id);
|
||||
const message = isStreaming ? streamMessage : regularMessage;
|
||||
return { isStreaming, message };
|
||||
}, [streamMessage, regularMessage, _message.id])
|
||||
return (
|
||||
<MessageStreamContainer>
|
||||
<MessageItem
|
||||
@ -66,4 +70,13 @@ const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MessageStream)
|
||||
// 使用自定义比较函数的memo包装组件,只在关键属性变化时重新渲染
|
||||
export default memo(MessageStream, (prevProps, nextProps) => {
|
||||
// 只在关键属性变化时重新渲染
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.topic.id === nextProps.topic.id
|
||||
);
|
||||
})
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
runAsyncFunction
|
||||
} from '@renderer/utils'
|
||||
import { flatten, last, take } from 'lodash'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
@ -198,14 +198,16 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
if (!hasMore || isLoadingMore) return
|
||||
|
||||
setIsLoadingMore(true)
|
||||
setTimeout(() => {
|
||||
// 使用requestAnimationFrame代替setTimeout,更好地与浏览器渲染周期同步
|
||||
requestAnimationFrame(() => {
|
||||
const currentLength = displayMessages.length
|
||||
const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT)
|
||||
|
||||
// 批量更新状态,减少渲染次数
|
||||
setDisplayMessages((prev) => [...prev, ...newMessages])
|
||||
setHasMore(currentLength + LOAD_MORE_COUNT < messages.length)
|
||||
setIsLoadingMore(false)
|
||||
}, 300)
|
||||
})
|
||||
}, [displayMessages.length, hasMore, isLoadingMore, messages])
|
||||
|
||||
useShortcut('copy_last_message', () => {
|
||||
@ -216,6 +218,19 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
}
|
||||
})
|
||||
|
||||
// 使用记忆化渲染消息组,避免不必要的重渲染
|
||||
const renderMessageGroups = useMemo(() => {
|
||||
const groupedMessages = getGroupedMessages(displayMessages)
|
||||
return Object.entries(groupedMessages).map(([key, groupMessages]) => (
|
||||
<MessageGroup
|
||||
key={key}
|
||||
messages={groupMessages}
|
||||
topic={topic}
|
||||
hidePresetMessages={assistant.settings?.hideMessages}
|
||||
/>
|
||||
))
|
||||
}, [displayMessages, topic, assistant.settings?.hideMessages])
|
||||
|
||||
return (
|
||||
<Container
|
||||
id="messages"
|
||||
@ -231,19 +246,14 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
loader={null}
|
||||
scrollableTarget="messages"
|
||||
inverse
|
||||
style={{ overflow: 'visible' }}>
|
||||
style={{ overflow: 'visible' }}
|
||||
scrollThreshold={0.8} // 提前触发加载更多
|
||||
initialScrollY={0}>
|
||||
<ScrollContainer>
|
||||
<LoaderContainer $loading={isLoadingMore}>
|
||||
<BeatLoader size={8} color="var(--color-text-2)" />
|
||||
</LoaderContainer>
|
||||
{Object.entries(getGroupedMessages(displayMessages)).map(([key, groupMessages]) => (
|
||||
<MessageGroup
|
||||
key={key}
|
||||
messages={groupMessages}
|
||||
topic={topic}
|
||||
hidePresetMessages={assistant.settings?.hideMessages}
|
||||
/>
|
||||
))}
|
||||
{renderMessageGroups}
|
||||
</ScrollContainer>
|
||||
</InfiniteScroll>
|
||||
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
|
||||
@ -255,7 +265,9 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
)
|
||||
}
|
||||
|
||||
// 优化的消息计算函数,使用Map代替多次数组查找,提高性能
|
||||
const computeDisplayMessages = (messages: Message[], startIndex: number, displayCount: number) => {
|
||||
// 使用缓存避免不必要的重复计算
|
||||
const reversedMessages = [...messages].reverse()
|
||||
|
||||
// 如果剩余消息数量小于 displayCount,直接返回所有剩余消息
|
||||
@ -267,6 +279,7 @@ const computeDisplayMessages = (messages: Message[], startIndex: number, display
|
||||
const assistantIdSet = new Set() // 助手消息 askId 集合
|
||||
const processedIds = new Set<string>() // 用于跟踪已处理的消息ID
|
||||
const displayMessages: Message[] = []
|
||||
const messageIdMap = new Map<string, boolean>() // 用于快速查找消息ID是否存在
|
||||
|
||||
// 处理单条消息的函数
|
||||
const processMessage = (message: Message) => {
|
||||
@ -285,19 +298,29 @@ const computeDisplayMessages = (messages: Message[], startIndex: number, display
|
||||
if (!idSet.has(messageId)) {
|
||||
idSet.add(messageId)
|
||||
displayMessages.push(message)
|
||||
messageIdMap.set(message.id, true)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是相同 askId 的助手消息,检查是否已经有相同ID的消息
|
||||
// 只有在没有相同ID的情况下才添加
|
||||
if (message.role === 'assistant' && !displayMessages.some(m => m.id === message.id)) {
|
||||
// 使用Map进行O(1)复杂度的查找,替代O(n)复杂度的数组some方法
|
||||
if (message.role === 'assistant' && !messageIdMap.has(message.id)) {
|
||||
displayMessages.push(message)
|
||||
messageIdMap.set(message.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历消息直到满足显示数量要求
|
||||
// 使用批处理方式处理消息,每次处理一批,减少循环次数
|
||||
const batchSize = Math.min(50, displayCount) // 每批处理的消息数量
|
||||
let processedCount = 0
|
||||
|
||||
for (let i = startIndex; i < reversedMessages.length && userIdSet.size + assistantIdSet.size < displayCount; i++) {
|
||||
processMessage(reversedMessages[i])
|
||||
processedCount++
|
||||
|
||||
// 每处理一批消息,检查是否已满足显示数量要求
|
||||
if (processedCount % batchSize === 0 && userIdSet.size + assistantIdSet.size >= displayCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return displayMessages
|
||||
@ -333,4 +356,7 @@ const Container = styled(Scrollbar)<ContainerProps>`
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
export default Messages
|
||||
export default memo(Messages, (prevProps, nextProps) => {
|
||||
// 只在关键属性变化时重新渲染
|
||||
return prevProps.assistant.id === nextProps.assistant.id && prevProps.topic.id === nextProps.topic.id
|
||||
})
|
||||
|
||||
@ -24,6 +24,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
|
||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||
const [reasoningEffort, setReasoningEffort] = useState(assistant?.settings?.reasoning_effort)
|
||||
const [thinkingBudget, setThinkingBudget] = useState(assistant?.settings?.thinkingBudget ?? 8192)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
|
||||
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
||||
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
||||
@ -47,6 +48,14 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
updateAssistantSettings({ reasoning_effort: value })
|
||||
}
|
||||
|
||||
const onThinkingBudgetChange = (value) => {
|
||||
// 确保值是数字,包括0
|
||||
if (value !== null && value !== undefined && !isNaN(value as number)) {
|
||||
console.log('[ThinkingBudget] 更新思考预算值:', value)
|
||||
updateAssistantSettings({ thinkingBudget: value })
|
||||
}
|
||||
}
|
||||
|
||||
const onContextCountChange = (value) => {
|
||||
if (!isNaN(value as number)) {
|
||||
updateAssistantSettings({ contextCount: value })
|
||||
@ -154,6 +163,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
setStreamOutput(true)
|
||||
setTopP(1)
|
||||
setReasoningEffort(undefined)
|
||||
setThinkingBudget(8192)
|
||||
setCustomParameters([])
|
||||
updateAssistantSettings({
|
||||
temperature: DEFAULT_TEMPERATURE,
|
||||
@ -163,6 +173,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
streamOutput: true,
|
||||
topP: 1,
|
||||
reasoning_effort: undefined,
|
||||
thinkingBudget: 8192,
|
||||
customParameters: []
|
||||
})
|
||||
}
|
||||
@ -404,6 +415,48 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
</Radio.Group>
|
||||
</SettingRow>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{assistant?.model?.id?.includes('gemini-2.5') && (
|
||||
<>
|
||||
<Row align="middle">
|
||||
<Label>{t('assistants.settings.thinking_budget')}</Label>
|
||||
<Tooltip title={t('assistants.settings.thinking_budget.tip')}>
|
||||
<QuestionIcon />
|
||||
</Tooltip>
|
||||
</Row>
|
||||
<Row align="middle" gutter={20}>
|
||||
<Col span={20}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={24576}
|
||||
onChange={setThinkingBudget}
|
||||
onChangeComplete={onThinkingBudgetChange}
|
||||
value={typeof thinkingBudget === 'number' ? thinkingBudget : 8192}
|
||||
marks={{ 0: '0', 8192: '8192', 16384: '16K', 24576: '24K' }}
|
||||
step={1024}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={24576}
|
||||
step={1024}
|
||||
value={thinkingBudget}
|
||||
changeOnBlur
|
||||
onChange={(value) => {
|
||||
// 确保值是数字,包括0
|
||||
if (value !== null && value !== undefined && !isNaN(value as number)) {
|
||||
console.log('[ThinkingBudget] 输入框更新思考预算值:', value)
|
||||
setThinkingBudget(value)
|
||||
setTimeout(() => updateAssistantSettings({ thinkingBudget: value }), 500)
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
)}
|
||||
<SettingRow style={{ minHeight: 30 }}>
|
||||
<Label>{t('models.custom_parameters')}</Label>
|
||||
<Button icon={<PlusOutlined />} onClick={onAddCustomParameter}>
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
} from '@renderer/services/MemoryService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import store from '@renderer/store' // Import store for direct access
|
||||
import { getModelUniqId } from '@renderer/utils'
|
||||
import {
|
||||
addMemory,
|
||||
clearMemories,
|
||||
|
||||
@ -100,7 +100,7 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
||||
return (
|
||||
<ProgramItem ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||
<ProgramContent>
|
||||
<AppLogo src={logo} alt={name} />
|
||||
{logo ? <AppLogo src={logo} alt={name} /> : <AppLogoPlaceholder />}
|
||||
<span>{name}</span>
|
||||
</ProgramContent>
|
||||
<CloseButton onClick={() => onMoveMiniApp(program, listType)}>
|
||||
@ -243,5 +243,11 @@ const EmptyPlaceholder = styled.div`
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
`
|
||||
const AppLogoPlaceholder = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
export default MiniAppIconsManager
|
||||
|
||||
98
src/renderer/src/pages/settings/PDFSettings.tsx
Normal file
98
src/renderer/src/pages/settings/PDFSettings.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import {
|
||||
SettingContainer,
|
||||
SettingDivider,
|
||||
SettingGroup,
|
||||
SettingRow,
|
||||
SettingRowTitle,
|
||||
SettingTitle
|
||||
} from '@renderer/pages/settings/styles'
|
||||
import { setPdfSettings } from '@renderer/store/settings'
|
||||
import { Input, Switch } from 'antd'
|
||||
import { FC, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const PDFSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme, pdfSettings } = useSettings()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// 初始化默认值,防止pdfSettings为undefined
|
||||
const defaultPdfSettings = useMemo(
|
||||
() => ({
|
||||
enablePdfSplitting: true,
|
||||
defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15'
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
// 在组件加载时初始化PDF设置
|
||||
useEffect(() => {
|
||||
console.log('[PDFSettings] Component mounted, initializing PDF settings')
|
||||
// 如果pdfSettings为undefined或缺少enablePdfSplitting属性,则使用默认值初始化
|
||||
if (!pdfSettings || pdfSettings.enablePdfSplitting === undefined) {
|
||||
console.log('[PDFSettings] pdfSettings is incomplete, initializing with defaults:', defaultPdfSettings)
|
||||
// 如果pdfSettings存在,则合并现有设置和默认设置
|
||||
const mergedSettings = {
|
||||
...defaultPdfSettings,
|
||||
...pdfSettings,
|
||||
// 确保 enablePdfSplitting 存在且为 true
|
||||
enablePdfSplitting: true
|
||||
}
|
||||
console.log('[PDFSettings] Merged settings:', mergedSettings)
|
||||
dispatch(setPdfSettings(mergedSettings))
|
||||
} else {
|
||||
console.log('[PDFSettings] Current pdfSettings:', pdfSettings)
|
||||
}
|
||||
}, [pdfSettings, defaultPdfSettings, dispatch])
|
||||
|
||||
const handleEnablePdfSplittingChange = (checked: boolean) => {
|
||||
dispatch(setPdfSettings({ enablePdfSplitting: checked }))
|
||||
}
|
||||
|
||||
const handleDefaultPageRangePromptChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setPdfSettings({ defaultPageRangePrompt: e.target.value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.pdf.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.pdf.enable_splitting')}</SettingRowTitle>
|
||||
<Switch
|
||||
checked={pdfSettings?.enablePdfSplitting ?? defaultPdfSettings.enablePdfSplitting}
|
||||
onChange={handleEnablePdfSplittingChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingDivider />
|
||||
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.pdf.default_page_range_prompt')}</SettingRowTitle>
|
||||
<Input
|
||||
value={pdfSettings?.defaultPageRangePrompt ?? defaultPdfSettings.defaultPageRangePrompt}
|
||||
onChange={handleDefaultPageRangePromptChange}
|
||||
placeholder={t('settings.pdf.default_page_range_prompt_placeholder')}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Description>{t('settings.pdf.description')}</Description>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Description = styled.div`
|
||||
margin-top: 20px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
`
|
||||
|
||||
export default PDFSettings
|
||||
@ -1,4 +1,4 @@
|
||||
import { ExperimentOutlined } from '@ant-design/icons'
|
||||
import { ExperimentOutlined, FilePdfOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||
@ -32,6 +32,7 @@ import { McpSettingsNavbar } from './MCPSettings/McpSettingsNavbar'
|
||||
import MemorySettings from './MemorySettings'
|
||||
import MiniAppSettings from './MiniappSettings/MiniAppSettings'
|
||||
import ModelCombinationSettings from './ModelCombinationSettings'
|
||||
import PDFSettings from './PDFSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import QuickPhraseSettings from './QuickPhraseSettings'
|
||||
@ -141,6 +142,12 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.voice.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/pdf">
|
||||
<MenuItem className={isRoute('/settings/pdf')}>
|
||||
<FilePdfOutlined />
|
||||
{t('settings.pdf.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/about">
|
||||
<MenuItem className={isRoute('/settings/about')}>
|
||||
<Info size={18} />
|
||||
@ -164,6 +171,7 @@ const SettingsPage: FC = () => {
|
||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||
<Route path="data/*" element={<DataSettings />} />
|
||||
<Route path="tts" element={<TTSSettings />} />
|
||||
<Route path="pdf" element={<PDFSettings />} />
|
||||
<Route path="about" element={<AboutSettings />} />
|
||||
<Route path="quickPhrase" element={<QuickPhraseSettings />} />
|
||||
</Routes>
|
||||
|
||||
42
src/renderer/src/pages/settings/styles.ts
Normal file
42
src/renderer/src/pages/settings/styles.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import styled from 'styled-components'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
|
||||
export const SettingContainer = styled.div<{ theme: ThemeMode }>`
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background-color: ${(props) => (props.theme === 'dark' ? 'var(--color-bg-1)' : 'var(--color-bg-1)')};
|
||||
`
|
||||
|
||||
export const SettingGroup = styled.div<{ theme: ThemeMode }>`
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: ${(props) => (props.theme === 'dark' ? 'var(--color-bg-2)' : 'var(--color-bg-2)')};
|
||||
`
|
||||
|
||||
export const SettingTitle = styled.h2`
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
export const SettingDivider = styled.div`
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 15px 0;
|
||||
`
|
||||
|
||||
export const SettingRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 15px 0;
|
||||
`
|
||||
|
||||
export const SettingRowTitle = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
@ -19,7 +19,7 @@ import {
|
||||
TextPart,
|
||||
Tool
|
||||
} from '@google/generative-ai'
|
||||
import { isGemmaModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import { isGemmaModel, isSupportedThinkingBudgetModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
@ -257,6 +257,62 @@ export default class GeminiProvider extends BaseProvider {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thinking budget configuration for Gemini 2.5 models
|
||||
* @param assistant - The assistant
|
||||
* @param model - The model
|
||||
* @returns The thinking budget configuration
|
||||
*/
|
||||
private getThinkingConfig(assistant: Assistant, model: Model): Record<string, any> {
|
||||
// 只对支持思考预算的模型应用思考预算功能
|
||||
if (!isSupportedThinkingBudgetModel(model)) {
|
||||
console.log('[ThinkingBudget] 模型不支持思考预算:', model.id)
|
||||
return {}
|
||||
}
|
||||
|
||||
console.log('[ThinkingBudget] 模型支持思考预算:', model.id)
|
||||
|
||||
// 从自定义参数中查找thinkingBudget参数
|
||||
const customParams = this.getCustomParameters(assistant) as Record<string, any>
|
||||
if (customParams.thinkingBudget !== undefined || customParams.thinking_budget !== undefined) {
|
||||
// 如果已经在自定义参数中设置了思考预算,直接使用
|
||||
const budget = customParams.thinkingBudget || customParams.thinking_budget
|
||||
console.log('[ThinkingBudget] 使用自定义参数中的思考预算:', budget)
|
||||
return {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: budget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从助手设置中获取思考预算
|
||||
if (assistant?.settings?.thinkingBudget !== undefined) {
|
||||
console.log('[ThinkingBudget] 使用助手设置中的思考预算:', assistant.settings.thinkingBudget)
|
||||
|
||||
// 确保思考预算是一个有效的数字
|
||||
const budget = Number(assistant.settings.thinkingBudget)
|
||||
if (!isNaN(budget) && budget >= 0) {
|
||||
return {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: budget
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[ThinkingBudget] 助手设置中的思考预算无效,使用默认值')
|
||||
}
|
||||
}
|
||||
|
||||
// 默认思考预算为8192 tokens
|
||||
const defaultThinkingBudget = 8192
|
||||
console.log('[ThinkingBudget] 使用默认思考预算:', defaultThinkingBudget)
|
||||
|
||||
return {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: defaultThinkingBudget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completions
|
||||
* @param messages - The messages
|
||||
@ -322,6 +378,13 @@ export default class GeminiProvider extends BaseProvider {
|
||||
// 使用与对话关联的SDK实例
|
||||
const sdk = this.getOrCreateSdk(conversationId)
|
||||
|
||||
// 打印思考预算值
|
||||
console.log('[completions] 助手设置中的思考预算值:', assistant?.settings?.thinkingBudget)
|
||||
|
||||
// 获取思考预算配置
|
||||
const thinkingConfig = this.getThinkingConfig(assistant, model)
|
||||
console.log('[completions] 思考预算配置:', JSON.stringify(thinkingConfig))
|
||||
|
||||
const geminiModel = sdk.getGenerativeModel(
|
||||
{
|
||||
model: model.id,
|
||||
@ -329,6 +392,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
safetySettings: this.getSafetySettings(model.id),
|
||||
tools: tools,
|
||||
generationConfig: {
|
||||
...thinkingConfig,
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature: assistant?.settings?.temperature,
|
||||
topP: assistant?.settings?.topP,
|
||||
@ -478,6 +542,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
{
|
||||
model: model.id,
|
||||
...(isGemmaModel(model) ? {} : { systemInstruction: enhancedPrompt }),
|
||||
...this.getThinkingConfig(assistant, model),
|
||||
generationConfig: {
|
||||
maxOutputTokens: maxTokens,
|
||||
temperature: assistant?.settings?.temperature
|
||||
@ -563,6 +628,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
{
|
||||
model: model.id,
|
||||
...(isGemmaModel(model) ? {} : { systemInstruction: systemMessage.content }),
|
||||
...this.getThinkingConfig(assistant, model),
|
||||
generationConfig: {
|
||||
temperature: assistant?.settings?.temperature
|
||||
}
|
||||
@ -627,7 +693,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
const geminiModel = sdk.getGenerativeModel(
|
||||
{
|
||||
model: model.id,
|
||||
...(isGemmaModel(model) ? {} : { systemInstruction: systemMessage.content })
|
||||
...(isGemmaModel(model) ? {} : { systemInstruction: systemMessage.content }),
|
||||
...this.getThinkingConfig({ model } as Assistant, model)
|
||||
},
|
||||
this.requestOptions
|
||||
)
|
||||
@ -687,6 +754,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
{
|
||||
model: model.id,
|
||||
systemInstruction: enhancedPrompt,
|
||||
...this.getThinkingConfig(assistant, model),
|
||||
generationConfig: {
|
||||
temperature: assistant?.settings?.temperature
|
||||
}
|
||||
@ -751,8 +819,11 @@ export default class GeminiProvider extends BaseProvider {
|
||||
// 使用与对话关联的图像SDK实例
|
||||
const imageSdk = this.getOrCreateImageSdk(conversationId)
|
||||
|
||||
// 获取思考预算值
|
||||
const thinkingBudget = assistant?.settings?.thinkingBudget
|
||||
|
||||
if (!streamOutput) {
|
||||
const response = await this.callGeminiGenerateContent(model.id, contents, maxTokens, imageSdk)
|
||||
const response = await this.callGeminiGenerateContent(model.id, contents, maxTokens, imageSdk, thinkingBudget)
|
||||
|
||||
const { isValid, message } = this.isValidGeminiResponse(response)
|
||||
if (!isValid) {
|
||||
@ -762,7 +833,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
this.processGeminiImageResponse(response, onChunk)
|
||||
return
|
||||
}
|
||||
const response = await this.callGeminiGenerateContentStream(model.id, contents, maxTokens, imageSdk)
|
||||
const response = await this.callGeminiGenerateContentStream(model.id, contents, maxTokens, imageSdk, thinkingBudget)
|
||||
|
||||
for await (const chunk of response) {
|
||||
this.processGeminiImageResponse(chunk, onChunk)
|
||||
@ -798,7 +869,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
modelId: string,
|
||||
contents: ContentListUnion,
|
||||
maxTokens?: number,
|
||||
sdk?: GoogleGenAI
|
||||
sdk?: GoogleGenAI,
|
||||
thinkingBudget?: number
|
||||
): Promise<GenerateContentResponse> {
|
||||
try {
|
||||
// 获取新的API密钥,实现轮流使用多个密钥
|
||||
@ -807,14 +879,28 @@ export default class GeminiProvider extends BaseProvider {
|
||||
// 创建新的SDK实例
|
||||
const apiSdk = sdk || new GoogleGenAI({ apiKey: apiKey, httpOptions: { baseUrl: this.getBaseURL() } })
|
||||
|
||||
// 检查是否为支持思考预算的模型
|
||||
const isThinkingBudgetSupported = modelId.includes('gemini-2.5')
|
||||
|
||||
// 使用传入的思考预算值或默认值
|
||||
const budget = thinkingBudget !== undefined ? thinkingBudget : 8192
|
||||
const thinkingConfig = isThinkingBudgetSupported ? { thinkingConfig: { thinkingBudget: budget } } : {}
|
||||
console.log('[API调用] 思考预算配置:', JSON.stringify(thinkingConfig))
|
||||
|
||||
// 构建请求配置
|
||||
const config = {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
maxOutputTokens: maxTokens,
|
||||
...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {})
|
||||
}
|
||||
|
||||
console.log('[API调用] 最终请求配置:', JSON.stringify(config))
|
||||
|
||||
return await apiSdk.models.generateContent({
|
||||
model: modelId,
|
||||
contents: contents,
|
||||
config: {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
maxOutputTokens: maxTokens
|
||||
}
|
||||
config: config
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Gemini API error:', error)
|
||||
@ -826,7 +912,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
modelId: string,
|
||||
contents: ContentListUnion,
|
||||
maxTokens?: number,
|
||||
sdk?: GoogleGenAI
|
||||
sdk?: GoogleGenAI,
|
||||
thinkingBudget?: number
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
try {
|
||||
// 获取新的API密钥,实现轮流使用多个密钥
|
||||
@ -835,14 +922,28 @@ export default class GeminiProvider extends BaseProvider {
|
||||
// 创建新的SDK实例
|
||||
const apiSdk = sdk || new GoogleGenAI({ apiKey: apiKey, httpOptions: { baseUrl: this.getBaseURL() } })
|
||||
|
||||
// 检查是否为支持思考预算的模型
|
||||
const isThinkingBudgetSupported = modelId.includes('gemini-2.5')
|
||||
|
||||
// 使用传入的思考预算值或默认值
|
||||
const budget = thinkingBudget !== undefined ? thinkingBudget : 8192
|
||||
const thinkingConfig = isThinkingBudgetSupported ? { thinkingConfig: { thinkingBudget: budget } } : {}
|
||||
console.log('[API流式调用] 思考预算配置:', JSON.stringify(thinkingConfig))
|
||||
|
||||
// 构建请求配置
|
||||
const config = {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
maxOutputTokens: maxTokens,
|
||||
...(isThinkingBudgetSupported && budget >= 0 ? { thinkingConfig: { thinkingBudget: budget } } : {})
|
||||
}
|
||||
|
||||
console.log('[API流式调用] 最终请求配置:', JSON.stringify(config))
|
||||
|
||||
return await apiSdk.models.generateContentStream({
|
||||
model: modelId,
|
||||
contents: contents,
|
||||
config: {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
maxOutputTokens: maxTokens
|
||||
}
|
||||
config: config
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Gemini API error:', error)
|
||||
|
||||
@ -371,8 +371,14 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
const combinedChunks = lastChunk + delta.content
|
||||
lastChunk = delta.content
|
||||
|
||||
// 检测思考结束
|
||||
if (combinedChunks.includes('###Response') || delta.content === '</think>') {
|
||||
// 检测思考结束 - 支持多种标签格式
|
||||
if (
|
||||
combinedChunks.includes('###Response') ||
|
||||
delta.content === '</think>' ||
|
||||
delta.content.includes('</think>') ||
|
||||
delta.content === '</thinking>' ||
|
||||
delta.content.includes('</thinking>')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -461,35 +467,107 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
})
|
||||
}
|
||||
|
||||
let content = ''
|
||||
let content = '' // Accumulates the full, raw content
|
||||
let isCurrentlyThinking = false // Flag to track if we are inside <thinking> tags
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
break
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
let deltaContent = delta?.content || '' // Get delta content for processing
|
||||
|
||||
// Accumulate raw content
|
||||
if (delta?.content) {
|
||||
content += delta.content
|
||||
}
|
||||
|
||||
let textToSend = '' // Content for the main answer part
|
||||
let reasoningToSend = delta?.reasoning_content || delta?.reasoning || '' // Content for the thinking box (includes specific fields + tagged content)
|
||||
|
||||
if (isReasoningModel(model)) {
|
||||
// Process content chunk by chunk, handling tags
|
||||
while (deltaContent.length > 0) {
|
||||
if (isCurrentlyThinking) {
|
||||
// Look for the end tag
|
||||
const endTagThinkIndex = deltaContent.indexOf('</think>')
|
||||
const endTagThinkingIndex = deltaContent.indexOf('</thinking>')
|
||||
let endTagIndex = -1
|
||||
let endTag = ''
|
||||
|
||||
if (endTagThinkIndex !== -1 && (endTagThinkingIndex === -1 || endTagThinkIndex < endTagThinkingIndex)) {
|
||||
endTagIndex = endTagThinkIndex
|
||||
endTag = '</think>'
|
||||
} else if (endTagThinkingIndex !== -1) {
|
||||
endTagIndex = endTagThinkingIndex
|
||||
endTag = '</thinking>'
|
||||
}
|
||||
|
||||
if (endTagIndex !== -1) {
|
||||
// End tag found in this chunk
|
||||
const thinkingPart = deltaContent.substring(0, endTagIndex + endTag.length)
|
||||
reasoningToSend += thinkingPart // Add content up to and including the tag to reasoning
|
||||
deltaContent = deltaContent.substring(endTagIndex + endTag.length) // Remaining content
|
||||
isCurrentlyThinking = false // Exited thinking state
|
||||
} else {
|
||||
// No end tag in this chunk, entire chunk is thinking content
|
||||
reasoningToSend += deltaContent
|
||||
deltaContent = '' // Consumed the chunk
|
||||
}
|
||||
} else {
|
||||
// Not currently thinking, look for the start tag
|
||||
const startTagThinkIndex = deltaContent.indexOf('<think>')
|
||||
const startTagThinkingIndex = deltaContent.indexOf('<thinking>')
|
||||
let startTagIndex = -1
|
||||
|
||||
if (
|
||||
startTagThinkIndex !== -1 &&
|
||||
(startTagThinkingIndex === -1 || startTagThinkIndex < startTagThinkingIndex)
|
||||
) {
|
||||
startTagIndex = startTagThinkIndex
|
||||
} else if (startTagThinkingIndex !== -1) {
|
||||
startTagIndex = startTagThinkingIndex
|
||||
}
|
||||
|
||||
if (startTagIndex !== -1) {
|
||||
// Start tag found in this chunk
|
||||
const nonThinkingPart = deltaContent.substring(0, startTagIndex)
|
||||
textToSend += nonThinkingPart // Add content before the tag to text
|
||||
// The part from the tag onwards will be handled in the next iteration or as reasoning
|
||||
deltaContent = deltaContent.substring(startTagIndex)
|
||||
isCurrentlyThinking = true // Entered thinking state
|
||||
} else {
|
||||
// No start tag in this chunk, entire chunk is non-thinking content
|
||||
textToSend += deltaContent
|
||||
deltaContent = '' // Consumed the chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-reasoning models: always assign to textToSend
|
||||
textToSend = delta?.content || '' // Use original delta content directly
|
||||
}
|
||||
|
||||
// --- Timing and other metadata calculation ---
|
||||
if (delta?.reasoning_content || delta?.reasoning) {
|
||||
// Keep track if specific reasoning fields were ever present
|
||||
hasReasoningContent = true
|
||||
}
|
||||
|
||||
if (time_first_token_millsec == 0) {
|
||||
if (time_first_token_millsec == 0 && delta?.content) {
|
||||
// First token with any content
|
||||
time_first_token_millsec = new Date().getTime() - start_time_millsec
|
||||
}
|
||||
|
||||
// Use the previously modified isReasoningJustDone for timing the end of *initial* reasoning phase
|
||||
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
|
||||
time_first_content_millsec = new Date().getTime()
|
||||
}
|
||||
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
|
||||
// --- End Timing ---
|
||||
|
||||
// Extract citations from the raw response if available
|
||||
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
|
||||
|
||||
const finishReason = chunk.choices[0]?.finish_reason
|
||||
|
||||
let webSearch: any[] | undefined = undefined
|
||||
@ -498,26 +576,35 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
if (firstChunk && assistant.enableWebSearch && isHunyuanSearchModel(model)) {
|
||||
webSearch = chunk?.search_info?.search_results
|
||||
firstChunk = true
|
||||
firstChunk = false // Corrected: set to false after processing first chunk
|
||||
}
|
||||
onChunk({
|
||||
text: delta?.content || '',
|
||||
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
|
||||
usage: chunk.usage,
|
||||
metrics: {
|
||||
completion_tokens: chunk.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
},
|
||||
webSearch,
|
||||
annotations: delta?.annotations,
|
||||
citations,
|
||||
mcpToolResponse: toolResponses
|
||||
})
|
||||
}
|
||||
|
||||
await processToolUses(content, idx)
|
||||
// 添加日志输出,帮助调试
|
||||
if (reasoningToSend && reasoningToSend.length > 0) {
|
||||
console.log('[OpenAIProvider] 发送思考内容,长度:', reasoningToSend.length)
|
||||
}
|
||||
|
||||
// Call onChunk only if there's something to send (text, reasoning, or metadata)
|
||||
if (textToSend || reasoningToSend || chunk.usage || webSearch || delta?.annotations || citations) {
|
||||
onChunk({
|
||||
text: textToSend, // Only non-thinking content
|
||||
reasoning_content: reasoningToSend, // Thinking content + specific reasoning fields
|
||||
usage: chunk.usage,
|
||||
metrics: {
|
||||
completion_tokens: chunk.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
},
|
||||
webSearch,
|
||||
annotations: delta?.annotations,
|
||||
citations,
|
||||
mcpToolResponse: toolResponses
|
||||
})
|
||||
}
|
||||
} // End for await loop
|
||||
|
||||
await processToolUses(content, idx) // Process tool uses based on the full accumulated content
|
||||
}
|
||||
|
||||
const stream = await this.sdk.chat.completions
|
||||
@ -614,7 +701,8 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
const deltaContent = chunk.choices[0]?.delta?.content || ''
|
||||
|
||||
if (isReasoning) {
|
||||
if (deltaContent.includes('<think>')) {
|
||||
// 检测思考开始 - 支持多种标签格式
|
||||
if (deltaContent.includes('<think>') || deltaContent.includes('<thinking>')) {
|
||||
isThinking = true
|
||||
}
|
||||
|
||||
@ -623,7 +711,8 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
onResponse?.(text)
|
||||
}
|
||||
|
||||
if (deltaContent.includes('</think>')) {
|
||||
// 检测思考结束 - 支持多种标签格式
|
||||
if (deltaContent.includes('</think>') || deltaContent.includes('</thinking>')) {
|
||||
isThinking = false
|
||||
}
|
||||
} else {
|
||||
@ -693,9 +782,11 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
max_tokens: 1000
|
||||
})
|
||||
|
||||
// 针对思考类模型的返回,总结仅截取</think>之后的内容
|
||||
// 针对思考类模型的返回,总结仅截取思考标签之后的内容
|
||||
let content = response.choices[0].message?.content || ''
|
||||
// 支持多种思考标签格式
|
||||
content = content.replace(/^<think>(.*?)<\/think>/s, '')
|
||||
content = content.replace(/^<thinking>(.*?)<\/thinking>/s, '')
|
||||
|
||||
return removeSpecialCharactersForTopicName(content.substring(0, 50))
|
||||
}
|
||||
@ -732,9 +823,11 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
}
|
||||
)
|
||||
|
||||
// 针对思考类模型的返回,总结仅截取</think>之后的内容
|
||||
// 针对思考类模型的返回,总结仅截取思考标签之后的内容
|
||||
let content = response.choices[0].message?.content || ''
|
||||
// 支持多种思考标签格式
|
||||
content = content.replace(/^<think>(.*?)<\/think>/s, '')
|
||||
content = content.replace(/^<thinking>(.*?)<\/thinking>/s, '')
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@ -244,14 +244,20 @@ const handleResponseMessageUpdate = (
|
||||
}
|
||||
|
||||
// Helper function to sync messages with database
|
||||
const syncMessagesWithDB = async (topicId: string, messages: Message[]) => {
|
||||
const topic = await db.topics.get(topicId)
|
||||
if (topic) {
|
||||
await db.topics.update(topicId, { messages })
|
||||
} else {
|
||||
await db.topics.add({ id: topicId, messages })
|
||||
// 使用节流函数减少数据库操作频率
|
||||
const syncMessagesWithDB = throttle(async (topicId: string, messages: Message[]) => {
|
||||
try {
|
||||
const topic = await db.topics.get(topicId)
|
||||
if (topic) {
|
||||
await db.topics.update(topicId, { messages })
|
||||
} else {
|
||||
await db.topics.add({ id: topicId, messages })
|
||||
}
|
||||
console.log(`[Messages] Synced ${messages.length} messages for topic ${topicId}`)
|
||||
} catch (error) {
|
||||
console.error(`[Messages] Error syncing messages for topic ${topicId}:`, error)
|
||||
}
|
||||
}
|
||||
}, 500) // 500ms节流,减少数据库写入频率
|
||||
|
||||
// Modified sendMessage thunk
|
||||
export const sendMessage =
|
||||
@ -523,7 +529,8 @@ export const resendMessage =
|
||||
}
|
||||
}
|
||||
|
||||
// Modified loadTopicMessages thunk - 优化性能,减少日志输出
|
||||
// 优化的loadTopicMessages thunk,实现分页加载
|
||||
// 使用分页加载机制,只加载最近的消息,减少内存占用和渲染压力
|
||||
export const loadTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDispatch) => {
|
||||
// 设置会话的loading状态
|
||||
dispatch(setTopicLoading({ topicId: topic.id, loading: true }))
|
||||
@ -539,16 +546,24 @@ export const loadTopicMessagesThunk = (topic: Topic) => async (dispatch: AppDisp
|
||||
try {
|
||||
// 使用 getTopic 获取会话对象,使用缓存减少数据库访问
|
||||
const topicWithDB = await TopicManager.getTopic(topic.id)
|
||||
if (topicWithDB) {
|
||||
// 如果数据库中有会话,加载消息
|
||||
dispatch(loadTopicMessages({ topicId: topic.id, messages: topicWithDB.messages }))
|
||||
if (topicWithDB && topicWithDB.messages) {
|
||||
// 只加载最近的N条消息,而不是全部加载
|
||||
const initialLoadCount = state.messages.displayCount * 2; // 初始加载显示数量的2倍
|
||||
const recentMessages = topicWithDB.messages.length > initialLoadCount
|
||||
? topicWithDB.messages.slice(-initialLoadCount)
|
||||
: topicWithDB.messages;
|
||||
|
||||
console.log(`[Messages] Loaded ${recentMessages.length}/${topicWithDB.messages.length} messages for topic ${topic.id}`);
|
||||
|
||||
dispatch(loadTopicMessages({ topicId: topic.id, messages: recentMessages }))
|
||||
} else {
|
||||
dispatch(loadTopicMessages({ topicId: topic.id, messages: [] }))
|
||||
}
|
||||
dispatch(setCurrentTopic(topic))
|
||||
} catch (error) {
|
||||
// 静默处理错误,减少日志输出
|
||||
console.error('[Messages] Error loading topic messages:', error);
|
||||
dispatch(setError(error instanceof Error ? error.message : 'Failed to load messages'))
|
||||
} finally {
|
||||
// 清除会话的loading状态
|
||||
dispatch(setTopicLoading({ topicId: topic.id, loading: false }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
|
||||
'translate',
|
||||
'minapp',
|
||||
'knowledge',
|
||||
'files'
|
||||
'files',
|
||||
'projects'
|
||||
]
|
||||
|
||||
export interface MinAppsState {
|
||||
|
||||
@ -66,6 +66,11 @@ export interface SettingsState {
|
||||
gridColumns: number
|
||||
gridPopoverTrigger: 'hover' | 'click'
|
||||
messageNavigation: 'none' | 'buttons' | 'anchor'
|
||||
// PDF设置
|
||||
pdfSettings: {
|
||||
enablePdfSplitting: boolean // 是否启用PDF分割功能
|
||||
defaultPageRangePrompt: string // 默认页码范围提示文本
|
||||
}
|
||||
// webdav 配置 host, user, pass, path
|
||||
webdavHost: string
|
||||
webdavUser: string
|
||||
@ -221,6 +226,11 @@ export const initialState: SettingsState = {
|
||||
gridColumns: 2,
|
||||
gridPopoverTrigger: 'click',
|
||||
messageNavigation: 'none',
|
||||
// PDF设置
|
||||
pdfSettings: {
|
||||
enablePdfSplitting: true, // 默认启用PDF分割功能
|
||||
defaultPageRangePrompt: '输入页码范围,例如:1-5,8,10-15' // 默认页码范围提示文本
|
||||
},
|
||||
webdavHost: '',
|
||||
webdavUser: '',
|
||||
webdavPass: '',
|
||||
@ -776,6 +786,19 @@ const settingsSlice = createSlice({
|
||||
},
|
||||
setEnableBackspaceDeleteModel: (state, action: PayloadAction<boolean>) => {
|
||||
state.enableBackspaceDeleteModel = action.payload
|
||||
},
|
||||
// PDF设置相关的action
|
||||
setPdfSettings: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
enablePdfSplitting?: boolean
|
||||
defaultPageRangePrompt?: string
|
||||
}>
|
||||
) => {
|
||||
state.pdfSettings = {
|
||||
...state.pdfSettings,
|
||||
...action.payload
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -906,4 +929,7 @@ export const {
|
||||
setEnableBackspaceDeleteModel
|
||||
} = settingsSlice.actions
|
||||
|
||||
// PDF设置相关的action
|
||||
export const setPdfSettings = settingsSlice.actions.setPdfSettings
|
||||
|
||||
export default settingsSlice.reducer
|
||||
|
||||
@ -43,6 +43,7 @@ export type AssistantSettings = {
|
||||
defaultModel?: Model
|
||||
customParameters?: AssistantSettingCustomParameters[]
|
||||
reasoning_effort?: 'low' | 'medium' | 'high'
|
||||
thinkingBudget?: number
|
||||
}
|
||||
|
||||
export type Agent = Omit<Assistant, 'model'> & {
|
||||
@ -197,6 +198,8 @@ export interface FileType {
|
||||
created_at: string
|
||||
count: number
|
||||
tokens?: number
|
||||
pdf_page_range?: string
|
||||
pdf_page_count?: number
|
||||
}
|
||||
|
||||
export enum FileTypes {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user