Merge branch 'main' into feat/sidebar-ui
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
interface IFilterList {
|
||||
WINDOWS: string[]
|
||||
MAC?: string[]
|
||||
MAC: string[]
|
||||
}
|
||||
|
||||
interface IFinetunedList {
|
||||
@@ -45,14 +45,17 @@ export const SELECTION_PREDEFINED_BLACKLIST: IFilterList = {
|
||||
'sldworks.exe',
|
||||
// Remote Desktop
|
||||
'mstsc.exe'
|
||||
]
|
||||
],
|
||||
MAC: []
|
||||
}
|
||||
|
||||
export const SELECTION_FINETUNED_LIST: IFinetunedList = {
|
||||
EXCLUDE_CLIPBOARD_CURSOR_DETECT: {
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe']
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe'],
|
||||
MAC: []
|
||||
},
|
||||
INCLUDE_CLIPBOARD_DELAY_READ: {
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe']
|
||||
WINDOWS: ['acrobat.exe', 'wps.exe', 'cajviewer.exe', 'foxitphantom.exe'],
|
||||
MAC: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ import { windowService } from './services/WindowService'
|
||||
|
||||
Logger.initialize()
|
||||
|
||||
/**
|
||||
* Disable hardware acceleration if setting is enabled
|
||||
*/
|
||||
const disableHardwareAcceleration = configManager.getDisableHardwareAcceleration()
|
||||
if (disableHardwareAcceleration) {
|
||||
app.disableHardwareAcceleration()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable chromium's window animations
|
||||
* main purpose for this is to avoid the transparent window flashing when it is shown
|
||||
|
||||
+74
-11
@@ -2,24 +2,25 @@ import fs from 'node:fs'
|
||||
import { arch } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell, webContents } from 'electron'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { CacheService } from './services/CacheService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileService from './services/FileService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import NotificationService from './services/NotificationService'
|
||||
@@ -27,6 +28,7 @@ import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
@@ -114,12 +116,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// launch on boot
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, openAtLogin: boolean) => {
|
||||
// Set login item settings for windows and mac
|
||||
// linux is not supported because it requires more file operations
|
||||
if (isWin || isMac) {
|
||||
app.setLoginItemSettings({ openAtLogin })
|
||||
}
|
||||
ipcMain.handle(IpcChannel.App_SetLaunchOnBoot, (_, isLaunchOnBoot: boolean) => {
|
||||
appService.setAppLaunchOnBoot(isLaunchOnBoot)
|
||||
})
|
||||
|
||||
// launch to tray
|
||||
@@ -159,6 +157,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
//only for mac
|
||||
if (isMac) {
|
||||
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||
return systemPreferences.isTrustedAccessibilityClient(false)
|
||||
})
|
||||
|
||||
//return is only the current state, not the new state
|
||||
ipcMain.handle(IpcChannel.App_MacRequestProcessTrust, (): boolean => {
|
||||
return systemPreferences.isTrustedAccessibilityClient(true)
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
|
||||
configManager.set(key, value, isNotify)
|
||||
})
|
||||
@@ -307,6 +317,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
// Relaunch app
|
||||
ipcMain.handle(IpcChannel.App_RelaunchApp, (_, options?: Electron.RelaunchOptions) => {
|
||||
// Fix for .AppImage
|
||||
if (isLinux && process.env.APPIMAGE) {
|
||||
log.info('Relaunching app with options:', process.env.APPIMAGE, options)
|
||||
// On Linux, we need to use the APPIMAGE environment variable to relaunch
|
||||
// https://github.com/electron-userland/electron-builder/issues/1727#issuecomment-769896927
|
||||
options = options || {}
|
||||
options.execPath = process.env.APPIMAGE
|
||||
options.args = options.args || []
|
||||
options.args.unshift('--appimage-extract-and-run')
|
||||
}
|
||||
|
||||
app.relaunch(options)
|
||||
app.exit(0)
|
||||
})
|
||||
@@ -345,6 +366,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection)
|
||||
ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile)
|
||||
ipcMain.handle(IpcChannel.Backup_BackupToLocalDir, backupManager.backupToLocalDir)
|
||||
ipcMain.handle(IpcChannel.Backup_RestoreFromLocalBackup, backupManager.restoreFromLocalBackup)
|
||||
ipcMain.handle(IpcChannel.Backup_ListLocalBackupFiles, backupManager.listLocalBackupFiles)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteLocalBackupFile, backupManager.deleteLocalBackupFile)
|
||||
ipcMain.handle(IpcChannel.Backup_SetLocalBackupDir, backupManager.setLocalBackupDir)
|
||||
ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3)
|
||||
ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3)
|
||||
ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files)
|
||||
ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File)
|
||||
ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection)
|
||||
|
||||
// file
|
||||
ipcMain.handle(IpcChannel.File_Open, fileManager.open)
|
||||
@@ -355,9 +386,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear)
|
||||
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile)
|
||||
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile)
|
||||
ipcMain.handle('file:deleteDir', fileManager.deleteDir)
|
||||
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile)
|
||||
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder)
|
||||
ipcMain.handle(IpcChannel.File_Create, fileManager.createTempFile)
|
||||
ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile)
|
||||
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile)
|
||||
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId)
|
||||
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage)
|
||||
@@ -368,6 +400,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile)
|
||||
ipcMain.handle(IpcChannel.File_Copy, fileManager.copyFile)
|
||||
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
|
||||
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath)
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
const service = FileServiceManager.getInstance().getService(provider)
|
||||
return await service.uploadFile(file)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.FileService_List, async (_, provider: Provider) => {
|
||||
const service = FileServiceManager.getInstance().getService(provider)
|
||||
return await service.listFiles()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.FileService_Delete, async (_, provider: Provider, fileId: string) => {
|
||||
const service = FileServiceManager.getInstance().getService(provider)
|
||||
return await service.deleteFile(fileId)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.FileService_Retrieve, async (_, provider: Provider, fileId: string) => {
|
||||
const service = FileServiceManager.getInstance().getService(provider)
|
||||
return await service.retrieveFile(fileId)
|
||||
})
|
||||
|
||||
// fs
|
||||
ipcMain.handle(IpcChannel.Fs_Read, FileService.readFile)
|
||||
@@ -398,6 +452,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Remove, KnowledgeService.remove)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Search, KnowledgeService.search)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota)
|
||||
|
||||
// window
|
||||
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
|
||||
@@ -448,6 +503,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
|
||||
ipcMain.handle(IpcChannel.Mcp_SetProgress, (_, progress: number) => {
|
||||
mainWindow.webContents.send('mcp-progress', progress)
|
||||
})
|
||||
|
||||
// Register Python execution handler
|
||||
ipcMain.handle(
|
||||
@@ -516,6 +575,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
|
||||
ipcMain.handle(IpcChannel.App_QuoteToMain, (_, text: string) => windowService.quoteToMainWindow(text))
|
||||
|
||||
ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => {
|
||||
configManager.setDisableHardwareAcceleration(isDisable)
|
||||
})
|
||||
|
||||
// Navigation
|
||||
ipcMain.handle(IpcChannel.Navigation_Url, (_, url: string) => {
|
||||
CacheService.set('navigation-url', url)
|
||||
|
||||
@@ -5,26 +5,19 @@ import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-op
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import { SUPPORTED_DIM_MODELS as VOYAGE_SUPPORTED_DIM_MODELS, VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
if (provider === 'voyageai') {
|
||||
if (VOYAGE_SUPPORTED_DIM_MODELS.includes(model)) {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: dimensions,
|
||||
batchSize: 8
|
||||
})
|
||||
} else {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
apiKey,
|
||||
outputDimension: VOYAGE_SUPPORTED_DIM_MODELS.includes(model) ? dimensions : undefined,
|
||||
batchSize: 8
|
||||
})
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
if (baseURL.includes('v1/')) {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage'
|
||||
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
|
||||
/**
|
||||
* 支持设置嵌入维度的模型
|
||||
*/
|
||||
export const SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
export class VoyageEmbeddings extends BaseEmbeddings {
|
||||
private model: _VoyageEmbeddings
|
||||
constructor(private readonly configuration?: ConstructorParameters<typeof _VoyageEmbeddings>[0]) {
|
||||
super()
|
||||
if (!this.configuration) this.configuration = {}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
if (!SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
throw new Error(`VoyageEmbeddings only supports ${SUPPORTED_DIM_MODELS.join(', ')}`)
|
||||
if (!this.configuration) {
|
||||
throw new Error('Pass in a configuration.')
|
||||
}
|
||||
if (!this.configuration.modelName) this.configuration.modelName = 'voyage-3'
|
||||
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
if (!VOYAGE_SUPPORTED_DIM_MODELS.includes(this.configuration.modelName) && this.configuration.outputDimension) {
|
||||
console.error(`VoyageEmbeddings only supports ${VOYAGE_SUPPORTED_DIM_MODELS.join(', ')} to set outputDimension.`)
|
||||
this.model = new _VoyageEmbeddings({ ...this.configuration, outputDimension: undefined })
|
||||
} else {
|
||||
this.model = new _VoyageEmbeddings(this.configuration)
|
||||
}
|
||||
}
|
||||
override async getDimensions(): Promise<number> {
|
||||
if (!this.configuration?.outputDimension) {
|
||||
throw new Error('You need to pass in the optional dimensions parameter for this model')
|
||||
}
|
||||
return this.configuration?.outputDimension
|
||||
return this.configuration?.outputDimension ?? (this.configuration?.modelName === 'voyage-code-2' ? 1536 : 1024)
|
||||
}
|
||||
|
||||
override async embedDocuments(texts: string[]): Promise<number[][]> {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const VOYAGE_SUPPORTED_DIM_MODELS = ['voyage-3-large', 'voyage-3.5', 'voyage-3.5-lite', 'voyage-code-3']
|
||||
|
||||
// NOTE: 下面的暂时没用上,但先留着吧
|
||||
export const OPENAI_SUPPORTED_DIM_MODELS = ['text-embedding-3-small', 'text-embedding-3-large']
|
||||
|
||||
export const DASHSCOPE_SUPPORTED_DIM_MODELS = ['text-embedding-v3', 'text-embedding-v4']
|
||||
|
||||
export const OPENSOURCE_SUPPORTED_DIM_MODELS = ['qwen3-embedding-0.6B', 'qwen3-embedding-4B', 'qwen3-embedding-8B']
|
||||
|
||||
export const GOOGLE_SUPPORTED_DIM_MODELS = ['gemini-embedding-exp-03-07', 'gemini-embedding-exp']
|
||||
|
||||
export const SUPPORTED_DIM_MODELS = [
|
||||
...VOYAGE_SUPPORTED_DIM_MODELS,
|
||||
...OPENAI_SUPPORTED_DIM_MODELS,
|
||||
...DASHSCOPE_SUPPORTED_DIM_MODELS,
|
||||
...OPENSOURCE_SUPPORTED_DIM_MODELS,
|
||||
...GOOGLE_SUPPORTED_DIM_MODELS
|
||||
]
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称。
|
||||
* 例如:
|
||||
* - 'deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/deepseek-r1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 基础名称
|
||||
*/
|
||||
export const getBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
const parts = id.split(delimiter)
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从模型 ID 中提取基础名称并转换为小写。
|
||||
* 例如:
|
||||
* - 'deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* - 'deepseek-ai/deepseek/DeepSeek-R1' => 'deepseek-r1'
|
||||
* @param {string} id 模型 ID
|
||||
* @param {string} [delimiter='/'] 分隔符,默认为 '/'
|
||||
* @returns {string} 小写的基础名称
|
||||
*/
|
||||
export const getLowerBaseModelName = (id: string, delimiter: string = '/'): string => {
|
||||
return getBaseModelName(id, delimiter).toLowerCase()
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@cherrystudio/embedjs'
|
||||
import type { AddLoaderReturn } from '@cherrystudio/embedjs-interfaces'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import { readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { FileType, KnowledgeBaseParams } from '@types'
|
||||
import { FileMetadata, KnowledgeBaseParams } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { DraftsExportLoader } from './draftsExportLoader'
|
||||
@@ -39,7 +38,7 @@ const FILE_LOADER_MAP: Record<string, string> = {
|
||||
|
||||
export async function addOdLoader(
|
||||
ragApplication: RAGApplication,
|
||||
file: FileType,
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
forceReload: boolean
|
||||
): Promise<AddLoaderReturn> {
|
||||
@@ -65,7 +64,7 @@ export async function addOdLoader(
|
||||
|
||||
export async function addFileLoader(
|
||||
ragApplication: RAGApplication,
|
||||
file: FileType,
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
forceReload: boolean
|
||||
): Promise<LoaderReturn> {
|
||||
@@ -115,7 +114,7 @@ export async function addFileLoader(
|
||||
// HTML类型处理
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: fs.readFileSync(file.path, 'utf-8'),
|
||||
urlOrContent: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
@@ -125,7 +124,7 @@ export async function addFileLoader(
|
||||
|
||||
case 'json':
|
||||
try {
|
||||
jsonObject = JSON.parse(fs.readFileSync(file.path, 'utf-8'))
|
||||
jsonObject = JSON.parse(await readTextFileWithAutoEncoding(file.path))
|
||||
} catch (error) {
|
||||
jsonParsed = false
|
||||
Logger.warn('[KnowledgeBase] failed parsing json file, falling back to text processing:', file.path, error)
|
||||
@@ -141,7 +140,7 @@ export async function addFileLoader(
|
||||
// 如果是其他文本类型且尚未读取文件,则读取文件
|
||||
loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({
|
||||
text: fs.readFileSync(file.path, 'utf-8'),
|
||||
text: await readTextFileWithAutoEncoding(file.path),
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getFileExt } from '@main/utils/file'
|
||||
import { FileMetadata, OcrProvider } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { TypedArray } from 'pdfjs-dist/types/src/display/api'
|
||||
|
||||
export default abstract class BaseOcrProvider {
|
||||
protected provider: OcrProvider
|
||||
public storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
|
||||
constructor(provider: OcrProvider) {
|
||||
if (!provider) {
|
||||
throw new Error('OCR provider is not set')
|
||||
}
|
||||
this.provider = provider
|
||||
}
|
||||
abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }>
|
||||
|
||||
/**
|
||||
* 检查文件是否已经被预处理过
|
||||
* 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理
|
||||
* @param file 文件信息
|
||||
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||
*/
|
||||
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||
try {
|
||||
// 检查 Data/Files/{file.id} 是否是目录
|
||||
const preprocessDirPath = path.join(this.storageDir, file.id)
|
||||
|
||||
if (fs.existsSync(preprocessDirPath)) {
|
||||
const stats = await fs.promises.stat(preprocessDirPath)
|
||||
|
||||
// 如果是目录,说明已经被预处理过
|
||||
if (stats.isDirectory()) {
|
||||
// 查找目录中的处理结果文件
|
||||
const files = await fs.promises.readdir(preprocessDirPath)
|
||||
|
||||
// 查找主要的处理结果文件(.md 或 .txt)
|
||||
const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt'))
|
||||
|
||||
if (processedFile) {
|
||||
const processedFilePath = path.join(preprocessDirPath, processedFile)
|
||||
const processedStats = await fs.promises.stat(processedFilePath)
|
||||
const ext = getFileExt(processedFile)
|
||||
|
||||
return {
|
||||
...file,
|
||||
name: file.name.replace(file.ext, ext),
|
||||
path: processedFilePath,
|
||||
ext: ext,
|
||||
size: processedStats.size,
|
||||
created_at: processedStats.birthtime.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
// 如果检查过程中出现错误,返回null表示未处理
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助方法:延迟执行
|
||||
*/
|
||||
public delay = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
public async readPdf(
|
||||
source: string | URL | TypedArray,
|
||||
passwordCallback?: (fn: (password: string) => void, reason: string) => string
|
||||
) {
|
||||
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
const documentLoadingTask = getDocument(source)
|
||||
if (passwordCallback) {
|
||||
documentLoadingTask.onPassword = passwordCallback
|
||||
}
|
||||
|
||||
const document = await documentLoadingTask.promise
|
||||
return document
|
||||
}
|
||||
|
||||
public async sendOcrProgress(sourceId: string, progress: number): Promise<void> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('file-ocr-progress', {
|
||||
itemId: sourceId,
|
||||
progress: progress
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件移动到附件目录
|
||||
* @param fileId 文件id
|
||||
* @param filePaths 需要移动的文件路径数组
|
||||
* @returns 移动后的文件路径数组
|
||||
*/
|
||||
public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] {
|
||||
const attachmentsPath = path.join(this.storageDir, fileId)
|
||||
if (!fs.existsSync(attachmentsPath)) {
|
||||
fs.mkdirSync(attachmentsPath, { recursive: true })
|
||||
}
|
||||
|
||||
const movedPaths: string[] = []
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fileName = path.basename(filePath)
|
||||
const destPath = path.join(attachmentsPath, fileName)
|
||||
fs.copyFileSync(filePath, destPath)
|
||||
fs.unlinkSync(filePath) // 删除原文件,实现"移动"
|
||||
movedPaths.push(destPath)
|
||||
}
|
||||
}
|
||||
return movedPaths
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FileMetadata, OcrProvider } from '@types'
|
||||
|
||||
import BaseOcrProvider from './BaseOcrProvider'
|
||||
|
||||
export default class DefaultOcrProvider extends BaseOcrProvider {
|
||||
constructor(provider: OcrProvider) {
|
||||
super(provider)
|
||||
}
|
||||
public parseFile(): Promise<{ processedFile: FileMetadata }> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isMac } from '@main/constant'
|
||||
import { FileMetadata, OcrProvider } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { TextItem } from 'pdfjs-dist/types/src/display/api'
|
||||
|
||||
import BaseOcrProvider from './BaseOcrProvider'
|
||||
|
||||
export default class MacSysOcrProvider extends BaseOcrProvider {
|
||||
private readonly MIN_TEXT_LENGTH = 1000
|
||||
private MacOCR: any
|
||||
|
||||
private async initMacOCR() {
|
||||
if (!isMac) {
|
||||
throw new Error('MacSysOcrProvider is only available on macOS')
|
||||
}
|
||||
if (!this.MacOCR) {
|
||||
try {
|
||||
// @ts-ignore This module is optional and only installed/available on macOS. Runtime checks prevent execution on other platforms.
|
||||
const module = await import('@cherrystudio/mac-system-ocr')
|
||||
this.MacOCR = module.default
|
||||
} catch (error) {
|
||||
Logger.error('[OCR] Failed to load mac-system-ocr:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return this.MacOCR
|
||||
}
|
||||
|
||||
private getRecognitionLevel(level?: number) {
|
||||
return level === 0 ? this.MacOCR.RECOGNITION_LEVEL_FAST : this.MacOCR.RECOGNITION_LEVEL_ACCURATE
|
||||
}
|
||||
|
||||
constructor(provider: OcrProvider) {
|
||||
super(provider)
|
||||
}
|
||||
|
||||
private async processPages(
|
||||
results: any,
|
||||
totalPages: number,
|
||||
sourceId: string,
|
||||
writeStream: fs.WriteStream
|
||||
): Promise<void> {
|
||||
await this.initMacOCR()
|
||||
// TODO: 下个版本后面使用批处理,以及p-queue来优化
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
// Convert pages to buffers
|
||||
const pageNum = i + 1
|
||||
const pageBuffer = await results.getPage(pageNum)
|
||||
|
||||
// Process batch
|
||||
const ocrResult = await this.MacOCR.recognizeFromBuffer(pageBuffer, {
|
||||
ocrOptions: {
|
||||
recognitionLevel: this.getRecognitionLevel(this.provider.options?.recognitionLevel),
|
||||
minConfidence: this.provider.options?.minConfidence || 0.5
|
||||
}
|
||||
})
|
||||
|
||||
// Write results in order
|
||||
writeStream.write(ocrResult.text + '\n')
|
||||
|
||||
// Update progress
|
||||
await this.sendOcrProgress(sourceId, (pageNum / totalPages) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
public async isScanPdf(buffer: Buffer): Promise<boolean> {
|
||||
const doc = await this.readPdf(new Uint8Array(buffer))
|
||||
const pageLength = doc.numPages
|
||||
let counts = 0
|
||||
const pagesToCheck = Math.min(pageLength, 10)
|
||||
for (let i = 0; i < pagesToCheck; i++) {
|
||||
const page = await doc.getPage(i + 1)
|
||||
const pageData = await page.getTextContent()
|
||||
const pageText = pageData.items.map((item) => (item as TextItem).str).join('')
|
||||
counts += pageText.length
|
||||
if (counts >= this.MIN_TEXT_LENGTH) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||
Logger.info(`[OCR] Starting OCR process for file: ${file.name}`)
|
||||
if (file.ext === '.pdf') {
|
||||
try {
|
||||
const { pdf } = await import('@cherrystudio/pdf-to-img-napi')
|
||||
const pdfBuffer = await fs.promises.readFile(file.path)
|
||||
const results = await pdf(pdfBuffer, {
|
||||
scale: 2
|
||||
})
|
||||
const totalPages = results.length
|
||||
|
||||
const baseDir = path.dirname(file.path)
|
||||
const baseName = path.basename(file.path, path.extname(file.path))
|
||||
const txtFileName = `${baseName}.txt`
|
||||
const txtFilePath = path.join(baseDir, txtFileName)
|
||||
|
||||
const writeStream = fs.createWriteStream(txtFilePath)
|
||||
await this.processPages(results, totalPages, sourceId, writeStream)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writeStream.end(() => {
|
||||
Logger.info(`[OCR] OCR process completed successfully for ${file.origin_name}`)
|
||||
resolve()
|
||||
})
|
||||
writeStream.on('error', reject)
|
||||
})
|
||||
const movedPaths = this.moveToAttachmentsDir(file.id, [txtFilePath])
|
||||
return {
|
||||
processedFile: {
|
||||
...file,
|
||||
name: txtFileName,
|
||||
path: movedPaths[0],
|
||||
ext: '.txt',
|
||||
size: fs.statSync(movedPaths[0]).size
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[OCR] Error during OCR process:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return { processedFile: file }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FileMetadata, OcrProvider as Provider } from '@types'
|
||||
|
||||
import BaseOcrProvider from './BaseOcrProvider'
|
||||
import OcrProviderFactory from './OcrProviderFactory'
|
||||
|
||||
export default class OcrProvider {
|
||||
private sdk: BaseOcrProvider
|
||||
constructor(provider: Provider) {
|
||||
this.sdk = OcrProviderFactory.create(provider)
|
||||
}
|
||||
public async parseFile(
|
||||
sourceId: string,
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota?: number }> {
|
||||
return this.sdk.parseFile(sourceId, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否已经被预处理过
|
||||
* @param file 文件信息
|
||||
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||
*/
|
||||
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||
return this.sdk.checkIfAlreadyProcessed(file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isMac } from '@main/constant'
|
||||
import { OcrProvider } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import BaseOcrProvider from './BaseOcrProvider'
|
||||
import DefaultOcrProvider from './DefaultOcrProvider'
|
||||
import MacSysOcrProvider from './MacSysOcrProvider'
|
||||
export default class OcrProviderFactory {
|
||||
static create(provider: OcrProvider): BaseOcrProvider {
|
||||
switch (provider.id) {
|
||||
case 'system':
|
||||
if (!isMac) {
|
||||
Logger.warn('[OCR] System OCR provider is only available on macOS')
|
||||
}
|
||||
return new MacSysOcrProvider(provider)
|
||||
default:
|
||||
return new DefaultOcrProvider(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getFileExt } from '@main/utils/file'
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { TypedArray } from 'pdfjs-dist/types/src/display/api'
|
||||
|
||||
export default abstract class BasePreprocessProvider {
|
||||
protected provider: PreprocessProvider
|
||||
protected userId?: string
|
||||
public storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
|
||||
|
||||
constructor(provider: PreprocessProvider, userId?: string) {
|
||||
if (!provider) {
|
||||
throw new Error('Preprocess provider is not set')
|
||||
}
|
||||
this.provider = provider
|
||||
this.userId = userId
|
||||
}
|
||||
abstract parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata; quota?: number }>
|
||||
|
||||
abstract checkQuota(): Promise<number>
|
||||
|
||||
/**
|
||||
* 检查文件是否已经被预处理过
|
||||
* 统一检测方法:如果 Data/Files/{file.id} 是目录,说明已被预处理
|
||||
* @param file 文件信息
|
||||
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||
*/
|
||||
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||
try {
|
||||
// 检查 Data/Files/{file.id} 是否是目录
|
||||
const preprocessDirPath = path.join(this.storageDir, file.id)
|
||||
|
||||
if (fs.existsSync(preprocessDirPath)) {
|
||||
const stats = await fs.promises.stat(preprocessDirPath)
|
||||
|
||||
// 如果是目录,说明已经被预处理过
|
||||
if (stats.isDirectory()) {
|
||||
// 查找目录中的处理结果文件
|
||||
const files = await fs.promises.readdir(preprocessDirPath)
|
||||
|
||||
// 查找主要的处理结果文件(.md 或 .txt)
|
||||
const processedFile = files.find((fileName) => fileName.endsWith('.md') || fileName.endsWith('.txt'))
|
||||
|
||||
if (processedFile) {
|
||||
const processedFilePath = path.join(preprocessDirPath, processedFile)
|
||||
const processedStats = await fs.promises.stat(processedFilePath)
|
||||
const ext = getFileExt(processedFile)
|
||||
|
||||
return {
|
||||
...file,
|
||||
name: file.name.replace(file.ext, ext),
|
||||
path: processedFilePath,
|
||||
ext: ext,
|
||||
size: processedStats.size,
|
||||
created_at: processedStats.birthtime.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
// 如果检查过程中出现错误,返回null表示未处理
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助方法:延迟执行
|
||||
*/
|
||||
public delay = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
public async readPdf(
|
||||
source: string | URL | TypedArray,
|
||||
passwordCallback?: (fn: (password: string) => void, reason: string) => string
|
||||
) {
|
||||
const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
const documentLoadingTask = getDocument(source)
|
||||
if (passwordCallback) {
|
||||
documentLoadingTask.onPassword = passwordCallback
|
||||
}
|
||||
|
||||
const document = await documentLoadingTask.promise
|
||||
return document
|
||||
}
|
||||
|
||||
public async sendPreprocessProgress(sourceId: string, progress: number): Promise<void> {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('file-preprocess-progress', {
|
||||
itemId: sourceId,
|
||||
progress: progress
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件移动到附件目录
|
||||
* @param fileId 文件id
|
||||
* @param filePaths 需要移动的文件路径数组
|
||||
* @returns 移动后的文件路径数组
|
||||
*/
|
||||
public moveToAttachmentsDir(fileId: string, filePaths: string[]): string[] {
|
||||
const attachmentsPath = path.join(this.storageDir, fileId)
|
||||
if (!fs.existsSync(attachmentsPath)) {
|
||||
fs.mkdirSync(attachmentsPath, { recursive: true })
|
||||
}
|
||||
|
||||
const movedPaths: string[] = []
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const fileName = path.basename(filePath)
|
||||
const destPath = path.join(attachmentsPath, fileName)
|
||||
fs.copyFileSync(filePath, destPath)
|
||||
fs.unlinkSync(filePath) // 删除原文件,实现"移动"
|
||||
movedPaths.push(destPath)
|
||||
}
|
||||
}
|
||||
return movedPaths
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
export default class DefaultPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider) {
|
||||
super(provider)
|
||||
}
|
||||
public parseFile(): Promise<{ processedFile: FileMetadata }> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
public checkQuota(): Promise<number> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
type ApiResponse<T> = {
|
||||
code: string
|
||||
data: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
type PreuploadResponse = {
|
||||
uid: string
|
||||
url: string
|
||||
}
|
||||
|
||||
type StatusResponse = {
|
||||
status: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
type ParsedFileResponse = {
|
||||
status: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider) {
|
||||
super(provider)
|
||||
}
|
||||
|
||||
private async validateFile(filePath: string): Promise<void> {
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const doc = await this.readPdf(new Uint8Array(pdfBuffer))
|
||||
|
||||
// 文件页数小于1000页
|
||||
if (doc.numPages >= 1000) {
|
||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 1000 pages`)
|
||||
}
|
||||
// 文件大小小于300MB
|
||||
if (pdfBuffer.length >= 300 * 1024 * 1024) {
|
||||
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 300MB`)
|
||||
}
|
||||
}
|
||||
|
||||
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||
try {
|
||||
Logger.info(`Preprocess processing started: ${file.path}`)
|
||||
|
||||
// 步骤1: 准备上传
|
||||
const { uid, url } = await this.preupload()
|
||||
Logger.info(`Preprocess preupload completed: uid=${uid}`)
|
||||
|
||||
await this.validateFile(file.path)
|
||||
|
||||
// 步骤2: 上传文件
|
||||
await this.putFile(file.path, url)
|
||||
|
||||
// 步骤3: 等待处理完成
|
||||
await this.waitForProcessing(sourceId, uid)
|
||||
Logger.info(`Preprocess parsing completed successfully for: ${file.path}`)
|
||||
|
||||
// 步骤4: 导出文件
|
||||
const { path: outputPath } = await this.exportFile(file, uid)
|
||||
|
||||
// 步骤5: 创建处理后的文件信息
|
||||
return {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Preprocess processing failed for ${file.path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||
const outputFilePath = `${outputPath}/${file.name.split('.').slice(0, -1).join('.')}.md`
|
||||
return {
|
||||
...file,
|
||||
name: file.name.replace('.pdf', '.md'),
|
||||
path: outputFilePath,
|
||||
ext: '.md',
|
||||
size: fs.statSync(outputFilePath).size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出文件
|
||||
* @param file 文件信息
|
||||
* @param uid 预上传响应的uid
|
||||
* @returns 导出文件的路径
|
||||
*/
|
||||
public async exportFile(file: FileMetadata, uid: string): Promise<{ path: string }> {
|
||||
Logger.info(`Exporting file: ${file.path}`)
|
||||
|
||||
// 步骤1: 转换文件
|
||||
await this.convertFile(uid, file.path)
|
||||
Logger.info(`File conversion completed for: ${file.path}`)
|
||||
|
||||
// 步骤2: 等待导出并获取URL
|
||||
const exportUrl = await this.waitForExport(uid)
|
||||
|
||||
// 步骤3: 下载并解压文件
|
||||
return this.downloadFile(exportUrl, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待处理完成
|
||||
* @param sourceId 源文件ID
|
||||
* @param uid 预上传响应的uid
|
||||
*/
|
||||
private async waitForProcessing(sourceId: string, uid: string): Promise<void> {
|
||||
while (true) {
|
||||
await this.delay(1000)
|
||||
const { status, progress } = await this.getStatus(uid)
|
||||
await this.sendPreprocessProgress(sourceId, progress)
|
||||
Logger.info(`Preprocess processing status: ${status}, progress: ${progress}%`)
|
||||
|
||||
if (status === 'success') {
|
||||
return
|
||||
} else if (status === 'failed') {
|
||||
throw new Error('Preprocess processing failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待导出完成
|
||||
* @param uid 预上传响应的uid
|
||||
* @returns 导出文件的url
|
||||
*/
|
||||
private async waitForExport(uid: string): Promise<string> {
|
||||
while (true) {
|
||||
await this.delay(1000)
|
||||
const { status, url } = await this.getParsedFile(uid)
|
||||
Logger.info(`Export status: ${status}`)
|
||||
|
||||
if (status === 'success' && url) {
|
||||
return url
|
||||
} else if (status === 'failed') {
|
||||
throw new Error('Export failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预上传文件
|
||||
* @returns 预上传响应的url和uid
|
||||
*/
|
||||
private async preupload(): Promise<PreuploadResponse> {
|
||||
const config = this.createAuthConfig()
|
||||
const endpoint = `${this.provider.apiHost}/api/v2/parse/preupload`
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<ApiResponse<PreuploadResponse>>(endpoint, null, config)
|
||||
|
||||
if (data.code === 'success' && data.data) {
|
||||
return data.data
|
||||
} else {
|
||||
throw new Error(`API returned error: ${data.message || JSON.stringify(data)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to get preupload URL: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error('Failed to get preupload URL')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param filePath 文件路径
|
||||
* @param url 预上传响应的url
|
||||
*/
|
||||
private async putFile(filePath: string, url: string): Promise<void> {
|
||||
try {
|
||||
const fileStream = fs.createReadStream(filePath)
|
||||
const response = await axios.put(url, fileStream)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to upload file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error('Failed to upload file')
|
||||
}
|
||||
}
|
||||
|
||||
private async getStatus(uid: string): Promise<StatusResponse> {
|
||||
const config = this.createAuthConfig()
|
||||
const endpoint = `${this.provider.apiHost}/api/v2/parse/status?uid=${uid}`
|
||||
|
||||
try {
|
||||
const response = await axios.get<ApiResponse<StatusResponse>>(endpoint, config)
|
||||
|
||||
if (response.data.code === 'success' && response.data.data) {
|
||||
return response.data.data
|
||||
} else {
|
||||
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to get status for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error('Failed to get processing status')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess文件
|
||||
* @param uid 预上传响应的uid
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
private async convertFile(uid: string, filePath: string): Promise<void> {
|
||||
const fileName = path.parse(filePath).name
|
||||
const config = {
|
||||
...this.createAuthConfig(),
|
||||
headers: {
|
||||
...this.createAuthConfig().headers,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
uid,
|
||||
to: 'md',
|
||||
formula_mode: 'normal',
|
||||
filename: fileName
|
||||
}
|
||||
|
||||
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse`
|
||||
|
||||
try {
|
||||
const response = await axios.post<ApiResponse<any>>(endpoint, payload, config)
|
||||
|
||||
if (response.data.code !== 'success') {
|
||||
throw new Error(`API returned error: ${response.data.message || JSON.stringify(response.data)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to convert file ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error('Failed to convert file')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解析后的文件信息
|
||||
* @param uid 预上传响应的uid
|
||||
* @returns 解析后的文件信息
|
||||
*/
|
||||
private async getParsedFile(uid: string): Promise<ParsedFileResponse> {
|
||||
const config = this.createAuthConfig()
|
||||
const endpoint = `${this.provider.apiHost}/api/v2/convert/parse/result?uid=${uid}`
|
||||
|
||||
try {
|
||||
const response = await axios.get<ApiResponse<ParsedFileResponse>>(endpoint, config)
|
||||
|
||||
if (response.status === 200 && response.data.data) {
|
||||
return response.data.data
|
||||
} else {
|
||||
throw new Error(`HTTP status ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to get parsed file for uid ${uid}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw new Error('Failed to get parsed file information')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param url 导出文件的url
|
||||
* @param file 文件信息
|
||||
* @returns 下载文件的路径
|
||||
*/
|
||||
private async downloadFile(url: string, file: FileMetadata): Promise<{ path: string }> {
|
||||
const dirPath = this.storageDir
|
||||
// 使用统一的存储路径:Data/Files/{file.id}/
|
||||
const extractPath = path.join(dirPath, file.id)
|
||||
const zipPath = path.join(dirPath, `${file.id}.zip`)
|
||||
|
||||
// 确保目录存在
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
fs.mkdirSync(extractPath, { recursive: true })
|
||||
|
||||
Logger.info(`Downloading to export path: ${zipPath}`)
|
||||
|
||||
try {
|
||||
// 下载文件
|
||||
const response = await axios.get(url, { responseType: 'arraybuffer' })
|
||||
fs.writeFileSync(zipPath, response.data)
|
||||
|
||||
// 确保提取目录存在
|
||||
if (!fs.existsSync(extractPath)) {
|
||||
fs.mkdirSync(extractPath, { recursive: true })
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(extractPath, true)
|
||||
Logger.info(`Extracted files to: ${extractPath}`)
|
||||
|
||||
// 删除临时ZIP文件
|
||||
fs.unlinkSync(zipPath)
|
||||
|
||||
return { path: extractPath }
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to download and extract file: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw new Error('Failed to download and extract file')
|
||||
}
|
||||
}
|
||||
|
||||
private createAuthConfig(): AxiosRequestConfig {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.provider.apiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public checkQuota(): Promise<number> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileMetadata, PreprocessProvider } from '@types'
|
||||
import AdmZip from 'adm-zip'
|
||||
import axios from 'axios'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
type ApiResponse<T> = {
|
||||
code: number
|
||||
data: T
|
||||
msg?: string
|
||||
trace_id?: string
|
||||
}
|
||||
|
||||
type BatchUploadResponse = {
|
||||
batch_id: string
|
||||
file_urls: string[]
|
||||
}
|
||||
|
||||
type ExtractProgress = {
|
||||
extracted_pages: number
|
||||
total_pages: number
|
||||
start_time: string
|
||||
}
|
||||
|
||||
type ExtractFileResult = {
|
||||
file_name: string
|
||||
state: 'done' | 'waiting-file' | 'pending' | 'running' | 'converting' | 'failed'
|
||||
err_msg: string
|
||||
full_zip_url?: string
|
||||
extract_progress?: ExtractProgress
|
||||
}
|
||||
|
||||
type ExtractResultResponse = {
|
||||
batch_id: string
|
||||
extract_result: ExtractFileResult[]
|
||||
}
|
||||
|
||||
type QuotaResponse = {
|
||||
code: number
|
||||
data: {
|
||||
user_left_quota: number
|
||||
total_left_quota: number
|
||||
}
|
||||
msg?: string
|
||||
trace_id?: string
|
||||
}
|
||||
|
||||
export default class MineruPreprocessProvider extends BasePreprocessProvider {
|
||||
constructor(provider: PreprocessProvider, userId?: string) {
|
||||
super(provider, userId)
|
||||
// todo:免费期结束后删除
|
||||
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
|
||||
}
|
||||
|
||||
public async parseFile(
|
||||
sourceId: string,
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota: number }> {
|
||||
try {
|
||||
Logger.info(`MinerU preprocess processing started: ${file.path}`)
|
||||
await this.validateFile(file.path)
|
||||
|
||||
// 1. 获取上传URL并上传文件
|
||||
const batchId = await this.uploadFile(file)
|
||||
Logger.info(`MinerU file upload completed: batch_id=${batchId}`)
|
||||
|
||||
// 2. 等待处理完成并获取结果
|
||||
const extractResult = await this.waitForCompletion(sourceId, batchId, file.origin_name)
|
||||
Logger.info(`MinerU processing completed for batch: ${batchId}`)
|
||||
|
||||
// 3. 下载并解压文件
|
||||
const { path: outputPath } = await this.downloadAndExtractFile(extractResult.full_zip_url!, file)
|
||||
|
||||
// 4. check quota
|
||||
const quota = await this.checkQuota()
|
||||
|
||||
// 5. 创建处理后的文件信息
|
||||
return {
|
||||
processedFile: this.createProcessedFileInfo(file, outputPath),
|
||||
quota
|
||||
}
|
||||
} catch (error: any) {
|
||||
Logger.error(`MinerU preprocess processing failed for ${file.path}: ${error.message}`)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuota() {
|
||||
try {
|
||||
const quota = await fetch(`${this.provider.apiHost}/api/v4/quota`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.provider.apiKey}`,
|
||||
token: this.userId ?? ''
|
||||
}
|
||||
})
|
||||
if (!quota.ok) {
|
||||
throw new Error(`HTTP ${quota.status}: ${quota.statusText}`)
|
||||
}
|
||||
const response: QuotaResponse = await quota.json()
|
||||
return response.data.user_left_quota
|
||||
} catch (error) {
|
||||
console.error('Error checking quota:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async validateFile(filePath: string): Promise<void> {
|
||||
const pdfBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const doc = await this.readPdf(new Uint8Array(pdfBuffer))
|
||||
|
||||
// 文件页数小于600页
|
||||
if (doc.numPages >= 600) {
|
||||
throw new Error(`PDF page count (${doc.numPages}) exceeds the limit of 600 pages`)
|
||||
}
|
||||
// 文件大小小于200MB
|
||||
if (pdfBuffer.length >= 200 * 1024 * 1024) {
|
||||
const fileSizeMB = Math.round(pdfBuffer.length / (1024 * 1024))
|
||||
throw new Error(`PDF file size (${fileSizeMB}MB) exceeds the limit of 200MB`)
|
||||
}
|
||||
}
|
||||
|
||||
private createProcessedFileInfo(file: FileMetadata, outputPath: string): FileMetadata {
|
||||
// 查找解压后的主要文件
|
||||
let finalPath = ''
|
||||
let finalName = file.origin_name.replace('.pdf', '.md')
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(outputPath)
|
||||
|
||||
const mdFile = files.find((f) => f.endsWith('.md'))
|
||||
if (mdFile) {
|
||||
const originalMdPath = path.join(outputPath, mdFile)
|
||||
const newMdPath = path.join(outputPath, finalName)
|
||||
|
||||
// 重命名文件为原始文件名
|
||||
try {
|
||||
fs.renameSync(originalMdPath, newMdPath)
|
||||
finalPath = newMdPath
|
||||
Logger.info(`Renamed markdown file from ${mdFile} to ${finalName}`)
|
||||
} catch (renameError) {
|
||||
Logger.warn(`Failed to rename file ${mdFile} to ${finalName}: ${renameError}`)
|
||||
// 如果重命名失败,使用原文件
|
||||
finalPath = originalMdPath
|
||||
finalName = mdFile
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(`Failed to read output directory ${outputPath}: ${error}`)
|
||||
finalPath = path.join(outputPath, `${file.id}.md`)
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
name: finalName,
|
||||
path: finalPath,
|
||||
ext: '.md',
|
||||
size: fs.existsSync(finalPath) ? fs.statSync(finalPath).size : 0
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAndExtractFile(zipUrl: string, file: FileMetadata): Promise<{ path: string }> {
|
||||
const dirPath = this.storageDir
|
||||
|
||||
const zipPath = path.join(dirPath, `${file.id}.zip`)
|
||||
const extractPath = path.join(dirPath, `${file.id}`)
|
||||
|
||||
Logger.info(`Downloading MinerU result to: ${zipPath}`)
|
||||
|
||||
try {
|
||||
// 下载ZIP文件
|
||||
const response = await axios.get(zipUrl, { responseType: 'arraybuffer' })
|
||||
fs.writeFileSync(zipPath, response.data)
|
||||
Logger.info(`Downloaded ZIP file: ${zipPath}`)
|
||||
|
||||
// 确保提取目录存在
|
||||
if (!fs.existsSync(extractPath)) {
|
||||
fs.mkdirSync(extractPath, { recursive: true })
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
const zip = new AdmZip(zipPath)
|
||||
zip.extractAllTo(extractPath, true)
|
||||
Logger.info(`Extracted files to: ${extractPath}`)
|
||||
|
||||
// 删除临时ZIP文件
|
||||
fs.unlinkSync(zipPath)
|
||||
|
||||
return { path: extractPath }
|
||||
} catch (error: any) {
|
||||
Logger.error(`Failed to download and extract file: ${error.message}`)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileMetadata): Promise<string> {
|
||||
try {
|
||||
// 步骤1: 获取上传URL
|
||||
const { batchId, fileUrls } = await this.getBatchUploadUrls(file)
|
||||
Logger.info(`Got upload URLs for batch: ${batchId}`)
|
||||
|
||||
console.log('batchId:', batchId, 'fileurls:', fileUrls)
|
||||
// 步骤2: 上传文件到获取的URL
|
||||
await this.putFileToUrl(file.path, fileUrls[0])
|
||||
Logger.info(`File uploaded successfully: ${file.path}`)
|
||||
|
||||
return batchId
|
||||
} catch (error: any) {
|
||||
Logger.error(`Failed to upload file ${file.path}: ${error.message}`)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async getBatchUploadUrls(file: FileMetadata): Promise<{ batchId: string; fileUrls: string[] }> {
|
||||
const endpoint = `${this.provider.apiHost}/api/v4/file-urls/batch`
|
||||
|
||||
const payload = {
|
||||
language: 'auto',
|
||||
enable_formula: true,
|
||||
enable_table: true,
|
||||
files: [
|
||||
{
|
||||
name: file.origin_name,
|
||||
is_ocr: true,
|
||||
data_id: file.id
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.provider.apiKey}`,
|
||||
token: this.userId ?? '',
|
||||
Accept: '*/*'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: ApiResponse<BatchUploadResponse> = await response.json()
|
||||
if (data.code === 0 && data.data) {
|
||||
const { batch_id, file_urls } = data.data
|
||||
return {
|
||||
batchId: batch_id,
|
||||
fileUrls: file_urls
|
||||
}
|
||||
} else {
|
||||
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
Logger.error(`Failed to get batch upload URLs: ${error.message}`)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async putFileToUrl(filePath: string, uploadUrl: string): Promise<void> {
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(filePath)
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: fileBuffer,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf'
|
||||
}
|
||||
// headers: {
|
||||
// 'Content-Length': fileBuffer.length.toString()
|
||||
// }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// 克隆 response 以避免消费 body stream
|
||||
const responseClone = response.clone()
|
||||
|
||||
try {
|
||||
const responseBody = await responseClone.text()
|
||||
const errorInfo = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: response.url,
|
||||
type: response.type,
|
||||
redirected: response.redirected,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseBody
|
||||
}
|
||||
|
||||
console.error('Response details:', errorInfo)
|
||||
throw new Error(`Upload failed with status ${response.status}: ${responseBody}`)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Upload failed with status ${response.status}. Could not parse response body.`)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`File uploaded successfully to: ${uploadUrl}`)
|
||||
} catch (error: any) {
|
||||
Logger.error(`Failed to upload file to URL ${uploadUrl}: ${error}`)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async getExtractResults(batchId: string): Promise<ExtractResultResponse> {
|
||||
const endpoint = `${this.provider.apiHost}/api/v4/extract-results/batch/${batchId}`
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.provider.apiKey}`,
|
||||
token: this.userId ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: ApiResponse<ExtractResultResponse> = await response.json()
|
||||
if (data.code === 0 && data.data) {
|
||||
return data.data
|
||||
} else {
|
||||
throw new Error(`API returned error: ${data.msg || JSON.stringify(data)}`)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
Logger.error(`Failed to get extract results for batch ${batchId}: ${error.message}`)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForCompletion(
|
||||
sourceId: string,
|
||||
batchId: string,
|
||||
fileName: string,
|
||||
maxRetries: number = 60,
|
||||
intervalMs: number = 5000
|
||||
): Promise<ExtractFileResult> {
|
||||
let retries = 0
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const result = await this.getExtractResults(batchId)
|
||||
|
||||
// 查找对应文件的处理结果
|
||||
const fileResult = result.extract_result.find((item) => item.file_name === fileName)
|
||||
if (!fileResult) {
|
||||
throw new Error(`File ${fileName} not found in batch results`)
|
||||
}
|
||||
|
||||
// 检查处理状态
|
||||
if (fileResult.state === 'done' && fileResult.full_zip_url) {
|
||||
Logger.info(`Processing completed for file: ${fileName}`)
|
||||
return fileResult
|
||||
} else if (fileResult.state === 'failed') {
|
||||
throw new Error(`Processing failed for file: ${fileName}, error: ${fileResult.err_msg}`)
|
||||
} else if (fileResult.state === 'running') {
|
||||
// 发送进度更新
|
||||
if (fileResult.extract_progress) {
|
||||
const progress = Math.round(
|
||||
(fileResult.extract_progress.extracted_pages / fileResult.extract_progress.total_pages) * 100
|
||||
)
|
||||
await this.sendPreprocessProgress(sourceId, progress)
|
||||
Logger.info(`File ${fileName} processing progress: ${progress}%`)
|
||||
} else {
|
||||
// 如果没有具体进度信息,发送一个通用进度
|
||||
await this.sendPreprocessProgress(sourceId, 50)
|
||||
Logger.info(`File ${fileName} is still processing...`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(`Failed to check status for batch ${batchId}, retry ${retries + 1}/${maxRetries}`)
|
||||
if (retries === maxRetries - 1) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
retries++
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||
}
|
||||
|
||||
throw new Error(`Processing timeout for batch: ${batchId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { MistralClientManager } from '@main/services/MistralClientManager'
|
||||
import { MistralService } from '@main/services/remotefile/MistralService'
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk'
|
||||
import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk'
|
||||
import { OCRResponse } from '@mistralai/mistralai/models/components/ocrresponse'
|
||||
import { FileMetadata, FileTypes, PreprocessProvider, Provider } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import path from 'path'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
|
||||
type PreuploadResponse = DocumentURLChunk | ImageURLChunk
|
||||
|
||||
export default class MistralPreprocessProvider extends BasePreprocessProvider {
|
||||
private sdk: Mistral
|
||||
private fileService: MistralService
|
||||
|
||||
constructor(provider: PreprocessProvider) {
|
||||
super(provider)
|
||||
const clientManager = MistralClientManager.getInstance()
|
||||
const aiProvider: Provider = {
|
||||
id: provider.id,
|
||||
type: 'mistral',
|
||||
name: provider.name,
|
||||
apiKey: provider.apiKey!,
|
||||
apiHost: provider.apiHost!,
|
||||
models: []
|
||||
}
|
||||
clientManager.initializeClient(aiProvider)
|
||||
this.sdk = clientManager.getClient()
|
||||
this.fileService = new MistralService(aiProvider)
|
||||
}
|
||||
|
||||
private async preupload(file: FileMetadata): Promise<PreuploadResponse> {
|
||||
let document: PreuploadResponse
|
||||
Logger.info(`preprocess preupload started for local file: ${file.path}`)
|
||||
|
||||
if (file.ext.toLowerCase() === '.pdf') {
|
||||
const uploadResponse = await this.fileService.uploadFile(file)
|
||||
|
||||
if (uploadResponse.status === 'failed') {
|
||||
Logger.error('File upload failed:', uploadResponse)
|
||||
throw new Error('Failed to upload file: ' + uploadResponse.displayName)
|
||||
}
|
||||
await this.sendPreprocessProgress(file.id, 15)
|
||||
const fileUrl = await this.sdk.files.getSignedUrl({
|
||||
fileId: uploadResponse.fileId
|
||||
})
|
||||
Logger.info('Got signed URL:', fileUrl)
|
||||
await this.sendPreprocessProgress(file.id, 20)
|
||||
document = {
|
||||
type: 'document_url',
|
||||
documentUrl: fileUrl.url
|
||||
}
|
||||
} else {
|
||||
const base64Image = Buffer.from(fs.readFileSync(file.path)).toString('base64')
|
||||
document = {
|
||||
type: 'image_url',
|
||||
imageUrl: `data:image/png;base64,${base64Image}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Unsupported file type')
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
public async parseFile(sourceId: string, file: FileMetadata): Promise<{ processedFile: FileMetadata }> {
|
||||
try {
|
||||
const document = await this.preupload(file)
|
||||
const result = await this.sdk.ocr.process({
|
||||
model: this.provider.model!,
|
||||
document: document,
|
||||
includeImageBase64: true
|
||||
})
|
||||
if (result) {
|
||||
await this.sendPreprocessProgress(sourceId, 100)
|
||||
const processedFile = this.convertFile(result, file)
|
||||
return {
|
||||
processedFile
|
||||
}
|
||||
} else {
|
||||
throw new Error('preprocess processing failed: OCR response is empty')
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('preprocess processing failed: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
private convertFile(result: OCRResponse, file: FileMetadata): FileMetadata {
|
||||
// 使用统一的存储路径:Data/Files/{file.id}/
|
||||
const conversionId = file.id
|
||||
const outputPath = path.join(this.storageDir, file.id)
|
||||
// const outputPath = this.storageDir
|
||||
const outputFileName = path.basename(file.path, path.extname(file.path))
|
||||
fs.mkdirSync(outputPath, { recursive: true })
|
||||
|
||||
const markdownParts: string[] = []
|
||||
let counter = 0
|
||||
|
||||
// Process each page
|
||||
result.pages.forEach((page) => {
|
||||
let pageMarkdown = page.markdown
|
||||
|
||||
// Process images from this page
|
||||
page.images.forEach((image) => {
|
||||
if (image.imageBase64) {
|
||||
let imageFormat = 'jpeg' // default format
|
||||
let imageBase64Data = image.imageBase64
|
||||
|
||||
// Check for data URL prefix more efficiently
|
||||
const prefixEnd = image.imageBase64.indexOf(';base64,')
|
||||
if (prefixEnd > 0) {
|
||||
const prefix = image.imageBase64.substring(0, prefixEnd)
|
||||
const formatIndex = prefix.indexOf('image/')
|
||||
if (formatIndex >= 0) {
|
||||
imageFormat = prefix.substring(formatIndex + 6)
|
||||
}
|
||||
imageBase64Data = image.imageBase64.substring(prefixEnd + 8)
|
||||
}
|
||||
|
||||
const imageFileName = `img-${counter}.${imageFormat}`
|
||||
const imagePath = path.join(outputPath, imageFileName)
|
||||
|
||||
// Save image file
|
||||
try {
|
||||
fs.writeFileSync(imagePath, Buffer.from(imageBase64Data, 'base64'))
|
||||
|
||||
// Update image reference in markdown
|
||||
// Use relative path for better portability
|
||||
const relativeImagePath = `./${imageFileName}`
|
||||
|
||||
// Find the start and end of the image markdown
|
||||
const imgStart = pageMarkdown.indexOf(image.imageBase64)
|
||||
if (imgStart >= 0) {
|
||||
// Find the markdown image syntax around this base64
|
||||
const mdStart = pageMarkdown.lastIndexOf('` +
|
||||
pageMarkdown.substring(mdEnd + 1)
|
||||
}
|
||||
}
|
||||
|
||||
counter++
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to save image ${imageFileName}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
markdownParts.push(pageMarkdown)
|
||||
})
|
||||
|
||||
// Combine all markdown content with double newlines for readability
|
||||
const combinedMarkdown = markdownParts.join('\n\n')
|
||||
|
||||
// Write the markdown content to a file
|
||||
const mdFileName = `${outputFileName}.md`
|
||||
const mdFilePath = path.join(outputPath, mdFileName)
|
||||
fs.writeFileSync(mdFilePath, combinedMarkdown)
|
||||
|
||||
return {
|
||||
id: conversionId,
|
||||
name: file.name.replace(/\.[^/.]+$/, '.md'),
|
||||
origin_name: file.origin_name,
|
||||
path: mdFilePath,
|
||||
created_at: new Date().toISOString(),
|
||||
type: FileTypes.DOCUMENT,
|
||||
ext: '.md',
|
||||
size: fs.statSync(mdFilePath).size,
|
||||
count: 1
|
||||
} as FileMetadata
|
||||
}
|
||||
|
||||
public checkQuota(): Promise<number> {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FileMetadata, PreprocessProvider as Provider } from '@types'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
import PreprocessProviderFactory from './PreprocessProviderFactory'
|
||||
|
||||
export default class PreprocessProvider {
|
||||
private sdk: BasePreprocessProvider
|
||||
constructor(provider: Provider, userId?: string) {
|
||||
this.sdk = PreprocessProviderFactory.create(provider, userId)
|
||||
}
|
||||
public async parseFile(
|
||||
sourceId: string,
|
||||
file: FileMetadata
|
||||
): Promise<{ processedFile: FileMetadata; quota?: number }> {
|
||||
return this.sdk.parseFile(sourceId, file)
|
||||
}
|
||||
|
||||
public async checkQuota(): Promise<number> {
|
||||
return this.sdk.checkQuota()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否已经被预处理过
|
||||
* @param file 文件信息
|
||||
* @returns 如果已处理返回处理后的文件信息,否则返回null
|
||||
*/
|
||||
public async checkIfAlreadyProcessed(file: FileMetadata): Promise<FileMetadata | null> {
|
||||
return this.sdk.checkIfAlreadyProcessed(file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PreprocessProvider } from '@types'
|
||||
|
||||
import BasePreprocessProvider from './BasePreprocessProvider'
|
||||
import DefaultPreprocessProvider from './DefaultPreprocessProvider'
|
||||
import Doc2xPreprocessProvider from './Doc2xPreprocessProvider'
|
||||
import MineruPreprocessProvider from './MineruPreprocessProvider'
|
||||
import MistralPreprocessProvider from './MistralPreprocessProvider'
|
||||
export default class PreprocessProviderFactory {
|
||||
static create(provider: PreprocessProvider, userId?: string): BasePreprocessProvider {
|
||||
switch (provider.id) {
|
||||
case 'doc2x':
|
||||
return new Doc2xPreprocessProvider(provider)
|
||||
case 'mistral':
|
||||
return new MistralPreprocessProvider(provider)
|
||||
case 'mineru':
|
||||
return new MineruPreprocessProvider(provider, userId)
|
||||
default:
|
||||
return new DefaultPreprocessProvider(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
export class AppService {
|
||||
private static instance: AppService
|
||||
|
||||
private constructor() {
|
||||
// Private constructor to prevent direct instantiation
|
||||
}
|
||||
|
||||
public static getInstance(): AppService {
|
||||
if (!AppService.instance) {
|
||||
AppService.instance = new AppService()
|
||||
}
|
||||
return AppService.instance
|
||||
}
|
||||
|
||||
public async setAppLaunchOnBoot(isLaunchOnBoot: boolean): Promise<void> {
|
||||
// Set login item settings for windows and mac
|
||||
// linux is not supported because it requires more file operations
|
||||
if (isWin || isMac) {
|
||||
app.setLoginItemSettings({ openAtLogin: isLaunchOnBoot })
|
||||
} else if (isLinux) {
|
||||
try {
|
||||
const autostartDir = path.join(os.homedir(), '.config', 'autostart')
|
||||
const desktopFile = path.join(autostartDir, isDev ? 'cherry-studio-dev.desktop' : 'cherry-studio.desktop')
|
||||
|
||||
if (isLaunchOnBoot) {
|
||||
// Ensure autostart directory exists
|
||||
try {
|
||||
await fs.promises.access(autostartDir)
|
||||
} catch {
|
||||
await fs.promises.mkdir(autostartDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Get executable path
|
||||
let executablePath = app.getPath('exe')
|
||||
if (process.env.APPIMAGE) {
|
||||
// For AppImage packaged apps, use APPIMAGE environment variable
|
||||
executablePath = process.env.APPIMAGE
|
||||
}
|
||||
|
||||
// Create desktop file content
|
||||
const desktopContent = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Cherry Studio
|
||||
Comment=A powerful AI assistant for producer.
|
||||
Exec=${executablePath}
|
||||
Icon=cherrystudio
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
Categories=Development;Utility;
|
||||
X-GNOME-Autostart-enabled=true
|
||||
Hidden=false`
|
||||
|
||||
// Write desktop file
|
||||
await fs.promises.writeFile(desktopFile, desktopContent)
|
||||
log.info('Created autostart desktop file for Linux')
|
||||
} else {
|
||||
// Remove desktop file
|
||||
try {
|
||||
await fs.promises.access(desktopFile)
|
||||
await fs.promises.unlink(desktopFile)
|
||||
log.info('Removed autostart desktop file for Linux')
|
||||
} catch {
|
||||
// File doesn't exist, no need to remove
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to set launch on boot for Linux:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default export as singleton instance
|
||||
export default AppService.getInstance()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { WebDavConfig } from '@types'
|
||||
import { S3Config } from '@types'
|
||||
import archiver from 'archiver'
|
||||
import { exec } from 'child_process'
|
||||
import { app } from 'electron'
|
||||
@@ -10,6 +11,7 @@ import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import S3Storage from './S3Storage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -25,6 +27,16 @@ class BackupManager {
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
||||
this.deleteWebdavFile = this.deleteWebdavFile.bind(this)
|
||||
this.listLocalBackupFiles = this.listLocalBackupFiles.bind(this)
|
||||
this.deleteLocalBackupFile = this.deleteLocalBackupFile.bind(this)
|
||||
this.backupToLocalDir = this.backupToLocalDir.bind(this)
|
||||
this.restoreFromLocalBackup = this.restoreFromLocalBackup.bind(this)
|
||||
this.setLocalBackupDir = this.setLocalBackupDir.bind(this)
|
||||
this.backupToS3 = this.backupToS3.bind(this)
|
||||
this.restoreFromS3 = this.restoreFromS3.bind(this)
|
||||
this.listS3Files = this.listS3Files.bind(this)
|
||||
this.deleteS3File = this.deleteS3File.bind(this)
|
||||
this.checkS3Connection = this.checkS3Connection.bind(this)
|
||||
}
|
||||
|
||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||
@@ -85,7 +97,11 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
|
||||
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -147,18 +163,23 @@ class BackupManager {
|
||||
let totalBytes = 0
|
||||
let processedBytes = 0
|
||||
|
||||
// 首先计算总文件数和总大小
|
||||
// 首先计算总文件数和总大小,但不记录详细日志
|
||||
const calculateTotals = async (dirPath: string) => {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await calculateTotals(fullPath)
|
||||
} else {
|
||||
totalEntries++
|
||||
const stats = await fs.stat(fullPath)
|
||||
totalBytes += stats.size
|
||||
try {
|
||||
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dirPath, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await calculateTotals(fullPath)
|
||||
} else {
|
||||
totalEntries++
|
||||
const stats = await fs.stat(fullPath)
|
||||
totalBytes += stats.size
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 仅在出错时记录日志
|
||||
Logger.error('[BackupManager] Error calculating totals:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +251,11 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
// 只在关键阶段记录日志
|
||||
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -382,21 +407,54 @@ class BackupManager {
|
||||
destination: string,
|
||||
onProgress: (size: number) => void
|
||||
): Promise<void> {
|
||||
const items = await fs.readdir(source, { withFileTypes: true })
|
||||
// 先统计总文件数
|
||||
let totalFiles = 0
|
||||
let processedFiles = 0
|
||||
let lastProgressReported = 0
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(source, item.name)
|
||||
const destPath = path.join(destination, item.name)
|
||||
// 计算总文件数
|
||||
const countFiles = async (dir: string): Promise<number> => {
|
||||
let count = 0
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) {
|
||||
count += await countFiles(path.join(dir, item.name))
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await this.copyDirWithProgress(sourcePath, destPath, onProgress)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
onProgress(stats.size)
|
||||
totalFiles = await countFiles(source)
|
||||
|
||||
// 复制文件并更新进度
|
||||
const copyDir = async (src: string, dest: string): Promise<void> => {
|
||||
const items = await fs.readdir(src, { withFileTypes: true })
|
||||
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(src, item.name)
|
||||
const destPath = path.join(dest, item.name)
|
||||
|
||||
if (item.isDirectory()) {
|
||||
await fs.ensureDir(destPath)
|
||||
await copyDir(sourcePath, destPath)
|
||||
} else {
|
||||
const stats = await fs.stat(sourcePath)
|
||||
await fs.copy(sourcePath, destPath)
|
||||
processedFiles++
|
||||
|
||||
// 只在进度变化超过5%时报告进度
|
||||
const currentProgress = Math.floor((processedFiles / totalFiles) * 100)
|
||||
if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) {
|
||||
lastProgressReported = currentProgress
|
||||
onProgress(stats.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await copyDir(source, destination)
|
||||
}
|
||||
|
||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
@@ -423,6 +481,191 @@ class BackupManager {
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
|
||||
async backupToLocalDir(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
data: string,
|
||||
fileName: string,
|
||||
localConfig: {
|
||||
localBackupDir: string
|
||||
skipBackupFile: boolean
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const backupDir = localConfig.localBackupDir
|
||||
// Create backup directory if it doesn't exist
|
||||
await fs.ensureDir(backupDir)
|
||||
|
||||
const backupedFilePath = await this.backup(_, fileName, data, backupDir, localConfig.skipBackupFile)
|
||||
return backupedFilePath
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Local backup failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||
const os = require('os')
|
||||
const deviceName = os.hostname ? os.hostname() : 'device'
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T.Z]/g, '')
|
||||
.slice(0, 14)
|
||||
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
|
||||
|
||||
Logger.log(`[BackupManager] Starting S3 backup to ${filename}`)
|
||||
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile)
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
try {
|
||||
const fileBuffer = await fs.promises.readFile(backupedFilePath)
|
||||
const result = await s3Client.putFileContents(filename, fileBuffer)
|
||||
await fs.remove(backupedFilePath)
|
||||
|
||||
Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`)
|
||||
return result
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] S3 backup failed:`, error)
|
||||
await fs.remove(backupedFilePath)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restoreFromLocalBackup(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
|
||||
try {
|
||||
const backupDir = localBackupDir
|
||||
const backupPath = path.join(backupDir, fileName)
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`)
|
||||
}
|
||||
|
||||
return await this.restore(_, backupPath)
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Local restore failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async listLocalBackupFiles(_: Electron.IpcMainInvokeEvent, localBackupDir: string) {
|
||||
try {
|
||||
const files = await fs.readdir(localBackupDir)
|
||||
const result: Array<{ fileName: string; modifiedTime: string; size: number }> = []
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(localBackupDir, file)
|
||||
const stat = await fs.stat(filePath)
|
||||
|
||||
if (stat.isFile() && file.endsWith('.zip')) {
|
||||
result.push({
|
||||
fileName: file,
|
||||
modifiedTime: stat.mtime.toISOString(),
|
||||
size: stat.size
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modified time, newest first
|
||||
return result.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] List local backup files failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLocalBackupFile(_: Electron.IpcMainInvokeEvent, fileName: string, localBackupDir: string) {
|
||||
try {
|
||||
const filePath = path.join(localBackupDir, fileName)
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Backup file not found: ${filePath}`)
|
||||
}
|
||||
|
||||
await fs.remove(filePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Delete local backup file failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setLocalBackupDir(_: Electron.IpcMainInvokeEvent, dirPath: string) {
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fs.ensureDir(dirPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Set local backup directory failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||
|
||||
Logger.log(`[BackupManager] Starting restore from S3: ${filename}`)
|
||||
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
try {
|
||||
const retrievedFile = await s3Client.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(backupedFilePath)
|
||||
writeStream.write(retrievedFile as Buffer)
|
||||
writeStream.end()
|
||||
writeStream.on('finish', () => resolve())
|
||||
writeStream.on('error', (error) => reject(error))
|
||||
})
|
||||
|
||||
Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`)
|
||||
return await this.restore(_, backupedFilePath)
|
||||
} catch (error: any) {
|
||||
Logger.error('[BackupManager] Failed to restore from S3:', error)
|
||||
throw new Error(error.message || 'Failed to restore backup file')
|
||||
}
|
||||
}
|
||||
|
||||
listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => {
|
||||
try {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
|
||||
const objects = await s3Client.listFiles()
|
||||
const files = objects
|
||||
.filter((obj) => obj.key.endsWith('.zip'))
|
||||
.map((obj) => {
|
||||
const segments = obj.key.split('/')
|
||||
const fileName = segments[segments.length - 1]
|
||||
return {
|
||||
fileName,
|
||||
modifiedTime: obj.lastModified || '',
|
||||
size: obj.size
|
||||
}
|
||||
})
|
||||
|
||||
return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to list S3 files:', error)
|
||||
throw new Error(error.message || 'Failed to list backup files')
|
||||
}
|
||||
}
|
||||
|
||||
async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) {
|
||||
try {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
return await s3Client.deleteFile(fileName)
|
||||
} catch (error: any) {
|
||||
Logger.error('Failed to delete S3 file:', error)
|
||||
throw new Error(error.message || 'Failed to delete backup file')
|
||||
}
|
||||
}
|
||||
|
||||
async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) {
|
||||
const s3Client = new S3Storage(s3Config)
|
||||
return await s3Client.checkConnection()
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@@ -24,7 +24,8 @@ export enum ConfigKeys {
|
||||
SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar',
|
||||
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
|
||||
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList'
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList',
|
||||
DisableHardwareAcceleration = 'disableHardwareAcceleration'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
@@ -218,6 +219,14 @@ export class ConfigManager {
|
||||
this.setAndNotify(ConfigKeys.SelectionAssistantFilterList, value)
|
||||
}
|
||||
|
||||
getDisableHardwareAcceleration(): boolean {
|
||||
return this.get<boolean>(ConfigKeys.DisableHardwareAcceleration, false)
|
||||
}
|
||||
|
||||
setDisableHardwareAcceleration(value: boolean) {
|
||||
this.set(ConfigKeys.DisableHardwareAcceleration, value)
|
||||
}
|
||||
|
||||
setAndNotify(key: string, value: unknown) {
|
||||
this.set(key, value, true)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getFilesDir, getFileType, getTempDir } from '@main/utils/file'
|
||||
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
|
||||
import { documentExts, imageExts, MB } from '@shared/config/constant'
|
||||
import { FileType } from '@types'
|
||||
import { FileMetadata } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import {
|
||||
dialog,
|
||||
@@ -53,8 +53,9 @@ class FileStorage {
|
||||
})
|
||||
}
|
||||
|
||||
findDuplicateFile = async (filePath: string): Promise<FileType | null> => {
|
||||
findDuplicateFile = async (filePath: string): Promise<FileMetadata | null> => {
|
||||
const stats = fs.statSync(filePath)
|
||||
console.log('stats', stats, filePath)
|
||||
const fileSize = stats.size
|
||||
|
||||
const files = await fs.promises.readdir(this.storageDir)
|
||||
@@ -92,7 +93,7 @@ class FileStorage {
|
||||
public selectFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options?: OpenDialogOptions
|
||||
): Promise<FileType[] | null> => {
|
||||
): Promise<FileMetadata[] | null> => {
|
||||
const defaultOptions: OpenDialogOptions = {
|
||||
properties: ['openFile']
|
||||
}
|
||||
@@ -151,7 +152,7 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise<FileType> => {
|
||||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
|
||||
const duplicateFile = await this.findDuplicateFile(file.path)
|
||||
|
||||
if (duplicateFile) {
|
||||
@@ -175,7 +176,7 @@ class FileStorage {
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: uuid,
|
||||
origin_name,
|
||||
name: uuid + ext,
|
||||
@@ -187,10 +188,12 @@ class FileStorage {
|
||||
count: 1
|
||||
}
|
||||
|
||||
logger.info('[FileStorage] File uploaded:', fileMetadata)
|
||||
|
||||
return fileMetadata
|
||||
}
|
||||
|
||||
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileType | null> => {
|
||||
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileMetadata | null> => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
@@ -199,7 +202,7 @@ class FileStorage {
|
||||
const ext = path.extname(filePath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileInfo: FileType = {
|
||||
const fileInfo: FileMetadata = {
|
||||
id: uuidv4(),
|
||||
origin_name: path.basename(filePath),
|
||||
name: path.basename(filePath),
|
||||
@@ -215,10 +218,24 @@ class FileStorage {
|
||||
}
|
||||
|
||||
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||
if (!fs.existsSync(path.join(this.storageDir, id))) {
|
||||
return
|
||||
}
|
||||
await fs.promises.unlink(path.join(this.storageDir, id))
|
||||
}
|
||||
|
||||
public readFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<string> => {
|
||||
public deleteDir = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||
if (!fs.existsSync(path.join(this.storageDir, id))) {
|
||||
return
|
||||
}
|
||||
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
|
||||
}
|
||||
|
||||
public readFile = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
id: string,
|
||||
detectEncoding: boolean = false
|
||||
): Promise<string> => {
|
||||
const filePath = path.join(this.storageDir, id)
|
||||
|
||||
const fileExtension = path.extname(filePath)
|
||||
@@ -245,15 +262,24 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
try {
|
||||
if (detectEncoding) {
|
||||
return readTextFileWithAutoEncoding(filePath)
|
||||
} else {
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return 'failed to read file'
|
||||
}
|
||||
}
|
||||
|
||||
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
const tempFilePath = path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||
return tempFilePath
|
||||
|
||||
return path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||||
}
|
||||
|
||||
public writeFile = async (
|
||||
@@ -280,7 +306,7 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileType> => {
|
||||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileMetadata> => {
|
||||
try {
|
||||
if (!base64Data) {
|
||||
throw new Error('Base64 data is required')
|
||||
@@ -306,7 +332,7 @@ class FileStorage {
|
||||
|
||||
await fs.promises.writeFile(destPath, buffer)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: uuid,
|
||||
origin_name: uuid + ext,
|
||||
name: uuid + ext,
|
||||
@@ -398,6 +424,19 @@ class FileStorage {
|
||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过相对路径打开文件,跨设备时使用
|
||||
* @param file
|
||||
*/
|
||||
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
|
||||
const filePath = path.join(this.storageDir, file.name)
|
||||
if (fs.existsSync(filePath)) {
|
||||
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
} else {
|
||||
logger.warn('[IPC - Warning] File does not exist:', filePath)
|
||||
}
|
||||
}
|
||||
|
||||
public save = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
@@ -465,7 +504,7 @@ class FileStorage {
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
url: string,
|
||||
isUseContentType?: boolean
|
||||
): Promise<FileType> => {
|
||||
): Promise<FileMetadata> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
@@ -507,7 +546,7 @@ class FileStorage {
|
||||
const stats = await fs.promises.stat(destPath)
|
||||
const fileType = getFileType(ext)
|
||||
|
||||
const fileMetadata: FileType = {
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: uuid,
|
||||
origin_name: filename,
|
||||
name: uuid + ext,
|
||||
|
||||
@@ -24,6 +24,8 @@ import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/knowledage/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledage/loader'
|
||||
import { NoteLoader } from '@main/knowledage/loader/noteLoader'
|
||||
import OcrProvider from '@main/knowledage/ocr/OcrProvider'
|
||||
import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledage/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
@@ -31,7 +33,7 @@ import { getAllFiles } from '@main/utils/file'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { FileMetadata, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -39,12 +41,14 @@ export interface KnowledgeBaseAddItemOptions {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload?: boolean
|
||||
userId?: string
|
||||
}
|
||||
|
||||
interface KnowledgeBaseAddItemOptionsNonNullableAttribute {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
forceReload: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface EvaluateTaskWorkload {
|
||||
@@ -96,7 +100,13 @@ class KnowledgeService {
|
||||
private knowledgeItemProcessingQueueMappingPromise: Map<LoaderTaskOfSet, () => void> = new Map()
|
||||
private static MAXIMUM_WORKLOAD = 80 * MB
|
||||
private static MAXIMUM_PROCESSING_ITEM_COUNT = 30
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = { entriesAdded: 0, uniqueId: '', uniqueIds: [''], loaderType: '' }
|
||||
private static ERROR_LOADER_RETURN: LoaderReturn = {
|
||||
entriesAdded: 0,
|
||||
uniqueId: '',
|
||||
uniqueIds: [''],
|
||||
loaderType: '',
|
||||
status: 'failed'
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.initStorageDir()
|
||||
@@ -150,6 +160,7 @@ class KnowledgeService {
|
||||
}
|
||||
|
||||
public delete = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||||
console.log('id', id)
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
@@ -162,28 +173,49 @@ class KnowledgeService {
|
||||
this.workload >= KnowledgeService.MAXIMUM_WORKLOAD
|
||||
)
|
||||
}
|
||||
|
||||
private fileTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
): LoaderTask {
|
||||
const { base, item, forceReload } = options
|
||||
const file = item.content as FileType
|
||||
const { base, item, forceReload, userId } = options
|
||||
const file = item.content as FileMetadata
|
||||
|
||||
const loaderTask: LoaderTask = {
|
||||
loaderTasks: [
|
||||
{
|
||||
state: LoaderTaskItemState.PENDING,
|
||||
task: () =>
|
||||
addFileLoader(ragApplication, file, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
}),
|
||||
task: async () => {
|
||||
try {
|
||||
// 添加预处理逻辑
|
||||
const fileToProcess: FileMetadata = await this.preprocessing(file, base, item, userId)
|
||||
|
||||
// 使用处理后的文件进行加载
|
||||
return addFileLoader(ragApplication, fileToProcess, base, forceReload)
|
||||
.then((result) => {
|
||||
loaderTask.loaderDoneReturn = result
|
||||
return result
|
||||
})
|
||||
.catch((e) => {
|
||||
Logger.error(`Error in addFileLoader for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
})
|
||||
} catch (e: any) {
|
||||
Logger.error(`Preprocessing failed for ${file.name}: ${e}`)
|
||||
const errorResult: LoaderReturn = {
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: e.message,
|
||||
messageSource: 'preprocess'
|
||||
}
|
||||
loaderTask.loaderDoneReturn = errorResult
|
||||
return errorResult
|
||||
}
|
||||
},
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
}
|
||||
],
|
||||
@@ -192,7 +224,6 @@ class KnowledgeService {
|
||||
|
||||
return loaderTask
|
||||
}
|
||||
|
||||
private directoryTask(
|
||||
ragApplication: RAGApplication,
|
||||
options: KnowledgeBaseAddItemOptionsNonNullableAttribute
|
||||
@@ -232,7 +263,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
return {
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add dir loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: file.size }
|
||||
})
|
||||
@@ -278,7 +313,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
return {
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add url loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: 2 * MB }
|
||||
@@ -318,7 +357,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
return {
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add sitemap loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
}),
|
||||
evaluateTaskWorkload: { workload: 20 * MB }
|
||||
}
|
||||
@@ -364,7 +407,11 @@ class KnowledgeService {
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
return KnowledgeService.ERROR_LOADER_RETURN
|
||||
return {
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add note loader: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
}
|
||||
})
|
||||
},
|
||||
evaluateTaskWorkload: { workload: contentBytes.length }
|
||||
@@ -430,10 +477,10 @@ class KnowledgeService {
|
||||
})
|
||||
}
|
||||
|
||||
public add = (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise<LoaderReturn> => {
|
||||
return new Promise((resolve) => {
|
||||
const { base, item, forceReload = false } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload }
|
||||
const { base, item, forceReload = false, userId = '' } = options
|
||||
const optionsNonNullableAttribute = { base, item, forceReload, userId }
|
||||
this.getRagApplication(base)
|
||||
.then((ragApplication) => {
|
||||
const task = (() => {
|
||||
@@ -459,12 +506,20 @@ class KnowledgeService {
|
||||
})
|
||||
this.processingQueueHandle()
|
||||
} else {
|
||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: 'Unsupported item type',
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.error(err)
|
||||
resolve(KnowledgeService.ERROR_LOADER_RETURN)
|
||||
resolve({
|
||||
...KnowledgeService.ERROR_LOADER_RETURN,
|
||||
message: `Failed to add item: ${err.message}`,
|
||||
messageSource: 'embedding'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -497,6 +552,69 @@ class KnowledgeService {
|
||||
}
|
||||
return await new Reranker(base).rerank(search, results)
|
||||
}
|
||||
|
||||
public getStorageDir = (): string => {
|
||||
return this.storageDir
|
||||
}
|
||||
|
||||
private preprocessing = async (
|
||||
file: FileMetadata,
|
||||
base: KnowledgeBaseParams,
|
||||
item: KnowledgeItem,
|
||||
userId: string
|
||||
): Promise<FileMetadata> => {
|
||||
let fileToProcess: FileMetadata = file
|
||||
if (base.preprocessOrOcrProvider && file.ext.toLowerCase() === '.pdf') {
|
||||
try {
|
||||
let provider: PreprocessProvider | OcrProvider
|
||||
if (base.preprocessOrOcrProvider.type === 'preprocess') {
|
||||
provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId)
|
||||
} else {
|
||||
provider = new OcrProvider(base.preprocessOrOcrProvider.provider)
|
||||
}
|
||||
// 首先检查文件是否已经被预处理过
|
||||
const alreadyProcessed = await provider.checkIfAlreadyProcessed(file)
|
||||
if (alreadyProcessed) {
|
||||
Logger.info(`File already preprocess processed, using cached result: ${file.path}`)
|
||||
return alreadyProcessed
|
||||
}
|
||||
|
||||
// 执行预处理
|
||||
Logger.info(`Starting preprocess processing for scanned PDF: ${file.path}`)
|
||||
const { processedFile, quota } = await provider.parseFile(item.id, file)
|
||||
fileToProcess = processedFile
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send('file-preprocess-finished', {
|
||||
itemId: item.id,
|
||||
quota: quota
|
||||
})
|
||||
} catch (err) {
|
||||
Logger.error(`Preprocess processing failed: ${err}`)
|
||||
// 如果预处理失败,使用原始文件
|
||||
// fileToProcess = file
|
||||
throw new Error(`Preprocess processing failed: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return fileToProcess
|
||||
}
|
||||
|
||||
public checkQuota = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
base: KnowledgeBaseParams,
|
||||
userId: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
if (base.preprocessOrOcrProvider && base.preprocessOrOcrProvider.type === 'preprocess') {
|
||||
const provider = new PreprocessProvider(base.preprocessOrOcrProvider.provider, userId)
|
||||
return await provider.checkQuota()
|
||||
}
|
||||
throw new Error('No preprocess provider configured')
|
||||
} catch (err) {
|
||||
Logger.error(`Failed to check quota: ${err}`)
|
||||
throw new Error(`Failed to check quota: ${err}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new KnowledgeService()
|
||||
|
||||
@@ -28,6 +28,7 @@ import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
@@ -71,6 +72,7 @@ function withCache<T extends unknown[], R>(
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
private pendingClients: Map<string, Promise<Client>> = new Map()
|
||||
private activeToolCalls: Map<string, AbortController> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.initClient = this.initClient.bind(this)
|
||||
@@ -84,6 +86,7 @@ class McpService {
|
||||
this.removeServer = this.removeServer.bind(this)
|
||||
this.restartServer = this.restartServer.bind(this)
|
||||
this.stopServer = this.stopServer.bind(this)
|
||||
this.abortTool = this.abortTool.bind(this)
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
}
|
||||
|
||||
@@ -455,10 +458,14 @@ class McpService {
|
||||
*/
|
||||
public async callTool(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
||||
{ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }
|
||||
): Promise<MCPCallToolResponse> {
|
||||
const toolCallId = callId || uuidv4()
|
||||
const abortController = new AbortController()
|
||||
this.activeToolCalls.set(toolCallId, abortController)
|
||||
|
||||
try {
|
||||
Logger.info('[MCP] Calling:', server.name, name, args)
|
||||
Logger.info('[MCP] Calling:', server.name, name, args, 'callId:', toolCallId)
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args)
|
||||
@@ -468,12 +475,19 @@ class McpService {
|
||||
}
|
||||
const client = await this.initClient(server)
|
||||
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||
timeout: server.timeout ? server.timeout * 1000 : 60000 // Default timeout of 1 minute
|
||||
onprogress: (process) => {
|
||||
console.log('[MCP] Progress:', process.progress / (process.total || 1))
|
||||
window.api.mcp.setProgress(process.progress / (process.total || 1))
|
||||
},
|
||||
timeout: server.timeout ? server.timeout * 1000 : 60000, // Default timeout of 1 minute
|
||||
signal: this.activeToolCalls.get(toolCallId)?.signal
|
||||
})
|
||||
return result as MCPCallToolResponse
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
this.activeToolCalls.delete(toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +678,20 @@ class McpService {
|
||||
delete env.http_proxy
|
||||
delete env.https_proxy
|
||||
}
|
||||
|
||||
// 实现 abortTool 方法
|
||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||
const activeToolCall = this.activeToolCalls.get(callId)
|
||||
if (activeToolCall) {
|
||||
activeToolCall.abort()
|
||||
this.activeToolCalls.delete(callId)
|
||||
Logger.info(`[MCP] Aborted tool call: ${callId}`)
|
||||
return true
|
||||
} else {
|
||||
Logger.warn(`[MCP] No active tool call found for callId: ${callId}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
import { Provider } from '@types'
|
||||
|
||||
export class MistralClientManager {
|
||||
private static instance: MistralClientManager
|
||||
private client: Mistral | null = null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MistralClientManager {
|
||||
if (!MistralClientManager.instance) {
|
||||
MistralClientManager.instance = new MistralClientManager()
|
||||
}
|
||||
return MistralClientManager.instance
|
||||
}
|
||||
|
||||
public initializeClient(provider: Provider): void {
|
||||
if (!this.client) {
|
||||
this.client = new Mistral({
|
||||
apiKey: provider.apiKey,
|
||||
serverURL: provider.apiHost
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public getClient(): Mistral {
|
||||
if (!this.client) {
|
||||
throw new Error('Mistral client not initialized. Call initializeClient first.')
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// import Logger from 'electron-log'
|
||||
// import { Operator } from 'opendal'
|
||||
|
||||
// export default class RemoteStorage {
|
||||
// public instance: Operator | undefined
|
||||
|
||||
// /**
|
||||
// *
|
||||
// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk"
|
||||
// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options.
|
||||
// *
|
||||
// * For example, use minio as remote storage:
|
||||
// *
|
||||
// * ```typescript
|
||||
// * const storage = new RemoteStorage('s3', {
|
||||
// * endpoint: 'http://localhost:9000',
|
||||
// * region: 'us-east-1',
|
||||
// * bucket: 'testbucket',
|
||||
// * access_key_id: 'user',
|
||||
// * secret_access_key: 'password',
|
||||
// * root: '/path/to/basepath',
|
||||
// * })
|
||||
// * ```
|
||||
// */
|
||||
// constructor(scheme: string, options?: Record<string, string> | undefined | null) {
|
||||
// this.instance = new Operator(scheme, options)
|
||||
|
||||
// this.putFileContents = this.putFileContents.bind(this)
|
||||
// this.getFileContents = this.getFileContents.bind(this)
|
||||
// }
|
||||
|
||||
// public putFileContents = async (filename: string, data: string | Buffer) => {
|
||||
// if (!this.instance) {
|
||||
// return new Error('RemoteStorage client not initialized')
|
||||
// }
|
||||
|
||||
// try {
|
||||
// return await this.instance.write(filename, data)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error putting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
|
||||
// public getFileContents = async (filename: string) => {
|
||||
// if (!this.instance) {
|
||||
// throw new Error('RemoteStorage client not initialized')
|
||||
// }
|
||||
|
||||
// try {
|
||||
// return await this.instance.read(filename)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error getting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client
|
||||
} from '@aws-sdk/client-s3'
|
||||
import type { S3Config } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import * as net from 'net'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
/**
|
||||
* 将可读流转换为 Buffer
|
||||
*/
|
||||
function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
}
|
||||
|
||||
// 需要使用 Virtual Host-Style 的服务商域名后缀白名单
|
||||
const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com']
|
||||
|
||||
/**
|
||||
* 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。
|
||||
*/
|
||||
export default class S3Storage {
|
||||
private client: S3Client
|
||||
private bucket: string
|
||||
private root: string
|
||||
|
||||
constructor(config: S3Config) {
|
||||
const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config
|
||||
|
||||
const usePathStyle = (() => {
|
||||
if (!endpoint) return false
|
||||
|
||||
try {
|
||||
const { hostname } = new URL(endpoint)
|
||||
|
||||
if (hostname === 'localhost' || net.isIP(hostname) !== 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
|
||||
return !isInWhiteList
|
||||
} catch (e) {
|
||||
Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e)
|
||||
return true
|
||||
}
|
||||
})()
|
||||
|
||||
this.client = new S3Client({
|
||||
region,
|
||||
endpoint: endpoint || undefined,
|
||||
credentials: {
|
||||
accessKeyId: accessKeyId,
|
||||
secretAccessKey: secretAccessKey
|
||||
},
|
||||
forcePathStyle: usePathStyle
|
||||
})
|
||||
|
||||
this.bucket = bucket
|
||||
this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || ''
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
this.deleteFile = this.deleteFile.bind(this)
|
||||
this.listFiles = this.listFiles.bind(this)
|
||||
this.checkConnection = this.checkConnection.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部辅助方法,用来拼接带 root 的对象 key
|
||||
*/
|
||||
private buildKey(key: string): string {
|
||||
if (!this.root) return key
|
||||
return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}`
|
||||
}
|
||||
|
||||
async putFileContents(key: string, data: Buffer | string) {
|
||||
try {
|
||||
const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream'
|
||||
|
||||
return await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: this.buildKey(key),
|
||||
Body: data,
|
||||
ContentType: contentType
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error putting object:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getFileContents(key: string): Promise<Buffer> {
|
||||
try {
|
||||
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) }))
|
||||
if (!res.Body || !(res.Body instanceof Readable)) {
|
||||
throw new Error('Empty body received from S3')
|
||||
}
|
||||
return await streamToBuffer(res.Body as Readable)
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error getting object:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(key: string) {
|
||||
try {
|
||||
const keyWithRoot = this.buildKey(key)
|
||||
const variations = new Set([keyWithRoot, key.replace(/^\//, '')])
|
||||
for (const k of variations) {
|
||||
try {
|
||||
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k }))
|
||||
} catch {
|
||||
// 忽略删除失败
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error deleting object:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列举指定前缀下的对象,默认列举全部。
|
||||
*/
|
||||
async listFiles(prefix = ''): Promise<Array<{ key: string; lastModified?: string; size: number }>> {
|
||||
const files: Array<{ key: string; lastModified?: string; size: number }> = []
|
||||
let continuationToken: string | undefined
|
||||
const fullPrefix = this.buildKey(prefix)
|
||||
|
||||
try {
|
||||
do {
|
||||
const res = await this.client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: fullPrefix === '' ? undefined : fullPrefix,
|
||||
ContinuationToken: continuationToken
|
||||
})
|
||||
)
|
||||
|
||||
res.Contents?.forEach((obj) => {
|
||||
if (!obj.Key) return
|
||||
files.push({
|
||||
key: obj.Key,
|
||||
lastModified: obj.LastModified?.toISOString(),
|
||||
size: obj.Size ?? 0
|
||||
})
|
||||
})
|
||||
|
||||
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined
|
||||
} while (continuationToken)
|
||||
|
||||
return files
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error listing objects:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试调用 HeadBucket 判断凭证/网络是否可用
|
||||
*/
|
||||
async checkConnection() {
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error('[S3Storage] Error checking connection:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELECTION_FINETUNED_LIST, SELECTION_PREDEFINED_BLACKLIST } from '@main/configs/SelectionConfig'
|
||||
import { isDev, isWin } from '@main/constant'
|
||||
import { isDev, isMac, isWin } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, screen, systemPreferences } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { join } from 'path'
|
||||
import type {
|
||||
@@ -16,9 +16,12 @@ import type { ActionItem } from '../../renderer/src/types/selectionTypes'
|
||||
import { ConfigKeys, configManager } from './ConfigManager'
|
||||
import storeSyncService from './StoreSyncService'
|
||||
|
||||
const isSupportedOS = isWin || isMac
|
||||
|
||||
let SelectionHook: SelectionHookConstructor | null = null
|
||||
try {
|
||||
if (isWin) {
|
||||
//since selection-hook v1.0.0, it supports macOS
|
||||
if (isSupportedOS) {
|
||||
SelectionHook = require('selection-hook')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -118,7 +121,7 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
public static getInstance(): SelectionService | null {
|
||||
if (!isWin) return null
|
||||
if (!isSupportedOS) return null
|
||||
|
||||
if (!SelectionService.instance) {
|
||||
SelectionService.instance = new SelectionService()
|
||||
@@ -138,7 +141,7 @@ export class SelectionService {
|
||||
* Initialize zoom factor from config and subscribe to changes
|
||||
* Ensures UI elements scale properly with system DPI settings
|
||||
*/
|
||||
private initZoomFactor() {
|
||||
private initZoomFactor(): void {
|
||||
const zoomFactor = configManager.getZoomFactor()
|
||||
if (zoomFactor) {
|
||||
this.setZoomFactor(zoomFactor)
|
||||
@@ -151,7 +154,7 @@ export class SelectionService {
|
||||
this.zoomFactor = zoomFactor
|
||||
}
|
||||
|
||||
private initConfig() {
|
||||
private initConfig(): void {
|
||||
this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode
|
||||
this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar()
|
||||
this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize()
|
||||
@@ -204,7 +207,7 @@ export class SelectionService {
|
||||
* @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist'
|
||||
* @param list - An array of strings representing the list of items to include or exclude
|
||||
*/
|
||||
private setHookGlobalFilterMode(mode: string, list: string[]) {
|
||||
private setHookGlobalFilterMode(mode: string, list: string[]): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const modeMap = {
|
||||
@@ -213,6 +216,8 @@ export class SelectionService {
|
||||
blacklist: SelectionHook!.FilterMode.EXCLUDE_LIST
|
||||
}
|
||||
|
||||
const predefinedBlacklist = isWin ? SELECTION_PREDEFINED_BLACKLIST.WINDOWS : SELECTION_PREDEFINED_BLACKLIST.MAC
|
||||
|
||||
let combinedList: string[] = list
|
||||
let combinedMode = mode
|
||||
|
||||
@@ -221,7 +226,7 @@ export class SelectionService {
|
||||
switch (mode) {
|
||||
case 'blacklist':
|
||||
//combine the predefined blacklist with the user-defined blacklist
|
||||
combinedList = [...new Set([...list, ...SELECTION_PREDEFINED_BLACKLIST.WINDOWS])]
|
||||
combinedList = [...new Set([...list, ...predefinedBlacklist])]
|
||||
break
|
||||
case 'whitelist':
|
||||
combinedList = [...list]
|
||||
@@ -229,7 +234,7 @@ export class SelectionService {
|
||||
case 'default':
|
||||
default:
|
||||
//use the predefined blacklist as the default filter list
|
||||
combinedList = [...SELECTION_PREDEFINED_BLACKLIST.WINDOWS]
|
||||
combinedList = [...predefinedBlacklist]
|
||||
combinedMode = 'blacklist'
|
||||
break
|
||||
}
|
||||
@@ -240,17 +245,24 @@ export class SelectionService {
|
||||
}
|
||||
}
|
||||
|
||||
private setHookFineTunedList() {
|
||||
private setHookFineTunedList(): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const excludeClipboardCursorDetectList = isWin
|
||||
? SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
|
||||
: SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.MAC
|
||||
const includeClipboardDelayReadList = isWin
|
||||
? SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
|
||||
: SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.MAC
|
||||
|
||||
this.selectionHook.setFineTunedList(
|
||||
SelectionHook!.FineTunedListType.EXCLUDE_CLIPBOARD_CURSOR_DETECT,
|
||||
SELECTION_FINETUNED_LIST.EXCLUDE_CLIPBOARD_CURSOR_DETECT.WINDOWS
|
||||
excludeClipboardCursorDetectList
|
||||
)
|
||||
|
||||
this.selectionHook.setFineTunedList(
|
||||
SelectionHook!.FineTunedListType.INCLUDE_CLIPBOARD_DELAY_READ,
|
||||
SELECTION_FINETUNED_LIST.INCLUDE_CLIPBOARD_DELAY_READ.WINDOWS
|
||||
includeClipboardDelayReadList
|
||||
)
|
||||
}
|
||||
|
||||
@@ -259,11 +271,33 @@ export class SelectionService {
|
||||
* @returns {boolean} Success status of service start
|
||||
*/
|
||||
public start(): boolean {
|
||||
if (!this.selectionHook || this.started) {
|
||||
this.logError(new Error('SelectionService start(): instance is null or already started'))
|
||||
if (!isSupportedOS) {
|
||||
this.logError(new Error('SelectionService start(): not supported on this OS'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.selectionHook) {
|
||||
this.logError(new Error('SelectionService start(): instance is null'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.started) {
|
||||
this.logError(new Error('SelectionService start(): already started'))
|
||||
return false
|
||||
}
|
||||
|
||||
//On macOS, we need to check if the process is trusted
|
||||
if (isMac) {
|
||||
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
|
||||
this.logError(
|
||||
new Error(
|
||||
'SelectionSerice not started: process is not trusted on macOS, please turn on the Accessibility permission'
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
//make sure the toolbar window is ready
|
||||
this.createToolbarWindow()
|
||||
@@ -306,6 +340,7 @@ export class SelectionService {
|
||||
if (!this.selectionHook) return false
|
||||
|
||||
this.selectionHook.stop()
|
||||
|
||||
this.selectionHook.cleanup() //already remove all listeners
|
||||
|
||||
//reset the listener states
|
||||
@@ -316,6 +351,7 @@ export class SelectionService {
|
||||
this.toolbarWindow.close()
|
||||
this.toolbarWindow = null
|
||||
}
|
||||
|
||||
this.closePreloadedActionWindows()
|
||||
|
||||
this.started = false
|
||||
@@ -342,7 +378,7 @@ export class SelectionService {
|
||||
* Toggle the enabled state of the selection service
|
||||
* Will sync the new enabled store to all renderer windows
|
||||
*/
|
||||
public toggleEnabled(enabled: boolean | undefined = undefined) {
|
||||
public toggleEnabled(enabled: boolean | undefined = undefined): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled
|
||||
@@ -358,7 +394,7 @@ export class SelectionService {
|
||||
* Sets up window properties, event handlers, and loads the toolbar UI
|
||||
* @param readyCallback Optional callback when window is ready to show
|
||||
*/
|
||||
private createToolbarWindow(readyCallback?: () => void) {
|
||||
private createToolbarWindow(readyCallback?: () => void): void {
|
||||
if (this.isToolbarAlive()) return
|
||||
|
||||
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
|
||||
@@ -366,21 +402,31 @@ export class SelectionService {
|
||||
this.toolbarWindow = new BrowserWindow({
|
||||
width: toolbarWidth,
|
||||
height: toolbarHeight,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
autoHideMenuBar: true,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false, // [macOS] must be false
|
||||
movable: true,
|
||||
focusable: false,
|
||||
hasShadow: false,
|
||||
thickFrame: false,
|
||||
roundedCorners: true,
|
||||
backgroundMaterial: 'none',
|
||||
type: 'toolbar',
|
||||
show: false,
|
||||
|
||||
// Platform specific settings
|
||||
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together
|
||||
// [macOS] `panel` conflicts with other settings ,
|
||||
// and log will show `NSWindow does not support nonactivating panel styleMask 0x80`
|
||||
// but it seems still work on fullscreen apps, so we set this anyway
|
||||
...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }),
|
||||
hiddenInMissionControl: true, // [macOS only]
|
||||
acceptFirstMouse: true, // [macOS only]
|
||||
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
@@ -392,7 +438,9 @@ export class SelectionService {
|
||||
|
||||
// Hide when losing focus
|
||||
this.toolbarWindow.on('blur', () => {
|
||||
this.hideToolbar()
|
||||
if (this.toolbarWindow!.isVisible()) {
|
||||
this.hideToolbar()
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up when closed
|
||||
@@ -437,10 +485,10 @@ export class SelectionService {
|
||||
* @param point Reference point for positioning, logical coordinates
|
||||
* @param orientation Preferred position relative to reference point
|
||||
*/
|
||||
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) {
|
||||
private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void {
|
||||
if (!this.isToolbarAlive()) {
|
||||
this.createToolbarWindow(() => {
|
||||
this.showToolbarAtPosition(point, orientation)
|
||||
this.showToolbarAtPosition(point, orientation, programName)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -460,15 +508,56 @@ export class SelectionService {
|
||||
//set the window to always on top (highest level)
|
||||
//should set every time the window is shown
|
||||
this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver')
|
||||
this.toolbarWindow!.show()
|
||||
|
||||
/**
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
if (!isMac) {
|
||||
this.toolbarWindow!.show()
|
||||
/**
|
||||
* [Windows]
|
||||
* In Windows 10, setOpacity(1) will make the window completely transparent
|
||||
* It's a strange behavior, so we don't use it for compatibility
|
||||
*/
|
||||
// this.toolbarWindow!.setOpacity(1)
|
||||
this.startHideByMouseKeyListener()
|
||||
return
|
||||
}
|
||||
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
// [macOS] a hacky way
|
||||
// when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing
|
||||
// so we just don't set `skipTransformProcessType: true` when in self app
|
||||
const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName)
|
||||
|
||||
if (!isSelf) {
|
||||
// [macOS] an ugly hacky way
|
||||
// `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces`
|
||||
// so we set `focusable: true` before showing, and then set false after showing
|
||||
this.toolbarWindow!.setFocusable(false)
|
||||
|
||||
// [macOS]
|
||||
// force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again
|
||||
// set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces`
|
||||
this.toolbarWindow!.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
}
|
||||
|
||||
// [macOS] MUST use `showInactive()` to prevent other windows bring to front together
|
||||
// [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false`
|
||||
this.toolbarWindow!.showInactive()
|
||||
|
||||
// [macOS] restore the focusable status
|
||||
this.toolbarWindow!.setFocusable(true)
|
||||
|
||||
this.startHideByMouseKeyListener()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,18 +566,60 @@ export class SelectionService {
|
||||
public hideToolbar(): void {
|
||||
if (!this.isToolbarAlive()) return
|
||||
|
||||
// this.toolbarWindow!.setOpacity(0)
|
||||
this.stopHideByMouseKeyListener()
|
||||
|
||||
// [Windows] just hide the toolbar window is enough
|
||||
if (!isMac) {
|
||||
this.toolbarWindow!.hide()
|
||||
return
|
||||
}
|
||||
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*************************************************/
|
||||
|
||||
// [macOS] a HACKY way
|
||||
// make sure other windows do not bring to front when toolbar is hidden
|
||||
// get all focusable windows and set them to not focusable
|
||||
const focusableWindows: BrowserWindow[] = []
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed() && window.isVisible()) {
|
||||
if (window.isFocusable()) {
|
||||
focusableWindows.push(window)
|
||||
window.setFocusable(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.toolbarWindow!.hide()
|
||||
|
||||
this.stopHideByMouseKeyListener()
|
||||
// set them back to focusable after 50ms
|
||||
setTimeout(() => {
|
||||
for (const window of focusableWindows) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.setFocusable(true)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// [macOS] hacky way
|
||||
// Because toolbar is not a FOCUSED window, so the hover status will remain when next time show
|
||||
// so we just send mouseMove event to the toolbar window to make the hover status disappear
|
||||
this.toolbarWindow!.webContents.sendInputEvent({
|
||||
type: 'mouseMove',
|
||||
x: -1,
|
||||
y: -1
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if toolbar window exists and is not destroyed
|
||||
* @returns {boolean} Toolbar window status
|
||||
*/
|
||||
private isToolbarAlive() {
|
||||
return this.toolbarWindow && !this.toolbarWindow.isDestroyed()
|
||||
private isToolbarAlive(): boolean {
|
||||
return !!(this.toolbarWindow && !this.toolbarWindow.isDestroyed())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -497,7 +628,7 @@ export class SelectionService {
|
||||
* @param width New toolbar width
|
||||
* @param height New toolbar height
|
||||
*/
|
||||
public determineToolbarSize(width: number, height: number) {
|
||||
public determineToolbarSize(width: number, height: number): void {
|
||||
const toolbarWidth = Math.ceil(width)
|
||||
|
||||
// only update toolbar width if it's changed
|
||||
@@ -510,7 +641,7 @@ export class SelectionService {
|
||||
* Get actual toolbar dimensions accounting for zoom factor
|
||||
* @returns Object containing toolbar width and height
|
||||
*/
|
||||
private getToolbarRealSize() {
|
||||
private getToolbarRealSize(): { toolbarWidth: number; toolbarHeight: number } {
|
||||
return {
|
||||
toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor,
|
||||
toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor
|
||||
@@ -520,71 +651,71 @@ export class SelectionService {
|
||||
/**
|
||||
* Calculate optimal toolbar position based on selection context
|
||||
* Ensures toolbar stays within screen boundaries and follows selection direction
|
||||
* @param point Reference point for positioning, must be INTEGER
|
||||
* @param refPoint Reference point for positioning, must be INTEGER
|
||||
* @param orientation Preferred position relative to reference point
|
||||
* @returns Calculated screen coordinates for toolbar, INTEGER
|
||||
*/
|
||||
private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point {
|
||||
private calculateToolbarPosition(refPoint: Point, orientation: RelativeOrientation): Point {
|
||||
// Calculate initial position based on the specified anchor
|
||||
let posX: number, posY: number
|
||||
const posPoint: Point = { x: 0, y: 0 }
|
||||
|
||||
const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize()
|
||||
|
||||
switch (orientation) {
|
||||
case 'topLeft':
|
||||
posX = point.x - toolbarWidth
|
||||
posY = point.y - toolbarHeight
|
||||
posPoint.x = refPoint.x - toolbarWidth
|
||||
posPoint.y = refPoint.y - toolbarHeight
|
||||
break
|
||||
case 'topRight':
|
||||
posX = point.x
|
||||
posY = point.y - toolbarHeight
|
||||
posPoint.x = refPoint.x
|
||||
posPoint.y = refPoint.y - toolbarHeight
|
||||
break
|
||||
case 'topMiddle':
|
||||
posX = point.x - toolbarWidth / 2
|
||||
posY = point.y - toolbarHeight
|
||||
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||
posPoint.y = refPoint.y - toolbarHeight
|
||||
break
|
||||
case 'bottomLeft':
|
||||
posX = point.x - toolbarWidth
|
||||
posY = point.y
|
||||
posPoint.x = refPoint.x - toolbarWidth
|
||||
posPoint.y = refPoint.y
|
||||
break
|
||||
case 'bottomRight':
|
||||
posX = point.x
|
||||
posY = point.y
|
||||
posPoint.x = refPoint.x
|
||||
posPoint.y = refPoint.y
|
||||
break
|
||||
case 'bottomMiddle':
|
||||
posX = point.x - toolbarWidth / 2
|
||||
posY = point.y
|
||||
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||
posPoint.y = refPoint.y
|
||||
break
|
||||
case 'middleLeft':
|
||||
posX = point.x - toolbarWidth
|
||||
posY = point.y - toolbarHeight / 2
|
||||
posPoint.x = refPoint.x - toolbarWidth
|
||||
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||
break
|
||||
case 'middleRight':
|
||||
posX = point.x
|
||||
posY = point.y - toolbarHeight / 2
|
||||
posPoint.x = refPoint.x
|
||||
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||
break
|
||||
case 'center':
|
||||
posX = point.x - toolbarWidth / 2
|
||||
posY = point.y - toolbarHeight / 2
|
||||
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||
break
|
||||
default:
|
||||
// Default to 'topMiddle' if invalid position
|
||||
posX = point.x - toolbarWidth / 2
|
||||
posY = point.y - toolbarHeight / 2
|
||||
posPoint.x = refPoint.x - toolbarWidth / 2
|
||||
posPoint.y = refPoint.y - toolbarHeight / 2
|
||||
}
|
||||
|
||||
//use original point to get the display
|
||||
const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y })
|
||||
const display = screen.getDisplayNearestPoint(refPoint)
|
||||
|
||||
// Ensure toolbar stays within screen boundaries
|
||||
posX = Math.round(
|
||||
Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth))
|
||||
posPoint.x = Math.round(
|
||||
Math.max(display.workArea.x, Math.min(posPoint.x, display.workArea.x + display.workArea.width - toolbarWidth))
|
||||
)
|
||||
posY = Math.round(
|
||||
Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight))
|
||||
posPoint.y = Math.round(
|
||||
Math.max(display.workArea.y, Math.min(posPoint.y, display.workArea.y + display.workArea.height - toolbarHeight))
|
||||
)
|
||||
|
||||
return { x: posX, y: posY }
|
||||
return posPoint
|
||||
}
|
||||
|
||||
private isSamePoint(point1: Point, point2: Point): boolean {
|
||||
@@ -773,13 +904,17 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
if (!isLogical) {
|
||||
// [macOS] don't need to convert by screenToDipPoint
|
||||
if (!isMac) {
|
||||
refPoint = screen.screenToDipPoint(refPoint)
|
||||
}
|
||||
//screenToDipPoint can be float, so we need to round it
|
||||
refPoint = screen.screenToDipPoint(refPoint)
|
||||
refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) }
|
||||
}
|
||||
|
||||
this.showToolbarAtPosition(refPoint, refOrientation)
|
||||
this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
|
||||
// [macOS] isFullscreen is only available on macOS
|
||||
this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName)
|
||||
this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -787,7 +922,7 @@ export class SelectionService {
|
||||
*/
|
||||
|
||||
// Start monitoring global mouse clicks
|
||||
private startHideByMouseKeyListener() {
|
||||
private startHideByMouseKeyListener(): void {
|
||||
try {
|
||||
// Register event handlers
|
||||
this.selectionHook!.on('mouse-down', this.handleMouseDownHide)
|
||||
@@ -800,7 +935,7 @@ export class SelectionService {
|
||||
}
|
||||
|
||||
// Stop monitoring global mouse clicks
|
||||
private stopHideByMouseKeyListener() {
|
||||
private stopHideByMouseKeyListener(): void {
|
||||
if (!this.isHideByMouseKeyListenerActive) return
|
||||
|
||||
try {
|
||||
@@ -832,8 +967,8 @@ export class SelectionService {
|
||||
return
|
||||
}
|
||||
|
||||
//data point is physical coordinates, convert to logical coordinates
|
||||
const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y })
|
||||
//data point is physical coordinates, convert to logical coordinates(only for windows/linux)
|
||||
const mousePoint = isMac ? { x: data.x, y: data.y } : screen.screenToDipPoint({ x: data.x, y: data.y })
|
||||
|
||||
const bounds = this.toolbarWindow!.getBounds()
|
||||
|
||||
@@ -966,7 +1101,8 @@ export class SelectionService {
|
||||
frame: false,
|
||||
transparent: true,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarStyle: 'hidden', // [macOS]
|
||||
trafficLightPosition: { x: 12, y: 9 }, // [macOS]
|
||||
hasShadow: false,
|
||||
thickFrame: false,
|
||||
show: false,
|
||||
@@ -993,7 +1129,7 @@ export class SelectionService {
|
||||
* Initialize preloaded action windows
|
||||
* Creates a pool of windows at startup for faster response
|
||||
*/
|
||||
private async initPreloadedActionWindows() {
|
||||
private async initPreloadedActionWindows(): Promise<void> {
|
||||
try {
|
||||
// Create initial pool of preloaded windows
|
||||
for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) {
|
||||
@@ -1007,7 +1143,7 @@ export class SelectionService {
|
||||
/**
|
||||
* Close all preloaded action windows
|
||||
*/
|
||||
private closePreloadedActionWindows() {
|
||||
private closePreloadedActionWindows(): void {
|
||||
for (const actionWindow of this.preloadedActionWindows) {
|
||||
if (!actionWindow.isDestroyed()) {
|
||||
actionWindow.destroy()
|
||||
@@ -1019,7 +1155,7 @@ export class SelectionService {
|
||||
* Preload a new action window asynchronously
|
||||
* This method is called after popping a window to ensure we always have windows ready
|
||||
*/
|
||||
private async pushNewActionWindow() {
|
||||
private async pushNewActionWindow(): Promise<void> {
|
||||
try {
|
||||
const actionWindow = this.createPreloadedActionWindow()
|
||||
this.preloadedActionWindows.push(actionWindow)
|
||||
@@ -1033,7 +1169,7 @@ export class SelectionService {
|
||||
* Immediately returns a window and asynchronously creates a new one
|
||||
* @returns {BrowserWindow} The action window
|
||||
*/
|
||||
private popActionWindow() {
|
||||
private popActionWindow(): BrowserWindow {
|
||||
// Get a window from the preloaded queue or create a new one if empty
|
||||
const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow()
|
||||
|
||||
@@ -1043,6 +1179,27 @@ export class SelectionService {
|
||||
if (!actionWindow.isDestroyed()) {
|
||||
actionWindow.destroy()
|
||||
}
|
||||
|
||||
// [macOS] a HACKY way
|
||||
// make sure other windows do not bring to front when action window is closed
|
||||
if (isMac) {
|
||||
const focusableWindows: BrowserWindow[] = []
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed() && window.isVisible()) {
|
||||
if (window.isFocusable()) {
|
||||
focusableWindows.push(window)
|
||||
window.setFocusable(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (const window of focusableWindows) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.setFocusable(true)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
})
|
||||
|
||||
//remember the action window size
|
||||
@@ -1063,20 +1220,26 @@ export class SelectionService {
|
||||
return actionWindow
|
||||
}
|
||||
|
||||
public processAction(actionItem: ActionItem): void {
|
||||
/**
|
||||
* Process action item
|
||||
* @param actionItem Action item to process
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
public processAction(actionItem: ActionItem, isFullScreen: boolean = false): void {
|
||||
const actionWindow = this.popActionWindow()
|
||||
|
||||
actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem)
|
||||
|
||||
this.showActionWindow(actionWindow)
|
||||
this.showActionWindow(actionWindow, isFullScreen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show action window with proper positioning relative to toolbar
|
||||
* Ensures window stays within screen boundaries
|
||||
* @param actionWindow Window to position and show
|
||||
* @param isFullScreen [macOS] only macOS has the available isFullscreen mode
|
||||
*/
|
||||
private showActionWindow(actionWindow: BrowserWindow) {
|
||||
private showActionWindow(actionWindow: BrowserWindow, isFullScreen: boolean = false): void {
|
||||
let actionWindowWidth = this.ACTION_WINDOW_WIDTH
|
||||
let actionWindowHeight = this.ACTION_WINDOW_HEIGHT
|
||||
|
||||
@@ -1086,63 +1249,124 @@ export class SelectionService {
|
||||
actionWindowHeight = this.lastActionWindowSize.height
|
||||
}
|
||||
|
||||
//center way
|
||||
/********************************************
|
||||
* Setting the position of the action window
|
||||
********************************************/
|
||||
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const workArea = display.workArea
|
||||
|
||||
// Center of the screen
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
if (this.isRemeberWinSize) {
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight
|
||||
})
|
||||
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
|
||||
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
|
||||
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: Math.round(centerX),
|
||||
y: Math.round(centerY)
|
||||
})
|
||||
} else {
|
||||
// Follow toolbar position
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
})
|
||||
}
|
||||
|
||||
if (!isMac) {
|
||||
actionWindow.show()
|
||||
this.hideToolbar()
|
||||
return
|
||||
}
|
||||
|
||||
//follow toolbar
|
||||
/************************************************
|
||||
* [macOS] the following code is only for macOS
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT MODIFY THESE CODES, UNLESS YOU REALLY KNOW WHAT YOU ARE DOING!!!!
|
||||
*************************************************/
|
||||
|
||||
const toolbarBounds = this.toolbarWindow!.getBounds()
|
||||
const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y })
|
||||
const workArea = display.workArea
|
||||
const GAP = 6 // 6px gap from screen edges
|
||||
|
||||
//make sure action window is inside screen
|
||||
if (actionWindowWidth > workArea.width - 2 * GAP) {
|
||||
actionWindowWidth = workArea.width - 2 * GAP
|
||||
// act normally when the app is not in fullscreen mode
|
||||
if (!isFullScreen) {
|
||||
actionWindow.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionWindowHeight > workArea.height - 2 * GAP) {
|
||||
actionWindowHeight = workArea.height - 2 * GAP
|
||||
}
|
||||
// [macOS] an UGLY HACKY way for fullscreen override settings
|
||||
|
||||
// Calculate initial position to center action window horizontally below toolbar
|
||||
let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - actionWindowWidth) / 2)
|
||||
let posY = Math.round(toolbarBounds.y)
|
||||
// FIXME sometimes the dock will be shown when the action window is shown
|
||||
// FIXME if actionWindow show on the fullscreen app, switch to other space will cause the mainWindow to be shown
|
||||
// FIXME When setVisibleOnAllWorkspaces is true, docker icon disappeared when the first action window is shown on the fullscreen app
|
||||
// use app.dock.show() to show the dock again will cause the action window to be closed when auto hide on blur is enabled
|
||||
|
||||
// Ensure action window stays within screen boundaries with a small gap
|
||||
if (posX + actionWindowWidth > workArea.x + workArea.width) {
|
||||
posX = workArea.x + workArea.width - actionWindowWidth - GAP
|
||||
} else if (posX < workArea.x) {
|
||||
posX = workArea.x + GAP
|
||||
}
|
||||
if (posY + actionWindowHeight > workArea.y + workArea.height) {
|
||||
// If window would go below screen, try to position it above toolbar
|
||||
posY = workArea.y + workArea.height - actionWindowHeight - GAP
|
||||
} else if (posY < workArea.y) {
|
||||
posY = workArea.y + GAP
|
||||
}
|
||||
// setFocusable(false) to prevent the action window hide when blur (if auto hide on blur is enabled)
|
||||
actionWindow.setFocusable(false)
|
||||
actionWindow.setAlwaysOnTop(true, 'floating')
|
||||
|
||||
actionWindow.setPosition(posX, posY, false)
|
||||
//KEY to make window not resize
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: posX,
|
||||
y: posY
|
||||
// `setVisibleOnAllWorkspaces(true)` will cause the dock icon disappeared
|
||||
// just store the dock icon status, and show it again
|
||||
const isDockShown = app.dock?.isVisible()
|
||||
|
||||
// DO NOT set `skipTransformProcessType: true`,
|
||||
// it will cause the action window to be shown on other space
|
||||
actionWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true
|
||||
})
|
||||
|
||||
actionWindow.show()
|
||||
actionWindow.showInactive()
|
||||
|
||||
// show the dock again if last time it was shown
|
||||
// do not put it after `actionWindow.focus()`, will cause the action window to be closed when auto hide on blur is enabled
|
||||
if (!app.dock?.isVisible() && isDockShown) {
|
||||
app.dock?.show()
|
||||
}
|
||||
|
||||
// unset everything
|
||||
setTimeout(() => {
|
||||
actionWindow.setVisibleOnAllWorkspaces(false, {
|
||||
visibleOnFullScreen: true,
|
||||
skipTransformProcessType: true
|
||||
})
|
||||
actionWindow.setAlwaysOnTop(false)
|
||||
|
||||
actionWindow.setFocusable(true)
|
||||
|
||||
// regain the focus when all the works done
|
||||
actionWindow.focus()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
public closeActionWindow(actionWindow: BrowserWindow): void {
|
||||
@@ -1162,38 +1386,40 @@ export class SelectionService {
|
||||
* Switches between selection-based and alt-key based triggering
|
||||
* Manages appropriate event listeners for each mode
|
||||
*/
|
||||
private processTriggerMode() {
|
||||
private processTriggerMode(): void {
|
||||
if (!this.selectionHook) return
|
||||
|
||||
switch (this.triggerMode) {
|
||||
case TriggerMode.Selected:
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(false)
|
||||
this.selectionHook.setSelectionPassiveMode(false)
|
||||
break
|
||||
case TriggerMode.Ctrlkey:
|
||||
if (!this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = true
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
this.selectionHook.setSelectionPassiveMode(true)
|
||||
break
|
||||
case TriggerMode.Shortcut:
|
||||
//remove the ctrlkey listener, don't need any key listener for shortcut mode
|
||||
if (this.isCtrlkeyListenerActive) {
|
||||
this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode)
|
||||
this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode)
|
||||
|
||||
this.isCtrlkeyListenerActive = false
|
||||
}
|
||||
|
||||
this.selectionHook!.setSelectionPassiveMode(true)
|
||||
this.selectionHook.setSelectionPassiveMode(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1214,7 +1440,7 @@ export class SelectionService {
|
||||
selectionService?.hideToolbar()
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => {
|
||||
ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string): boolean => {
|
||||
return selectionService?.writeToClipboard(text) ?? false
|
||||
})
|
||||
|
||||
@@ -1246,8 +1472,9 @@ export class SelectionService {
|
||||
configManager.setSelectionAssistantFilterList(filterList)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => {
|
||||
selectionService?.processAction(actionItem)
|
||||
// [macOS] only macOS has the available isFullscreen mode
|
||||
ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem, isFullScreen: boolean = false) => {
|
||||
selectionService?.processAction(actionItem, isFullScreen)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => {
|
||||
@@ -1274,13 +1501,13 @@ export class SelectionService {
|
||||
this.isIpcHandlerRegistered = true
|
||||
}
|
||||
|
||||
private logInfo(message: string, forceShow: boolean = false) {
|
||||
private logInfo(message: string, forceShow: boolean = false): void {
|
||||
if (isDev || forceShow) {
|
||||
Logger.info('[SelectionService] Info: ', message)
|
||||
}
|
||||
}
|
||||
|
||||
private logError(...args: [...string[], Error]) {
|
||||
private logError(...args: [...string[], Error]): void {
|
||||
Logger.error('[SelectionService] Error: ', ...args)
|
||||
}
|
||||
}
|
||||
@@ -1291,9 +1518,9 @@ export class SelectionService {
|
||||
* @returns {boolean} Success status of initialization
|
||||
*/
|
||||
export function initSelectionService(): boolean {
|
||||
if (!isWin) return false
|
||||
if (!isSupportedOS) return false
|
||||
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => {
|
||||
configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => {
|
||||
//avoid closure
|
||||
const ss = SelectionService.getInstance()
|
||||
if (!ss) {
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ThemeMode } from '@types'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { configManager } from './ConfigManager'
|
||||
|
||||
class ThemeService {
|
||||
private theme: ThemeMode = ThemeMode.system
|
||||
constructor() {
|
||||
this.theme = configManager.getTheme()
|
||||
|
||||
if (this.theme === ThemeMode.dark || this.theme === ThemeMode.light || this.theme === ThemeMode.system) {
|
||||
nativeTheme.themeSource = this.theme
|
||||
} else {
|
||||
// 兼容旧版本
|
||||
configManager.setTheme(ThemeMode.system)
|
||||
nativeTheme.themeSource = ThemeMode.system
|
||||
}
|
||||
nativeTheme.on('updated', this.themeUpdatadHandler.bind(this))
|
||||
}
|
||||
|
||||
themeUpdatadHandler() {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
if (win && !win.isDestroyed() && win.setTitleBarOverlay) {
|
||||
try {
|
||||
win.setTitleBarOverlay(nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
} catch (error) {
|
||||
// don't throw error if setTitleBarOverlay failed
|
||||
// Because it may be called with some windows have some title bar
|
||||
}
|
||||
}
|
||||
win.webContents.send(IpcChannel.ThemeUpdated, nativeTheme.shouldUseDarkColors ? ThemeMode.dark : ThemeMode.light)
|
||||
})
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeMode) {
|
||||
if (theme === this.theme) {
|
||||
return
|
||||
}
|
||||
|
||||
this.theme = theme
|
||||
nativeTheme.themeSource = theme
|
||||
configManager.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
export const themeService = new ThemeService()
|
||||
|
||||
@@ -84,10 +84,8 @@ export class TrayService {
|
||||
label: trayLocale.show_mini_window,
|
||||
click: () => windowService.showMiniWindow()
|
||||
},
|
||||
isWin && {
|
||||
(isWin || isMac) && {
|
||||
label: selectionLocale.name + (selectionAssistantEnabled ? ' - On' : ' - Off'),
|
||||
// type: 'checkbox',
|
||||
// checked: selectionAssistantEnabled,
|
||||
click: () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
|
||||
@@ -42,8 +42,8 @@ export class WindowService {
|
||||
}
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 1080,
|
||||
defaultHeight: 670,
|
||||
defaultWidth: 960,
|
||||
defaultHeight: 600,
|
||||
fullScreen: false,
|
||||
maximize: false
|
||||
})
|
||||
@@ -53,7 +53,7 @@ export class WindowService {
|
||||
y: mainWindowState.y,
|
||||
width: mainWindowState.width,
|
||||
height: mainWindowState.height,
|
||||
minWidth: 1080,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
|
||||
export abstract class BaseFileService {
|
||||
protected readonly provider: Provider
|
||||
protected constructor(provider: Provider) {
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
abstract uploadFile(file: FileMetadata): Promise<FileUploadResponse>
|
||||
abstract deleteFile(fileId: string): Promise<void>
|
||||
abstract listFiles(): Promise<FileListResponse>
|
||||
abstract retrieveFile(fileId: string): Promise<FileUploadResponse>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Provider } from '@types'
|
||||
|
||||
import { BaseFileService } from './BaseFileService'
|
||||
import { GeminiService } from './GeminiService'
|
||||
import { MistralService } from './MistralService'
|
||||
|
||||
export class FileServiceManager {
|
||||
private static instance: FileServiceManager
|
||||
private services: Map<string, BaseFileService> = new Map()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): FileServiceManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new FileServiceManager()
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
getService(provider: Provider): BaseFileService {
|
||||
const type = provider.type
|
||||
let service = this.services.get(type)
|
||||
|
||||
if (!service) {
|
||||
switch (type) {
|
||||
case 'gemini':
|
||||
service = new GeminiService(provider)
|
||||
break
|
||||
case 'mistral':
|
||||
service = new MistralService(provider)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported service type: ${type}`)
|
||||
}
|
||||
this.services.set(type, service)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { CacheService } from '../CacheService'
|
||||
import { BaseFileService } from './BaseFileService'
|
||||
|
||||
export class GeminiService extends BaseFileService {
|
||||
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
|
||||
private static readonly FILE_CACHE_DURATION = 48 * 60 * 60 * 1000
|
||||
private static readonly LIST_CACHE_DURATION = 3000
|
||||
|
||||
protected readonly fileManager: Files
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
this.fileManager = new GoogleGenAI({
|
||||
vertexai: false,
|
||||
apiKey: provider.apiKey,
|
||||
httpOptions: {
|
||||
baseUrl: provider.apiHost
|
||||
}
|
||||
}).files
|
||||
}
|
||||
|
||||
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const uploadResult = await this.fileManager.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
displayName: file.origin_name
|
||||
}
|
||||
})
|
||||
|
||||
// 根据文件状态设置响应状态
|
||||
let status: 'success' | 'processing' | 'failed' | 'unknown'
|
||||
switch (uploadResult.state) {
|
||||
case FileState.ACTIVE:
|
||||
status = 'success'
|
||||
break
|
||||
case FileState.PROCESSING:
|
||||
status = 'processing'
|
||||
break
|
||||
case FileState.FAILED:
|
||||
status = 'failed'
|
||||
break
|
||||
default:
|
||||
status = 'unknown'
|
||||
}
|
||||
|
||||
const response: FileUploadResponse = {
|
||||
fileId: uploadResult.name || '',
|
||||
displayName: file.origin_name,
|
||||
status,
|
||||
originalFile: {
|
||||
type: 'gemini',
|
||||
file: uploadResult
|
||||
}
|
||||
}
|
||||
|
||||
// 只缓存成功的文件
|
||||
if (status === 'success') {
|
||||
const cacheKey = `${GeminiService.FILE_LIST_CACHE_KEY}_${response.fileId}`
|
||||
CacheService.set<FileUploadResponse>(cacheKey, response, GeminiService.FILE_CACHE_DURATION)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
Logger.error('Error uploading file to Gemini:', error)
|
||||
return {
|
||||
fileId: '',
|
||||
displayName: file.origin_name,
|
||||
status: 'failed',
|
||||
originalFile: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveFile(fileId: string): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const cachedResponse = CacheService.get<FileUploadResponse>(`${GeminiService.FILE_LIST_CACHE_KEY}_${fileId}`)
|
||||
Logger.info('[GeminiService] cachedResponse', cachedResponse)
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
const files: File[] = []
|
||||
|
||||
for await (const f of await this.fileManager.list()) {
|
||||
files.push(f)
|
||||
}
|
||||
Logger.info('[GeminiService] files', files)
|
||||
const file = files
|
||||
.filter((file) => file.state === FileState.ACTIVE)
|
||||
.find((file) => file.name?.substring(6) === fileId) // 去掉 files/ 前缀
|
||||
Logger.info('[GeminiService] file', file)
|
||||
if (file) {
|
||||
return {
|
||||
fileId: fileId,
|
||||
displayName: file.displayName || '',
|
||||
status: 'success',
|
||||
originalFile: {
|
||||
type: 'gemini',
|
||||
file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileId: fileId,
|
||||
displayName: '',
|
||||
status: 'failed',
|
||||
originalFile: undefined
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Error retrieving file from Gemini:', error)
|
||||
return {
|
||||
fileId: fileId,
|
||||
displayName: '',
|
||||
status: 'failed',
|
||||
originalFile: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(): Promise<FileListResponse> {
|
||||
try {
|
||||
const cachedList = CacheService.get<FileListResponse>(GeminiService.FILE_LIST_CACHE_KEY)
|
||||
if (cachedList) {
|
||||
return cachedList
|
||||
}
|
||||
const geminiFiles: File[] = []
|
||||
|
||||
for await (const f of await this.fileManager.list()) {
|
||||
geminiFiles.push(f)
|
||||
}
|
||||
const fileList: FileListResponse = {
|
||||
files: geminiFiles
|
||||
.filter((file) => file.state === FileState.ACTIVE)
|
||||
.map((file) => {
|
||||
// 更新单个文件的缓存
|
||||
const fileResponse: FileUploadResponse = {
|
||||
fileId: file.name || uuidv4(),
|
||||
displayName: file.displayName || '',
|
||||
status: 'success',
|
||||
originalFile: {
|
||||
type: 'gemini',
|
||||
file
|
||||
}
|
||||
}
|
||||
CacheService.set(
|
||||
`${GeminiService.FILE_LIST_CACHE_KEY}_${file.name}`,
|
||||
fileResponse,
|
||||
GeminiService.FILE_CACHE_DURATION
|
||||
)
|
||||
|
||||
return {
|
||||
id: file.name || uuidv4(),
|
||||
displayName: file.displayName || '',
|
||||
size: Number(file.sizeBytes),
|
||||
status: 'success',
|
||||
originalFile: {
|
||||
type: 'gemini',
|
||||
file
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新文件列表缓存
|
||||
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, fileList, GeminiService.LIST_CACHE_DURATION)
|
||||
return fileList
|
||||
} catch (error) {
|
||||
Logger.error('Error listing files from Gemini:', error)
|
||||
return { files: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(fileId: string): Promise<void> {
|
||||
try {
|
||||
await this.fileManager.delete({ name: fileId })
|
||||
Logger.info(`File ${fileId} deleted from Gemini`)
|
||||
} catch (error) {
|
||||
Logger.error('Error deleting file from Gemini:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { Mistral } from '@mistralai/mistralai'
|
||||
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { MistralClientManager } from '../MistralClientManager'
|
||||
import { BaseFileService } from './BaseFileService'
|
||||
|
||||
export class MistralService extends BaseFileService {
|
||||
private readonly client: Mistral
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
const clientManager = MistralClientManager.getInstance()
|
||||
clientManager.initializeClient(provider)
|
||||
this.client = clientManager.getClient()
|
||||
}
|
||||
|
||||
async uploadFile(file: FileMetadata): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(file.path)
|
||||
const response = await this.client.files.upload({
|
||||
file: {
|
||||
fileName: file.origin_name,
|
||||
content: new Uint8Array(fileBuffer)
|
||||
},
|
||||
purpose: 'ocr'
|
||||
})
|
||||
|
||||
return {
|
||||
fileId: response.id,
|
||||
displayName: file.origin_name,
|
||||
status: 'success',
|
||||
originalFile: {
|
||||
type: 'mistral',
|
||||
file: response
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Error uploading file:', error)
|
||||
return {
|
||||
fileId: '',
|
||||
displayName: file.origin_name,
|
||||
status: 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(): Promise<FileListResponse> {
|
||||
try {
|
||||
const response = await this.client.files.list({})
|
||||
return {
|
||||
files: response.data.map((file) => ({
|
||||
id: file.id,
|
||||
displayName: file.filename || '',
|
||||
size: file.sizeBytes,
|
||||
status: 'success', // All listed files are processed,
|
||||
originalFile: {
|
||||
type: 'mistral',
|
||||
file
|
||||
}
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Error listing files:', error)
|
||||
return { files: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(fileId: string): Promise<void> {
|
||||
try {
|
||||
await this.client.files.delete({
|
||||
fileId
|
||||
})
|
||||
Logger.info(`File ${fileId} deleted`)
|
||||
} catch (error) {
|
||||
Logger.error('Error deleting file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveFile(fileId: string): Promise<FileUploadResponse> {
|
||||
try {
|
||||
const response = await this.client.files.retrieve({
|
||||
fileId
|
||||
})
|
||||
|
||||
return {
|
||||
fileId: response.id,
|
||||
displayName: response.filename || '',
|
||||
status: 'success' // Retrieved files are always processed
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Error retrieving file:', error)
|
||||
return {
|
||||
fileId: fileId,
|
||||
displayName: '',
|
||||
status: 'failed',
|
||||
originalFile: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isMac } from '@main/constant'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
@@ -33,8 +34,13 @@ export async function handleProvidersProtocolUrl(url: URL) {
|
||||
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
|
||||
) {
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
|
||||
|
||||
if (isMac) {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
Logger.info('handleProvidersProtocolUrl timeout', { data, version })
|
||||
handleProvidersProtocolUrl(url)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
// }
|
||||
// }
|
||||
// cherrystudio://mcp/install?servers={base64Encode(JSON.stringify(jsonConfig))}
|
||||
|
||||
const data = params.get('servers')
|
||||
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('install MCP servers from urlschema: ', stringify)
|
||||
@@ -63,10 +65,8 @@ export function handleMcpProtocolUrl(url: URL) {
|
||||
}
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.executeJavaScript("window.navigate('/mcp-servers')")
|
||||
}
|
||||
windowService.getMainWindow()?.show()
|
||||
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as fsPromises from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileTypes } from '@types'
|
||||
import iconv from 'iconv-lite'
|
||||
import { detectAll as detectEncodingAll } from 'jschardet'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { readTextFileWithAutoEncoding } from '../file'
|
||||
import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('node:fs')
|
||||
vi.mock('node:fs/promises')
|
||||
vi.mock('node:os')
|
||||
vi.mock('node:path')
|
||||
vi.mock('uuid', () => ({
|
||||
@@ -241,4 +246,54 @@ describe('file', () => {
|
||||
expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextFileWithAutoEncoding', () => {
|
||||
const mockFilePath = '/path/to/mock/file.txt'
|
||||
|
||||
it('should read file with auto encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
|
||||
it('should try to fix bad detected encoding', async () => {
|
||||
const content = '这是一段GB2312编码的测试内容'
|
||||
const buffer = iconv.encode(content, 'GB2312')
|
||||
|
||||
// 创建模拟的 FileHandle 对象
|
||||
const mockFileHandle = {
|
||||
read: vi.fn().mockResolvedValue({
|
||||
bytesRead: buffer.byteLength,
|
||||
buffer: buffer
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
// 模拟 fs.open 方法
|
||||
vi.spyOn(fsPromises, 'open').mockResolvedValue(mockFileHandle as any)
|
||||
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer)
|
||||
vi.mocked(vi.fn(detectEncodingAll)).mockReturnValue([
|
||||
{ encoding: 'UTF-8', confidence: 0.9 },
|
||||
{ encoding: 'GB2312', confidence: 0.8 }
|
||||
])
|
||||
|
||||
const result = await readTextFileWithAutoEncoding(mockFilePath)
|
||||
expect(result).toBe(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+83
-12
@@ -1,11 +1,15 @@
|
||||
import * as fs from 'node:fs'
|
||||
import { open, readFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileType, FileTypes } from '@types'
|
||||
import { isLinux, isPortable } from '@main/constant'
|
||||
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import { FileMetadata, FileTypes } from '@types'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import iconv from 'iconv-lite'
|
||||
import * as jschardet from 'jschardet'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function initAppDataDir() {
|
||||
@@ -59,6 +63,13 @@ function getAppDataPathFromConfig() {
|
||||
return null
|
||||
}
|
||||
|
||||
let executablePath = app.getPath('exe')
|
||||
if (isLinux && process.env.APPIMAGE) {
|
||||
// 如果是 AppImage 打包的应用,直接使用 APPIMAGE 环境变量
|
||||
// 这样可以确保获取到正确的可执行文件路径
|
||||
executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage')
|
||||
}
|
||||
|
||||
let appDataPath = null
|
||||
// 兼容旧版本
|
||||
if (config.appDataPath && typeof config.appDataPath === 'string') {
|
||||
@@ -67,7 +78,7 @@ function getAppDataPathFromConfig() {
|
||||
appDataPath && updateAppDataConfig(appDataPath)
|
||||
} else {
|
||||
appDataPath = config.appDataPath.find(
|
||||
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
|
||||
(item: { executablePath: string }) => item.executablePath === executablePath
|
||||
)?.dataPath
|
||||
}
|
||||
|
||||
@@ -90,11 +101,13 @@ export function updateAppDataConfig(appDataPath: string) {
|
||||
// config.json
|
||||
// appDataPath: [{ executablePath: string, dataPath: string }]
|
||||
const configPath = path.join(getConfigDir(), 'config.json')
|
||||
let executablePath = app.getPath('exe')
|
||||
if (isLinux && process.env.APPIMAGE) {
|
||||
executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage')
|
||||
}
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ appDataPath: [{ executablePath: app.getPath('exe'), dataPath: appDataPath }] }, null, 2)
|
||||
)
|
||||
fs.writeFileSync(configPath, JSON.stringify({ appDataPath: [{ executablePath, dataPath: appDataPath }] }, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,13 +117,13 @@ export function updateAppDataConfig(appDataPath: string) {
|
||||
}
|
||||
|
||||
const existingPath = config.appDataPath.find(
|
||||
(item: { executablePath: string }) => item.executablePath === app.getPath('exe')
|
||||
(item: { executablePath: string }) => item.executablePath === executablePath
|
||||
)
|
||||
|
||||
if (existingPath) {
|
||||
existingPath.dataPath = appDataPath
|
||||
} else {
|
||||
config.appDataPath.push({ executablePath: app.getPath('exe'), dataPath: appDataPath })
|
||||
config.appDataPath.push({ executablePath, dataPath: appDataPath })
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2))
|
||||
@@ -121,7 +134,19 @@ export function getFileType(ext: string): FileTypes {
|
||||
return fileTypeMap.get(ext) || FileTypes.OTHER
|
||||
}
|
||||
|
||||
export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): FileType[] {
|
||||
export function getFileDir(filePath: string) {
|
||||
return path.dirname(filePath)
|
||||
}
|
||||
|
||||
export function getFileName(filePath: string) {
|
||||
return path.basename(filePath)
|
||||
}
|
||||
|
||||
export function getFileExt(filePath: string) {
|
||||
return path.extname(filePath)
|
||||
}
|
||||
|
||||
export function getAllFiles(dirPath: string, arrayOfFiles: FileMetadata[] = []): FileMetadata[] {
|
||||
const files = fs.readdirSync(dirPath)
|
||||
|
||||
files.forEach((file) => {
|
||||
@@ -143,7 +168,7 @@ export function getAllFiles(dirPath: string, arrayOfFiles: FileType[] = []): Fil
|
||||
const name = path.basename(file)
|
||||
const size = fs.statSync(fullPath).size
|
||||
|
||||
const fileItem: FileType = {
|
||||
const fileItem: FileMetadata = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
path: fullPath,
|
||||
@@ -181,3 +206,49 @@ export function getCacheDir() {
|
||||
export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
* @returns 解码后的文件内容
|
||||
*/
|
||||
export async function readTextFileWithAutoEncoding(filePath: string): Promise<string> {
|
||||
// 读取前1MB以检测编码
|
||||
const buffer = Buffer.alloc(1 * MB)
|
||||
const fh = await open(filePath, 'r')
|
||||
const { buffer: bufferRead } = await fh.read(buffer, 0, 1 * MB, 0)
|
||||
await fh.close()
|
||||
|
||||
// 获取文件编码格式,最多取前两个可能的编码
|
||||
const encodings = jschardet
|
||||
.detectAll(bufferRead)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
encoding: item.encoding === 'ascii' ? 'UTF-8' : item.encoding
|
||||
}))
|
||||
.filter((item, index, array) => array.findIndex((prevItem) => prevItem.encoding === item.encoding) === index)
|
||||
.slice(0, 2)
|
||||
|
||||
if (encodings.length === 0) {
|
||||
Logger.error('Failed to detect encoding. Use utf-8 to decode.')
|
||||
const data = await readFile(filePath)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
const data = await readFile(filePath)
|
||||
|
||||
for (const item of encodings) {
|
||||
const encoding = item.encoding
|
||||
const content = iconv.decode(data, encoding)
|
||||
if (content.includes('\uFFFD')) {
|
||||
Logger.error(
|
||||
`File ${filePath} was auto-detected as ${encoding} encoding, but contains invalid characters. Trying other encodings`
|
||||
)
|
||||
} else {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
Logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getBinaryPath(name?: string): Promise<string> {
|
||||
|
||||
const binaryName = await getBinaryName(name)
|
||||
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const binariesDirExists = await fs.existsSync(binariesDir)
|
||||
const binariesDirExists = fs.existsSync(binariesDir)
|
||||
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
|
||||
}
|
||||
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
|
||||
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
|
||||
if (reset) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(1)
|
||||
})
|
||||
configManager.setZoomFactor(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentZoom = configManager.getZoomFactor()
|
||||
const newZoom = Number((currentZoom + delta).toFixed(1))
|
||||
if (newZoom >= 0.5 && newZoom <= 2.0) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(newZoom)
|
||||
})
|
||||
configManager.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
import { configManager } from '../services/ConfigManager'
|
||||
|
||||
export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) {
|
||||
if (reset) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(1)
|
||||
})
|
||||
configManager.setZoomFactor(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (delta === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentZoom = configManager.getZoomFactor()
|
||||
const newZoom = Number((currentZoom + delta).toFixed(1))
|
||||
if (newZoom >= 0.5 && newZoom <= 2.0) {
|
||||
wins.forEach((win) => {
|
||||
win.webContents.setZoomFactor(newZoom)
|
||||
})
|
||||
configManager.setZoomFactor(newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
+87
-24
@@ -2,7 +2,19 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import {
|
||||
FileListResponse,
|
||||
FileMetadata,
|
||||
FileUploadResponse,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
MCPServer,
|
||||
Provider,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
@@ -42,6 +54,10 @@ const api = {
|
||||
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
|
||||
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
|
||||
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
|
||||
mac: {
|
||||
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
|
||||
},
|
||||
notification: {
|
||||
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.Notification_Send, notification)
|
||||
},
|
||||
@@ -57,9 +73,9 @@ const api = {
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text)
|
||||
},
|
||||
backup: {
|
||||
backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile),
|
||||
restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath),
|
||||
backup: (filename: string, content: string, path: string, skipBackupFile: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile),
|
||||
restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path),
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig),
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) =>
|
||||
@@ -71,17 +87,51 @@ const api = {
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options),
|
||||
deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig),
|
||||
backupToLocalDir: (
|
||||
data: string,
|
||||
fileName: string,
|
||||
localConfig: { localBackupDir?: string; skipBackupFile?: boolean }
|
||||
) => ipcRenderer.invoke(IpcChannel.Backup_BackupToLocalDir, data, fileName, localConfig),
|
||||
restoreFromLocalBackup: (fileName: string, localBackupDir?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_RestoreFromLocalBackup, fileName, localBackupDir),
|
||||
listLocalBackupFiles: (localBackupDir?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_ListLocalBackupFiles, localBackupDir),
|
||||
deleteLocalBackupFile: (fileName: string, localBackupDir?: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteLocalBackupFile, fileName, localBackupDir),
|
||||
setLocalBackupDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.Backup_SetLocalBackupDir, dirPath),
|
||||
checkWebdavConnection: (webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig),
|
||||
|
||||
backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config),
|
||||
restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config),
|
||||
listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config),
|
||||
deleteS3File: (fileName: string, s3Config: S3Config) =>
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config),
|
||||
checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
upload: (file: FileType) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
|
||||
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
|
||||
read: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Read, fileId),
|
||||
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
|
||||
read: (fileId: string, detectEncoding?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
|
||||
clear: () => ipcRenderer.invoke(IpcChannel.File_Clear),
|
||||
get: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
create: (fileName: string) => ipcRenderer.invoke(IpcChannel.File_Create, fileName),
|
||||
/**
|
||||
* 创建一个空的临时文件
|
||||
* @param fileName 文件名
|
||||
* @returns 临时文件路径
|
||||
*/
|
||||
createTempFile: (fileName: string): Promise<string> => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName),
|
||||
/**
|
||||
* 写入文件
|
||||
* @param filePath 文件路径
|
||||
* @param data 数据
|
||||
*/
|
||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
|
||||
|
||||
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
|
||||
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
|
||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
|
||||
@@ -89,15 +139,16 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
|
||||
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
|
||||
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
|
||||
download: (url: string, isUseContentType?: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
|
||||
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
|
||||
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
|
||||
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
|
||||
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||
@@ -116,31 +167,38 @@ const api = {
|
||||
add: ({
|
||||
base,
|
||||
item,
|
||||
userId,
|
||||
forceReload = false
|
||||
}: {
|
||||
base: KnowledgeBaseParams
|
||||
item: KnowledgeItem
|
||||
userId?: string
|
||||
forceReload?: boolean
|
||||
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload }),
|
||||
}) => ipcRenderer.invoke(IpcChannel.KnowledgeBase_Add, { base, item, forceReload, userId }),
|
||||
remove: ({ uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams }) =>
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Remove, { uniqueId, uniqueIds, base }),
|
||||
search: ({ search, base }: { search: string; base: KnowledgeBaseParams }) =>
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Search, { search, base }),
|
||||
rerank: ({ search, base, results }: { search: string; base: KnowledgeBaseParams; results: ExtractChunkData[] }) =>
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results })
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Rerank, { search, base, results }),
|
||||
checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId)
|
||||
},
|
||||
window: {
|
||||
setMinimumSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
||||
resetMinimumSize: () => ipcRenderer.invoke(IpcChannel.Windows_ResetMinimumSize)
|
||||
},
|
||||
gemini: {
|
||||
uploadFile: (file: FileType, { apiKey, baseURL }: { apiKey: string; baseURL: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Gemini_UploadFile, file, { apiKey, baseURL }),
|
||||
base64File: (file: FileType) => ipcRenderer.invoke(IpcChannel.Gemini_Base64File, file),
|
||||
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_RetrieveFile, file, apiKey),
|
||||
listFiles: (apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_ListFiles, apiKey),
|
||||
deleteFile: (fileId: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.Gemini_DeleteFile, fileId, apiKey)
|
||||
fileService: {
|
||||
upload: (provider: Provider, file: FileMetadata): Promise<FileUploadResponse> =>
|
||||
ipcRenderer.invoke(IpcChannel.FileService_Upload, provider, file),
|
||||
list: (provider: Provider): Promise<FileListResponse> => ipcRenderer.invoke(IpcChannel.FileService_List, provider),
|
||||
delete: (provider: Provider, fileId: string) => ipcRenderer.invoke(IpcChannel.FileService_Delete, provider, fileId),
|
||||
retrieve: (provider: Provider, fileId: string): Promise<FileUploadResponse> =>
|
||||
ipcRenderer.invoke(IpcChannel.FileService_Retrieve, provider, fileId)
|
||||
},
|
||||
selectionMenu: {
|
||||
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
|
||||
},
|
||||
|
||||
vertexAI: {
|
||||
@@ -172,8 +230,8 @@ const api = {
|
||||
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
|
||||
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
|
||||
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
|
||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
|
||||
callTool: ({ server, name, args, callId }: { server: MCPServer; name: string; args: any; callId?: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args, callId }),
|
||||
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),
|
||||
getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }),
|
||||
@@ -181,7 +239,9 @@ const api = {
|
||||
getResource: ({ server, uri }: { server: MCPServer; uri: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server)
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server),
|
||||
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
|
||||
setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
@@ -251,7 +311,8 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.Selection_SetRemeberWinSize, isRemeberWinSize),
|
||||
setFilterMode: (filterMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterMode, filterMode),
|
||||
setFilterList: (filterList: string[]) => ipcRenderer.invoke(IpcChannel.Selection_SetFilterList, filterList),
|
||||
processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem),
|
||||
processAction: (actionItem: ActionItem, isFullScreen: boolean = false) =>
|
||||
ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem, isFullScreen),
|
||||
closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose),
|
||||
minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize),
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
@@ -259,7 +320,9 @@ const api = {
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
|
||||
navigation: {
|
||||
url: (url: string) => ipcRenderer.invoke(IpcChannel.Navigation_Url, url)
|
||||
}
|
||||
},
|
||||
setDisableHardwareAcceleration: (isDisable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
+39
-40
@@ -1,46 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#spinner {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script>
|
||||
console.time('init')
|
||||
</script>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/entryPoint.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script>
|
||||
console.time('init')
|
||||
</script>
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/mini/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Assistant</title>
|
||||
</head>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio Selection Toolbar</title>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
</head>
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
|
||||
<style>
|
||||
html {
|
||||
margin: 0 !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
#root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: max-content !important;
|
||||
height: fit-content !important;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
import { VertexAPIClient } from './gemini/VertexAPIClient'
|
||||
import { NewAPIClient } from './NewAPIClient'
|
||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
|
||||
@@ -32,6 +33,11 @@ export class ApiClientFactory {
|
||||
instance = new AihubmixAPIClient(provider) as BaseApiClient
|
||||
return instance
|
||||
}
|
||||
if (provider.id === 'new-api') {
|
||||
console.log(`[ApiClientFactory] Creating NewAPIClient for provider: ${provider.id}`)
|
||||
instance = new NewAPIClient(provider) as BaseApiClient
|
||||
return instance
|
||||
}
|
||||
if (provider.id === 'ppio') {
|
||||
console.log(`[ApiClientFactory] Creating PPIOAPIClient for provider: ${provider.id}`)
|
||||
instance = new PPIOAPIClient(provider) as BaseApiClient
|
||||
@@ -41,10 +47,9 @@ export class ApiClientFactory {
|
||||
// 然后检查标准的provider type
|
||||
switch (provider.type) {
|
||||
case 'openai':
|
||||
case 'azure-openai':
|
||||
console.log(`[ApiClientFactory] Creating OpenAIApiClient for provider: ${provider.id}`)
|
||||
instance = new OpenAIAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
case 'azure-openai':
|
||||
case 'openai-response':
|
||||
instance = new OpenAIResponseAPIClient(provider) as BaseApiClient
|
||||
break
|
||||
|
||||
@@ -254,7 +254,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
for (const fileBlock of textFileBlocks) {
|
||||
const file = fileBlock.file
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
const fileNameRow = 'file: ' + file.origin_name + '\n\n'
|
||||
text = text + fileNameRow + fileContent + divider
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { isSupportedModel } from '@renderer/config/models'
|
||||
import {
|
||||
GenerateImageParams,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
Model,
|
||||
Provider,
|
||||
ToolCallResponse
|
||||
} from '@renderer/types'
|
||||
import {
|
||||
NewApiModel,
|
||||
RequestOptions,
|
||||
SdkInstance,
|
||||
SdkMessageParam,
|
||||
SdkParams,
|
||||
SdkRawChunk,
|
||||
SdkRawOutput,
|
||||
SdkTool,
|
||||
SdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
|
||||
import { CompletionsContext } from '../middleware/types'
|
||||
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
|
||||
import { BaseApiClient } from './BaseApiClient'
|
||||
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
|
||||
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
|
||||
import { RequestTransformer, ResponseChunkTransformer } from './types'
|
||||
|
||||
export class NewAPIClient extends BaseApiClient {
|
||||
// 使用联合类型而不是any,保持类型安全
|
||||
private clients: Map<string, AnthropicAPIClient | GeminiAPIClient | OpenAIResponseAPIClient | OpenAIAPIClient> =
|
||||
new Map()
|
||||
private defaultClient: OpenAIAPIClient
|
||||
private currentClient: BaseApiClient
|
||||
|
||||
constructor(provider: Provider) {
|
||||
super(provider)
|
||||
|
||||
const claudeClient = new AnthropicAPIClient(provider)
|
||||
const geminiClient = new GeminiAPIClient(provider)
|
||||
const openaiClient = new OpenAIAPIClient(provider)
|
||||
const openaiResponseClient = new OpenAIResponseAPIClient(provider)
|
||||
|
||||
this.clients.set('claude', claudeClient)
|
||||
this.clients.set('gemini', geminiClient)
|
||||
this.clients.set('openai', openaiClient)
|
||||
this.clients.set('openai-response', openaiResponseClient)
|
||||
|
||||
// 设置默认client
|
||||
this.defaultClient = openaiClient
|
||||
this.currentClient = this.defaultClient as BaseApiClient
|
||||
}
|
||||
|
||||
override getBaseURL(): string {
|
||||
if (!this.currentClient) {
|
||||
return this.provider.apiHost
|
||||
}
|
||||
return this.currentClient.getBaseURL()
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:确保client是BaseApiClient的实例
|
||||
*/
|
||||
private isValidClient(client: unknown): client is BaseApiClient {
|
||||
return (
|
||||
client !== null &&
|
||||
client !== undefined &&
|
||||
typeof client === 'object' &&
|
||||
'createCompletions' in client &&
|
||||
'getRequestTransformer' in client &&
|
||||
'getResponseChunkTransformer' in client
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型获取合适的client
|
||||
*/
|
||||
private getClient(model: Model): BaseApiClient {
|
||||
if (!model.endpoint_type) {
|
||||
throw new Error('Model endpoint type is not defined')
|
||||
}
|
||||
|
||||
if (model.endpoint_type === 'anthropic') {
|
||||
const client = this.clients.get('claude')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('Failed to get claude client')
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
if (model.endpoint_type === 'openai-response') {
|
||||
const client = this.clients.get('openai-response')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('Failed to get openai-response client')
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
if (model.endpoint_type === 'gemini') {
|
||||
const client = this.clients.get('gemini')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('Failed to get gemini client')
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
if (model.endpoint_type === 'openai' || model.endpoint_type === 'image-generation') {
|
||||
const client = this.clients.get('openai')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('Failed to get openai client')
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
throw new Error('Invalid model endpoint type: ' + model.endpoint_type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型选择合适的client并委托调用
|
||||
*/
|
||||
public getClientForModel(model: Model): BaseApiClient {
|
||||
this.currentClient = this.getClient(model)
|
||||
return this.currentClient
|
||||
}
|
||||
|
||||
// ============ BaseApiClient 抽象方法实现 ============
|
||||
|
||||
async createCompletions(payload: SdkParams, options?: RequestOptions): Promise<SdkRawOutput> {
|
||||
// 尝试从payload中提取模型信息来选择client
|
||||
const modelId = this.extractModelFromPayload(payload)
|
||||
if (modelId) {
|
||||
const modelObj = { id: modelId } as Model
|
||||
const targetClient = this.getClient(modelObj)
|
||||
return targetClient.createCompletions(payload, options)
|
||||
}
|
||||
|
||||
// 如果无法从payload中提取模型,使用当前设置的client
|
||||
return this.currentClient.createCompletions(payload, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从SDK payload中提取模型ID
|
||||
*/
|
||||
private extractModelFromPayload(payload: SdkParams): string | null {
|
||||
// 不同的SDK可能有不同的字段名
|
||||
if ('model' in payload && typeof payload.model === 'string') {
|
||||
return payload.model
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async generateImage(params: GenerateImageParams): Promise<string[]> {
|
||||
return this.currentClient.generateImage(params)
|
||||
}
|
||||
|
||||
async getEmbeddingDimensions(model?: Model): Promise<number> {
|
||||
const client = model ? this.getClient(model) : this.currentClient
|
||||
return client.getEmbeddingDimensions(model)
|
||||
}
|
||||
|
||||
override async listModels(): Promise<NewApiModel[]> {
|
||||
try {
|
||||
const sdk = await this.defaultClient.getSdkInstance()
|
||||
// Explicitly type the expected response shape so that `data` is recognised.
|
||||
const response = await sdk.request<{ data: NewApiModel[] }>({
|
||||
method: 'get',
|
||||
path: '/models'
|
||||
})
|
||||
const models: NewApiModel[] = response.data ?? []
|
||||
|
||||
models.forEach((model) => {
|
||||
model.id = model.id.trim()
|
||||
})
|
||||
|
||||
return models.filter(isSupportedModel)
|
||||
} catch (error) {
|
||||
console.error('Error listing models:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getSdkInstance(): Promise<SdkInstance> {
|
||||
return this.currentClient.getSdkInstance()
|
||||
}
|
||||
|
||||
getRequestTransformer(): RequestTransformer<SdkParams, SdkMessageParam> {
|
||||
return this.currentClient.getRequestTransformer()
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(ctx: CompletionsContext): ResponseChunkTransformer<SdkRawChunk> {
|
||||
return this.currentClient.getResponseChunkTransformer(ctx)
|
||||
}
|
||||
|
||||
convertMcpToolsToSdkTools(mcpTools: MCPTool[]): SdkTool[] {
|
||||
return this.currentClient.convertMcpToolsToSdkTools(mcpTools)
|
||||
}
|
||||
|
||||
convertSdkToolCallToMcp(toolCall: SdkToolCall, mcpTools: MCPTool[]): MCPTool | undefined {
|
||||
return this.currentClient.convertSdkToolCallToMcp(toolCall, mcpTools)
|
||||
}
|
||||
|
||||
convertSdkToolCallToMcpToolResponse(toolCall: SdkToolCall, mcpTool: MCPTool): ToolCallResponse {
|
||||
return this.currentClient.convertSdkToolCallToMcpToolResponse(toolCall, mcpTool)
|
||||
}
|
||||
|
||||
buildSdkMessages(
|
||||
currentReqMessages: SdkMessageParam[],
|
||||
output: SdkRawOutput | string,
|
||||
toolResults: SdkMessageParam[],
|
||||
toolCalls?: SdkToolCall[]
|
||||
): SdkMessageParam[] {
|
||||
return this.currentClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
|
||||
}
|
||||
|
||||
convertMcpToolResponseToSdkMessageParam(
|
||||
mcpToolResponse: MCPToolResponse,
|
||||
resp: MCPCallToolResponse,
|
||||
model: Model
|
||||
): SdkMessageParam | undefined {
|
||||
const client = this.getClient(model)
|
||||
return client.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
|
||||
}
|
||||
|
||||
extractMessagesFromSdkPayload(sdkPayload: SdkParams): SdkMessageParam[] {
|
||||
return this.currentClient.extractMessagesFromSdkPayload(sdkPayload)
|
||||
}
|
||||
|
||||
estimateMessageTokens(message: SdkMessageParam): number {
|
||||
return this.currentClient.estimateMessageTokens(message)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,9 @@ import {
|
||||
LLMWebSearchInProgressChunk,
|
||||
MCPToolCreatedChunk,
|
||||
TextDeltaChunk,
|
||||
ThinkingDeltaChunk
|
||||
TextStartChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { type Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
@@ -229,7 +231,7 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -517,7 +519,6 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
return () => {
|
||||
let accumulatedJson = ''
|
||||
const toolCalls: Record<number, ToolUseBlock> = {}
|
||||
|
||||
return {
|
||||
async transform(rawChunk: AnthropicSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
switch (rawChunk.type) {
|
||||
@@ -612,6 +613,19 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
toolCalls[rawChunk.index] = contentBlock
|
||||
break
|
||||
}
|
||||
case 'text': {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
break
|
||||
}
|
||||
case 'thinking':
|
||||
case 'redacted_thinking': {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Content,
|
||||
createPartFromUri,
|
||||
File,
|
||||
FileState,
|
||||
FunctionCall,
|
||||
GenerateContentConfig,
|
||||
GenerateImagesConfig,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
HarmCategory,
|
||||
Modality,
|
||||
Model as GeminiModel,
|
||||
Pager,
|
||||
Part,
|
||||
SafetySetting,
|
||||
SendMessageParameters,
|
||||
@@ -26,13 +25,13 @@ import {
|
||||
isSupportedThinkingTokenGeminiModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { CacheService } from '@renderer/services/CacheService'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import {
|
||||
Assistant,
|
||||
EFFORT_RATIO,
|
||||
FileType,
|
||||
FileMetadata,
|
||||
FileTypes,
|
||||
FileUploadResponse,
|
||||
GenerateImageParams,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
@@ -42,7 +41,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType, LLMWebSearchCompleteChunk, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
GeminiOptions,
|
||||
@@ -198,7 +197,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
* @param file - The file
|
||||
* @returns The part
|
||||
*/
|
||||
private async handlePdfFile(file: FileType): Promise<Part> {
|
||||
private async handlePdfFile(file: FileMetadata): Promise<Part> {
|
||||
const smallFileSize = 20 * MB
|
||||
const isSmallFile = file.size < smallFileSize
|
||||
|
||||
@@ -213,26 +212,17 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
// Retrieve file from Gemini uploaded files
|
||||
const fileMetadata: File | undefined = await this.retrieveFile(file)
|
||||
const fileMetadata: FileUploadResponse = await window.api.fileService.retrieve(this.provider, file.id)
|
||||
|
||||
if (fileMetadata) {
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: fileMetadata.uri,
|
||||
mimeType: fileMetadata.mimeType
|
||||
} as Part['fileData']
|
||||
}
|
||||
if (fileMetadata.status === 'success') {
|
||||
const remoteFile = fileMetadata.originalFile?.file as File
|
||||
return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!)
|
||||
}
|
||||
|
||||
// If file is not found, upload it to Gemini
|
||||
const result = await this.uploadFile(file)
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: result.uri,
|
||||
mimeType: result.mimeType
|
||||
} as Part['fileData']
|
||||
}
|
||||
const result = await window.api.fileService.upload(this.provider, file)
|
||||
const remoteFile = result.originalFile?.file as File
|
||||
return createPartFromUri(remoteFile.uri!, remoteFile.mimeType!)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,7 +288,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
continue
|
||||
}
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
})
|
||||
@@ -557,20 +547,34 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
|
||||
getResponseChunkTransformer(): ResponseChunkTransformer<GeminiSdkRawChunk> {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
let isFirstTextChunk = true
|
||||
let isFirstThinkingChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: GeminiSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
const toolCalls: FunctionCall[] = []
|
||||
if (chunk.candidates && chunk.candidates.length > 0) {
|
||||
for (const candidate of chunk.candidates) {
|
||||
if (candidate.content) {
|
||||
candidate.content.parts?.forEach((part) => {
|
||||
const text = part.text || ''
|
||||
if (part.thought) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: text
|
||||
})
|
||||
} else if (part.text) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: text
|
||||
@@ -603,6 +607,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
}
|
||||
} as LLMWebSearchCompleteChunk)
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_calls: [...toolCalls]
|
||||
})
|
||||
toolCalls.length = 0
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_RESPONSE_COMPLETE,
|
||||
response: {
|
||||
@@ -767,61 +778,11 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
return [...(sdkPayload.history || []), messageParam]
|
||||
}
|
||||
|
||||
private async uploadFile(file: FileType): Promise<File> {
|
||||
return await this.sdkInstance!.files.upload({
|
||||
file: file.path,
|
||||
config: {
|
||||
mimeType: 'application/pdf',
|
||||
name: file.id,
|
||||
displayName: file.origin_name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async base64File(file: FileType) {
|
||||
private async base64File(file: FileMetadata) {
|
||||
const { data } = await window.api.file.base64File(file.id + file.ext)
|
||||
return {
|
||||
data,
|
||||
mimeType: 'application/pdf'
|
||||
}
|
||||
}
|
||||
|
||||
private async retrieveFile(file: FileType): Promise<File | undefined> {
|
||||
const cachedResponse = CacheService.get<any>('gemini_file_list')
|
||||
|
||||
if (cachedResponse) {
|
||||
return this.processResponse(cachedResponse, file)
|
||||
}
|
||||
|
||||
const response = await this.sdkInstance!.files.list()
|
||||
CacheService.set('gemini_file_list', response, 3000)
|
||||
|
||||
return this.processResponse(response, file)
|
||||
}
|
||||
|
||||
private async processResponse(response: Pager<File>, file: FileType) {
|
||||
for await (const f of response) {
|
||||
if (f.state === FileState.ACTIVE) {
|
||||
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// @ts-ignore unused
|
||||
private async listFiles(): Promise<File[]> {
|
||||
const files: File[] = []
|
||||
for await (const f of await this.sdkInstance!.files.list()) {
|
||||
files.push(f)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// @ts-ignore unused
|
||||
private async deleteFile(fileId: string) {
|
||||
await this.sdkInstance!.files.delete({ name: fileId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
ToolCallResponse,
|
||||
WebSearchSource
|
||||
} from '@renderer/types'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
|
||||
import { Message } from '@renderer/types/newMessage'
|
||||
import {
|
||||
OpenAISdkMessageParam,
|
||||
@@ -307,7 +307,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = await (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -564,11 +564,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// Perplexity citations
|
||||
// @ts-ignore - citations may not be in standard type definitions
|
||||
if (context.provider?.id === 'perplexity' && chunk.citations && chunk.citations.length > 0) {
|
||||
if (context.provider?.id === 'perplexity' && chunk.search_results && chunk.search_results.length > 0) {
|
||||
hasBeenCollectedWebSearch = true
|
||||
return {
|
||||
// @ts-ignore - citations may not be in standard type definitions
|
||||
results: chunk.citations,
|
||||
results: chunk.search_results,
|
||||
source: WebSearchSource.PERPLEXITY
|
||||
}
|
||||
}
|
||||
@@ -659,6 +659,8 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return (context: ResponseChunkTransformerContext) => ({
|
||||
async transform(chunk: OpenAISdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 持续更新usage信息
|
||||
@@ -672,74 +674,21 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 处理chunk
|
||||
if ('choices' in chunk && chunk.choices && chunk.choices.length > 0) {
|
||||
const choice = chunk.choices[0]
|
||||
for (const choice of chunk.choices) {
|
||||
if (!choice) continue
|
||||
|
||||
if (!choice) return
|
||||
|
||||
// 对于流式响应,使用 delta;对于非流式响应,使用 message。
|
||||
// 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。
|
||||
// 如果 delta 为空对象,应当忽略它并回退到 message,避免造成内容缺失。
|
||||
let contentSource: OpenAISdkRawContentSource | null = null
|
||||
if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) {
|
||||
contentSource = choice.delta
|
||||
} else if ('message' in choice) {
|
||||
contentSource = choice.message
|
||||
}
|
||||
|
||||
if (!contentSource) return
|
||||
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
|
||||
llm_web_search: webSearchData
|
||||
})
|
||||
}
|
||||
|
||||
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: reasoningText
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
})
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (contentSource.tool_calls) {
|
||||
for (const toolCall of contentSource.tool_calls) {
|
||||
if ('index' in toolCall) {
|
||||
const { id, index, function: fun } = toolCall
|
||||
if (fun?.name) {
|
||||
toolCalls[index] = {
|
||||
id: id || '',
|
||||
function: {
|
||||
name: fun.name,
|
||||
arguments: fun.arguments || ''
|
||||
},
|
||||
type: 'function'
|
||||
}
|
||||
} else if (fun?.arguments) {
|
||||
toolCalls[index].function.arguments += fun.arguments
|
||||
}
|
||||
} else {
|
||||
toolCalls.push(toolCall)
|
||||
}
|
||||
// 对于流式响应,使用 delta;对于非流式响应,使用 message。
|
||||
// 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。
|
||||
// 如果 delta 为空对象,应当忽略它并回退到 message,避免造成内容缺失。
|
||||
let contentSource: OpenAISdkRawContentSource | null = null
|
||||
if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) {
|
||||
contentSource = choice.delta
|
||||
} else if ('message' in choice) {
|
||||
contentSource = choice.message
|
||||
}
|
||||
}
|
||||
|
||||
// 处理finish_reason,发送流结束信号
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
|
||||
if (!contentSource) continue
|
||||
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
controller.enqueue({
|
||||
@@ -747,7 +696,72 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
llm_web_search: webSearchData
|
||||
})
|
||||
}
|
||||
emitCompletionSignals(controller)
|
||||
|
||||
// 处理推理内容 (e.g. from OpenRouter DeepSeek-R1)
|
||||
// @ts-ignore - reasoning_content is not in standard OpenAI types but some providers use it
|
||||
const reasoningText = contentSource.reasoning_content || contentSource.reasoning
|
||||
if (reasoningText) {
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: reasoningText
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if (contentSource.content) {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: contentSource.content
|
||||
})
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (contentSource.tool_calls) {
|
||||
for (const toolCall of contentSource.tool_calls) {
|
||||
if ('index' in toolCall) {
|
||||
const { id, index, function: fun } = toolCall
|
||||
if (fun?.name) {
|
||||
toolCalls[index] = {
|
||||
id: id || '',
|
||||
function: {
|
||||
name: fun.name,
|
||||
arguments: fun.arguments || ''
|
||||
},
|
||||
type: 'function'
|
||||
}
|
||||
} else if (fun?.arguments) {
|
||||
toolCalls[index].function.arguments += fun.arguments
|
||||
}
|
||||
} else {
|
||||
toolCalls.push(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理finish_reason,发送流结束信号
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
Logger.debug(`[OpenAIApiClient] Stream finished with reason: ${choice.finish_reason}`)
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_WEB_SEARCH_COMPLETE,
|
||||
llm_web_search: webSearchData
|
||||
})
|
||||
}
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
|
||||
const data = await sdk.embeddings.create({
|
||||
model: model.id,
|
||||
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
|
||||
encoding_format: 'float'
|
||||
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
|
||||
})
|
||||
return data.data[0].embedding.length
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
|
||||
import {
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAILLMModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isVisionModel
|
||||
} from '@renderer/config/models'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import {
|
||||
FileType,
|
||||
FileMetadata,
|
||||
FileTypes,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
@@ -38,7 +39,7 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
|
||||
import { buildSystemPrompt } from '@renderer/utils/prompt'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { isEmpty } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { ResponseInput } from 'openai/resources/responses/responses'
|
||||
|
||||
import { RequestTransformer, ResponseChunkTransformer } from '../types'
|
||||
@@ -64,10 +65,13 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
* 根据模型特征选择合适的客户端
|
||||
*/
|
||||
public getClient(model: Model) {
|
||||
if (isOpenAIChatCompletionOnlyModel(model)) {
|
||||
return this.client
|
||||
} else {
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiVersion: 'preview' }
|
||||
}
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,15 +80,25 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return this.sdkInstance
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
|
||||
return new AzureOpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
apiVersion: this.provider.apiVersion,
|
||||
baseURL: this.provider.apiHost
|
||||
})
|
||||
} else {
|
||||
return new OpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
baseURL: this.getBaseURL(),
|
||||
defaultHeaders: {
|
||||
...this.defaultHeaders(),
|
||||
...this.provider.extra_headers
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async createCompletions(
|
||||
@@ -95,7 +109,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return await sdk.responses.create(payload, options)
|
||||
}
|
||||
|
||||
private async handlePdfFile(file: FileType): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
|
||||
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
|
||||
if (file.size > 32 * MB) return undefined
|
||||
try {
|
||||
const pageCount = await window.api.file.pdfInfo(file.id + file.ext)
|
||||
@@ -172,7 +186,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext)).trim()
|
||||
const fileContent = (await window.api.file.read(file.id + file.ext, true)).trim()
|
||||
parts.push({
|
||||
type: 'input_text',
|
||||
text: file.origin_name + '\n' + fileContent
|
||||
@@ -353,16 +367,15 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
(m) => (m as OpenAI.Responses.EasyInputMessage).role === 'assistant'
|
||||
) as OpenAI.Responses.EasyInputMessage
|
||||
const finalUserMessage = userMessage.pop() as OpenAI.Responses.EasyInputMessage
|
||||
if (
|
||||
finalAssistantMessage &&
|
||||
Array.isArray(finalAssistantMessage.content) &&
|
||||
finalUserMessage &&
|
||||
Array.isArray(finalUserMessage.content)
|
||||
) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
if (finalUserMessage && Array.isArray(finalUserMessage.content)) {
|
||||
if (finalAssistantMessage && Array.isArray(finalAssistantMessage.content)) {
|
||||
finalAssistantMessage.content = [...finalAssistantMessage.content, ...finalUserMessage.content]
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
} else {
|
||||
userMessage.push(finalUserMessage)
|
||||
}
|
||||
}
|
||||
// 这里是故意将上条助手消息的内容(包含图片和文件)作为用户消息发送
|
||||
userMessage = [{ ...finalAssistantMessage, role: 'user' } as OpenAI.Responses.EasyInputMessage]
|
||||
}
|
||||
|
||||
// 4. 最终请求消息
|
||||
@@ -423,6 +436,8 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
const outputItems: OpenAI.Responses.ResponseOutputItem[] = []
|
||||
let hasBeenCollectedToolCalls = false
|
||||
let hasReasoningSummary = false
|
||||
let isFirstThinkingChunk = true
|
||||
let isFirstTextChunk = true
|
||||
return () => ({
|
||||
async transform(chunk: OpenAIResponseSdkRawChunk, controller: TransformStreamDefaultController<GenericChunk>) {
|
||||
// 处理chunk
|
||||
@@ -434,6 +449,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
switch (output.type) {
|
||||
case 'message':
|
||||
if (output.content[0].type === 'output_text') {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: output.content[0].text
|
||||
@@ -450,6 +471,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
break
|
||||
case 'reasoning':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: output.summary.map((s) => s.text).join('\n')
|
||||
@@ -492,6 +519,10 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
case 'response.output_item.added':
|
||||
if (chunk.item.type === 'function_call') {
|
||||
outputItems.push(chunk.item)
|
||||
} else if (chunk.item.type === 'web_search_call') {
|
||||
controller.enqueue({
|
||||
type: ChunkType.LLM_WEB_SEARCH_IN_PROGRESS
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'response.reasoning_summary_part.added':
|
||||
@@ -505,6 +536,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
hasReasoningSummary = true
|
||||
break
|
||||
case 'response.reasoning_summary_text.delta':
|
||||
if (isFirstThinkingChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
})
|
||||
isFirstThinkingChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: chunk.delta
|
||||
@@ -530,6 +567,12 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
})
|
||||
break
|
||||
case 'response.output_text.delta': {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: chunk.delta
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isEnabledToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { OpenAIAPIClient } from './clients'
|
||||
import { AihubmixAPIClient } from './clients/AihubmixAPIClient'
|
||||
import { AnthropicAPIClient } from './clients/anthropic/AnthropicAPIClient'
|
||||
import { NewAPIClient } from './clients/NewAPIClient'
|
||||
import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient'
|
||||
import { CompletionsMiddlewareBuilder } from './middleware/builder'
|
||||
import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware'
|
||||
@@ -48,6 +49,11 @@ export default class AiProvider {
|
||||
if (client instanceof OpenAIResponseAPIClient) {
|
||||
client = client.getClient(model) as BaseApiClient
|
||||
}
|
||||
} else if (this.apiClient instanceof NewAPIClient) {
|
||||
client = this.apiClient.getClientForModel(model)
|
||||
if (client instanceof OpenAIResponseAPIClient) {
|
||||
client = client.getClient(model) as BaseApiClient
|
||||
}
|
||||
} else if (this.apiClient instanceof OpenAIResponseAPIClient) {
|
||||
// OpenAIResponseAPIClient: 根据模型特征选择API类型
|
||||
client = this.apiClient.getClient(model) as BaseApiClient
|
||||
@@ -69,11 +75,12 @@ export default class AiProvider {
|
||||
} else {
|
||||
// Existing logic for other models
|
||||
if (!params.enableReasoning) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
// 这里注释掉不会影响正常的关闭思考,可忽略不计的性能下降
|
||||
// builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
builder.remove(ThinkChunkMiddlewareName)
|
||||
}
|
||||
// 注意:用client判断会导致typescript类型收窄
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient)) {
|
||||
if (!(this.apiClient instanceof OpenAIAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
builder.remove(ThinkingTagExtractionMiddlewareName)
|
||||
}
|
||||
if (!(this.apiClient instanceof AnthropicAPIClient) && !(this.apiClient instanceof OpenAIResponseAPIClient)) {
|
||||
|
||||
@@ -67,7 +67,12 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
|
||||
const streamWithAbortHandler = (result.stream as ReadableStream<Chunk>).pipeThrough(
|
||||
new TransformStream<Chunk, Chunk | ErrorChunk>({
|
||||
transform(chunk, controller) {
|
||||
// 检查 abort 状态
|
||||
// 如果已经收到错误块,不再检查 abort 状态
|
||||
if (chunk.type === ChunkType.ERROR) {
|
||||
controller.enqueue(chunk)
|
||||
return
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
// 转换为 ErrorChunk
|
||||
const errorChunk: ErrorChunk = {
|
||||
|
||||
@@ -136,7 +136,6 @@ function extractAndAccumulateUsageMetrics(ctx: CompletionsContext, chunk: Generi
|
||||
Logger.debug(`[${MIDDLEWARE_NAME}] First token timestamp: ${ctx._internal.customState.firstTokenTimestamp}`)
|
||||
}
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
Logger.debug(`[${MIDDLEWARE_NAME}] LLM_RESPONSE_COMPLETE chunk received:`, ctx._internal)
|
||||
// 从LLM_RESPONSE_COMPLETE chunk中提取usage数据
|
||||
if (chunk.response?.usage) {
|
||||
accumulateUsage(ctx._internal.observer.usage, chunk.response.usage)
|
||||
|
||||
@@ -89,6 +89,11 @@ function createToolHandlingTransform(
|
||||
let hasToolUseResponses = false
|
||||
let streamEnded = false
|
||||
|
||||
// 存储已执行的工具结果
|
||||
const executedToolResults: SdkMessageParam[] = []
|
||||
const executedToolCalls: SdkToolCall[] = []
|
||||
const executionPromises: Promise<void>[] = []
|
||||
|
||||
return new TransformStream({
|
||||
async transform(chunk: GenericChunk, controller) {
|
||||
try {
|
||||
@@ -98,22 +103,64 @@ function createToolHandlingTransform(
|
||||
|
||||
// 1. 处理Function Call方式的工具调用
|
||||
if (createdChunk.tool_calls && createdChunk.tool_calls.length > 0) {
|
||||
toolCalls.push(...createdChunk.tool_calls)
|
||||
hasToolCalls = true
|
||||
|
||||
for (const toolCall of createdChunk.tool_calls) {
|
||||
toolCalls.push(toolCall)
|
||||
|
||||
const executionPromise = (async () => {
|
||||
try {
|
||||
const result = await executeToolCalls(
|
||||
ctx,
|
||||
[toolCall],
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
|
||||
// 缓存执行结果
|
||||
executedToolResults.push(...result.toolResults)
|
||||
executedToolCalls.push(...result.confirmedToolCalls)
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool call asynchronously:`, error)
|
||||
}
|
||||
})()
|
||||
|
||||
executionPromises.push(executionPromise)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理Tool Use方式的工具调用
|
||||
if (createdChunk.tool_use_responses && createdChunk.tool_use_responses.length > 0) {
|
||||
toolUseResponses.push(...createdChunk.tool_use_responses)
|
||||
hasToolUseResponses = true
|
||||
for (const toolUseResponse of createdChunk.tool_use_responses) {
|
||||
toolUseResponses.push(toolUseResponse)
|
||||
const executionPromise = (async () => {
|
||||
try {
|
||||
const result = await executeToolUseResponses(
|
||||
ctx,
|
||||
[toolUseResponse], // 单个执行
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
|
||||
// 缓存执行结果
|
||||
executedToolResults.push(...result.toolResults)
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error executing tool use response asynchronously:`, error)
|
||||
// 错误时不影响其他工具的执行
|
||||
}
|
||||
})()
|
||||
|
||||
executionPromises.push(executionPromise)
|
||||
}
|
||||
}
|
||||
|
||||
// 不转发MCP工具进展chunks,避免重复处理
|
||||
return
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
// 转发其他所有chunk
|
||||
controller.enqueue(chunk)
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error processing chunk:`, error)
|
||||
controller.error(error)
|
||||
@@ -121,43 +168,33 @@ function createToolHandlingTransform(
|
||||
},
|
||||
|
||||
async flush(controller) {
|
||||
const shouldExecuteToolCalls = hasToolCalls && toolCalls.length > 0
|
||||
const shouldExecuteToolUseResponses = hasToolUseResponses && toolUseResponses.length > 0
|
||||
|
||||
if (!streamEnded && (shouldExecuteToolCalls || shouldExecuteToolUseResponses)) {
|
||||
// 在流结束时等待所有异步工具执行完成,然后进行递归调用
|
||||
if (!streamEnded && (hasToolCalls || hasToolUseResponses)) {
|
||||
streamEnded = true
|
||||
|
||||
try {
|
||||
let toolResult: SdkMessageParam[] = []
|
||||
|
||||
if (shouldExecuteToolCalls) {
|
||||
toolResult = await executeToolCalls(
|
||||
ctx,
|
||||
toolCalls,
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
} else if (shouldExecuteToolUseResponses) {
|
||||
toolResult = await executeToolUseResponses(
|
||||
ctx,
|
||||
toolUseResponses,
|
||||
mcpTools,
|
||||
allToolResponses,
|
||||
currentParams.onChunk,
|
||||
currentParams.assistant.model!
|
||||
)
|
||||
}
|
||||
|
||||
if (toolResult.length > 0) {
|
||||
await Promise.all(executionPromises)
|
||||
if (executedToolResults.length > 0) {
|
||||
const output = ctx._internal.toolProcessingState?.output
|
||||
const newParams = buildParamsWithToolResults(
|
||||
ctx,
|
||||
currentParams,
|
||||
output,
|
||||
executedToolResults,
|
||||
executedToolCalls
|
||||
)
|
||||
|
||||
// 在递归调用前通知UI开始新的LLM响应处理
|
||||
if (currentParams.onChunk) {
|
||||
currentParams.onChunk({
|
||||
type: ChunkType.LLM_RESPONSE_CREATED
|
||||
})
|
||||
}
|
||||
|
||||
const newParams = buildParamsWithToolResults(ctx, currentParams, output, toolResult, toolCalls)
|
||||
await executeWithToolHandling(newParams, depth + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
|
||||
Logger.error(`🔧 [${MIDDLEWARE_NAME}] Error in tool processing:`, error)
|
||||
controller.error(error)
|
||||
} finally {
|
||||
hasToolCalls = false
|
||||
@@ -178,8 +215,7 @@ async function executeToolCalls(
|
||||
allToolResponses: MCPToolResponse[],
|
||||
onChunk: CompletionsParams['onChunk'],
|
||||
model: Model
|
||||
): Promise<SdkMessageParam[]> {
|
||||
// 转换为MCPToolResponse格式
|
||||
): Promise<{ toolResults: SdkMessageParam[]; confirmedToolCalls: SdkToolCall[] }> {
|
||||
const mcpToolResponses: ToolCallResponse[] = toolCalls
|
||||
.map((toolCall) => {
|
||||
const mcpTool = ctx.apiClientInstance.convertSdkToolCallToMcp(toolCall, mcpTools)
|
||||
@@ -192,11 +228,11 @@ async function executeToolCalls(
|
||||
|
||||
if (mcpToolResponses.length === 0) {
|
||||
console.warn(`🔧 [${MIDDLEWARE_NAME}] No valid MCP tool responses to execute`)
|
||||
return []
|
||||
return { toolResults: [], confirmedToolCalls: [] }
|
||||
}
|
||||
|
||||
// 使用现有的parseAndCallTools函数执行工具
|
||||
const toolResults = await parseAndCallTools(
|
||||
const { toolResults, confirmedToolResponses } = await parseAndCallTools(
|
||||
mcpToolResponses,
|
||||
allToolResponses,
|
||||
onChunk,
|
||||
@@ -204,10 +240,26 @@ async function executeToolCalls(
|
||||
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
|
||||
},
|
||||
model,
|
||||
mcpTools
|
||||
mcpTools,
|
||||
ctx._internal?.flowControl?.abortSignal
|
||||
)
|
||||
|
||||
return toolResults
|
||||
// 找出已确认工具对应的原始toolCalls
|
||||
const confirmedToolCalls = toolCalls.filter((toolCall) => {
|
||||
return confirmedToolResponses.find((confirmed) => {
|
||||
// 根据不同的ID字段匹配原始toolCall
|
||||
return (
|
||||
('name' in toolCall &&
|
||||
(toolCall.name?.includes(confirmed.tool.name) || toolCall.name?.includes(confirmed.tool.id))) ||
|
||||
confirmed.tool.name === toolCall.id ||
|
||||
confirmed.tool.id === toolCall.id ||
|
||||
('toolCallId' in confirmed && confirmed.toolCallId === toolCall.id) ||
|
||||
('function' in toolCall && toolCall.function.name.toLowerCase().includes(confirmed.tool.name.toLowerCase()))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return { toolResults, confirmedToolCalls }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,9 +273,9 @@ async function executeToolUseResponses(
|
||||
allToolResponses: MCPToolResponse[],
|
||||
onChunk: CompletionsParams['onChunk'],
|
||||
model: Model
|
||||
): Promise<SdkMessageParam[]> {
|
||||
): Promise<{ toolResults: SdkMessageParam[] }> {
|
||||
// 直接使用parseAndCallTools函数处理已经解析好的ToolUseResponse
|
||||
const toolResults = await parseAndCallTools(
|
||||
const { toolResults } = await parseAndCallTools(
|
||||
toolUseResponses,
|
||||
allToolResponses,
|
||||
onChunk,
|
||||
@@ -231,10 +283,11 @@ async function executeToolUseResponses(
|
||||
return ctx.apiClientInstance.convertMcpToolResponseToSdkMessageParam(mcpToolResponse, resp, model)
|
||||
},
|
||||
model,
|
||||
mcpTools
|
||||
mcpTools,
|
||||
ctx._internal?.flowControl?.abortSignal
|
||||
)
|
||||
|
||||
return toolResults
|
||||
return { toolResults }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,7 +298,7 @@ function buildParamsWithToolResults(
|
||||
currentParams: CompletionsParams,
|
||||
output: SdkRawOutput | string | undefined,
|
||||
toolResults: SdkMessageParam[],
|
||||
toolCalls: SdkToolCall[]
|
||||
confirmedToolCalls: SdkToolCall[]
|
||||
): CompletionsParams {
|
||||
// 获取当前已经转换好的reqMessages,如果没有则使用原始messages
|
||||
const currentReqMessages = getCurrentReqMessages(ctx)
|
||||
@@ -253,7 +306,7 @@ function buildParamsWithToolResults(
|
||||
const apiClient = ctx.apiClientInstance
|
||||
|
||||
// 从回复中构建助手消息
|
||||
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, toolCalls)
|
||||
const newReqMessages = apiClient.buildSdkMessages(currentReqMessages, output, toolResults, confirmedToolCalls)
|
||||
|
||||
if (output && ctx._internal.toolProcessingState) {
|
||||
ctx._internal.toolProcessingState.output = undefined
|
||||
|
||||
@@ -38,7 +38,6 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 用于跨chunk的状态管理
|
||||
let accumulatedTextContent = ''
|
||||
let hasEnqueue = false
|
||||
const enhancedTextStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -53,30 +52,32 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
|
||||
// 创建新的chunk,包含处理后的文本
|
||||
controller.enqueue(chunk)
|
||||
} else if (accumulatedTextContent) {
|
||||
if (chunk.type !== ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
const finalText = accumulatedTextContent
|
||||
ctx._internal.customState!.accumulatedText = finalText
|
||||
if (ctx._internal.toolProcessingState && !ctx._internal.toolProcessingState?.output) {
|
||||
ctx._internal.toolProcessingState.output = finalText
|
||||
}
|
||||
|
||||
// 处理 onResponse 回调 - 发送最终完整文本
|
||||
if (params.onResponse) {
|
||||
params.onResponse(finalText, true)
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
text: finalText
|
||||
})
|
||||
controller.enqueue(chunk)
|
||||
} else {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
text: accumulatedTextContent
|
||||
})
|
||||
controller.enqueue(chunk)
|
||||
hasEnqueue = true
|
||||
}
|
||||
const finalText = accumulatedTextContent
|
||||
ctx._internal.customState!.accumulatedText = finalText
|
||||
if (ctx._internal.toolProcessingState && !ctx._internal.toolProcessingState?.output) {
|
||||
ctx._internal.toolProcessingState.output = finalText
|
||||
}
|
||||
|
||||
// 处理 onResponse 回调 - 发送最终完整文本
|
||||
if (params.onResponse) {
|
||||
params.onResponse(finalText, true)
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
text: finalText
|
||||
})
|
||||
accumulatedTextContent = ''
|
||||
if (!hasEnqueue) {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
} else {
|
||||
// 其他类型的chunk直接传递
|
||||
controller.enqueue(chunk)
|
||||
|
||||
@@ -65,7 +65,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(enhancedChunk)
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0) {
|
||||
} else if (hasThinkingContent && thinkingStartTime > 0 && chunk.type !== ChunkType.THINKING_START) {
|
||||
// 收到任何非THINKING_DELTA的chunk时,如果有累积的思考内容,生成THINKING_COMPLETE
|
||||
const thinkingCompleteChunk: ThinkingCompleteChunk = {
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
|
||||
@@ -42,7 +42,12 @@ export const WebSearchMiddleware: CompletionsMiddleware =
|
||||
const providerType = model.provider || 'openai'
|
||||
// 使用当前可用的Web搜索结果进行链接转换
|
||||
const text = chunk.text
|
||||
const result = smartLinkConverter(text, providerType, isFirstChunk)
|
||||
const result = smartLinkConverter(
|
||||
text,
|
||||
providerType,
|
||||
isFirstChunk,
|
||||
ctx._internal.webSearchState!.results
|
||||
)
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import { ChunkType, TextDeltaChunk, ThinkingCompleteChunk, ThinkingDeltaChunk } from '@renderer/types/chunk'
|
||||
import {
|
||||
ChunkType,
|
||||
TextDeltaChunk,
|
||||
ThinkingCompleteChunk,
|
||||
ThinkingDeltaChunk,
|
||||
ThinkingStartChunk
|
||||
} from '@renderer/types/chunk'
|
||||
import { TagConfig, TagExtractor } from '@renderer/utils/tagExtraction'
|
||||
import Logger from 'electron-log/renderer'
|
||||
|
||||
@@ -59,6 +65,8 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
let hasThinkingContent = false
|
||||
let thinkingStartTime = 0
|
||||
|
||||
let isFirstTextChunk = true
|
||||
|
||||
const processedStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -69,7 +77,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
const extractionResults = tagExtractor.processText(textChunk.text)
|
||||
|
||||
for (const extractionResult of extractionResults) {
|
||||
if (extractionResult.complete && extractionResult.tagContentExtracted) {
|
||||
if (extractionResult.complete && extractionResult.tagContentExtracted?.trim()) {
|
||||
// 生成 THINKING_COMPLETE 事件
|
||||
const thinkingCompleteChunk: ThinkingCompleteChunk = {
|
||||
type: ChunkType.THINKING_COMPLETE,
|
||||
@@ -87,15 +95,26 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
if (!hasThinkingContent) {
|
||||
hasThinkingContent = true
|
||||
thinkingStartTime = Date.now()
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
}
|
||||
|
||||
const thinkingDeltaChunk: ThinkingDeltaChunk = {
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: extractionResult.content,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
if (extractionResult.content?.trim()) {
|
||||
const thinkingDeltaChunk: ThinkingDeltaChunk = {
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: extractionResult.content,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
}
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
} else {
|
||||
if (isFirstTextChunk) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
})
|
||||
isFirstTextChunk = false
|
||||
}
|
||||
// 发送清理后的文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
@@ -105,7 +124,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (chunk.type !== ChunkType.TEXT_START) {
|
||||
// 其他类型的chunk直接传递(包括 THINKING_DELTA, THINKING_COMPLETE 等)
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ const TOOL_USE_TAG_CONFIG: TagConfig = {
|
||||
* 1. 从文本流中检测并提取 <tool_use></tool_use> 标签
|
||||
* 2. 解析工具调用信息并转换为 ToolUseResponse 格式
|
||||
* 3. 生成 MCP_TOOL_CREATED chunk 供 McpToolChunkMiddleware 处理
|
||||
* 4. 清理文本流,移除工具使用标签但保留正常文本
|
||||
* 4. 丢弃 tool_use 之后的所有内容(助手幻觉)
|
||||
* 5. 清理文本流,移除工具使用标签但保留正常文本
|
||||
*
|
||||
* 注意:此中间件只负责提取和转换,实际工具调用由 McpToolChunkMiddleware 处理
|
||||
*/
|
||||
@@ -32,13 +33,10 @@ export const ToolUseExtractionMiddleware: CompletionsMiddleware =
|
||||
async (ctx: CompletionsContext, params: CompletionsParams): Promise<CompletionsResult> => {
|
||||
const mcpTools = params.mcpTools || []
|
||||
|
||||
// 如果没有工具,直接调用下一个中间件
|
||||
if (!mcpTools || mcpTools.length === 0) return next(ctx, params)
|
||||
|
||||
// 调用下游中间件
|
||||
const result = await next(ctx, params)
|
||||
|
||||
// 响应后处理:处理工具使用标签提取
|
||||
if (result.stream) {
|
||||
const resultFromUpstream = result.stream as ReadableStream<GenericChunk>
|
||||
|
||||
@@ -60,7 +58,9 @@ function createToolUseExtractionTransform(
|
||||
_ctx: CompletionsContext,
|
||||
mcpTools: MCPTool[]
|
||||
): TransformStream<GenericChunk, GenericChunk> {
|
||||
const tagExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
|
||||
const toolUseExtractor = new TagExtractor(TOOL_USE_TAG_CONFIG)
|
||||
let hasAnyToolUse = false
|
||||
let toolCounter = 0
|
||||
|
||||
return new TransformStream({
|
||||
async transform(chunk: GenericChunk, controller) {
|
||||
@@ -68,30 +68,38 @@ function createToolUseExtractionTransform(
|
||||
// 处理文本内容,检测工具使用标签
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
const textChunk = chunk as TextDeltaChunk
|
||||
const extractionResults = tagExtractor.processText(textChunk.text)
|
||||
|
||||
for (const result of extractionResults) {
|
||||
// 处理 tool_use 标签
|
||||
const toolUseResults = toolUseExtractor.processText(textChunk.text)
|
||||
|
||||
for (const result of toolUseResults) {
|
||||
if (result.complete && result.tagContentExtracted) {
|
||||
// 提取到完整的工具使用内容,解析并转换为 SDK ToolCall 格式
|
||||
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools)
|
||||
const toolUseResponses = parseToolUse(result.tagContentExtracted, mcpTools, toolCounter)
|
||||
toolCounter += toolUseResponses.length
|
||||
|
||||
if (toolUseResponses.length > 0) {
|
||||
// 生成 MCP_TOOL_CREATED chunk,复用现有的处理流程
|
||||
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
|
||||
// 生成 MCP_TOOL_CREATED chunk
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_use_responses: toolUseResponses
|
||||
}
|
||||
controller.enqueue(mcpToolCreatedChunk)
|
||||
|
||||
// 标记已有工具调用
|
||||
hasAnyToolUse = true
|
||||
}
|
||||
} else if (!result.isTagContent && result.content) {
|
||||
// 发送标签外的正常文本内容
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
text: result.content
|
||||
if (!hasAnyToolUse) {
|
||||
const cleanTextChunk: TextDeltaChunk = {
|
||||
...textChunk,
|
||||
text: result.content
|
||||
}
|
||||
controller.enqueue(cleanTextChunk)
|
||||
}
|
||||
controller.enqueue(cleanTextChunk)
|
||||
}
|
||||
// 注意:标签内的内容不会作为TEXT_DELTA转发,避免重复显示
|
||||
// tool_use 标签内的内容不转发,避免重复显示
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -105,16 +113,17 @@ function createToolUseExtractionTransform(
|
||||
},
|
||||
|
||||
async flush(controller) {
|
||||
// 检查是否有未完成的标签内容
|
||||
const finalResult = tagExtractor.finalize()
|
||||
if (finalResult && finalResult.tagContentExtracted) {
|
||||
const toolUseResponses = parseToolUse(finalResult.tagContentExtracted, mcpTools)
|
||||
// 检查是否有未完成的 tool_use 标签内容
|
||||
const finalToolUseResult = toolUseExtractor.finalize()
|
||||
if (finalToolUseResult && finalToolUseResult.tagContentExtracted) {
|
||||
const toolUseResponses = parseToolUse(finalToolUseResult.tagContentExtracted, mcpTools, toolCounter)
|
||||
if (toolUseResponses.length > 0) {
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
tool_use_responses: toolUseResponses
|
||||
}
|
||||
controller.enqueue(mcpToolCreatedChunk)
|
||||
hasAnyToolUse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range:
|
||||
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
|
||||
src: url('TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 国旗字体样式类 */
|
||||
.country-flag-font {
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Twemoji Country Flags';
|
||||
unicode-range:
|
||||
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
|
||||
/*https://github.com/beyondkmp/country-flag-emoji-polyfill/blob/master/font/TwemojiCountryFlags.woff2 */
|
||||
src: url('TwemojiCountryFlags.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 国旗字体样式类 */
|
||||
.country-flag-font {
|
||||
font-family: 'Twemoji Country Flags', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'iconfont'; /* Project id 4753420 */
|
||||
src: url('iconfont.woff2?t=1742184675192') format('woff2');
|
||||
src: url('iconfont.woff2?t=1742793497518') format('woff2');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@@ -11,6 +11,18 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-plugin:before {
|
||||
content: '\e612';
|
||||
}
|
||||
|
||||
.icon-tools:before {
|
||||
content: '\e762';
|
||||
}
|
||||
|
||||
.icon-OCRshibie:before {
|
||||
content: '\e658';
|
||||
}
|
||||
|
||||
.icon-obsidian:before {
|
||||
content: '\e677';
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="macOS" role="img"
|
||||
viewBox="0 0 512 512"><rect
|
||||
width="512" height="512"
|
||||
rx="15%"
|
||||
fill="#ffffff"/><path d="M282 170v-4c-52 0-5 34 0 4zm24-18c7-21 43-23 47 3h-10c-3-15-28-16-28 11 0 15 23 24 28 6h10c-6 33-59 21-47-20zm-146-16h10v9c5-12 27-13 31 1 7-15 35-14 35 7v37h-11v-34c0-15-22-15-22 1v33h-11v-35c-2.447-9.36-14.915-11.23-20-3l-2 5v33h-10zm23 259c-47 0-76-33-76-86s29-85 76-85 77 33 77 85-29 86-77 86zm88-205c-29 7-33-30-3-31l14-1v-4c1-12-19-13-22-2h-10a14 14 0 012-7c8-14 41-14 41 8v37h-10v-9a18 18 0 01-12 9zm68 205c-36-2-61-19-63-49h24c23 72 146-5 25-30-19-4-33-13-39-24-38-74 109-96 113-20h-23c-7-49-98-22-65 12 14 14 43 13 64 22 50 23 26 91-36 89zM183 245c-32 0-52 25-52 64s20 64 52 64 53-24 53-64-20-64-53-64z"/></svg>
|
||||
|
After Width: | Height: | Size: 896 B |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -109,6 +109,9 @@
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
padding: 0 0 8px 0;
|
||||
.ant-modal-close {
|
||||
margin-right: 2px;
|
||||
}
|
||||
.ant-modal-header {
|
||||
padding: 16px 16px 0 16px;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
--color-reference-text: #ffffff;
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--color-list-item: rgba(255, 255, 255, 0.1);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.05);
|
||||
--color-list-item: #252525;
|
||||
--color-list-item-hover: #1e1e1e;
|
||||
|
||||
--modal-background: #111111;
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--color-status-success: #52c41a;
|
||||
--color-status-error: #ff4d4f;
|
||||
--color-status-warning: #faad14;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
// Windows系统专用字体配置
|
||||
body[os='windows'] {
|
||||
--font-family:
|
||||
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
:root {
|
||||
--font-family:
|
||||
Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--font-family-serif:
|
||||
serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
// Windows系统专用字体配置
|
||||
body[os='windows'] {
|
||||
--font-family:
|
||||
'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ ul {
|
||||
}
|
||||
}
|
||||
.message-content-container {
|
||||
border-radius: 10px 0 10px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px 10px 16px;
|
||||
background-color: var(--chat-background-user);
|
||||
align-self: self-end;
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 1em 0;
|
||||
margin: 1.5em 0 1em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 2em;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
padding-bottom: 0.3em;
|
||||
@@ -53,8 +55,9 @@
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
margin: 1.3em 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 5px;
|
||||
@@ -82,7 +85,7 @@
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
pre {
|
||||
margin: 1.5em 0;
|
||||
margin: 1.5em 0 !important;
|
||||
}
|
||||
&::marker {
|
||||
color: var(--color-text-3);
|
||||
@@ -108,6 +111,7 @@
|
||||
li code {
|
||||
background: var(--color-background-mute);
|
||||
padding: 3px 5px;
|
||||
margin: 0 2px;
|
||||
border-radius: 5px;
|
||||
word-break: keep-all;
|
||||
white-space: pre;
|
||||
@@ -122,9 +126,7 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(.mermaid),
|
||||
&:has(.plantuml-preview),
|
||||
&:has(.svg-preview) {
|
||||
&:has(.special-preview) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
@@ -148,16 +150,19 @@
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: var(--color-text-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
font-family: var(--font-family);
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 1.5em;
|
||||
background-color: var(--color-background-soft);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
table {
|
||||
--table-border-radius: 8px;
|
||||
margin: 1em 0;
|
||||
margin: 2em 0;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
border-radius: var(--table-border-radius);
|
||||
overflow: hidden;
|
||||
@@ -182,13 +187,19 @@
|
||||
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
a,
|
||||
@@ -323,6 +334,13 @@ mjx-container {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
/* Shiki 相关样式 */
|
||||
.shiki {
|
||||
font-family: var(--code-font-family);
|
||||
// 保持行高为初始值,在 shiki 代码块中处理
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
/* CodeMirror 相关样式 */
|
||||
.cm-editor {
|
||||
border-radius: inherit;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
:root {
|
||||
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
|
||||
--color-scrollbar-thumb-dark: rgba(255, 255, 255, 0.15);
|
||||
--color-scrollbar-thumb-dark-hover: rgba(255, 255, 255, 0.2);
|
||||
--color-scrollbar-thumb-light: rgba(0, 0, 0, 0.15);
|
||||
--color-scrollbar-thumb-light-hover: rgba(0, 0, 0, 0.2);
|
||||
|
||||
--color-scrollbar-thumb: var(--color-scrollbar-thumb-dark);
|
||||
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-dark-hover);
|
||||
}
|
||||
|
||||
body[theme-mode='light'] {
|
||||
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
|
||||
--color-scrollbar-thumb: var(--color-scrollbar-thumb-light);
|
||||
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-light-hover);
|
||||
}
|
||||
|
||||
/* 全局初始化滚动条样式 */
|
||||
@@ -34,3 +39,21 @@ pre:not(.shiki)::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.shiki-dark {
|
||||
--color-scrollbar-thumb: var(--color-scrollbar-thumb-dark);
|
||||
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-dark-hover);
|
||||
}
|
||||
|
||||
.shiki-light {
|
||||
--color-scrollbar-thumb: var(--color-scrollbar-thumb-light);
|
||||
--color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-light-hover);
|
||||
}
|
||||
|
||||
/* 用于截图时隐藏滚动条
|
||||
* FIXME: 临时方案,因为 html-to-image 没有正确处理伪元素。
|
||||
*/
|
||||
.hide-scrollbar,
|
||||
.hide-scrollbar * {
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
|
||||
@@ -18,25 +18,37 @@ html {
|
||||
|
||||
--selection-toolbar-logo-display: flex; // values: flex | none
|
||||
--selection-toolbar-logo-size: 22px; // default: 22px
|
||||
--selection-toolbar-logo-margin: 0 0 0 5px; // default: 0 0 05px
|
||||
--selection-toolbar-logo-border-width: 0.5px 0 0.5px 0.5px; // default: none
|
||||
--selection-toolbar-logo-border-style: solid; // default: none
|
||||
--selection-toolbar-logo-border-color: rgba(255, 255, 255, 0.2);
|
||||
--selection-toolbar-logo-margin: 0; // default: 0
|
||||
--selection-toolbar-logo-padding: 0 6px 0 8px; // default: 0 4px 0 8px
|
||||
--selection-toolbar-logo-background: transparent; // default: transparent
|
||||
|
||||
// DO NOT MODIFY THESE VALUES, IF YOU DON'T KNOW WHAT YOU ARE DOING
|
||||
--selection-toolbar-padding: 2px 4px 2px 2px; // default: 2px 4px 2px 2px
|
||||
--selection-toolbar-padding: 0; // default: 0
|
||||
--selection-toolbar-margin: 2px 3px 5px 3px; // default: 2px 3px 5px 3px
|
||||
// ------------------------------------------------------------
|
||||
|
||||
--selection-toolbar-border-radius: 6px;
|
||||
--selection-toolbar-border: 1px solid rgba(55, 55, 55, 0.5);
|
||||
--selection-toolbar-border-radius: 10px;
|
||||
--selection-toolbar-border: none;
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-background: rgba(20, 20, 20, 0.95);
|
||||
|
||||
// Buttons
|
||||
--selection-toolbar-buttons-border-width: 0.5px 0.5px 0.5px 0;
|
||||
--selection-toolbar-buttons-border-style: solid;
|
||||
--selection-toolbar-buttons-border-color: rgba(255, 255, 255, 0.2);
|
||||
--selection-toolbar-buttons-border-radius: 0 var(--selection-toolbar-border-radius)
|
||||
var(--selection-toolbar-border-radius) 0;
|
||||
|
||||
--selection-toolbar-button-icon-size: 16px; // default: 16px
|
||||
--selection-toolbar-button-text-margin: 0 0 0 3px; // default: 0 0 0 3px
|
||||
--selection-toolbar-button-margin: 0 2px; // default: 0 2px
|
||||
--selection-toolbar-button-padding: 4px 6px; // default: 4px 6px
|
||||
--selection-toolbar-button-border-radius: 4px; // default: 4px
|
||||
--selection-toolbar-button-direction: row; // default: row | column
|
||||
--selection-toolbar-button-text-margin: 0 0 0 0; // default: 0 0 0 0
|
||||
--selection-toolbar-button-margin: 0; // default: 0
|
||||
--selection-toolbar-button-padding: 0 8px; // default: 0 8px
|
||||
--selection-toolbar-button-last-padding: 0 12px 0 8px;
|
||||
--selection-toolbar-button-border-radius: 0; // default: 0
|
||||
--selection-toolbar-button-border: none; // default: none
|
||||
--selection-toolbar-button-box-shadow: none; // default: none
|
||||
|
||||
@@ -45,14 +57,19 @@ html {
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-icon-color-hover: var(--selection-toolbar-color-primary);
|
||||
--selection-toolbar-button-bgcolor: transparent; // default: transparent
|
||||
--selection-toolbar-button-bgcolor-hover: #222222;
|
||||
--selection-toolbar-button-bgcolor-hover: #333333;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
--selection-toolbar-border: 1px solid rgba(200, 200, 200, 0.5);
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.3);
|
||||
--selection-toolbar-border: none;
|
||||
--selection-toolbar-box-shadow: 0px 2px 3px rgba(50, 50, 50, 0.1);
|
||||
--selection-toolbar-background: rgba(245, 245, 245, 0.95);
|
||||
|
||||
// Buttons
|
||||
--selection-toolbar-buttons-border-color: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--selection-toolbar-logo-border-color: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--selection-toolbar-button-text-color: rgba(0, 0, 0, 1);
|
||||
--selection-toolbar-button-icon-color: var(--selection-toolbar-button-text-color);
|
||||
--selection-toolbar-button-text-color-hover: var(--selection-toolbar-color-primary);
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Alert } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LOCALSTORAGE_KEY = 'openai_alert_closed'
|
||||
|
||||
const OpenAIAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
interface Props {
|
||||
message?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
const OpenAIAlert = ({ message = t('settings.provider.openai.alert'), key = LOCALSTORAGE_KEY }: Props) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const closed = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
const closed = localStorage.getItem(key)
|
||||
setVisible(!closed)
|
||||
}, [])
|
||||
}, [key])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Alert
|
||||
style={{ width: '100%', marginTop: 5, marginBottom: 5 }}
|
||||
message={t('settings.provider.openai.alert')}
|
||||
message={message}
|
||||
closable
|
||||
afterClose={() => {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, '1')
|
||||
|
||||
@@ -1,311 +1,298 @@
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { getReactStyleFromToken } from '@renderer/utils/shiki'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { debounce } from 'lodash'
|
||||
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodePreviewProps {
|
||||
children: string
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
interface CodePreviewProps extends BasicPreviewProps {
|
||||
language: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const MAX_COLLAPSE_HEIGHT = 350
|
||||
|
||||
/**
|
||||
* Shiki 流式代码高亮组件
|
||||
*
|
||||
* - 通过 shiki tokenizer 处理流式响应,高性能
|
||||
* - 进入视口后触发高亮,改善页面内有大量长代码块时的响应
|
||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
|
||||
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
|
||||
const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
|
||||
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
|
||||
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
|
||||
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
|
||||
const [isInViewport, setIsInViewport] = useState(false)
|
||||
const codeContainerRef = useRef<HTMLDivElement>(null)
|
||||
const processingRef = useRef(false)
|
||||
const latestRequestedContentRef = useRef<string | null>(null)
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
const shikiThemeRef = useRef(activeShikiTheme)
|
||||
|
||||
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { registerTool, removeTool } = useCodeTool(setTools)
|
||||
|
||||
// 展开/折叠工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.expand,
|
||||
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
|
||||
icon: expandOverride ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
|
||||
tooltip: expandOverride ? t('code_block.collapse') : t('code_block.expand'),
|
||||
visible: () => {
|
||||
const scrollHeight = codeContainerRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > 350
|
||||
const scrollHeight = scrollerRef.current?.scrollHeight
|
||||
return codeCollapsible && (scrollHeight ?? 0) > MAX_COLLAPSE_HEIGHT
|
||||
},
|
||||
onClick: () => setIsExpanded((prev) => !prev)
|
||||
onClick: () => setExpandOverride((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.expand.id)
|
||||
}, [codeCollapsible, isExpanded, registerTool, removeTool, t])
|
||||
}, [codeCollapsible, expandOverride, registerTool, removeTool, t])
|
||||
|
||||
// 自动换行工具
|
||||
useEffect(() => {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
icon: unwrapOverride ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
|
||||
tooltip: unwrapOverride ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
visible: () => codeWrappable,
|
||||
onClick: () => setIsUnwrapped((prev) => !prev)
|
||||
onClick: () => setUnwrapOverride((prev) => !prev)
|
||||
})
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
|
||||
}, [codeWrappable, unwrapOverride, registerTool, removeTool, t])
|
||||
|
||||
// 更新展开状态
|
||||
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
||||
useEffect(() => {
|
||||
setIsExpanded(!codeCollapsible)
|
||||
setExpandOverride(!codeCollapsible)
|
||||
}, [codeCollapsible])
|
||||
|
||||
// 更新换行状态
|
||||
// 重置用户操作(可以考虑移除,保持用户操作结果)
|
||||
useEffect(() => {
|
||||
setIsUnwrapped(!codeWrappable)
|
||||
setUnwrapOverride(!codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
const highlightCode = useCallback(async () => {
|
||||
const currentContent = typeof children === 'string' ? children.trimEnd() : ''
|
||||
const shouldCollapse = useMemo(() => codeCollapsible && !expandOverride, [codeCollapsible, expandOverride])
|
||||
const shouldWrap = useMemo(() => codeWrappable && !unwrapOverride, [codeWrappable, unwrapOverride])
|
||||
|
||||
// 记录最新要处理的内容,为了保证最终状态正确
|
||||
latestRequestedContentRef.current = currentContent
|
||||
|
||||
// 如果正在处理,先跳出,等到完成后会检查是否有新内容
|
||||
if (processingRef.current) return
|
||||
|
||||
processingRef.current = true
|
||||
|
||||
try {
|
||||
// 循环处理,确保会处理最新内容
|
||||
while (latestRequestedContentRef.current !== null) {
|
||||
const contentToProcess = latestRequestedContentRef.current
|
||||
latestRequestedContentRef.current = null // 标记开始处理
|
||||
|
||||
// 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
|
||||
const result = await highlightStreamingCode(contentToProcess, language, callerId)
|
||||
|
||||
// 如有结果,更新 tokenLines
|
||||
if (result.lines.length > 0 || result.recall !== 0) {
|
||||
setTokenLines((prev) => {
|
||||
return result.recall === -1
|
||||
? result.lines
|
||||
: [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines]
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processingRef.current = false
|
||||
}
|
||||
}, [highlightStreamingCode, language, callerId, children])
|
||||
|
||||
// 主题变化时强制重新高亮
|
||||
useEffect(() => {
|
||||
if (shikiThemeRef.current !== activeShikiTheme) {
|
||||
shikiThemeRef.current = activeShikiTheme
|
||||
cleanupTokenizers(callerId)
|
||||
setTokenLines([])
|
||||
}
|
||||
}, [activeShikiTheme, callerId, cleanupTokenizers])
|
||||
|
||||
// 组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => cleanupTokenizers(callerId)
|
||||
}, [callerId, cleanupTokenizers])
|
||||
|
||||
// 视口检测逻辑,进入视口后触发第一次代码高亮
|
||||
useEffect(() => {
|
||||
const codeElement = codeContainerRef.current
|
||||
if (!codeElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0) {
|
||||
setIsInViewport(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '50px 0px 50px 0px'
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(codeElement)
|
||||
return () => observer.disconnect()
|
||||
}, []) // 只执行一次
|
||||
|
||||
// 触发代码高亮
|
||||
useEffect(() => {
|
||||
if (!isInViewport) return
|
||||
|
||||
setTimeout(highlightCode, 0)
|
||||
}, [isInViewport, highlightCode])
|
||||
|
||||
const lastDigitsRef = useRef(1)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = codeContainerRef.current
|
||||
if (!container || !codeShowLineNumbers) return
|
||||
|
||||
const digits = Math.max(tokenLines.length.toString().length, 1)
|
||||
if (digits === lastDigitsRef.current) return
|
||||
|
||||
const gutterWidth = digits * 0.6
|
||||
container.style.setProperty('--gutter-width', `${gutterWidth}rem`)
|
||||
lastDigitsRef.current = digits
|
||||
}, [codeShowLineNumbers, tokenLines.length])
|
||||
|
||||
const hasHighlightedCode = tokenLines.length > 0
|
||||
|
||||
return (
|
||||
<ContentContainer
|
||||
ref={codeContainerRef}
|
||||
$wrap={codeWrappable && !isUnwrapped}
|
||||
$fadeIn={hasHighlightedCode}
|
||||
style={{
|
||||
fontSize: fontSize - 1,
|
||||
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
|
||||
}}>
|
||||
{hasHighlightedCode ? (
|
||||
<ShikiTokensRenderer language={language} tokenLines={tokenLines} showLineNumbers={codeShowLineNumbers} />
|
||||
) : (
|
||||
<CodePlaceholder>{children}</CodePlaceholder>
|
||||
)}
|
||||
</ContentContainer>
|
||||
// 计算行号数字位数
|
||||
const gutterDigits = useMemo(
|
||||
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||
[codeShowLineNumbers, rawLines.length]
|
||||
)
|
||||
}
|
||||
|
||||
interface ShikiTokensRendererProps {
|
||||
language: string
|
||||
tokenLines: ThemedToken[][]
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Shiki 高亮后的 tokens
|
||||
*
|
||||
* 独立出来,方便将来做 virtual list
|
||||
*/
|
||||
const ShikiTokensRenderer: React.FC<ShikiTokensRendererProps> = memo(({ language, tokenLines, showLineNumbers }) => {
|
||||
const { getShikiPreProperties } = useCodeStyle()
|
||||
const rendererRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
// 设置 pre 标签属性
|
||||
useLayoutEffect(() => {
|
||||
getShikiPreProperties(language).then((properties) => {
|
||||
const pre = rendererRef.current
|
||||
if (pre) {
|
||||
pre.className = properties.class
|
||||
pre.style.cssText = properties.style
|
||||
pre.tabIndex = properties.tabindex
|
||||
const shikiTheme = shikiThemeRef.current
|
||||
if (shikiTheme) {
|
||||
shikiTheme.className = `${properties.class || 'shiki'}`
|
||||
// 滚动条适应 shiki 主题变化而非应用主题
|
||||
shikiTheme.classList.add(isShikiThemeDark ? 'shiki-dark' : 'shiki-light')
|
||||
|
||||
if (properties.style) {
|
||||
shikiTheme.style.cssText += `${properties.style}`
|
||||
}
|
||||
shikiTheme.tabIndex = properties.tabindex
|
||||
}
|
||||
})
|
||||
}, [language, getShikiPreProperties])
|
||||
}, [language, getShikiPreProperties, isShikiThemeDark])
|
||||
|
||||
// Virtualizer 配置
|
||||
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
||||
const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId])
|
||||
// `line-height: 1.6` 为全局样式,但是为了避免测量误差在这里取整
|
||||
const estimateSize = useCallback(() => Math.round((fontSize - 1) * 1.6), [fontSize])
|
||||
|
||||
// 创建 virtualizer 实例
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rawLines.length,
|
||||
getScrollElement,
|
||||
getItemKey,
|
||||
estimateSize,
|
||||
overscan: 20
|
||||
})
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
|
||||
// 使用代码高亮 Hook
|
||||
const { tokenLines, highlightLines } = useCodeHighlight({
|
||||
rawLines,
|
||||
language,
|
||||
callerId
|
||||
})
|
||||
|
||||
// 防抖高亮提高流式响应的性能,数字大一点也不会影响用户体验
|
||||
const debouncedHighlightLines = useMemo(() => debounce(highlightLines, 300), [highlightLines])
|
||||
|
||||
// 渐进式高亮
|
||||
useEffect(() => {
|
||||
if (virtualItems.length > 0 && shikiThemeRef.current) {
|
||||
const lastIndex = virtualItems[virtualItems.length - 1].index
|
||||
debouncedHighlightLines(lastIndex + 1)
|
||||
}
|
||||
}, [virtualItems, debouncedHighlightLines])
|
||||
|
||||
return (
|
||||
<pre className="shiki" ref={rendererRef}>
|
||||
<code>
|
||||
{tokenLines.map((lineTokens, lineIndex) => (
|
||||
<span key={`line-${lineIndex}`} className="line">
|
||||
{showLineNumbers && <span className="line-number">{lineIndex + 1}</span>}
|
||||
<span className="line-content">
|
||||
{lineTokens.map((token, tokenIndex) => (
|
||||
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<div ref={shikiThemeRef}>
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={shouldWrap}
|
||||
$lineHeight={estimateSize()}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
maxHeight: shouldCollapse ? MAX_COLLAPSE_HEIGHT : undefined,
|
||||
overflowY: shouldCollapse ? 'auto' : 'hidden'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
<div
|
||||
className="shiki-list"
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<div key={virtualItem.key} data-index={virtualItem.index} ref={virtualizer.measureElement}>
|
||||
<VirtualizedRow
|
||||
rawLine={rawLines[virtualItem.index]}
|
||||
tokenLine={tokenLines[virtualItem.index]}
|
||||
showLineNumbers={codeShowLineNumbers}
|
||||
index={virtualItem.index}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const ContentContainer = styled.div<{
|
||||
$wrap: boolean
|
||||
$fadeIn: boolean
|
||||
}>`
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border-radius: inherit;
|
||||
margin-top: 0;
|
||||
|
||||
/* gutter 宽度默认值 */
|
||||
--gutter-width: 0.6rem;
|
||||
|
||||
.shiki {
|
||||
padding: 1em;
|
||||
border-radius: inherit;
|
||||
|
||||
code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 1.3rem;
|
||||
|
||||
.line-number {
|
||||
width: var(--gutter-width);
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
margin-right: 1rem;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
|
||||
* {
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contentFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.1s ease-in forwards' : 'none')};
|
||||
`
|
||||
|
||||
const CodePlaceholder = styled.div`
|
||||
display: block;
|
||||
opacity: 0.1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: hidden;
|
||||
min-height: 1.3rem;
|
||||
`
|
||||
}
|
||||
|
||||
CodePreview.displayName = 'CodePreview'
|
||||
|
||||
/**
|
||||
* 补全代码行 tokens,把原始内容拼接到高亮内容之后,确保渲染出整行来。
|
||||
*/
|
||||
function completeLineTokens(themedTokens: ThemedToken[], rawLine: string): ThemedToken[] {
|
||||
// 如果出现空行,补一个空格保证行高
|
||||
if (rawLine.length === 0) {
|
||||
return [
|
||||
{
|
||||
content: ' ',
|
||||
offset: 0,
|
||||
color: 'inherit',
|
||||
bgColor: 'inherit',
|
||||
htmlStyle: {
|
||||
opacity: '0.35'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const themedContent = themedTokens.map((token) => token.content).join('')
|
||||
const extraContent = rawLine.slice(themedContent.length)
|
||||
|
||||
// 已有内容已经全部高亮,直接返回
|
||||
if (!extraContent) return themedTokens
|
||||
|
||||
// 补全剩余内容
|
||||
return [
|
||||
...themedTokens,
|
||||
{
|
||||
content: extraContent,
|
||||
offset: themedContent.length,
|
||||
color: 'inherit',
|
||||
bgColor: 'inherit',
|
||||
htmlStyle: {
|
||||
opacity: '0.35'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
interface VirtualizedRowData {
|
||||
rawLine: string
|
||||
tokenLine?: ThemedToken[]
|
||||
showLineNumbers: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行代码渲染
|
||||
*/
|
||||
const VirtualizedRow = memo(
|
||||
({ rawLine, tokenLine, showLineNumbers, index }: VirtualizedRowData & { index: number }) => {
|
||||
return (
|
||||
<div className="line">
|
||||
{showLineNumbers && <span className="line-number">{index + 1}</span>}
|
||||
<span className="line-content">
|
||||
{completeLineTokens(tokenLine ?? [], rawLine).map((token, tokenIndex) => (
|
||||
<span key={tokenIndex} style={getReactStyleFromToken(token)}>
|
||||
{token.content}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
VirtualizedRow.displayName = 'VirtualizedRow'
|
||||
|
||||
const ScrollContainer = styled.div<{
|
||||
$wrap?: boolean
|
||||
$lineHeight?: number
|
||||
}>`
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
padding: 0.5em 1em;
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
line-height: ${(props) => props.$lineHeight}px;
|
||||
|
||||
.line-number {
|
||||
width: var(--gutter-width, 1.2ch);
|
||||
text-align: right;
|
||||
opacity: 0.35;
|
||||
margin-right: 1rem;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
* {
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodePreview)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
|
||||
import { Flex, Spin } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
// 管理 viz 实例
|
||||
const vizInitializer = new AsyncInitializer(async () => {
|
||||
const module = await import('@viz-js/viz')
|
||||
return await module.instance()
|
||||
})
|
||||
|
||||
/** 预览 Graphviz 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
*/
|
||||
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const graphvizRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 使用通用图像工具
|
||||
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
|
||||
imgSelector: 'svg',
|
||||
prefix: 'graphviz',
|
||||
enableWheelZoom: true
|
||||
})
|
||||
|
||||
// 使用工具栏
|
||||
usePreviewTools({
|
||||
setTools,
|
||||
handleZoom,
|
||||
handleCopyImage,
|
||||
handleDownload
|
||||
})
|
||||
|
||||
// 实际的渲染函数
|
||||
const renderGraphviz = useCallback(async (content: string) => {
|
||||
if (!content || !graphvizRef.current) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const viz = await vizInitializer.get()
|
||||
const svgElement = viz.renderSVGElement(content)
|
||||
|
||||
// 清空容器并添加新的 SVG
|
||||
graphvizRef.current.innerHTML = ''
|
||||
graphvizRef.current.appendChild(svgElement)
|
||||
|
||||
// 渲染成功,清除错误记录
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
setError((error as Error).message || 'DOT syntax error or rendering failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// debounce 渲染
|
||||
const debouncedRender = useMemo(
|
||||
() =>
|
||||
debounce((content: string) => {
|
||||
startTransition(() => renderGraphviz(content))
|
||||
}, 300),
|
||||
[renderGraphviz]
|
||||
)
|
||||
|
||||
// 触发渲染
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsLoading(true)
|
||||
debouncedRender(children)
|
||||
} else {
|
||||
debouncedRender.cancel()
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
debouncedRender.cancel()
|
||||
}
|
||||
}, [children, debouncedRender])
|
||||
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{error && <PreviewError>{error}</PreviewError>}
|
||||
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGraphviz = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default memo(GraphvizPreview)
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
* 在应用内打开
|
||||
*/
|
||||
const handleOpenInApp = async () => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: filePath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="html-artifacts">
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
export default Artifacts
|
||||
@@ -0,0 +1,404 @@
|
||||
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, Download, Globe, Sparkles } from 'lucide-react'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClipLoader } from 'react-spinners'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const htmlContent = html || ''
|
||||
const hasContent = htmlContent.trim().length > 0
|
||||
|
||||
// 判断是否正在流式生成的逻辑
|
||||
const isStreaming = useMemo(() => {
|
||||
if (!hasContent) return false
|
||||
|
||||
const trimmedHtml = htmlContent.trim()
|
||||
|
||||
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
|
||||
if (/<\/html\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
|
||||
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 HTML 是否看起来是完整的
|
||||
const indicators = {
|
||||
// 1. 检查常见的 HTML 结构完整性
|
||||
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
|
||||
|
||||
// 2. 检查 body 标签完整性
|
||||
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
|
||||
|
||||
// 3. 检查是否以未闭合的标签结尾
|
||||
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
|
||||
|
||||
// 4. 检查是否有未配对的标签
|
||||
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
|
||||
|
||||
// 5. 检查是否以常见的"流式结束"模式结尾
|
||||
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
|
||||
}
|
||||
|
||||
// 如果有明显的未完成标志,则认为正在生成
|
||||
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 HTML 结构但不完整
|
||||
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 body 结构但不完整
|
||||
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 对于简单的 HTML 片段,检查是否看起来是完整的
|
||||
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
|
||||
// 如果是简单片段且没有明显的结束标志,可能还在生成
|
||||
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
|
||||
}
|
||||
|
||||
return false
|
||||
}, [htmlContent, hasContent])
|
||||
|
||||
// 检查未配对标签的辅助函数
|
||||
function checkUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
|
||||
// HTML5 void 元素(自闭合元素)的完整列表
|
||||
const voidElements = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
]
|
||||
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true // 找到不匹配的闭合标签
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0 // 还有未闭合的标签
|
||||
}
|
||||
|
||||
// 获取格式化的代码预览
|
||||
function getFormattedCodePreview(html: string): string {
|
||||
const trimmed = html.trim()
|
||||
const lines = trimmed.split('\n')
|
||||
const lastFewLines = lines.slice(-3) // 显示最后3行
|
||||
return lastFewLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器中打开
|
||||
*/
|
||||
const handleOpenInEditor = () => {
|
||||
setIsPopupOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const handleClosePopup = () => {
|
||||
setIsPopupOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, htmlContent)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到本地
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
window.message.success({ content: t('message.download.success'), key: 'download' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container $isStreaming={isStreaming}>
|
||||
<Header>
|
||||
<IconWrapper $isStreaming={isStreaming}>
|
||||
{isStreaming ? <Sparkles size={20} color="white" /> : <Globe size={20} color="white" />}
|
||||
</IconWrapper>
|
||||
<TitleSection>
|
||||
<Title>{title}</Title>
|
||||
<TypeBadge>
|
||||
<Code size={12} />
|
||||
<span>HTML</span>
|
||||
</TypeBadge>
|
||||
</TitleSection>
|
||||
</Header>
|
||||
<Content>
|
||||
{isStreaming && !hasContent ? (
|
||||
<GeneratingContainer>
|
||||
<ClipLoader size={20} color="var(--color-primary)" />
|
||||
<GeneratingText>{t('html_artifacts.generating_content', 'Generating content...')}</GeneratingText>
|
||||
</GeneratingContainer>
|
||||
) : isStreaming && hasContent ? (
|
||||
<>
|
||||
<TerminalPreview $theme={theme}>
|
||||
<TerminalContent $theme={theme}>
|
||||
<TerminalLine>
|
||||
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
||||
<TerminalCodeLine $theme={theme}>
|
||||
{getFormattedCodePreview(htmlContent)}
|
||||
<TerminalCursor $theme={theme} />
|
||||
</TerminalCodeLine>
|
||||
</TerminalLine>
|
||||
</TerminalContent>
|
||||
</TerminalPreview>
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</>
|
||||
) : (
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
|
||||
{t('code_block.download')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)}
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div<{ $isStreaming: boolean }>`
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 16px 0;
|
||||
`
|
||||
|
||||
const GeneratingContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
min-height: 78px;
|
||||
`
|
||||
|
||||
const GeneratingText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
|
||||
background-size: 200% 100%;
|
||||
animation: ${shimmer} 3s ease-in-out infinite;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||
transition: background 0.3s ease;
|
||||
|
||||
${(props) =>
|
||||
props.$isStreaming &&
|
||||
`
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
|
||||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
||||
`}
|
||||
`
|
||||
|
||||
const TitleSection = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 0 !important;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const TypeBadge = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
background: var(--color-background-mute);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
padding: 0;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
margin: 16px !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
margin: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
`
|
||||
|
||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
min-height: 80px;
|
||||
`
|
||||
|
||||
const TerminalLine = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
background-color: transparent !important;
|
||||
`
|
||||
|
||||
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
animation: ${keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`} 1s infinite;
|
||||
margin-left: 2px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsCard
|
||||
@@ -0,0 +1,459 @@
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface HtmlArtifactsPopupProps {
|
||||
open: boolean
|
||||
title: string
|
||||
html: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = 'split' | 'code' | 'preview'
|
||||
|
||||
// 视图模式配置
|
||||
const VIEW_MODE_CONFIG = {
|
||||
split: {
|
||||
key: 'split' as const,
|
||||
icon: MonitorSpeaker,
|
||||
i18nKey: 'html_artifacts.split'
|
||||
},
|
||||
code: {
|
||||
key: 'code' as const,
|
||||
icon: Code,
|
||||
i18nKey: 'html_artifacts.code'
|
||||
},
|
||||
preview: {
|
||||
key: 'preview' as const,
|
||||
icon: Monitor,
|
||||
i18nKey: 'html_artifacts.preview'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 抽取头部组件
|
||||
interface ModalHeaderProps {
|
||||
title: string
|
||||
isFullscreen: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onToggleFullscreen: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
|
||||
title,
|
||||
isFullscreen,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onToggleFullscreen,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const viewButtons = useMemo(() => {
|
||||
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
|
||||
<ViewButton
|
||||
key={key}
|
||||
size="small"
|
||||
type={viewMode === key ? 'primary' : 'default'}
|
||||
icon={<Icon size={14} />}
|
||||
onClick={() => onViewModeChange(key)}>
|
||||
{t(i18nKey)}
|
||||
</ViewButton>
|
||||
))
|
||||
}, [viewMode, onViewModeChange, t])
|
||||
|
||||
return (
|
||||
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
<HeaderCenter>
|
||||
<ViewControls>{viewButtons}</ViewControls>
|
||||
</HeaderCenter>
|
||||
<HeaderRight>
|
||||
<Button
|
||||
onClick={onToggleFullscreen}
|
||||
type="text"
|
||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
className="nodrag"
|
||||
/>
|
||||
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
|
||||
</HeaderRight>
|
||||
</ModalHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取代码编辑器组件
|
||||
interface CodeSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
onCodeChange: (code: string) => void
|
||||
}
|
||||
|
||||
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<CodeSection $visible={visible}>
|
||||
<CodeEditorWrapper>
|
||||
<CodeEditor
|
||||
value={html}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onCodeChange}
|
||||
style={{ height: '100%' }}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
}}
|
||||
/>
|
||||
</CodeEditorWrapper>
|
||||
</CodeSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取预览组件
|
||||
interface PreviewSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
|
||||
const htmlContent = html || ''
|
||||
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(htmlContent)
|
||||
const currentRenderedHtmlRef = useRef(htmlContent)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 更新最新的HTML内容引用
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = htmlContent
|
||||
}, [htmlContent])
|
||||
|
||||
// 固定频率渲染 HTML 内容,每2秒钟检查并更新一次
|
||||
useEffect(() => {
|
||||
// 立即设置初始内容
|
||||
setDebouncedHtml(htmlContent)
|
||||
currentRenderedHtmlRef.current = htmlContent
|
||||
|
||||
// 设置定时器,每2秒检查一次内容是否有变化
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
|
||||
setDebouncedHtml(latestHtmlRef.current)
|
||||
currentRenderedHtmlRef.current = latestHtmlRef.current
|
||||
}
|
||||
}, 2000) // 2秒固定频率
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
|
||||
if (!visible) return null
|
||||
const isHtmlEmpty = !debouncedHtml.trim()
|
||||
|
||||
return (
|
||||
<PreviewSection $visible={visible}>
|
||||
{isHtmlEmpty ? (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
) : (
|
||||
<PreviewFrame
|
||||
key={debouncedHtml} // 强制重新创建iframe当内容变化时
|
||||
srcDoc={debouncedHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 主弹窗组件
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
}, [html])
|
||||
|
||||
// 计算视图可见性
|
||||
const viewVisibility = useMemo(
|
||||
() => ({
|
||||
code: viewMode === 'split' || viewMode === 'code',
|
||||
preview: viewMode === 'split' || viewMode === 'preview'
|
||||
}),
|
||||
[viewMode]
|
||||
)
|
||||
|
||||
// 计算Modal属性
|
||||
const modalProps = useMemo(
|
||||
() => ({
|
||||
width: isFullscreen ? '100vw' : '90vw',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
|
||||
}),
|
||||
[isFullscreen]
|
||||
)
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCodeChange = useCallback((newCode: string) => {
|
||||
setCurrentHtml(newCode)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
$isFullscreen={isFullscreen}
|
||||
title={
|
||||
<ModalHeaderComponent
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
afterClose={handleClose}
|
||||
centered
|
||||
destroyOnClose
|
||||
{...modalProps}
|
||||
footer={null}
|
||||
closable={false}>
|
||||
<Container>
|
||||
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
|
||||
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
|
||||
</Container>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件保持不变
|
||||
const commonModalBodyStyles = `
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
`
|
||||
|
||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
${(props) =>
|
||||
props.$isFullscreen
|
||||
? `
|
||||
.ant-modal-wrap {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
height: calc(100vh - 45px) !important;
|
||||
${commonModalBodyStyles}
|
||||
max-height: initial !important;
|
||||
}
|
||||
`
|
||||
: `
|
||||
.ant-modal-body {
|
||||
height: 80vh !important;
|
||||
${commonModalBodyStyles}
|
||||
min-height: 600px !important;
|
||||
}
|
||||
`}
|
||||
|
||||
.ant-modal-body {
|
||||
${commonModalBodyStyles}
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: ${(props) => (props.$isFullscreen ? '0px' : '12px')};
|
||||
overflow: hidden;
|
||||
height: ${(props) => (props.$isFullscreen ? '100vh' : 'auto')};
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 10px 12px !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
const ModalHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
|
||||
`
|
||||
|
||||
const HeaderCenter = styled.div`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const TitleText = styled.span`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
display: flex;
|
||||
width: auto;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: var(--color-background-mute);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
-webkit-app-region: no-drag;
|
||||
`
|
||||
|
||||
const ViewButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.ant-btn-default {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const CodeSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const CodeEditorWrapper = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.monaco-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'block' : 'none')};
|
||||
`
|
||||
|
||||
const PreviewFrame = styled.iframe`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
`
|
||||
const EmptyPreview = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||
import { useMermaid } from '@renderer/hooks/useMermaid'
|
||||
import { Flex, Spin } from 'antd'
|
||||
@@ -7,16 +7,14 @@ import { debounce } from 'lodash'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import PreviewError from './PreviewError'
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/** 预览 Mermaid 图表
|
||||
* 通过防抖渲染提供比较统一的体验,减少闪烁。
|
||||
* FIXME: 等将来容易判断代码块结束位置时再重构。
|
||||
*/
|
||||
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
|
||||
const mermaidRef = useRef<HTMLDivElement>(null)
|
||||
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
|
||||
@@ -143,7 +141,7 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
return (
|
||||
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
|
||||
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
|
||||
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
|
||||
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
|
||||
<StyledMermaid ref={mermaidRef} className="mermaid" />
|
||||
</Flex>
|
||||
</Spin>
|
||||
@@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
const StyledError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(MermaidPreview)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { Spin } from 'antd'
|
||||
import pako from 'pako'
|
||||
import React, { memo, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
|
||||
function encode64(data: Uint8Array) {
|
||||
let r = ''
|
||||
@@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
|
||||
)
|
||||
}
|
||||
|
||||
interface PlantUMLProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
|
||||
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { memo } from 'react'
|
||||
import { styled } from 'styled-components'
|
||||
|
||||
const PreviewError = styled.div`
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ff4d4f;
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
`
|
||||
|
||||
export default memo(PreviewError)
|
||||
@@ -1,15 +1,12 @@
|
||||
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
import { BasicPreviewProps } from './types'
|
||||
|
||||
/**
|
||||
* 使用 Shadow DOM 渲染 SVG
|
||||
*/
|
||||
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
|
||||
const svgContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
|
||||
handleDownload
|
||||
})
|
||||
|
||||
return <div ref={svgContainerRef} className="svg-preview" />
|
||||
return <div ref={svgContainerRef} className="svg-preview special-preview" />
|
||||
}
|
||||
|
||||
export default memo(SvgPreview)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import GraphvizPreview from './GraphvizPreview'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
/**
|
||||
* 特殊视图语言列表
|
||||
*/
|
||||
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
|
||||
|
||||
/**
|
||||
* 特殊视图组件映射表
|
||||
*/
|
||||
export const SPECIAL_VIEW_COMPONENTS = {
|
||||
mermaid: MermaidPreview,
|
||||
plantuml: PlantUmlPreview,
|
||||
svg: SvgPreview,
|
||||
dot: GraphvizPreview,
|
||||
graphviz: GraphvizPreview
|
||||
} as const
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CodeTool } from '@renderer/components/CodeToolbar'
|
||||
|
||||
/**
|
||||
* 预览组件的基本 props
|
||||
*/
|
||||
export interface BasicPreviewProps {
|
||||
children: string
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 视图模式
|
||||
*/
|
||||
export type ViewMode = 'source' | 'special' | 'split'
|
||||
+26
-25
@@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CodePreview from './CodePreview'
|
||||
import HtmlArtifacts from './HtmlArtifacts'
|
||||
import MermaidPreview from './MermaidPreview'
|
||||
import PlantUmlPreview from './PlantUmlPreview'
|
||||
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
|
||||
import HtmlArtifactsCard from './HtmlArtifactsCard'
|
||||
import StatusBar from './StatusBar'
|
||||
import SvgPreview from './SvgPreview'
|
||||
|
||||
type ViewMode = 'source' | 'special' | 'split'
|
||||
import { ViewMode } from './types'
|
||||
|
||||
interface Props {
|
||||
children: string
|
||||
@@ -42,9 +39,10 @@ interface Props {
|
||||
* - quick 工具
|
||||
* - core 工具
|
||||
*/
|
||||
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
|
||||
const { t } = useTranslation()
|
||||
const { codeEditor, codeExecution } = useSettings()
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('special')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [output, setOutput] = useState('')
|
||||
@@ -56,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
return codeExecution.enabled && language === 'python'
|
||||
}, [codeExecution.enabled, language])
|
||||
|
||||
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
|
||||
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
|
||||
|
||||
const isInSpecialView = useMemo(() => {
|
||||
return hasSpecialView && viewMode === 'special'
|
||||
@@ -200,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
|
||||
// 特殊视图组件映射
|
||||
const specialView = useMemo(() => {
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
|
||||
} else if (language === 'plantuml' && isValidPlantUML(children)) {
|
||||
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
|
||||
} else if (language === 'svg') {
|
||||
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
|
||||
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
|
||||
|
||||
if (!SpecialView) return null
|
||||
|
||||
// PlantUML 语法验证
|
||||
if (language === 'plantuml' && !isValidPlantUML(children)) {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
|
||||
return <SpecialView setTools={setTools}>{children}</SpecialView>
|
||||
}, [children, language])
|
||||
|
||||
const renderHeader = useMemo(() => {
|
||||
@@ -228,27 +228,29 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
|
||||
)
|
||||
}, [specialView, sourceView, viewMode])
|
||||
|
||||
const renderArtifacts = useMemo(() => {
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifacts html={children} />
|
||||
}
|
||||
return null
|
||||
}, [children, language])
|
||||
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
|
||||
if (language === 'html') {
|
||||
return <HtmlArtifactsCard html={children} />
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
|
||||
{renderHeader}
|
||||
<CodeToolbar tools={tools} />
|
||||
{renderContent}
|
||||
{renderArtifacts}
|
||||
{isExecutable && output && <StatusBar>{output}</StatusBar>}
|
||||
</CodeBlockWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* FIXME: 最小宽度用于解决两个问题。
|
||||
* 一是 CodePreview 在气泡样式下的用户消息中无法撑开气泡,
|
||||
* 二是 代码块内容过少时 toolbar 会和 title 重叠。
|
||||
*/
|
||||
min-width: 45ch;
|
||||
|
||||
.code-toolbar {
|
||||
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
|
||||
@@ -296,7 +298,6 @@ const SplitViewWrapper = styled.div`
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(CodeBlockView)
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Extension } from '@uiw/react-codemirror'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
let linterPromise: Promise<any> | null = null
|
||||
function importLintPackage() {
|
||||
if (!linterPromise) {
|
||||
linterPromise = import('@codemirror/lint').then((mod) => mod.linter)
|
||||
}
|
||||
return linterPromise
|
||||
}
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const [linter, jsonParseLinter] = await Promise.all([
|
||||
importLintPackage(),
|
||||
import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
])
|
||||
return linter(jsonParseLinter())
|
||||
}
|
||||
}
|
||||
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
// 加载语言
|
||||
useEffect(() => {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
import('@uiw/codemirror-extensions-langs')
|
||||
.then(({ loadLanguage }) => {
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
if (extension) {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.debug(`Failed to load language: ${normalizedLang}`, error)
|
||||
})
|
||||
}, [language, languageMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lint) return
|
||||
|
||||
const loader = linterLoaders[language]
|
||||
if (loader) {
|
||||
loader()
|
||||
.then((extension) => {
|
||||
setExtensions((prev) => [...prev, extension])
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to load linter for ${language}`, error)
|
||||
})
|
||||
}
|
||||
}, [language, lint])
|
||||
|
||||
return extensions
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { Extension, keymap } from '@uiw/react-codemirror'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
return linter(jsonParseLinter())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 特殊语言加载器
|
||||
*/
|
||||
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
|
||||
dot: async () => {
|
||||
const mod = await import('@viz-js/lang-dot')
|
||||
return mod.dot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言扩展
|
||||
*/
|
||||
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
|
||||
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
|
||||
|
||||
// 如果语言名包含 `-`,转换为驼峰命名法
|
||||
if (normalizedLang.includes('-')) {
|
||||
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
// 尝试加载特殊语言
|
||||
const specialLoader = specialLanguageLoaders[normalizedLang]
|
||||
if (specialLoader) {
|
||||
try {
|
||||
return await specialLoader()
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load language ${normalizedLang}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 uiw/codemirror 包含的语言
|
||||
try {
|
||||
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
|
||||
const extension = loadLanguage(normalizedLang as any)
|
||||
return extension || null
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load language ${normalizedLang}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 linter 扩展
|
||||
*/
|
||||
async function loadLinterExtension(language: string): Promise<Extension | null> {
|
||||
const loader = linterLoaders[language]
|
||||
if (!loader) return null
|
||||
|
||||
try {
|
||||
return await loader()
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load linter for ${language}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言相关扩展
|
||||
*/
|
||||
export const useLanguageExtensions = (language: string, lint?: boolean) => {
|
||||
const { languageMap } = useCodeStyle()
|
||||
const [extensions, setExtensions] = useState<Extension[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadAllExtensions = async () => {
|
||||
try {
|
||||
// 加载所有扩展
|
||||
const [languageResult, linterResult] = await Promise.allSettled([
|
||||
loadLanguageExtension(language, languageMap),
|
||||
lint ? loadLinterExtension(language) : Promise.resolve(null)
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const results: Extension[] = []
|
||||
|
||||
// 语言扩展
|
||||
if (languageResult.status === 'fulfilled' && languageResult.value) {
|
||||
results.push(languageResult.value)
|
||||
}
|
||||
|
||||
// linter 扩展
|
||||
if (linterResult.status === 'fulfilled' && linterResult.value) {
|
||||
results.push(linterResult.value)
|
||||
}
|
||||
|
||||
setExtensions(results)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.debug('Failed to load language extensions:', error)
|
||||
setExtensions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAllExtensions()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [language, lint, languageMap])
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
interface UseSaveKeymapProps {
|
||||
onSave?: (content: string) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeMirror 扩展,用于处理保存快捷键 (Cmd/Ctrl + S)
|
||||
* @param onSave 保存时触发的回调函数
|
||||
* @param enabled 是否启用此快捷键
|
||||
* @returns 扩展或空数组
|
||||
*/
|
||||
export function useSaveKeymap({ onSave, enabled = true }: UseSaveKeymapProps) {
|
||||
return useMemo(() => {
|
||||
if (!enabled || !onSave) {
|
||||
return []
|
||||
}
|
||||
|
||||
return keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: (view: EditorView) => {
|
||||
onSave(view.state.doc.toString())
|
||||
return true
|
||||
},
|
||||
preventDefault: true
|
||||
}
|
||||
])
|
||||
}, [onSave, enabled])
|
||||
}
|
||||
|
||||
interface UseBlurHandlerProps {
|
||||
onBlur?: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeMirror 扩展,用于处理编辑器的 blur 事件
|
||||
* @param onBlur blur 事件触发时的回调函数
|
||||
* @returns 扩展或空数组
|
||||
*/
|
||||
export function useBlurHandler({ onBlur }: UseBlurHandlerProps) {
|
||||
return useMemo(() => {
|
||||
if (!onBlur) {
|
||||
return []
|
||||
}
|
||||
return EditorView.domEventHandlers({
|
||||
blur: (_event, view) => {
|
||||
onBlur(view.state.doc.toString())
|
||||
}
|
||||
})
|
||||
}, [onBlur])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
|
||||
import diff from 'fast-diff'
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useLanguageExtensions } from './hook'
|
||||
import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||
|
||||
// 标记非用户编辑的变更
|
||||
const External = Annotation.define<boolean>()
|
||||
@@ -24,6 +24,7 @@ interface Props {
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
onBlur?: (newContent: string) => void
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
|
||||
height?: string
|
||||
minHeight?: string
|
||||
@@ -40,6 +41,7 @@ interface Props {
|
||||
extensions?: Extension[]
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
style?: React.CSSProperties
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,13 +55,15 @@ const CodeEditor = ({
|
||||
language,
|
||||
onSave,
|
||||
onChange,
|
||||
onBlur,
|
||||
setTools,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
options,
|
||||
extensions,
|
||||
style
|
||||
style,
|
||||
editable = true
|
||||
}: Props) => {
|
||||
const {
|
||||
fontSize,
|
||||
@@ -165,28 +169,18 @@ const CodeEditor = ({
|
||||
setIsUnwrapped(!wrappable)
|
||||
}, [wrappable])
|
||||
|
||||
// 保存功能的快捷键
|
||||
const saveKeymap = useMemo(() => {
|
||||
return keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
handleSave()
|
||||
return true
|
||||
},
|
||||
preventDefault: true
|
||||
}
|
||||
])
|
||||
}, [handleSave])
|
||||
const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap })
|
||||
const blurExtension = useBlurHandler({ onBlur })
|
||||
|
||||
const customExtensions = useMemo(() => {
|
||||
return [
|
||||
...(extensions ?? []),
|
||||
...langExtensions,
|
||||
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
|
||||
...(enableKeymap ? [saveKeymap] : [])
|
||||
]
|
||||
}, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap])
|
||||
saveKeymapExtension,
|
||||
blurExtension
|
||||
].flat()
|
||||
}, [extensions, langExtensions, isUnwrapped, saveKeymapExtension, blurExtension])
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
@@ -197,7 +191,7 @@ const CodeEditor = ({
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={collapsible && !isExpanded ? (maxHeight ?? '350px') : 'none'}
|
||||
editable={true}
|
||||
editable={editable}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
extensions={customExtensions}
|
||||
|
||||
@@ -140,7 +140,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||
const [allRanges, setAllRanges] = useState<Range[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const prevSearchText = useRef('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -182,15 +182,18 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
[allRanges, currentIndex]
|
||||
)
|
||||
|
||||
const search = useCallback(() => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(0)
|
||||
}
|
||||
}, [target, filter, isCaseSensitive, isWholeWord])
|
||||
const search = useCallback(
|
||||
(jump = false) => {
|
||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||
setSearchCompleted(SearchCompletedState.Searched)
|
||||
if (target && searchText !== null && searchText !== '') {
|
||||
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||
setAllRanges(ranges)
|
||||
setCurrentIndex(jump && ranges.length > 0 ? 0 : -1)
|
||||
}
|
||||
},
|
||||
[target, filter, isCaseSensitive, isWholeWord]
|
||||
)
|
||||
|
||||
const implementation = useMemo(
|
||||
() => ({
|
||||
@@ -207,9 +210,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus()
|
||||
inputEl.select()
|
||||
search()
|
||||
CSS.highlights.clear()
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
search(false)
|
||||
})
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -233,11 +234,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||
},
|
||||
search: () => {
|
||||
search()
|
||||
search(true)
|
||||
locateByIndex(true)
|
||||
},
|
||||
silentSearch: () => {
|
||||
search()
|
||||
search(false)
|
||||
locateByIndex(false)
|
||||
},
|
||||
focus: () => {
|
||||
@@ -304,7 +305,7 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
|
||||
useEffect(() => {
|
||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||
search()
|
||||
search(true)
|
||||
}
|
||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
|
||||
|
||||
@@ -370,16 +371,12 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
</InputWrapper>
|
||||
<Separator></Separator>
|
||||
<SearchResults>
|
||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||
allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<NoResults>{t('common.no_results')}</NoResults>
|
||||
)
|
||||
{searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? (
|
||||
<>
|
||||
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||
<SearchResultSeparator>/</SearchResultSeparator>
|
||||
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||
</>
|
||||
) : (
|
||||
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
|
||||
)}
|
||||
@@ -482,10 +479,6 @@ const SearchResultsPlaceholder = styled.span`
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const NoResults = styled.span`
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const SearchResultCount = styled.span`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as DraggableList } from './list'
|
||||
export { default as DraggableVirtualList } from './virtual-list'
|
||||
+2
-2
@@ -23,7 +23,7 @@ interface Props<T> {
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
}
|
||||
|
||||
const DragableList: FC<Props<any>> = ({
|
||||
const DraggableList: FC<Props<any>> = ({
|
||||
children,
|
||||
list,
|
||||
style,
|
||||
@@ -82,4 +82,4 @@ const DragableList: FC<Props<any>> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default DragableList
|
||||
export default DraggableList
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user