diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 34ae919b0..7ba2b7ee9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -22,7 +22,8 @@ export default defineConfig({ '@llm-tools/embedjs-loader-sitemap', '@llm-tools/embedjs-libsql', '@llm-tools/embedjs-loader-image', - 'p-queue' + 'p-queue', + 'webdav' ] }), ...visualizerPlugin('main') diff --git a/eslint.config.mjs b/eslint.config.mjs index 72775324c..a196af52d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,6 +53,6 @@ export default defineConfig([ } ], { - ignores: ['node_modules/**', 'dist/**', 'out/**', '.gitignore', 'scripts/cloudflare-worker.js'] + ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js'] } ]) diff --git a/package.json b/package.json index 1716cc92b..067c2ed79 100644 --- a/package.json +++ b/package.json @@ -85,10 +85,11 @@ "npx-scope-finder": "^1.2.0", "officeparser": "^4.1.1", "p-queue": "^8.1.0", + "proxy-agent": "^6.5.0", "tar": "^7.4.3", "tokenx": "^0.4.1", "undici": "^7.4.0", - "webdav": "4.11.4", + "webdav": "^5.8.0", "zipread": "^1.3.3" }, "devDependencies": { diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index a5fca5afc..34f7d1658 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -103,7 +103,10 @@ export const textExts = [ '.cxx', // C++ 源文件 '.cppm', // C++20 模块接口文件 '.ipp', // 模板实现文件 - '.ixx' // C++20 模块实现文件 + '.ixx', // C++20 模块实现文件 + '.f90', // Fortran 90 源文件 + '.f', // Fortran 固定格式源代码文件 + '.f03' // Fortran 2003+ 源代码文件 ] export const ZOOM_SHORTCUTS = [ diff --git a/resources/scripts/download.js b/resources/scripts/download.js index cc97f1df9..270f8cbed 100644 --- a/resources/scripts/download.js +++ b/resources/scripts/download.js @@ -16,21 +16,23 @@ async function downloadWithRedirects(url, destinationPath) { const proxyAgent = new SocksProxyAgent(proxyUrl) return new Promise((resolve, reject) => { const request = (url) => { - https.get(url, { agent: proxyAgent }, (response) => { - if (response.statusCode == 301 || response.statusCode == 302) { - request(response.headers.location) - return - } - if (response.statusCode !== 200) { - reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)) - return - } - const file = fs.createWriteStream(destinationPath) - response.pipe(file) - file.on('finish', () => resolve()) - }).on('error', (err) => { - reject(err) - }) + https + .get(url, { agent: proxyAgent }, (response) => { + if (response.statusCode == 301 || response.statusCode == 302) { + request(response.headers.location) + return + } + if (response.statusCode !== 200) { + reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)) + return + } + const file = fs.createWriteStream(destinationPath) + response.pipe(file) + file.on('finish', () => resolve()) + }) + .on('error', (err) => { + reject(err) + }) } request(url) }) diff --git a/src/main/index.ts b/src/main/index.ts index 19a121ad4..907ef2b64 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { electronApp, optimizer } from '@electron-toolkit/utils' import { replaceDevtoolsFont } from '@main/utils/windowUtil' -import { app } from 'electron' +import { app, ipcMain } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' @@ -44,6 +44,9 @@ if (!app.requestSingleInstanceLock()) { .then((name) => console.log(`Added Extension: ${name}`)) .catch((err) => console.log('An error occurred: ', err)) } + ipcMain.handle('system:getDeviceType', () => { + return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux' + }) }) // Listen for second instance diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0c7d23e78..10255e563 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -84,8 +84,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { }) // theme - ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => { + ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => { + if (theme === configManager.getTheme()) return + configManager.setTheme(theme) + + // should sync theme change to all windows + const senderWindowId = event.sender.id + const windows = BrowserWindow.getAllWindows() + // 向其他窗口广播主题变化 + windows.forEach((win) => { + if (win.webContents.id !== senderWindowId) { + win.webContents.send('theme:change', theme) + } + }) + mainWindow?.setTitleBarOverlay && mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight) }) @@ -130,6 +143,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('backup:restore', backupManager.restore) ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav) ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav) + ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles) // file ipcMain.handle('file:open', fileManager.open) diff --git a/src/main/reranker/JinaReranker.ts b/src/main/reranker/JinaReranker.ts new file mode 100644 index 000000000..dbee063cc --- /dev/null +++ b/src/main/reranker/JinaReranker.ts @@ -0,0 +1,48 @@ +import { ExtractChunkData } from '@llm-tools/embedjs-interfaces' +import { KnowledgeBaseParams } from '@types' +import axios from 'axios' + +import BaseReranker from './BaseReranker' + +export default class JinaReranker extends BaseReranker { + constructor(base: KnowledgeBaseParams) { + super(base) + } + + public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { + const baseURL = this.base?.rerankBaseURL?.endsWith('/') + ? this.base.rerankBaseURL.slice(0, -1) + : this.base.rerankBaseURL + const url = `${baseURL}/rerank` + + const requestBody = { + model: this.base.rerankModel, + query, + documents: searchResults.map((doc) => doc.pageContent), + top_n: this.base.topN + } + + try { + const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() }) + + const rerankResults = data.results + console.log(rerankResults) + const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) + return searchResults + .map((doc: ExtractChunkData, index: number) => { + const score = resultMap.get(index) + if (score === undefined) return undefined + + return { + ...doc, + score + } + }) + .filter((doc): doc is ExtractChunkData => doc !== undefined) + .sort((a, b) => b.score - a.score) + } catch (error) { + console.error('Jina Reranker API 错误:', error) + throw error + } + } +} diff --git a/src/main/reranker/RerankerFactory.ts b/src/main/reranker/RerankerFactory.ts index 2c15fe6cc..0c2e8d7dd 100644 --- a/src/main/reranker/RerankerFactory.ts +++ b/src/main/reranker/RerankerFactory.ts @@ -2,12 +2,15 @@ import { KnowledgeBaseParams } from '@types' import BaseReranker from './BaseReranker' import DefaultReranker from './DefaultReranker' +import JinaReranker from './JinaReranker' import SiliconFlowReranker from './SiliconFlowReranker' export default class RerankerFactory { static create(base: KnowledgeBaseParams): BaseReranker { if (base.rerankModelProvider === 'silicon') { return new SiliconFlowReranker(base) + } else if (base.rerankModelProvider === 'jina') { + return new JinaReranker(base) } return new DefaultReranker(base) } diff --git a/src/main/reranker/SiliconFlowReranker.ts b/src/main/reranker/SiliconFlowReranker.ts index 1730efdae..ee82362e2 100644 --- a/src/main/reranker/SiliconFlowReranker.ts +++ b/src/main/reranker/SiliconFlowReranker.ts @@ -15,35 +15,36 @@ export default class SiliconFlowReranker extends BaseReranker { : this.base.rerankBaseURL const url = `${baseURL}/rerank` - const { data } = await axios.post( - url, - { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_n: this.base.topN, - max_chunks_per_doc: this.base.chunkSize, - overlap_tokens: this.base.chunkOverlap - }, - { - headers: this.defaultHeaders() - } - ) + const requestBody = { + model: this.base.rerankModel, + query, + documents: searchResults.map((doc) => doc.pageContent), + top_n: this.base.topN, + max_chunks_per_doc: this.base.chunkSize, + overlap_tokens: this.base.chunkOverlap + } - const rerankResults = data.results - const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) + try { + const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() }) - return searchResults - .map((doc: ExtractChunkData, index: number) => { - const score = resultMap.get(index) - if (score === undefined) return undefined + const rerankResults = data.results + const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) - return { - ...doc, - score - } - }) - .filter((doc): doc is ExtractChunkData => doc !== undefined) - .sort((a, b) => b.score - a.score) + return searchResults + .map((doc: ExtractChunkData, index: number) => { + const score = resultMap.get(index) + if (score === undefined) return undefined + + return { + ...doc, + score + } + }) + .filter((doc): doc is ExtractChunkData => doc !== undefined) + .sort((a, b) => b.score - a.score) + } catch (error) { + console.error('SiliconFlow Reranker API 错误:', error) + throw error + } } } diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index cbccdc90a..92b5ef654 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -5,6 +5,7 @@ import { app } from 'electron' import Logger from 'electron-log' import * as fs from 'fs-extra' import * as path from 'path' +import { createClient, FileStat } from 'webdav' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -18,6 +19,7 @@ class BackupManager { this.restore = this.restore.bind(this) this.backupToWebdav = this.backupToWebdav.bind(this) this.restoreFromWebdav = this.restoreFromWebdav.bind(this) + this.listWebdavFiles = this.listWebdavFiles.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -117,10 +119,10 @@ class BackupManager { await fs.remove(this.tempDir) onProgress({ stage: 'completed', progress: 100, total: 100 }) - Logger.log('Backup completed successfully') + Logger.log('[BackupManager] Backup completed successfully') return backupedFilePath } catch (error) { - Logger.error('Backup failed:', error) + Logger.error('[BackupManager] Backup failed:', error) throw error } } @@ -186,7 +188,7 @@ class BackupManager { } async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { - const filename = 'cherry-studio.backup.zip' + const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' const backupedFilePath = await this.backup(_, filename, data) const webdavClient = new WebDav(webdavConfig) return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), { @@ -195,18 +197,48 @@ class BackupManager { } async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { - const filename = 'cherry-studio.backup.zip' + const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' const webdavClient = new WebDav(webdavConfig) - const retrievedFile = await webdavClient.getFileContents(filename) - const backupedFilePath = path.join(this.backupDir, filename) + try { + const retrievedFile = await webdavClient.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) - if (!fs.existsSync(this.backupDir)) { - fs.mkdirSync(this.backupDir, { recursive: true }) + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }) + } + + // sync为同步写,无须await + fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) + + return await this.restore(_, backupedFilePath) + } catch (error: any) { + Logger.error('[backup] Failed to restore from WebDAV:', error) + throw new Error(error.message || 'Failed to restore backup file') } + } - await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) + listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => { + try { + const client = createClient(config.webdavHost, { + username: config.webdavUser, + password: config.webdavPass + }) - return await this.restore(_, backupedFilePath) + const response = await client.getDirectoryContents(config.webdavPath) + const files = Array.isArray(response) ? response : response.data + + return files + .filter((file: FileStat) => file.type === 'file' && file.basename.endsWith('.zip')) + .map((file: FileStat) => ({ + fileName: file.basename, + modifiedTime: file.lastmod, + size: file.size + })) + .sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error: any) { + Logger.error('Failed to list WebDAV files:', error) + throw new Error(error.message || 'Failed to list backup files') + } } private async getDirSize(dirPath: string): Promise { diff --git a/src/main/services/GeminiService.ts b/src/main/services/GeminiService.ts index 5b9510c0d..b79193ff0 100644 --- a/src/main/services/GeminiService.ts +++ b/src/main/services/GeminiService.ts @@ -3,14 +3,12 @@ import { FileType } from '@types' import fs from 'fs' import { CacheService } from './CacheService' -import { proxyManager } from './ProxyManager' export class GeminiService { private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list' private static readonly CACHE_DURATION = 3000 static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string) { - proxyManager.setGlobalProxy() const fileManager = new GoogleAIFileManager(apiKey) const uploadResult = await fileManager.uploadFile(file.path, { mimeType: 'application/pdf', @@ -31,7 +29,6 @@ export class GeminiService { file: FileType, apiKey: string ): Promise { - proxyManager.setGlobalProxy() const fileManager = new GoogleAIFileManager(apiKey) const cachedResponse = CacheService.get(GeminiService.FILE_LIST_CACHE_KEY) @@ -55,13 +52,11 @@ export class GeminiService { } static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string) { - proxyManager.setGlobalProxy() const fileManager = new GoogleAIFileManager(apiKey) return await fileManager.listFiles() } static async deleteFile(_: Electron.IpcMainInvokeEvent, apiKey: string, fileId: string) { - proxyManager.setGlobalProxy() const fileManager = new GoogleAIFileManager(apiKey) await fileManager.deleteFile(fileId) } diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index fb5634074..eb4c11717 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -25,7 +25,6 @@ import { AzureOpenAiEmbeddings, OpenAiEmbeddings } from '@llm-tools/embedjs-open import { addFileLoader } from '@main/loader' import OcrProvider from '@main/ocr/OcrProvider' import Reranker from '@main/reranker/Reranker' -import { proxyManager } from '@main/services/ProxyManager' import { windowService } from '@main/services/WindowService' import { getInstanceName } from '@main/utils' import { getAllFiles } from '@main/utils/file' @@ -126,14 +125,12 @@ class KnowledgeService { azureOpenAIApiVersion: apiVersion, azureOpenAIApiDeploymentName: model, azureOpenAIApiInstanceName: getInstanceName(baseURL), - configuration: { httpAgent: proxyManager.getProxyAgent() }, dimensions, batchSize }) : new OpenAiEmbeddings({ model, apiKey, - configuration: { baseURL, httpAgent: proxyManager.getProxyAgent() }, dimensions, batchSize }) @@ -427,7 +424,6 @@ class KnowledgeService { } public add = async (_: Electron.IpcMainInvokeEvent, options: KnowledgeBaseAddItemOptions): Promise => { - proxyManager.setGlobalProxy() const { base, item } = options if (base.preprocessing) { const ocrProvider = new OcrProvider(base) diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index dd08e1c8c..573d0a9f3 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -1,25 +1,23 @@ import { ProxyConfig as _ProxyConfig, session } from 'electron' import { socksDispatcher } from 'fetch-socks' -import { HttpsProxyAgent } from 'https-proxy-agent' +import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent' import { ProxyAgent, setGlobalDispatcher } from 'undici' type ProxyMode = 'system' | 'custom' | 'none' export interface ProxyConfig { mode: ProxyMode - url?: string | null + url?: string } export class ProxyManager { private config: ProxyConfig - private proxyAgent: HttpsProxyAgent | null = null - private proxyUrl: string | null = null + private proxyAgent: GeneralProxyAgent | null = null private systemProxyInterval: NodeJS.Timeout | null = null constructor() { this.config = { - mode: 'none', - url: '' + mode: 'none' } } @@ -51,7 +49,7 @@ export class ProxyManager { if (this.config.mode === 'system') { await this.setSystemProxy() this.monitorSystemProxy() - } else if (this.config.mode == 'custom') { + } else if (this.config.mode === 'custom') { await this.setCustomProxy() } else { await this.clearProxy() @@ -73,11 +71,14 @@ export class ProxyManager { private async setSystemProxy(): Promise { try { await this.setSessionsProxy({ mode: 'system' }) - const url = await this.resolveSystemProxy() - if (url && url !== this.proxyUrl) { - this.proxyUrl = url.toLowerCase() - this.proxyAgent = new HttpsProxyAgent(this.proxyUrl) - this.setEnvironment(this.proxyUrl) + const proxyString = await session.defaultSession.resolveProxy('https://dummy.com') + const [protocol, address] = proxyString.split(';')[0].split(' ') + console.log('protocol', protocol) + const url = protocol === 'PROXY' ? `http://${address}` : null + if (url && url !== this.config.url) { + this.config.url = url.toLowerCase() + this.setEnvironment(this.config.url) + this.proxyAgent = new GeneralProxyAgent() } } catch (error) { console.error('Failed to set system proxy:', error) @@ -88,10 +89,9 @@ export class ProxyManager { private async setCustomProxy(): Promise { try { if (this.config.url) { - this.proxyUrl = this.config.url.toLowerCase() - this.proxyAgent = new HttpsProxyAgent(this.proxyUrl) - this.setEnvironment(this.proxyUrl) - await this.setSessionsProxy({ proxyRules: this.proxyUrl }) + this.setEnvironment(this.config.url) + this.proxyAgent = new GeneralProxyAgent() + await this.setSessionsProxy({ proxyRules: this.config.url }) } } catch (error) { console.error('Failed to set custom proxy:', error) @@ -99,45 +99,31 @@ export class ProxyManager { } } - private async clearProxy(): Promise { + private clearEnvironment(): void { delete process.env.HTTP_PROXY delete process.env.HTTPS_PROXY - await this.setSessionsProxy({}) + delete process.env.grpc_proxy + delete process.env.http_proxy + delete process.env.https_proxy + } + + private async clearProxy(): Promise { + this.clearEnvironment() + await this.setSessionsProxy({ mode: 'direct' }) this.config = { mode: 'none' } this.proxyAgent = null - this.proxyUrl = null } - private async resolveSystemProxy(): Promise { - try { - return await this.resolveElectronProxy() - } catch (error) { - console.error('Failed to resolve system proxy:', error) - return null - } - } - - private async resolveElectronProxy(): Promise { - try { - const proxyString = await session.defaultSession.resolveProxy('https://dummy.com') - const [protocol, address] = proxyString.split(';')[0].split(' ') - return protocol === 'PROXY' ? `http://${address}` : null - } catch (error) { - console.error('Failed to resolve electron proxy:', error) - return null - } - } - - getProxyAgent(): HttpsProxyAgent | null { + getProxyAgent(): GeneralProxyAgent | null { return this.proxyAgent } - getProxyUrl(): string | null { - return this.proxyUrl + getProxyUrl(): string { + return this.config.url || '' } setGlobalProxy() { - const proxyUrl = this.proxyUrl + const proxyUrl = this.config.url if (proxyUrl) { const [protocol, address] = proxyUrl.split('://') const [host, port] = address.split(':') diff --git a/src/main/services/WebDav.ts b/src/main/services/WebDav.ts index f9e9549f7..90d4e8cf7 100644 --- a/src/main/services/WebDav.ts +++ b/src/main/services/WebDav.ts @@ -1,7 +1,5 @@ -import { proxyManager } from '@main/services/ProxyManager' import { WebDavConfig } from '@types' import Logger from 'electron-log' -import { HttpProxyAgent } from 'http-proxy-agent' import Stream from 'stream' import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav' export default class WebDav { @@ -10,15 +8,12 @@ export default class WebDav { constructor(params: WebDavConfig) { this.webdavPath = params.webdavPath - const url = proxyManager.getProxyUrl() this.instance = createClient(params.webdavHost, { username: params.webdavUser, password: params.webdavPass, maxBodyLength: Infinity, - maxContentLength: Infinity, - httpAgent: url ? new HttpProxyAgent(url) : undefined, - httpsAgent: proxyManager.getProxyAgent() + maxContentLength: Infinity }) this.putFileContents = this.putFileContents.bind(this) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6aeb2ddf2..4d0b88048 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -6,7 +6,12 @@ import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, import type { LoaderReturn } from '@shared/config/types' import type { OpenDialogOptions } from 'electron' import type { UpdateInfo } from 'electron-updater' -import { Readable } from 'stream' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} declare global { interface Window { @@ -24,6 +29,9 @@ declare global { minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void reload: () => void clearCache: () => Promise<{ success: boolean; error?: string }> + system: { + getDeviceType: () => Promise<'mac' | 'windows' | 'linux'> + } zip: { compress: (text: string) => Promise decompress: (text: Buffer) => Promise @@ -33,6 +41,7 @@ declare global { restore: (backupPath: string) => Promise backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise + listWebdavFiles: (webdavConfig: WebDavConfig) => Promise } file: { select: (options?: OpenDialogOptions) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 9965ea6d6..831b59a7f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,9 @@ const api = { openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url), clearCache: () => ipcRenderer.invoke('app:clear-cache'), + system: { + getDeviceType: () => ipcRenderer.invoke('system:getDeviceType') + }, zip: { compress: (text: string) => ipcRenderer.invoke('zip:compress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) @@ -27,7 +30,8 @@ const api = { restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig), - restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig) + restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig), + listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index e9242b3b1..95478b35d 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -45,7 +45,15 @@ export const ThemeProvider: React.FC = ({ children, defaultT useEffect(() => { document.body.setAttribute('os', isMac ? 'mac' : 'windows') - }, []) + + // listen theme change from main process from other windows + const themeChangeListenerRemover = window.electron.ipcRenderer.on('theme:change', (_, newTheme) => { + setTheme(newTheme) + }) + return () => { + themeChangeListenerRemover() + } + }) return {children} } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 710c1e237..8c68ccdbe 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -762,6 +762,12 @@ "password": "WebDAV Password", "path": "WebDAV Path", "path.placeholder": "/backup", + "backup.modal.title": "Backup to WebDAV", + "backup.modal.filename.placeholder": "Please enter backup filename", + "restore.modal.title": "Restore from WebDAV", + "restore.modal.select.placeholder": "Please select a backup file to restore", + "restore.confirm.title": "Confirm Restore", + "restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?", "restore.button": "Restore from WebDAV", "restore.content": "Restore from WebDAV will overwrite the current data, continue?", "restore.title": "Restore from WebDAV", @@ -920,6 +926,10 @@ "jsonSaveError": "Failed to save JSON configuration." }, "messages.divider": "Show divider between messages", + "messages.navigation": "Message Navigation", + "messages.navigation.none": "None", + "messages.navigation.buttons": "Navigation Buttons", + "messages.navigation.anchor": "Message Anchor", "messages.grid_columns": "Message grid display columns", "messages.grid_popover_trigger": "Grid detail trigger", "messages.grid_popover_trigger.click": "Click to display", @@ -953,7 +963,7 @@ "models.check.enabled": "Enabled", "models.check.failed": "Failed", "models.check.keys_status_count": "Passed: {{count_passed}} keys, failed: {{count_failed}} keys", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} models passed all keys, {{count_failed}} models failed all keys, {{count_partial}} models failed some keys", + "models.check.model_status_summary": "{{provider}}: {{count_passed}} models passed health checks ({{count_partial}} models had inaccessible keys), {{count_failed}} models completely inaccessible.", "models.check.no_api_keys": "No API keys found, please add API keys first.", "models.check.passed": "Passed", "models.check.select_api_key": "Select the API key to use:", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1166208e4..68066152d 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -768,7 +768,13 @@ "syncError": "バックアップエラー", "syncStatus": "バックアップ状態", "title": "WebDAV", - "user": "WebDAVユーザー" + "user": "WebDAVユーザー", + "backup.modal.title": "WebDAV にバックアップ", + "backup.modal.filename.placeholder": "バックアップファイル名を入力してください", + "restore.modal.title": "WebDAV から復元", + "restore.modal.select.placeholder": "復元するバックアップファイルを選択してください", + "restore.confirm.title": "復元を確認", + "restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?" }, "yuque": { "check": { @@ -920,6 +926,10 @@ "jsonSaveError": "JSON設定の保存に失敗しました" }, "messages.divider": "メッセージ間に区切り線を表示", + "messages.navigation": "メッセージナビゲーション", + "messages.navigation.none": "表示しない", + "messages.navigation.buttons": "上下ボタン", + "messages.navigation.anchor": "会話アンカー", "messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_popover_trigger": "グリッド詳細トリガー", "messages.grid_popover_trigger.click": "クリックで表示", @@ -953,7 +963,7 @@ "models.check.enabled": "開く", "models.check.failed": "失敗", "models.check.keys_status_count": "合格:{{count_passed}}個のキー、不合格:{{count_failed}}個のキー", - "models.check.model_status_summary": "{{provider}}: {{count_passed}}個のモデルが成功しました、{{count_failed}}個のモデルが失敗しました、{{count_partial}}個のモデルが一部成功しました", + "models.check.model_status_summary": "{{provider}}: {{count_passed}} 個のモデルが健康チェックを完了しました({{count_partial}} 個のモデルは一部のキーにアクセスできませんでした)、{{count_failed}} 個のモデルは完全にアクセスできませんでした。", "models.check.no_api_keys": "APIキーが見つかりません。まずAPIキーを追加してください。", "models.check.passed": "成功", "models.check.select_api_key": "使用するAPIキーを選択:", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 583bd5d94..b1b32680e 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -768,7 +768,13 @@ "syncError": "Ошибка резервного копирования", "syncStatus": "Статус резервного копирования", "title": "WebDAV", - "user": "Пользователь WebDAV" + "user": "Пользователь WebDAV", + "backup.modal.title": "Резервное копирование на WebDAV", + "backup.modal.filename.placeholder": "Введите имя файла резервной копии", + "restore.modal.title": "Восстановление с WebDAV", + "restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления", + "restore.confirm.title": "Подтверждение восстановления", + "restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?" }, "yuque": { "check": { @@ -920,6 +926,10 @@ "jsonSaveError": "Не удалось сохранить конфигурацию JSON" }, "messages.divider": "Показывать разделитель между сообщениями", + "messages.navigation": "Навигация сообщений", + "messages.navigation.none": "Не показывать", + "messages.navigation.buttons": "Кнопки пагинации", + "messages.navigation.anchor": "Диалог анкор", "messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке", "messages.grid_popover_trigger.click": "Нажатие для отображения", @@ -953,7 +963,7 @@ "models.check.enabled": "Включено", "models.check.failed": "Не прошло", "models.check.keys_status_count": "Прошло: {{count_passed}} ключей, Не прошло: {{count_failed}} ключей", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} модели прошли все ключи, {{count_failed}} модели не прошли все ключи, {{count_partial}} модели не прошли некоторые ключи", + "models.check.model_status_summary": "{{provider}}: {{count_passed}} моделей прошли проверку состояния (из них {{count_partial}} моделей недоступны с некоторыми ключами), {{count_failed}} моделей полностью недоступны.", "models.check.no_api_keys": "API ключи не найдены, пожалуйста, добавьте API ключи.", "models.check.passed": "Прошло", "models.check.select_api_key": "Выберите API ключ для использования:", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 78ef03f6c..7de957c78 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -772,6 +772,12 @@ "password": "WebDAV 密码", "path": "WebDAV 路径", "path.placeholder": "/backup", + "backup.modal.title": "备份到 WebDAV", + "backup.modal.filename.placeholder": "请输入备份文件名", + "restore.modal.title": "从 WebDAV 恢复", + "restore.modal.select.placeholder": "请选择要恢复的备份文件", + "restore.confirm.title": "确认恢复", + "restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?", "restore.button": "从 WebDAV 恢复", "restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?", "restore.title": "从 WebDAV 恢复", @@ -937,6 +943,10 @@ "jsonSaveError": "保存JSON配置失败" }, "messages.divider": "消息分割线", + "messages.navigation": "对话导航按钮", + "messages.navigation.none": "不显示", + "messages.navigation.buttons": "上下按钮", + "messages.navigation.anchor": "对话锚点", "messages.grid_columns": "消息网格展示列数", "messages.grid_popover_trigger": "网格详情触发", "messages.grid_popover_trigger.click": "点击显示", @@ -970,7 +980,7 @@ "models.check.enabled": "开启", "models.check.failed": "失败", "models.check.keys_status_count": "通过:{{count_passed}}个密钥,失败:{{count_failed}}个密钥", - "models.check.model_status_summary": "{{provider}}: {{count_passed}}个模型通过所有密钥,{{count_failed}}个模型未通过任何密钥,{{count_partial}}个模型未通过某些密钥", + "models.check.model_status_summary": "{{provider}}: {{count_passed}} 个模型完成健康检查(其中 {{count_partial}} 个模型用某些密钥无法访问),{{count_failed}} 个模型完全无法访问。", "models.check.no_api_keys": "未找到API密钥,请先添加API密钥。", "models.check.passed": "通过", "models.check.select_api_key": "选择要使用的API密钥:", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c8c541265..12d92d300 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -768,7 +768,13 @@ "syncError": "備份錯誤", "syncStatus": "備份狀態", "title": "WebDAV", - "user": "WebDAV 使用者名稱" + "user": "WebDAV 使用者名稱", + "backup.modal.title": "備份到 WebDAV", + "backup.modal.filename.placeholder": "請輸入備份文件名", + "restore.modal.title": "從 WebDAV 恢復", + "restore.modal.select.placeholder": "請選擇要恢復的備份文件", + "restore.confirm.title": "復元確認", + "restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?" }, "yuque": { "check": { @@ -920,6 +926,10 @@ "jsonSaveError": "保存JSON配置失敗" }, "messages.divider": "訊息間顯示分隔線", + "messages.navigation": "訊息導航", + "messages.navigation.none": "不顯示", + "messages.navigation.buttons": "上下按鈕", + "messages.navigation.anchor": "對話錨點", "messages.grid_columns": "訊息網格展示列數", "messages.grid_popover_trigger": "網格詳細資訊觸發", "messages.grid_popover_trigger.click": "點選顯示", @@ -953,7 +963,7 @@ "models.check.enabled": "開啟", "models.check.failed": "失敗", "models.check.keys_status_count": "通過:{{count_passed}}個密鑰,失敗:{{count_failed}}個密鑰", - "models.check.model_status_summary": "{{provider}}: {{count_passed}}個模型通過所有密鑰,{{count_failed}}個模型未通過所有密鑰,{{count_partial}}個模型未通過某些密鑰", + "models.check.model_status_summary": "{{provider}}: {{count_passed}} 個模型完成健康檢查(其中 {{count_partial}} 個模型用某些密鑰無法訪問),{{count_failed}} 個模型完全無法訪問。", "models.check.no_api_keys": "未找到API密鑰,請先添加API密鑰。", "models.check.passed": "通過", "models.check.select_api_key": "選擇要使用的API密鑰:", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 82d37ebcc..dce62038a 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -716,11 +716,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = - onMentionModel(model, mentionFromKeyboard)} - ToolbarButton={ToolbarButton} - /> = ({ assistant: _assistant, setActiveTopic, topic }) = ToolbarButton={ToolbarButton} /> )} + onMentionModel(model, mentionFromKeyboard)} + ToolbarButton={ToolbarButton} + /> { + if (isLocalAi) return AppLogo + return modelId ? getModelLogo(modelId) : undefined +} + +const MessageAnchorLine: FC = ({ messages }) => { + const { t } = useTranslation() + const avatar = useAvatar() + const { theme } = useTheme() + const dispatch = useAppDispatch() + + const { userName } = useSettings() + const messagesListRef = useRef(null) + const messageItemsRef = useRef>(new Map()) + const containerRef = useRef(null) + const [mouseY, setMouseY] = useState(null) + const { topicPosition, showTopics } = useSettings() + const showRightTopics = topicPosition === 'right' && showTopics + const right = showRightTopics ? 'calc(var(--topic-list-width) + 15px)' : '15px' + + const [listOffsetY, setListOffsetY] = useState(0) + const [containerHeight, setContainerHeight] = useState(null) + + useEffect(() => { + const updateHeight = () => { + if (containerRef.current) { + const parentElement = containerRef.current.parentElement + if (parentElement) { + setContainerHeight(parentElement.clientHeight) + } + } + } + + updateHeight() + window.addEventListener('resize', updateHeight) + + return () => { + window.removeEventListener('resize', updateHeight) + } + }, [messages]) + + // 函数用于计算根据距离的变化值 + const calculateValueByDistance = useCallback( + (itemId: string, maxValue: number) => { + if (mouseY === null) return 0 + + const element = messageItemsRef.current.get(itemId) + if (!element) return 0 + + const rect = element.getBoundingClientRect() + const centerY = rect.top + rect.height / 2 + const distance = Math.abs(centerY - (messagesListRef.current?.getBoundingClientRect().top || 0) - mouseY) + const maxDistance = 100 + + return Math.max(0, maxValue * (1 - distance / maxDistance)) + }, + [mouseY] + ) + + const getUserName = useCallback( + (message: Message) => { + if (isLocalAi && message.role !== 'user') { + return APP_NAME + } + + if (message.role === 'assistant') { + if (message.model) { + return getModelName(message.model) || message.model.name || message.modelId || '' + } + + const modelId = getMessageModelId(message) + return modelId || '' + } + + return userName || t('common.you') + }, + [userName, t] + ) + + const setSelectedMessage = useCallback( + (message: Message) => { + const groupMessages = messages.filter((m) => m.askId === message.askId) + if (groupMessages.length > 1) { + groupMessages.forEach((m) => { + dispatch( + updateMessage({ + topicId: m.topicId, + messageId: m.id, + updates: { foldSelected: m.id === message.id } + }) + ) + }) + + setTimeout(() => { + const messageElement = document.getElementById(`message-${message.id}`) + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, 100) + } + }, + [dispatch, messages] + ) + + const scrollToMessage = useCallback( + (message: Message) => { + const messageElement = document.getElementById(`message-${message.id}`) + + if (!messageElement) return + + const display = messageElement ? window.getComputedStyle(messageElement).display : null + if (display === 'none') { + setSelectedMessage(message) + return + } + + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, + [messages, setSelectedMessage] + ) + + if (messages.length === 0) return null + + const handleMouseMove = (e: React.MouseEvent) => { + if (messagesListRef.current) { + const containerRect = e.currentTarget.getBoundingClientRect() + const listRect = messagesListRef.current.getBoundingClientRect() + setMouseY(e.clientY - listRect.top) + + if (listRect.height > containerRect.height) { + const mousePositionRatio = (e.clientY - containerRect.top) / containerRect.height + const maxOffset = (containerRect.height - listRect.height) / 2 - 20 + setListOffsetY(-maxOffset + mousePositionRatio * (maxOffset * 2)) + } else { + setListOffsetY(0) + } + } + } + + const handleMouseLeave = () => { + setMouseY(null) + setListOffsetY(0) + } + + return ( + + + {messages.map((message, index) => { + const opacity = 0.5 + calculateValueByDistance(message.id, 1) + const scale = 1 + calculateValueByDistance(message.id, 1) + const size = 10 + calculateValueByDistance(message.id, 20) + const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message)) + const username = removeLeadingEmoji(getUserName(message)) + + return ( + { + if (el) messageItemsRef.current.set(message.id, el) + else messageItemsRef.current.delete(message.id) + }} + style={{ + opacity: mouseY ? opacity : Math.max(0, 0.6 - (0.3 * Math.abs(index - messages.length / 2)) / 5) + }} + onClick={() => scrollToMessage(message)}> + + {username} + {message.content.substring(0, 50)} + + + {message.role === 'assistant' ? ( + + A + + ) : ( + <> + {isEmoji(avatar) ? ( + {avatar} + ) : ( + + )} + + )} + + ) + })} + + + ) +} + +const MessageItemContainer = styled.div` + line-height: 1; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + text-align: right; + gap: 4px; + text-shadow: 0 0 2px rgba(255, 255, 255, 0.5); + opacity: 0; + transform-origin: right center; +` + +const MessageLineContainer = styled.div<{ $right: string; $height: number | null }>` + width: 14px; + position: fixed; + top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')}; + right: ${(props) => props.$right}; + max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')}; + transform: translateY(-50%); + z-index: 0; + user-select: none; + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 5px; + overflow: hidden; + &:hover { + width: 440px; + overflow-x: visible; + overflow-y: hidden; + ${MessageItemContainer} { + opacity: 1; + } + } +` + +const MessagesList = styled.div` + display: flex; + flex-direction: column-reverse; + will-change: transform; +` + +const MessageItem = styled.div` + display: flex; + position: relative; + cursor: pointer; + justify-content: flex-end; + align-items: center; + gap: 10px; + transform-origin: right center; + padding: 2px 0; + will-change: opacity; + opacity: 0.4; + transition: opacity 0.1s linear; +` + +const MessageItemTitle = styled.div` + font-weight: 500; + color: var(--color-text); + white-space: nowrap; +` +const MessageItemContent = styled.div` + color: var(--color-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +` + +const EmojiAvatar = styled.div<{ size: number }>` + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; + background-color: var(--color-background-soft); + border-radius: 20%; + display: flex; + align-items: center; + justify-content: center; + font-size: ${(props) => props.size * 0.6}px; + border: 0.5px solid var(--color-border); +` + +export default MessageAnchorLine diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index ca75e87a7..89586b9c2 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -1,4 +1,5 @@ import Scrollbar from '@renderer/components/Scrollbar' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { MultiModelMessageStyle } from '@renderer/store/settings' import type { Message, Topic } from '@renderer/types' @@ -17,10 +18,12 @@ interface Props { } const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { + const { editMessage } = useMessageOperations(topic) const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() - const [multiModelMessageStyle, setMultiModelMessageStyle] = - useState(multiModelMessageStyleSetting) + const [multiModelMessageStyle, setMultiModelMessageStyle] = useState( + messages[0].multiModelMessageStyle || multiModelMessageStyleSetting + ) const messageLength = messages.length const [selectedIndex, setSelectedIndex] = useState(messageLength - 1) @@ -33,6 +36,22 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { setSelectedIndex(messageLength - 1) }, [messageLength]) + const setSelectedMessage = useCallback( + (message: Message) => { + messages.forEach(async (m) => { + await editMessage(m.id, { foldSelected: m.id === message.id }) + }) + + setTimeout(() => { + const messageElement = document.getElementById(`message-${message.id}`) + if (messageElement) { + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, 100) + }, + [editMessage, messages] + ) + const renderMessage = useCallback( (message: Message & { index: number }, index: number) => { const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped @@ -49,11 +68,16 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { const messageWrapper = ( + className={classNames({ + 'group-message-wrapper': message.role === 'assistant' && isHorizontal && isGrouped, + [multiModelMessageStyle]: true, + selected: 'foldSelected' in message ? message.foldSelected : index === 0 + })}> ) @@ -95,6 +119,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { return ( @@ -108,10 +133,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { {isGrouped && ( { + setMultiModelMessageStyle(style) + messages.forEach((message) => { + editMessage(message.id, { multiModelMessageStyle: style }) + }) + }} messages={messages} - selectedIndex={selectedIndex} - setSelectedIndex={setSelectedIndex} + setSelectedMessage={setSelectedMessage} topic={topic} /> )} @@ -173,15 +202,18 @@ interface MessageWrapperProps { const MessageWrapper = styled(Scrollbar)` width: 100%; - display: ${(props) => { - if (props.$layout === 'fold') { - return props.$selected ? 'block' : 'none' + &.horizontal { + display: inline-block; + } + &.grid { + display: inline-block; + } + &.fold { + display: none; + &.selected { + display: inline-block; } - if (props.$layout === 'horizontal') { - return 'inline-block' - } - return 'block' - }}; + } ${({ $layout, $isGrouped }) => { if ($layout === 'horizontal' && $isGrouped) { diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx index c19e7cc14..0dceb3adb 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx @@ -21,8 +21,7 @@ interface Props { multiModelMessageStyle: MultiModelMessageStyle setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void messages: Message[] - selectedIndex: number - setSelectedIndex: (index: number) => void + setSelectedMessage: (message: Message) => void topic: Topic } @@ -30,8 +29,7 @@ const MessageGroupMenuBar: FC = ({ multiModelMessageStyle, setMultiModelMessageStyle, messages, - selectedIndex, - setSelectedIndex, + setSelectedMessage, topic }) => { const { t } = useTranslation() @@ -77,11 +75,7 @@ const MessageGroupMenuBar: FC = ({ ))} {multiModelMessageStyle === 'fold' && ( - + )} {multiModelMessageStyle === 'grid' && } diff --git a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx index dc098bc57..315db3aaf 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx @@ -1,7 +1,6 @@ import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import Scrollbar from '@renderer/components/Scrollbar' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Message, Model } from '@renderer/types' import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd' import { FC, useState } from 'react' @@ -10,13 +9,12 @@ import styled from 'styled-components' interface MessageGroupModelListProps { messages: Message[] - selectedIndex: number - setSelectedIndex: (index: number) => void + setSelectedMessage: (message: Message) => void } type DisplayMode = 'compact' | 'expanded' -const MessageGroupModelList: FC = ({ messages, selectedIndex, setSelectedIndex }) => { +const MessageGroupModelList: FC = ({ messages, setSelectedMessage }) => { const { t } = useTranslation() const [displayMode, setDisplayMode] = useState('expanded') const isCompact = displayMode === 'compact' @@ -43,10 +41,9 @@ const MessageGroupModelList: FC = ({ messages, selec { - setSelectedIndex(index) - EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[index].id, false) + setSelectedMessage(message) }}> @@ -56,19 +53,19 @@ const MessageGroupModelList: FC = ({ messages, selec ) : ( /* Expanded style display */ message.foldSelected)?.id || messages[0].id} onChange={(value) => { - setSelectedIndex(Number(value)) - EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false) + const message = messages.find((message) => message.id === value) as Message + setSelectedMessage(message) }} - options={messages.map((message, index) => ({ + options={messages.map((message) => ({ label: ( {message.model?.name} ), - value: index.toString() + value: message.id }))} size="small" /> diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 03374989f..0eb5fa19e 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -16,6 +16,7 @@ import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' +import { isReasoningModel } from '@renderer/config/models' import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle, resetAssistantMessage } from '@renderer/services/MessagesService' @@ -23,6 +24,7 @@ import { translateText } from '@renderer/services/TranslateService' import { Message, Model } from '@renderer/types' import { Assistant, Topic } from '@renderer/types' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils' +import { withMessageThought } from '@renderer/utils/formats' import { exportMarkdownToJoplin, exportMarkdownToNotion, @@ -35,6 +37,8 @@ import dayjs from 'dayjs' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { clone } from 'lodash' + interface Props { message: Message assistant: Assistant @@ -54,6 +58,8 @@ const MessageMenubar: FC = (props) => { const { t } = useTranslation() const [copied, setCopied] = useState(false) const [isTranslating, setIsTranslating] = useState(false) + const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) + const [showDeleteTooltip, setShowDeleteTooltip] = useState(false) const assistantModel = assistant?.model const { loading, @@ -70,12 +76,21 @@ const MessageMenubar: FC = (props) => { const onCopy = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content)) + + // 只处理助手消息和来自推理模型的消息 + if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) { + const processedMessage = withMessageThought(clone(message)) + navigator.clipboard.writeText(removeTrailingDoubleSpaces(processedMessage.content.trimStart())) + } else { + // 其他情况直接复制原始内容 + navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content.trimStart())) + } + window.message.success({ content: t('message.copied'), key: 'copy-message' }) setCopied(true) setTimeout(() => setCopied(false), 2000) }, - [message.content, t] + [message, t] ) const onNewBranch = useCallback(async () => { @@ -96,8 +111,15 @@ const MessageMenubar: FC = (props) => { const onEdit = useCallback(async () => { let resendMessage = false + let textToEdit = message.content + + if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) { + const processedMessage = withMessageThought(clone(message)) + textToEdit = processedMessage.content + } + const editedText = await TextEditPopup.show({ - text: message.content, + text: textToEdit, children: (props) => { const onPress = () => { props.onOk?.() @@ -113,7 +135,7 @@ const MessageMenubar: FC = (props) => { } }) - if (editedText && editedText !== message.content) { + if (editedText && editedText !== textToEdit) { await editMessage(message.id, { content: editedText }) resendMessage && handleResendUserMessage({ ...message, content: editedText }) } @@ -290,10 +312,14 @@ const MessageMenubar: FC = (props) => { } - onConfirm={onRegenerate}> - + onConfirm={onRegenerate} + onOpenChange={(open) => open && setShowRegenerateTooltip(false)}> + @@ -345,9 +371,14 @@ const MessageMenubar: FC = (props) => { title={t('message.message.delete.content')} okButtonProps={{ danger: true }} icon={} + onOpenChange={(open) => open && setShowDeleteTooltip(false)} onConfirm={() => deleteMessage(message)}> e.stopPropagation()}> - + diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index fb34ed9db..47ffa60d7 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -27,6 +27,7 @@ import styled from 'styled-components' import ChatNavigation from './ChatNavigation' import MessageGroup from './MessageGroup' +import MessageAnchorLine from './MessageAnchorLine' import NarrowLayout from './NarrowLayout' import NewTopicButton from './NewTopicButton' import Prompt from './Prompt' @@ -39,7 +40,7 @@ interface MessagesProps { const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { const { t } = useTranslation() - const { showTopics, topicPosition, showAssistants } = useSettings() + const { showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() const { updateTopic, addTopic } = useAssistant(assistant.id) const dispatch = useAppDispatch() const containerRef = useRef(null) @@ -225,7 +226,10 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) - + + {messageNavigation === 'anchor' && } + + {messageNavigation === 'buttons' && } ) } diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index fe386f12a..68d5fc998 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -31,6 +31,7 @@ import { setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, setShowMessageDivider, + setMessageNavigation, setThoughtAutoCollapse } from '@renderer/store/settings' import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -76,7 +77,8 @@ const SettingsTab: FC = (props) => { autoTranslateWithSpace, pasteLongTextThreshold, multiModelMessageStyle, - thoughtAutoCollapse + thoughtAutoCollapse, + messageNavigation } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { @@ -361,6 +363,19 @@ const SettingsTab: FC = (props) => { + + {t('settings.messages.navigation')} + dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))} + style={{ width: 135 }}> + {t('settings.messages.navigation.none')} + {t('settings.messages.navigation.buttons')} + {t('settings.messages.navigation.anchor')} + + + {t('message.message.code_style')} = ({ base: _base, resolve }) => { name="rerankModel" label={t('models.rerank_model')} tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} + initialValue={getModelUniqId(base.rerankModel) || undefined} rules={[{ required: false, message: t('message.error.enter.model') }]}> setCustomFileName(e.target.value)} + placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')} + /> + + + setIsRestoreModalVisible(false)} + okButtonProps={{ loading: restoring }} + width={600}> +
+