修改1亿点点

This commit is contained in:
1600822305 2025-04-19 20:34:26 +08:00
parent 7a82a61bbe
commit adac7659a8
42 changed files with 4171 additions and 173 deletions

View File

@ -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",

View File

@ -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'
}

View File

@ -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))
}

View 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()

View 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 []
}
}
}

View File

@ -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>
}
}
}

View File

@ -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)
}
}

View File

@ -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 />

View File

@ -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

View 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

View File

@ -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;
}

View File

@ -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)
}

View 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

View 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: '全词匹配';
}

View File

@ -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)

View 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

View 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

View File

@ -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;
}
`

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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)和语音通话。",

View File

@ -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}
/>
)}
</>
)
}

View File

@ -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')
}
}

View 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

View File

@ -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标签

View File

@ -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
})

View File

@ -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
);
})

View File

@ -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
})

View File

@ -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}>

View File

@ -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,

View File

@ -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

View 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

View File

@ -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>

View 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);
`

View File

@ -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)

View File

@ -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
}

View File

@ -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 }))
}
}

View File

@ -9,7 +9,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'translate',
'minapp',
'knowledge',
'files'
'files',
'projects'
]
export interface MinAppsState {

View File

@ -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

View File

@ -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 {

924
yarn.lock

File diff suppressed because it is too large Load Diff