Merge branch 'main' into feat-knowlege-ocr

This commit is contained in:
eeee0717
2025-03-21 16:32:12 +08:00
45 changed files with 1649 additions and 811 deletions
+2 -1
View File
@@ -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')
+1 -1
View File
@@ -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']
}
])
+2 -1
View File
@@ -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": {
+4 -1
View File
@@ -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 = [
+17 -15
View File
@@ -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)
})
+4 -1
View File
@@ -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
+15 -1
View File
@@ -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)
+48
View File
@@ -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<ExtractChunkData[]> => {
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
}
}
}
+3
View File
@@ -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)
}
+28 -27
View File
@@ -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
}
}
}
+42 -10
View File
@@ -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<void> {
@@ -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<number> {
-5
View File
@@ -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<FileMetadataResponse | undefined> {
proxyManager.setGlobalProxy()
const fileManager = new GoogleAIFileManager(apiKey)
const cachedResponse = CacheService.get<any>(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)
}
-4
View File
@@ -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<LoaderReturn> => {
proxyManager.setGlobalProxy()
const { base, item } = options
if (base.preprocessing) {
const ocrProvider = new OcrProvider(base)
+29 -43
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
this.clearEnvironment()
await this.setSessionsProxy({ mode: 'direct' })
this.config = { mode: 'none' }
this.proxyAgent = null
this.proxyUrl = null
}
private async resolveSystemProxy(): Promise<string | null> {
try {
return await this.resolveElectronProxy()
} catch (error) {
console.error('Failed to resolve system proxy:', error)
return null
}
}
private async resolveElectronProxy(): Promise<string | null> {
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(':')
+1 -6
View File
@@ -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)
+10 -1
View File
@@ -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<Buffer>
decompress: (text: Buffer) => Promise<string>
@@ -33,6 +41,7 @@ declare global {
restore: (backupPath: string) => Promise<string>
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
}
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
+5 -1
View File
@@ -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),
+9 -1
View File
@@ -45,7 +45,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ 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 <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider>
}
+11 -1
View File
@@ -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:",
+12 -2
View File
@@ -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キーを選択:",
+12 -2
View File
@@ -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 ключ для использования:",
+11 -1
View File
@@ -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密钥:",
+12 -2
View File
@@ -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密鑰:",
@@ -716,11 +716,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
</ToolbarButton>
</Tooltip>
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
<MentionModelsButton
mentionModels={mentionModels}
onMentionModel={(model) => onMentionModel(model, mentionFromKeyboard)}
ToolbarButton={ToolbarButton}
/>
<Tooltip placement="top" title={t('chat.input.web_search')} arrow>
<ToolbarButton type="text" onClick={onEnableWebSearch}>
<GlobalOutlined
@@ -743,6 +738,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
ToolbarButton={ToolbarButton}
/>
)}
<MentionModelsButton
mentionModels={mentionModels}
onMentionModel={(model) => onMentionModel(model, mentionFromKeyboard)}
ToolbarButton={ToolbarButton}
/>
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
@@ -0,0 +1,304 @@
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelName } from '@renderer/services/ModelService'
import { useAppDispatch } from '@renderer/store'
import { updateMessage } from '@renderer/store/messages'
import { Message } from '@renderer/types'
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MessageLineProps {
messages: Message[]
}
const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => {
if (isLocalAi) return AppLogo
return modelId ? getModelLogo(modelId) : undefined
}
const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
const { t } = useTranslation()
const avatar = useAvatar()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { userName } = useSettings()
const messagesListRef = useRef<HTMLDivElement>(null)
const messageItemsRef = useRef<Map<string, HTMLDivElement>>(new Map())
const containerRef = useRef<HTMLDivElement>(null)
const [mouseY, setMouseY] = useState<number | null>(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<number | null>(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 (
<MessageLineContainer
ref={containerRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
$right={right}
$height={containerHeight}>
<MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}>
{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 (
<MessageItem
key={message.id}
ref={(el) => {
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)}>
<MessageItemContainer style={{ transform: ` scale(${scale})` }}>
<MessageItemTitle>{username}</MessageItemTitle>
<MessageItemContent>{message.content.substring(0, 50)}</MessageItemContent>
</MessageItemContainer>
{message.role === 'assistant' ? (
<Avatar
src={avatarSource}
size={size}
style={{
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}>
A
</Avatar>
) : (
<>
{isEmoji(avatar) ? (
<EmojiAvatar size={size}>{avatar}</EmojiAvatar>
) : (
<Avatar src={avatar} size={size} />
)}
</>
)}
</MessageItem>
)
})}
</MessagesList>
</MessageLineContainer>
)
}
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
@@ -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<MultiModelMessageStyle>(multiModelMessageStyleSetting)
const [multiModelMessageStyle, setMultiModelMessageStyle] = useState<MultiModelMessageStyle>(
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 = (
<MessageWrapper
id={`message-${message.id}`}
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
className={classNames({
'group-message-wrapper': message.role === 'assistant' && isHorizontal && isGrouped,
[multiModelMessageStyle]: true,
selected: 'foldSelected' in message ? message.foldSelected : index === 0
})}>
<MessageStream {...messageProps} />
</MessageWrapper>
)
@@ -95,6 +119,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
return (
<GroupContainer
id={`message-group-${messages[0].askId}`}
$isGrouped={isGrouped}
$layout={multiModelMessageStyle}
className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
@@ -108,10 +133,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
{isGrouped && (
<MessageGroupMenuBar
multiModelMessageStyle={multiModelMessageStyle}
setMultiModelMessageStyle={setMultiModelMessageStyle}
setMultiModelMessageStyle={(style) => {
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)<MessageWrapperProps>`
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) {
@@ -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<Props> = ({
multiModelMessageStyle,
setMultiModelMessageStyle,
messages,
selectedIndex,
setSelectedIndex,
setSelectedMessage,
topic
}) => {
const { t } = useTranslation()
@@ -77,11 +75,7 @@ const MessageGroupMenuBar: FC<Props> = ({
))}
</LayoutContainer>
{multiModelMessageStyle === 'fold' && (
<MessageGroupModelList
messages={messages}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
/>
<MessageGroupModelList messages={messages} setSelectedMessage={setSelectedMessage} />
)}
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
</HStack>
@@ -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<MessageGroupModelListProps> = ({ messages, selectedIndex, setSelectedIndex }) => {
const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, setSelectedMessage }) => {
const { t } = useTranslation()
const [displayMode, setDisplayMode] = useState<DisplayMode>('expanded')
const isCompact = displayMode === 'compact'
@@ -43,10 +41,9 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
<AvatarWrapper
className="avatar-wrapper"
isSelected={selectedIndex === index}
isSelected={'foldSelected' in message ? message.foldSelected! : index === 0}
onClick={() => {
setSelectedIndex(index)
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[index].id, false)
setSelectedMessage(message)
}}>
<ModelAvatar model={message.model as Model} size={28} />
</AvatarWrapper>
@@ -56,19 +53,19 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
) : (
/* Expanded style display */
<Segmented
value={selectedIndex.toString()}
value={messages.find((message) => 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: (
<SegmentedLabel>
<ModelAvatar model={message.model as Model} size={20} />
<ModelName>{message.model?.name}</ModelName>
</SegmentedLabel>
),
value: index.toString()
value: message.id
}))}
size="small"
/>
@@ -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> = (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> = (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> = (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> = (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> = (props) => {
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
destroyTooltipOnHide
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={onRegenerate}>
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
onConfirm={onRegenerate}
onOpenChange={(open) => open && setShowRegenerateTooltip(false)}>
<Tooltip
title={t('common.regenerate')}
mouseEnterDelay={0.8}
open={showRegenerateTooltip}
onOpenChange={setShowRegenerateTooltip}>
<ActionButton className="message-action-button">
<SyncOutlined />
</ActionButton>
@@ -345,9 +371,14 @@ const MessageMenubar: FC<Props> = (props) => {
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}
onConfirm={() => deleteMessage(message)}>
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<Tooltip
title={t('common.delete')}
mouseEnterDelay={1}
open={showDeleteTooltip}
onOpenChange={setShowDeleteTooltip}>
<DeleteOutlined />
</Tooltip>
</ActionButton>
@@ -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<MessagesProps> = ({ 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<HTMLDivElement>(null)
@@ -225,7 +226,10 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
</InfiniteScroll>
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
</NarrowLayout>
<ChatNavigation containerId="messages" />
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
</Container>
)
}
@@ -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> = (props) => {
autoTranslateWithSpace,
pasteLongTextThreshold,
multiModelMessageStyle,
thoughtAutoCollapse
thoughtAutoCollapse,
messageNavigation
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@@ -361,6 +363,19 @@ const SettingsTab: FC<Props> = (props) => {
</StyledSelect>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.navigation')}</SettingRowTitleSmall>
<StyledSelect
size="small"
value={messageNavigation}
onChange={(value) => dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))}
style={{ width: 135 }}>
<Select.Option value="none">{t('settings.messages.navigation.none')}</Select.Option>
<Select.Option value="buttons">{t('settings.messages.navigation.buttons')}</Select.Option>
<Select.Option value="anchor">{t('settings.messages.navigation.anchor')}</Select.Option>
</StyledSelect>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<StyledSelect
@@ -142,10 +142,10 @@ const PopupContainer: React.FC<Props> = ({ 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') }]}>
<Select
style={{ width: '100%' }}
defaultValue={getModelUniqId(base.rerankModel) || undefined}
options={rerankSelectOptions}
placeholder={t('settings.models.empty')}
allowClear
@@ -1,10 +1,9 @@
import { FolderOpenOutlined, SaveOutlined, SyncOutlined } from '@ant-design/icons'
import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
import { useAppDispatch } from '@renderer/store'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setWebdavAutoSync,
setWebdavHost as _setWebdavHost,
@@ -13,12 +12,19 @@ import {
setWebdavSyncInterval as _setWebdavSyncInterval,
setWebdavUser as _setWebdavUser
} from '@renderer/store/settings'
import { Button, Input, Select } from 'antd'
import { Button, Input, Modal, Select, Spin, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { formatFileSize } from '@renderer/utils'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
const WebDavSettings: FC = () => {
const {
@@ -38,45 +44,22 @@ const WebDavSettings: FC = () => {
const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false)
const [isModalVisible, setIsModalVisible] = useState(false)
const [customFileName, setCustomFileName] = useState('')
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [selectedFile, setSelectedFile] = useState<string>('')
const [loadingFiles, setLoadingFiles] = useState(false)
const dispatch = useAppDispatch()
const { theme } = useTheme()
const { t } = useTranslation()
const { webdavSync } = useRuntime()
const { webdavSync } = useAppSelector((state) => state.backup)
// 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path
const onBackup = async () => {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setBackuping(true)
await backupToWebdav({ showMessage: true })
setBackuping(false)
}
const onRestore = async () => {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setRestoring(true)
await restoreFromWebdav()
setRestoring(false)
}
const onPressRestore = () => {
window.modal.confirm({
title: t('settings.data.webdav.restore.title'),
content: t('settings.data.webdav.restore.content'),
centered: true,
onOk: onRestore
})
}
const onSyncIntervalChange = (value: number) => {
setSyncInterval(value)
dispatch(_setWebdavSyncInterval(value))
@@ -99,20 +82,102 @@ const WebDavSettings: FC = () => {
return (
<HStack gap="5px" alignItems="center">
{webdavSync.syncing && <SyncOutlined spin />}
{!webdavSync.syncing && webdavSync.lastSyncError && (
<Tooltip title={`${t('settings.data.webdav.syncError')}: ${webdavSync.lastSyncError}`}>
<WarningOutlined style={{ color: 'red' }} />
</Tooltip>
)}
{webdavSync.lastSyncTime && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('settings.data.webdav.lastSync')}: {dayjs(webdavSync.lastSyncTime).format('HH:mm:ss')}
</span>
)}
{webdavSync.lastSyncError && (
<span style={{ color: 'var(--error-color)' }}>
{t('settings.data.webdav.syncError')}: {webdavSync.lastSyncError}
</span>
)}
</HStack>
)
}
const showBackupModal = async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}
const handleBackup = async () => {
setBackuping(true)
try {
await backupToWebdav({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showRestoreModal = async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}
const handleRestore = async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await restoreFromWebdav(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}
const formatFileOption = (file: BackupFile) => {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = formatFileSize(file.size)
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
@@ -165,10 +230,10 @@ const WebDavSettings: FC = () => {
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button onClick={onBackup} icon={<SaveOutlined />} loading={backuping}>
<Button onClick={showBackupModal} icon={<SaveOutlined />} loading={backuping}>
{t('settings.data.webdav.backup.button')}
</Button>
<Button onClick={onPressRestore} icon={<FolderOpenOutlined />} loading={restoring}>
<Button onClick={showRestoreModal} icon={<FolderOpenOutlined />} loading={restoring}>
{t('settings.data.webdav.restore.button')}
</Button>
</HStack>
@@ -198,6 +263,46 @@ const WebDavSettings: FC = () => {
</SettingRow>
</>
)}
<>
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={() => setIsRestoreModalVisible(false)}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
</>
</SettingGroup>
)
}
@@ -95,14 +95,10 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
.map((k) => k.trim())
.filter((k) => k)
// Add an empty key to enable health checks for local models.
// Error messages will be shown for each model if a valid key is needed.
if (keys.length === 0) {
window.message.error({
key: 'no-api-keys',
style: { marginTop: '3vh' },
duration: 5,
content: t('settings.models.check.no_api_keys')
})
return
keys.push('')
}
// Show configuration dialog to get health check parameters
@@ -112,7 +108,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
apiKeys: keys
})
if (result.cancelled || result.apiKeys.length === 0) {
if (result.cancelled) {
return
}
@@ -162,9 +158,9 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
duration: 10,
content: t('settings.models.check.model_status_summary', {
provider: provider.name,
count_passed: successModels.length,
count_failed: failedModels.length,
count_partial: partialModels.length
count_passed: successModels.length + partialModels.length,
count_partial: partialModels.length,
count_failed: failedModels.length
})
})
@@ -306,7 +302,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
</Space.Compact>
{apiKeyWebsite && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<HStack gap={5}>
<HStack>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
@@ -112,8 +112,8 @@ const TranslatePage: FC = () => {
message,
assistant,
onResponse: (text) => {
translatedText = text
setResult(text)
translatedText = text.replace(/^\s*\n+/g, '')
setResult(translatedText)
}
})
} catch (error) {
@@ -197,9 +197,7 @@ const TranslatePage: FC = () => {
// Calculate scroll position by ratio
const inputScrollRatio = inputEl.scrollTop / (inputEl.scrollHeight - inputEl.clientHeight || 1)
const outputScrollPosition = inputScrollRatio * (outputEl.scrollHeight - outputEl.clientHeight || 1)
outputEl.scrollTop = outputScrollPosition
outputEl.scrollTop = inputScrollRatio * (outputEl.scrollHeight - outputEl.clientHeight || 1)
requestAnimationFrame(() => {
isProgrammaticScroll.current = false
@@ -217,9 +215,7 @@ const TranslatePage: FC = () => {
// Calculate scroll position by ratio
const outputScrollRatio = outputEl.scrollTop / (outputEl.scrollHeight - outputEl.clientHeight || 1)
const inputScrollPosition = outputScrollRatio * (inputEl.scrollHeight - inputEl.clientHeight || 1)
inputEl.scrollTop = inputScrollPosition
inputEl.scrollTop = outputScrollRatio * (inputEl.scrollHeight - inputEl.clientHeight || 1)
requestAnimationFrame(() => {
isProgrammaticScroll.current = false
@@ -381,7 +377,7 @@ const TranslatePage: FC = () => {
/>
</OperationBar>
<OutputText ref={outputTextRef} onScroll={handleOutputScroll}>
<OutputText ref={outputTextRef} onScroll={handleOutputScroll} className="selectable">
{result || t('translate.output.placeholder')}
</OutputText>
</OutputContainer>
+50 -21
View File
@@ -1,8 +1,9 @@
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setWebDAVSyncState } from '@renderer/store/runtime'
import { setWebDAVSyncState } from '@renderer/store/backup'
import dayjs from 'dayjs'
import Logger from 'electron-log'
export async function backup() {
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
@@ -59,16 +60,27 @@ export async function reset() {
}
// 备份到 webdav
export async function backupToWebdav({ showMessage = false }: { showMessage?: boolean } = {}) {
export async function backupToWebdav({
showMessage = false,
customFileName = ''
}: { showMessage?: boolean; customFileName?: string } = {}) {
if (isManualBackupRunning) {
console.log('[Backup] Manual backup already in progress')
Logger.log('[Backup] Manual backup already in progress')
return
}
store.dispatch(setWebDAVSyncState({ syncing: true, lastSyncError: null }))
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let deviceType = 'unknown'
try {
deviceType = (await window.api.system.getDeviceType()) || 'unknown'
} catch (error) {
Logger.error('[Backup] Failed to get device type:', error)
}
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
const backupData = await getBackupData()
// 上传文件
@@ -77,43 +89,47 @@ export async function backupToWebdav({ showMessage = false }: { showMessage?: bo
webdavHost,
webdavUser,
webdavPass,
webdavPath
webdavPath,
fileName: finalFileName
})
if (success) {
store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
lastSyncError: null
})
)
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} else {
store.dispatch(setWebDAVSyncState({ lastSyncError: 'Backup failed' }))
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
}
} catch (error: any) {
store.dispatch(setWebDAVSyncState({ lastSyncError: error.message }))
console.error('[backup] backupToWebdav: Error uploading file to WebDAV:', error)
showMessage &&
window.modal.error({
title: i18n.t('message.backup.failed'),
content: error.message
})
console.error('[Backup] backupToWebdav: Error uploading file to WebDAV:', error)
window.modal.error({
title: i18n.t('message.backup.failed'),
content: error.message
})
} finally {
store.dispatch(setWebDAVSyncState({ syncing: false }))
store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
syncing: false
})
)
isManualBackupRunning = false
}
}
// 从 webdav 恢复
export async function restoreFromWebdav() {
export async function restoreFromWebdav(fileName?: string) {
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let data = ''
try {
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath })
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath, fileName })
} catch (error: any) {
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
console.error('[Backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
window.modal.error({
title: i18n.t('message.restore.failed'),
content: error.message
@@ -123,7 +139,7 @@ export async function restoreFromWebdav() {
try {
await handleData(JSON.parse(data))
} catch (error) {
console.error('[backup] Error downloading file from WebDAV:', error)
console.error('[Backup] Error downloading file from WebDAV:', error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
}
}
@@ -158,6 +174,7 @@ export function startAutoSync() {
}
const { webdavSyncInterval } = store.getState().settings
const { webdavSync } = store.getState().backup
if (webdavSyncInterval <= 0) {
console.log('[AutoSync] Invalid sync interval, auto sync disabled')
@@ -165,9 +182,21 @@ export function startAutoSync() {
return
}
syncTimeout = setTimeout(performAutoBackup, webdavSyncInterval * 60 * 1000)
// 用户指定的自动备份时间间隔(毫秒)
const requiredInterval = webdavSyncInterval * 60 * 1000
console.log(`[AutoSync] Next sync scheduled in ${webdavSyncInterval} minutes`)
// 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间
const timeUntilNextSync = webdavSync?.lastSyncTime
? Math.max(1000, webdavSync.lastSyncTime + requiredInterval - Date.now())
: requiredInterval
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
console.log(
`[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
(timeUntilNextSync / 1000) % 60
)} seconds`
)
}
async function performAutoBackup() {
@@ -179,7 +208,7 @@ export function startAutoSync() {
isAutoBackupRunning = true
try {
console.log('[AutoSync] Performing auto backup...')
console.log('[AutoSync] Starting auto backup...')
await backupToWebdav({ showMessage: false })
} catch (error) {
console.error('[AutoSync] Auto backup failed:', error)
+32
View File
@@ -0,0 +1,32 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface WebDAVSyncState {
lastSyncTime: number | null
syncing: boolean
lastSyncError: string | null
}
export interface BackupState {
webdavSync: WebDAVSyncState
}
const initialState: BackupState = {
webdavSync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
}
}
const backupSlice = createSlice({
name: 'backup',
initialState,
reducers: {
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
}
}
})
export const { setWebDAVSyncState } = backupSlice.actions
export default backupSlice.reducer
+3 -1
View File
@@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'
import agents from './agents'
import assistants from './assistants'
import backup from './backup'
import copilot from './copilot'
import knowledge from './knowledge'
import llm from './llm'
@@ -21,6 +22,7 @@ import websearch from './websearch'
const rootReducer = combineReducers({
assistants,
agents,
backup,
paintings,
llm,
settings,
@@ -38,7 +40,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 82,
version: 83,
blacklist: ['runtime', 'messages'],
migrate
},
+1 -39
View File
@@ -28,31 +28,6 @@ const initialState: MessagesState = {
error: null
}
// const MAX_RECENT_TOPICS = 10
// // 只初始化最近的会话消息
// export const initializeMessagesState = createAsyncThunk('messages/initialize', async () => {
// try {
// // 获取所有会话的基本信息
// const recentTopics = await TopicManager.getTopicLimit(MAX_RECENT_TOPICS)
// console.log('recentTopics', recentTopics)
// const messagesByTopic: Record<string, Message[]> = {}
// // 只加载最近会话的消息
// for (const topic of recentTopics) {
// if (topic.messages && topic.messages.length > 0) {
// const messages = topic.messages.map((msg) => ({ ...msg }))
// messagesByTopic[topic.id] = messages
// }
// }
// return messagesByTopic
// } catch (error) {
// console.error('Failed to initialize recent messages:', error)
// return {}
// }
// })
// 新增准备会话消息的函数,实现懒加载机制
export const prepareTopicMessages = createAsyncThunk(
'messages/prepareTopic',
@@ -144,7 +119,7 @@ const messagesSlice = createSlice({
if (message) {
Object.assign(message, updates)
db.topics.update(topicId, {
messages: topicMessages.map((m) => (m.id === message.id ? cloneDeep(message) : m))
messages: topicMessages.map((m) => (m.id === message.id ? cloneDeep(message) : cloneDeep(m)))
})
}
}
@@ -210,19 +185,6 @@ const messagesSlice = createSlice({
}
}
}
// extraReducers: (builder) => {
// builder
// .addCase(initializeMessagesState.pending, (state) => {
// state.error = null
// })
// .addCase(initializeMessagesState.fulfilled, (state, action) => {
// console.log('initializeMessagesState.fulfilled', action.payload)
// state.messagesByTopic = action.payload
// })
// .addCase(initializeMessagesState.rejected, (state, action) => {
// state.error = action.error.message || 'Failed to load messages'
// })
// }
})
const handleResponseMessageUpdate = (
+17
View File
@@ -783,6 +783,23 @@ const migrateConfig = {
apiKey: '',
apiHost: 'https://v2.doc2x.noedgeai.com'
})
const runtimeState = state.runtime as any
if (runtimeState?.webdavSync) {
state.backup = state.backup || {}
state.backup = {
...state.backup,
webdavSync: {
lastSyncTime: runtimeState.webdavSync.lastSyncTime || null,
syncing: runtimeState.webdavSync.syncing || false,
lastSyncError: runtimeState.webdavSync.lastSyncError || null
}
}
delete runtimeState.webdavSync
}
return state
},
'83': (state: RootState) => {
state.settings.messageNavigation = 'buttons'
return state
}
}
-16
View File
@@ -11,12 +11,6 @@ export interface UpdateState {
available: boolean
}
export interface WebDAVSyncState {
lastSyncTime: number | null
syncing: boolean
lastSyncError: string | null
}
export interface RuntimeState {
avatar: string
generating: boolean
@@ -25,7 +19,6 @@ export interface RuntimeState {
filesPath: string
resourcesPath: string
update: UpdateState
webdavSync: WebDAVSyncState
export: ExportState
}
@@ -48,11 +41,6 @@ const initialState: RuntimeState = {
downloadProgress: 0,
available: false
},
webdavSync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
},
export: {
isExporting: false
}
@@ -83,9 +71,6 @@ const runtimeSlice = createSlice({
setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => {
state.update = { ...state.update, ...action.payload }
},
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
},
setExportState: (state, action: PayloadAction<Partial<ExportState>>) => {
state.export = { ...state.export, ...action.payload }
}
@@ -100,7 +85,6 @@ export const {
setFilesPath,
setResourcesPath,
setUpdateState,
setWebDAVSyncState,
setExportState
} = runtimeSlice.actions
+7 -1
View File
@@ -48,6 +48,7 @@ export interface SettingsState {
codeStyle: CodeStyleVarious
gridColumns: number
gridPopoverTrigger: 'hover' | 'click'
messageNavigation: 'none' | 'buttons' | 'anchor'
// webdav 配置 host, user, pass, path
webdavHost: string
webdavUser: string
@@ -122,6 +123,7 @@ const initialState: SettingsState = {
codeStyle: 'auto',
gridColumns: 2,
gridPopoverTrigger: 'hover',
messageNavigation: 'none',
webdavHost: '',
webdavUser: '',
webdavPass: '',
@@ -363,6 +365,9 @@ const settingsSlice = createSlice({
},
setJoplinUrl: (state, action: PayloadAction<string>) => {
state.joplinUrl = action.payload
},
setMessageNavigation: (state, action: PayloadAction<'none' | 'buttons' | 'anchor'>) => {
state.messageNavigation = action.payload
}
}
})
@@ -432,7 +437,8 @@ export const {
setObsidianApiKey,
setObsidianUrl,
setJoplinToken,
setJoplinUrl
setJoplinUrl,
setMessageNavigation
} = settingsSlice.actions
export default settingsSlice.reducer
+5
View File
@@ -78,6 +78,10 @@ export type Message = {
// MCP Tools
mcpTools?: MCPToolResponse[]
}
// 多模型消息样式
multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid'
// fold时是否选中
foldSelected?: boolean
}
export type Metrics = {
@@ -203,6 +207,7 @@ export type WebDavConfig = {
webdavUser: string
webdavPass: string
webdavPath: string
fileName?: string
}
export type AppInfo = {
@@ -29,7 +29,7 @@ const HomeWindow: FC = () => {
const textChange = useState(() => {})[1]
const { defaultAssistant } = useDefaultAssistant()
const { defaultModel: model } = useDefaultModel()
const { language, readClipboardAtStartup } = useSettings()
const { language, readClipboardAtStartup, windowStyle, theme } = useSettings()
const { t } = useTranslation()
const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null)
@@ -201,9 +201,24 @@ const HomeWindow: FC = () => {
}
}, [route])
const backgroundColor = () => {
// ONLY MAC: when transparent style + light theme: use vibrancy effect
// because the dark style under mac's vibrancy effect has not been implemented
if (
isMac &&
windowStyle === 'transparent' &&
theme === 'light' &&
!window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return 'transparent'
}
return 'var(--color-background)'
}
if (['chat', 'summary', 'explanation'].includes(route)) {
return (
<Container>
<Container style={{ backgroundColor: backgroundColor() }}>
{route === 'chat' && (
<>
<InputBar
@@ -232,7 +247,7 @@ const HomeWindow: FC = () => {
if (route === 'translate') {
return (
<Container>
<Container style={{ backgroundColor: backgroundColor() }}>
<TranslateWindow text={referenceText} />
<Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} />
@@ -241,7 +256,7 @@ const HomeWindow: FC = () => {
}
return (
<Container>
<Container style={{ backgroundColor: backgroundColor() }}>
<InputBar
text={text}
model={model}
@@ -280,7 +295,6 @@ const Container = styled.div`
flex-direction: column;
-webkit-app-region: drag;
padding: 8px 10px;
background-color: ${isMac ? 'transparent' : 'var(--color-background)'};
`
const Main = styled.main`
+649 -487
View File
File diff suppressed because it is too large Load Diff