Merge branch 'main' into feat-knowlege-ocr
This commit is contained in:
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Vendored
+10
-1
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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キーを選択:",
|
||||
|
||||
@@ -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 ключ для использования:",
|
||||
|
||||
@@ -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密钥:",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user